From 84b388fae287e3ace7df9ed524de763764ed2852 Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Thu, 11 Jun 2026 23:20:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(sync):=20rsync=20=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E9=9D=9E=20root=20=E8=87=AA=E5=8A=A8=20sudo=20=E6=8F=90?= =?UTF-8?q?=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件推送与文件管理对齐:非 root 默认 auto_sudo 时使用 sudo -n rsync; 新增批量 sudoers 补丁脚本以补全 rsync 白名单。 --- .../2026-06-11-rsync-push-sudo-elevation.md | 41 +++++ .../2026-06-11-rsync-push-sudo-elevation.md | 43 +++++ docs/deploy/nexus-files-sudoers.example | 3 +- .../2026-06-11-rsync-push-sudo-elevation.md | 35 ++++ scripts/batch_patch_files_sudoers.py | 153 ++++++++++++++++++ scripts/batch_patch_files_sudoers.sh | 24 +++ server/api/servers.py | 83 ++-------- server/api/sync_v2.py | 13 +- .../services/files_sudoers_service.py | 140 ++++++++++++++++ server/application/services/sync_engine_v2.py | 52 ++++-- server/infrastructure/ssh/files_capability.py | 8 +- server/utils/rsync_elevation.py | 37 +++++ tests/test_nexus_files_sudoers.py | 17 ++ tests/test_rsync_elevation.py | 78 +++++++++ 14 files changed, 637 insertions(+), 90 deletions(-) create mode 100644 docs/audit/2026-06-11-rsync-push-sudo-elevation.md create mode 100644 docs/changelog/2026-06-11-rsync-push-sudo-elevation.md create mode 100644 docs/design/plans/2026-06-11-rsync-push-sudo-elevation.md create mode 100644 scripts/batch_patch_files_sudoers.py create mode 100644 scripts/batch_patch_files_sudoers.sh create mode 100644 server/application/services/files_sudoers_service.py create mode 100644 server/utils/rsync_elevation.py create mode 100644 tests/test_nexus_files_sudoers.py create mode 100644 tests/test_rsync_elevation.py diff --git a/docs/audit/2026-06-11-rsync-push-sudo-elevation.md b/docs/audit/2026-06-11-rsync-push-sudo-elevation.md new file mode 100644 index 00000000..39ec4e16 --- /dev/null +++ b/docs/audit/2026-06-11-rsync-push-sudo-elevation.md @@ -0,0 +1,41 @@ +# 审计 — rsync 推送 sudo 提权 + 批量 sudoers 补丁 + +## 范围(文件清单) + +| 文件 | 变更 | +|------|------| +| `server/utils/rsync_elevation.py` | 非 root 默认 sudo rsync | +| `server/application/services/sync_engine_v2.py` | `_rsync_push` 接入 | +| `server/application/services/files_sudoers_service.py` | sudoers 模板与安装 | +| `server/api/sync_v2.py` | 诊断写入 sudo 回退 | +| `server/api/servers.py` | setup-files-sudo 复用 service | +| `scripts/batch_patch_files_sudoers.py` | 批量补丁脚本 | + +## Step 3 规则扫描 + +| H | 规则 | 结论 | +|---|------|------| +| H1 | sudoers 无用户注入 | PASS — `build_nexus_files_sudoers` 固定白名单 | +| H2 | rsync-path 无外部输入 | PASS — 常量 `sudo -n rsync` | +| H3 | 密码不经 argv | PASS — stdin `sudo -S` | + +## Closure + +| H | 判定 | 依据 | +|---|------|------| +| H1 | PASS | files_sudoers_service | +| H2 | PASS | rsync_elevation.py | +| H3 | PASS | _write_sudoers_with_password | + +## DoD + +- [x] pytest test_rsync_elevation + test_nexus_files_sudoers +- [x] changelog 2026-06-11-rsync-push-sudo-elevation.md +- [x] 批量脚本 batch_patch_files_sudoers.sh + +## 验证 + +```bash +pytest tests/test_rsync_elevation.py tests/test_nexus_files_sudoers.py -q +bash scripts/batch_patch_files_sudoers.sh --name 温胜夜 --dry-run +``` diff --git a/docs/changelog/2026-06-11-rsync-push-sudo-elevation.md b/docs/changelog/2026-06-11-rsync-push-sudo-elevation.md new file mode 100644 index 00000000..5470e1fa --- /dev/null +++ b/docs/changelog/2026-06-11-rsync-push-sudo-elevation.md @@ -0,0 +1,43 @@ +# Changelog — 2026-06-11 rsync 推送非 root 自动 sudo 提权 + +## 摘要 + +文件推送(rsync)与文件管理对齐:**非 root SSH 用户**在默认 `files_elevation=auto_sudo` 下,远程接收端自动使用 `sudo -n rsync`,不再裸连导致宝塔 `www:www` 站点目录 Permission denied。 + +## 动机 + +- 用户反馈:温胜夜两台子机推送失败,诊断 `touch` 权限被拒绝 +- 产品设计已定:除 root 外默认 `auto_sudo` 自动提权;此前 rsync 推送未接入该策略 + +## 变更 + +| 文件 | 说明 | +|------|------| +| `server/utils/rsync_elevation.py` | 非 root 且非 `off` → `--rsync-path=sudo -n rsync` | +| `server/application/services/sync_engine_v2.py` | `_rsync_push` 接入提权;日志含 `rsync=sudo` | +| `server/api/sync_v2.py` | 诊断写入测试走 `exec_ssh_command_with_fallback` | +| `server/api/servers.py` | `setup-files-sudo` 模板增加 rsync | +| `docs/deploy/nexus-files-sudoers.example` | 增加 rsync | +| `server/infrastructure/ssh/files_capability.py` | 白名单探测含 rsync,提示推送 | +| `scripts/batch_patch_files_sudoers.py` | 批量补全 sudoers(含 rsync) | +| `scripts/batch_patch_files_sudoers.sh` | 包装入口 | +| `tests/test_nexus_files_sudoers.py` | 模板单测 | + +## 迁移 / 重启 + +- 需重启 API / 重建 Docker 镜像 +- **目标机**:已有 `nexus-files` sudoers 须补 `/usr/bin/rsync`(或重新点「一键配置 sudo」) +- 无 DB 迁移 + +## 验证 + +```bash +pytest tests/test_rsync_elevation.py tests/test_nexus_files_sudoers.py tests/test_rsync_push_permissions.py -q +bash scripts/batch_patch_files_sudoers.sh --name 温胜夜 +``` + +部署后:非 root 服务器推送 → `sync_logs.push_permission` 含 `rsync=sudo`;诊断写入 ✓(或 `path_elevated=true`)。 + +## 回滚 + +Revert 上述文件;推送恢复为仅 root 可写 www 目录(或手动 chmod)。 diff --git a/docs/deploy/nexus-files-sudoers.example b/docs/deploy/nexus-files-sudoers.example index 5fcb50f8..b7266014 100644 --- a/docs/deploy/nexus-files-sudoers.example +++ b/docs/deploy/nexus-files-sudoers.example @@ -22,4 +22,5 @@ deploy ALL=(ALL) NOPASSWD: \ /bin/tar, /usr/bin/tar, \ /usr/bin/unzip, /usr/bin/zip, \ /usr/bin/tee, /bin/cat, /usr/bin/cat, \ - /usr/bin/touch, /bin/touch + /usr/bin/touch, /bin/touch, \ + /usr/bin/rsync, /bin/rsync diff --git a/docs/design/plans/2026-06-11-rsync-push-sudo-elevation.md b/docs/design/plans/2026-06-11-rsync-push-sudo-elevation.md new file mode 100644 index 00000000..e3c39bb6 --- /dev/null +++ b/docs/design/plans/2026-06-11-rsync-push-sudo-elevation.md @@ -0,0 +1,35 @@ +# 实施说明 — rsync 推送 sudo 提权 + +## 背景 + +非 root SSH 用户推送至宝塔 `www:www` 站点目录时,rsync 接收端无写权限;文件管理的 `files_elevation` 此前未作用于 rsync 推送。 + +## 方案 + +与文件管理一致:**root 不提权**;**非 root** 且 `files_elevation` 非 `off`(默认 `auto_sudo`)→ rsync 附加 `--rsync-path="sudo -n rsync"`。仅显式 `off` 禁用。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `server/utils/rsync_elevation.py` | 新增 | +| `server/application/services/sync_engine_v2.py` | `_rsync_push` 提权与重试 | +| `server/api/sync_v2.py` | diagnose 写入测试 sudo 回退 | +| `server/api/servers.py` | setup-files-sudo 模板加 rsync | +| `docs/deploy/nexus-files-sudoers.example` | 加 rsync | +| `server/infrastructure/ssh/files_capability.py` | 白名单探测含 rsync | +| `tests/test_rsync_elevation.py` | 单测 | + +## 目标机要求 + +`/etc/sudoers.d/nexus-files` 须含 `NOPASSWD: /usr/bin/rsync`(或 `/bin/rsync`)。可通过服务器页「配置 sudo」或手动安装示例文件。 + +## 验证 + +```bash +pytest tests/test_rsync_elevation.py tests/test_rsync_push_permissions.py -q +``` + +## 回滚 + +Revert 上述文件;推送恢复为仅 root SSH 可写站点目录。 diff --git a/scripts/batch_patch_files_sudoers.py b/scripts/batch_patch_files_sudoers.py new file mode 100644 index 00000000..0ef4c978 --- /dev/null +++ b/scripts/batch_patch_files_sudoers.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Batch patch /etc/sudoers.d/nexus-files on sub-servers (add rsync for push elevation). + +Usage: + python scripts/batch_patch_files_sudoers.py --name 温胜夜 + python scripts/batch_patch_files_sudoers.py --non-root-only --limit 50 + python scripts/batch_patch_files_sudoers.py --ids 12,34 --dry-run + +Requires .env with DATABASE_URL. SSH creds from servers table. +Run from dev machine or on central host: bash scripts/batch_patch_files_sudoers.sh +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +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, + *, + server_id: int | None, + server_ids: list[int] | None, + name_like: str | None, + non_root_only: bool, + limit: int, +) -> list[Any]: + from sqlalchemy import or_, select + + from server.domain.models import Server + + q = select(Server).order_by(Server.id) + if server_id is not None: + q = q.where(Server.id == server_id) + elif server_ids: + q = q.where(Server.id.in_(server_ids)) + if name_like: + q = q.where(or_(Server.name.like(f"%{name_like}%"), Server.domain.like(f"%{name_like}%"))) + if non_root_only: + q = q.where(Server.username.isnot(None)).where(Server.username != "root") + + result = await session.execute(q) + servers = list(result.scalars().all()) + if limit > 0: + servers = servers[:limit] + return servers + + +async def run_patch(args: argparse.Namespace) -> list[dict[str, Any]]: + from server.application.services.files_sudoers_service import install_nexus_files_sudoers + from server.config import settings + from server.infrastructure.database.session import AsyncSessionLocal, init_db + from server.infrastructure.redis.client import close_redis, init_redis + + 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, + server_id=args.id, + server_ids=ids, + name_like=args.name, + non_root_only=args.non_root_only, + limit=args.limit, + ) + + if not servers: + print("No servers matched.", file=sys.stderr) + return [] + + sem = asyncio.Semaphore(max(1, args.concurrency)) + results: list[dict[str, Any]] = [] + + async def _one(server: Any) -> None: + async with sem: + try: + row = await install_nexus_files_sudoers(server, dry_run=args.dry_run) + except Exception as exc: + row = { + "server_id": server.id, + "server_name": server.name, + "ssh_user": server.username, + "ok": False, + "action": "error", + "detail": str(exc)[:500], + } + results.append(row) + status = "OK" if row.get("ok") else "FAIL" + print( + f"[{status}] #{row.get('server_id')} {row.get('server_name')} " + f"({row.get('ssh_user')}) → {row.get('action')}" + + (f" — {row.get('detail')}" if row.get("detail") else ""), + flush=True, + ) + + await asyncio.gather(*[_one(s) for s in servers]) + + 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") + return results + + +def main() -> int: + _load_dotenv() + parser = argparse.ArgumentParser(description="Batch patch nexus-files sudoers (rsync for push)") + parser.add_argument("--id", type=int, help="Single server id") + parser.add_argument("--ids", type=str, help="Comma-separated server ids") + parser.add_argument("--name", type=str, help="Filter name/domain LIKE") + parser.add_argument("--non-root-only", action="store_true", help="Skip username=root") + parser.add_argument("--limit", type=int, default=0, help="Max servers (0=all)") + parser.add_argument("--concurrency", type=int, default=5) + parser.add_argument("--dry-run", action="store_true", help="Report only, no SSH writes") + args = parser.parse_args() + + if not args.id and not args.ids and not args.name and not args.non_root_only: + parser.error("Specify --id, --ids, --name, and/or --non-root-only") + + results = asyncio.run(run_patch(args)) + return 0 if results and all(r.get("ok") for r in results) else (1 if results else 2) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/batch_patch_files_sudoers.sh b/scripts/batch_patch_files_sudoers.sh new file mode 100644 index 00000000..3c1a41de --- /dev/null +++ b/scripts/batch_patch_files_sudoers.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Batch patch nexus-files sudoers on sub-servers (rsync for file push elevation). +# +# Usage: +# bash scripts/batch_patch_files_sudoers.sh --name 温胜夜 +# bash scripts/batch_patch_files_sudoers.sh --non-root-only --limit 100 +# bash scripts/batch_patch_files_sudoers.sh --ids 12,34 --dry-run +# +# Run on machine with .env (local dev or ssh nexus + cd /opt/nexus). +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SECRETS="${ROOT}/deploy/nexus-1panel.secrets.sh" +if [[ -f "$SECRETS" ]]; then + # shellcheck disable=SC1090 + source "$SECRETS" +fi +PYTHON="${ROOT}/.venv/bin/python" +if [[ ! -x "$PYTHON" ]]; then + PYTHON="${ROOT}/venv/bin/python" +fi +if [[ ! -x "$PYTHON" ]]; then + PYTHON=python3 +fi +exec "$PYTHON" "${ROOT}/scripts/batch_patch_files_sudoers.py" "$@" diff --git a/server/api/servers.py b/server/api/servers.py index 9099a8b4..42c94f85 100644 --- a/server/api/servers.py +++ b/server/api/servers.py @@ -1246,15 +1246,11 @@ async def setup_files_sudo( admin: Admin = Depends(get_current_admin), service: ServerService = Depends(get_server_service), ): - """Auto-configure passwordless sudoers for file manager on non-root servers. - - Uses the stored SSH password to run `sudo -S` and write - /etc/sudoers.d/nexus-files on the target server. - Requires the SSH user to have sudo access with password. - """ - import textwrap - from server.infrastructure.ssh.asyncssh_pool import ssh_pool - from server.infrastructure.database.crypto import decrypt_value + """Auto-configure passwordless sudoers for file manager + rsync push on non-root servers.""" + from server.application.services.files_sudoers_service import ( + NEXUS_FILES_SUDOERS_PATH, + install_nexus_files_sudoers, + ) server = await service.get_server(id) if not server: @@ -1264,70 +1260,23 @@ async def setup_files_sudo( if ssh_user == "root": return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"} - if not server.password: - raise HTTPException( - status_code=400, - detail="该服务器未存储 SSH 密码,无法自动配置。请手动在目标机配置 sudoers。", - ) - - plain_password = decrypt_value(server.password) - sudoers_content = textwrap.dedent(f"""\ - # nexus-files: auto-configured by Nexus file manager - {ssh_user} ALL=(root) NOPASSWD: \\ - /bin/ls, /usr/bin/ls, \\ - /bin/cat, /usr/bin/cat, \\ - /usr/bin/tee, /bin/tee, \\ - /bin/mkdir, /usr/bin/mkdir, \\ - /bin/cp, /usr/bin/cp, \\ - /bin/mv, /usr/bin/mv, \\ - /bin/rm, /usr/bin/rm, \\ - /bin/chmod, /usr/bin/chmod, \\ - /bin/chown, /usr/bin/chown, \\ - /bin/bash, /usr/bin/bash - """) - - sudoers_path = "/etc/sudoers.d/nexus-files" - # Use heredoc via sudo -S to avoid exposing password in command arguments - write_cmd = ( - f"printf %s {shlex.quote(sudoers_content)}" - f" | sudo -S tee {shlex.quote(sudoers_path)} > /dev/null" - f" && sudo chmod 440 {shlex.quote(sudoers_path)}" - f" && sudo visudo -cf {shlex.quote(sudoers_path)}" - ) - - conn = None - try: - conn = await ssh_pool.acquire(server) - # Feed password via stdin for sudo -S (avoids exposing password in process args) - result = await asyncio.wait_for( - conn.run(write_cmd, input=plain_password + "\n", timeout=15), - timeout=20, - ) - exit_code = result.exit_status if result.exit_status is not None else -1 - stderr_out = (result.stderr or "").strip() - stdout_out = (result.stdout or "").strip() - if exit_code != 0: - detail = stderr_out or stdout_out or f"命令退出码 {exit_code}" - raise HTTPException( - status_code=500, - detail=f"sudoers 写入失败:{detail[:300]}", - ) - except HTTPException: - raise - except Exception as exc: + outcome = await install_nexus_files_sudoers(server) + if outcome.get("action") == "already_ok": + return { + "ok": True, + "message": f"sudo -n rsync 已可用,无需重复配置({NEXUS_FILES_SUDOERS_PATH})", + } + if not outcome.get("ok"): raise HTTPException( status_code=500, - detail=f"SSH 执行失败:{exc}", - ) from exc - finally: - if conn is not None: - await ssh_pool.release(server.id) + detail=outcome.get("detail") or f"sudoers 配置失败:{outcome.get('action')}", + ) return { "ok": True, "message": ( - f"sudoers 已配置:{sudoers_path}。" - "现在文件管理器可使用 sudo -n 自动提权浏览/操作文件。" + f"sudoers 已配置:{NEXUS_FILES_SUDOERS_PATH}。" + "文件管理与文件推送可使用 sudo -n 自动提权。" ), } diff --git a/server/api/sync_v2.py b/server/api/sync_v2.py index e290b2c4..a51d6f8b 100644 --- a/server/api/sync_v2.py +++ b/server/api/sync_v2.py @@ -626,6 +626,7 @@ async def diagnose_push( """ import shlex from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.utils.sync_error_message import translate_sync_error_message @@ -643,6 +644,7 @@ async def diagnose_push( "path_exists": None, "path_perms": None, "path_writable": None, + "path_elevated": False, "errors": [], } @@ -699,16 +701,15 @@ async def diagnose_push( # 4. Write test if result["path_exists"]: try: - test_file = remote_join(dest, ".nexus_diag_test_$$") - r = await exec_ssh_command( - server, - f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}", - timeout=10, - ) + test_file = remote_join(dest, ".nexus_diag_test") + write_cmd = f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}" + r = await exec_ssh_command_with_fallback(server, write_cmd, timeout=10) result["path_writable"] = r["exit_code"] == 0 if r["exit_code"] != 0: stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or "" result["errors"].append(f"目标路径不可写: {stderr_zh}") + elif r.get("elevated"): + result["path_elevated"] = True except Exception as e: result["errors"].append(f"写入测试失败: {e}") result["path_writable"] = False diff --git a/server/application/services/files_sudoers_service.py b/server/application/services/files_sudoers_service.py new file mode 100644 index 00000000..253dded3 --- /dev/null +++ b/server/application/services/files_sudoers_service.py @@ -0,0 +1,140 @@ +"""Shared Nexus files sudoers template and remote install helpers.""" + +from __future__ import annotations + +import asyncio +import shlex +import textwrap + +from server.domain.models import Server +from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command, ssh_pool +from server.infrastructure.database.crypto import decrypt_value +from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback + +NEXUS_FILES_SUDOERS_PATH = "/etc/sudoers.d/nexus-files" + + +def build_nexus_files_sudoers(ssh_user: str) -> str: + """Return sudoers.d fragment for file manager + rsync push elevation.""" + user = (ssh_user or "deploy").strip() or "deploy" + return textwrap.dedent(f"""\ + # nexus-files: configured by Nexus (file manager + rsync push) + {user} ALL=(root) NOPASSWD: \\ + /bin/ls, /usr/bin/ls, \\ + /bin/cat, /usr/bin/cat, \\ + /usr/bin/tee, /bin/tee, \\ + /bin/mkdir, /usr/bin/mkdir, \\ + /bin/cp, /usr/bin/cp, \\ + /bin/mv, /usr/bin/mv, \\ + /bin/rm, /usr/bin/rm, \\ + /bin/chmod, /usr/bin/chmod, \\ + /bin/chown, /usr/bin/chown, \\ + /bin/bash, /usr/bin/bash, \\ + /usr/bin/rsync, /bin/rsync + """) + + +async def probe_rsync_sudo(server: Server, *, timeout: int = 15) -> bool: + """True when ``sudo -n rsync`` works on the target.""" + ssh_user = (server.username or "root").strip() or "root" + if ssh_user == "root": + result = await exec_ssh_command(server, "rsync --version", timeout=timeout) + return result.get("exit_code") == 0 + result = await exec_ssh_command(server, "sudo -n rsync --version", timeout=timeout) + return result.get("exit_code") == 0 + + +async def _write_sudoers_with_password( + server: Server, + content: str, + sudoers_path: str, + plain_password: str, +) -> dict: + write_cmd = ( + f"printf %s {shlex.quote(content)}" + f" | sudo -S tee {shlex.quote(sudoers_path)} > /dev/null" + f" && sudo chmod 440 {shlex.quote(sudoers_path)}" + f" && sudo visudo -cf {shlex.quote(sudoers_path)}" + ) + conn = None + try: + conn = await ssh_pool.acquire(server) + result = await asyncio.wait_for( + conn.run(write_cmd, input=plain_password + "\n", timeout=20), + timeout=25, + ) + exit_code = result.exit_status if result.exit_status is not None else -1 + stderr_out = (result.stderr or "").strip() + stdout_out = (result.stdout or "").strip() + if exit_code != 0: + detail = stderr_out or stdout_out or f"exit {exit_code}" + return {"ok": False, "action": "password_sudo_failed", "detail": detail[:500]} + return {"ok": True, "action": "patched_password_sudo"} + finally: + if conn is not None: + await ssh_pool.release(server.id) + + +async def install_nexus_files_sudoers(server: Server, *, dry_run: bool = False) -> dict: + """Ensure ``/etc/sudoers.d/nexus-files`` includes rsync (and file ops whitelist). + + Returns ``{ok, action, server_id, server_name, ssh_user, detail?}``. + """ + ssh_user = (server.username or "root").strip() or "root" + base = { + "server_id": server.id, + "server_name": server.name, + "ssh_user": ssh_user, + } + + if ssh_user == "root": + return {**base, "ok": True, "action": "skip_root"} + + if await probe_rsync_sudo(server): + return {**base, "ok": True, "action": "already_ok"} + + if dry_run: + return {**base, "ok": True, "action": "would_patch"} + + content = build_nexus_files_sudoers(ssh_user) + sudoers_path = NEXUS_FILES_SUDOERS_PATH + + if server.password: + plain = decrypt_value(server.password) + outcome = await _write_sudoers_with_password(server, content, sudoers_path, plain) + if not outcome["ok"]: + return {**base, **outcome} + if await probe_rsync_sudo(server): + return {**base, **outcome} + return { + **base, + "ok": False, + "action": "written_but_rsync_probe_failed", + "detail": "sudoers 已写入但 sudo -n rsync 仍失败", + } + + write_inner = ( + f"printf %s {shlex.quote(content)}" + f" | tee {shlex.quote(sudoers_path)} > /dev/null" + f" && chmod 440 {shlex.quote(sudoers_path)}" + f" && visudo -cf {shlex.quote(sudoers_path)}" + ) + result = await exec_ssh_command_with_fallback(server, write_inner, timeout=30) + if result.get("exit_code") != 0: + detail = (result.get("stderr") or result.get("stdout") or "")[:500] + return { + **base, + "ok": False, + "action": "nopasswd_write_failed", + "detail": detail or "无法写入 sudoers(需存储 SSH 密码或已配置 bash 免密 sudo)", + } + + if await probe_rsync_sudo(server): + return {**base, "ok": True, "action": "patched_nopasswd"} + + return { + **base, + "ok": False, + "action": "written_but_rsync_probe_failed", + "detail": "sudoers 已写入但 sudo -n rsync 仍失败,请检查 visudo 与白名单路径", + } diff --git a/server/application/services/sync_engine_v2.py b/server/application/services/sync_engine_v2.py index a099305f..0f063a5d 100644 --- a/server/application/services/sync_engine_v2.py +++ b/server/application/services/sync_engine_v2.py @@ -43,9 +43,10 @@ _RSYNC_CHOWN_RE = re.compile(r"^[a-zA-Z0-9_.-]+(:[a-zA-Z0-9_.-]+)?$") _RSYNC_CHMOD_RE = re.compile(r"^[DF0-9a-zA-Z=+,:-]+$") -def rsync_push_permission_summary() -> str | None: - """Human/log snapshot of rsync --chown/--chmod applied on real pushes.""" +def rsync_push_permission_summary(server: Server | None = None) -> str | None: + """Human/log snapshot of rsync --chown/--chmod and elevation applied on real pushes.""" from server.config import settings + from server.utils.rsync_elevation import rsync_elevation_log_hint parts: list[str] = [] chown = (settings.RSYNC_PUSH_CHOWN or "").strip() @@ -54,6 +55,10 @@ def rsync_push_permission_summary() -> str | None: parts.append(f"chown={chown}") if chmod and _RSYNC_CHMOD_RE.fullmatch(chmod): parts.append(f"chmod={chmod}") + if server is not None: + elevation_hint = rsync_elevation_log_hint(server) + if elevation_hint: + parts.append(elevation_hint) return " ".join(parts) if parts else None @@ -185,7 +190,7 @@ class SyncEngineV2: operator=operator, status="running", sync_mode=sync_mode, - push_permission=rsync_push_permission_summary(), + push_permission=rsync_push_permission_summary(server), started_at=datetime.now(timezone.utc), ) async with _db_lock: @@ -452,9 +457,36 @@ async def _rsync_push( ) -> dict: """Execute rsync on Nexus host, pushing to target server via SSH. - Handles both password auth (sshpass) and key auth (temp key file). - Returns {exit_code, stdout, stderr}. + Non-root servers with ``files_elevation`` not ``off`` use + ``--rsync-path=sudo -n rsync`` (default ``auto_sudo``, same as file manager). """ + from server.utils.rsync_elevation import rsync_receiver_path_for_push + + sudo_path = rsync_receiver_path_for_push(server) + result = await _execute_rsync_push( + server, + source_path, + target_path, + sync_mode=sync_mode, + dry_run=dry_run, + verbose=verbose, + rsync_receiver_path=sudo_path, + ) + result["elevated"] = bool(sudo_path and result.get("exit_code") == 0) + return result + + +async def _execute_rsync_push( + server: Server, + source_path: str, + target_path: str, + *, + sync_mode: str = "incremental", + dry_run: bool = False, + verbose: bool = False, + rsync_receiver_path: str | None = None, +) -> dict: + """Run one rsync attempt on the Nexus host. Returns {exit_code, stdout, stderr}.""" import shlex ssh_user = server.username or "root" @@ -463,7 +495,6 @@ async def _rsync_push( remote_dest = normalize_remote_abs_path(to_posix(target_path)) rsync_remote = f"{ssh_user}@{ssh_host}:{remote_dest}" - # Build rsync args args = ["rsync", "-az"] if sync_mode == "full": args.append("--delete") @@ -481,18 +512,17 @@ async def _rsync_push( if not dry_run: args.extend(rsync_push_permission_args()) - # SSH options: port + no strict host key checking (matches asyncssh pool behavior) + if rsync_receiver_path: + args.extend(["--rsync-path", rsync_receiver_path]) + ssh_opts = f"ssh -o StrictHostKeyChecking=no -p {ssh_port}" - # Auth: prepare temp key file or sshpass key_file = None env_override = None try: if server.auth_method == "password" and server.password: password = decrypt_value(server.password) - # sshpass -p requires the password as an argument (visible in /proc on Linux, - # but acceptable for internal ops tool; sshpass -e from env is slightly safer) env_override = {"SSHPASS": password} args = ["sshpass", "-e"] + args ssh_opts += " -o PubkeyAuthentication=no" @@ -512,11 +542,9 @@ async def _rsync_push( return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"} args.extend(["-e", ssh_opts]) - # source_path: Nexus 主机本地目录(生产环境为 Linux);保持原样供 rsync 读取 args.append(source_path.rstrip("/") + "/") args.append(rsync_remote.rstrip("/") + "/") - # Execute rsync on Nexus host proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, diff --git a/server/infrastructure/ssh/files_capability.py b/server/infrastructure/ssh/files_capability.py index 6fe1508a..5d92121b 100644 --- a/server/infrastructure/ssh/files_capability.py +++ b/server/infrastructure/ssh/files_capability.py @@ -33,17 +33,17 @@ async def probe_files_capability(server: Server) -> dict: whitelist_probe = await exec_ssh_command( server, - "sudo -n bash -c 'command -v chmod >/dev/null && command -v chown >/dev/null'", + "sudo -n bash -c 'command -v chmod >/dev/null && command -v chown >/dev/null && command -v rsync >/dev/null'", timeout=15, ) whitelist_ok = can_sudo_nopasswd and whitelist_probe.get("exit_code") == 0 if can_sudo_nopasswd and whitelist_ok: - message = "已配置免密 sudo,文件管理可自动提权" + message = "已配置免密 sudo,文件管理与推送可自动提权" elif can_sudo_nopasswd: message = ( - "可免密 sudo,但 chmod/chown 可能未列入白名单;" - "请参考仓库内 sudoers 示例补全" + "可免密 sudo,但 chmod/chown/rsync 可能未列入白名单;" + "文件推送至 www 目录会失败,请参考仓库内 sudoers 示例补全" ) else: message = ( diff --git a/server/utils/rsync_elevation.py b/server/utils/rsync_elevation.py new file mode 100644 index 00000000..114351d5 --- /dev/null +++ b/server/utils/rsync_elevation.py @@ -0,0 +1,37 @@ +"""Rsync remote receiver elevation — mirrors ``files_elevation`` for file push. + +Policy (same as file manager): **root** SSH → no sudo; **non-root** with default +``auto_sudo`` (or ``always_sudo``) → ``sudo -n rsync`` on the receiver. +Only ``files_elevation=off`` disables elevation. +""" + +from __future__ import annotations + +from server.domain.models import Server +from server.utils.files_elevation import FilesElevation, get_files_elevation + +# Fixed remote receiver command (no user input); requires NOPASSWD rsync in sudoers. +RSYNC_SUDO_RECEIVER_PATH = "sudo -n rsync" + + +def is_root_ssh_user(server: Server) -> bool: + ssh_user = (server.username or "root").strip() or "root" + return ssh_user == "root" + + +def rsync_receiver_path_for_push(server: Server) -> str | None: + """Return ``--rsync-path`` for non-root servers when elevation is enabled.""" + if is_root_ssh_user(server): + return None + if get_files_elevation(server) == FilesElevation.OFF: + return None + return RSYNC_SUDO_RECEIVER_PATH + + +def rsync_elevation_log_hint(server: Server) -> str | None: + """Short tag for sync_logs.push_permission.""" + if is_root_ssh_user(server): + return None + if get_files_elevation(server) == FilesElevation.OFF: + return None + return "rsync=sudo" diff --git a/tests/test_nexus_files_sudoers.py b/tests/test_nexus_files_sudoers.py new file mode 100644 index 00000000..59b869ad --- /dev/null +++ b/tests/test_nexus_files_sudoers.py @@ -0,0 +1,17 @@ +"""Tests for nexus-files sudoers template and batch patch helpers.""" + +from server.application.services.files_sudoers_service import build_nexus_files_sudoers + + +def test_build_nexus_files_sudoers_includes_rsync(): + content = build_nexus_files_sudoers("ubuntu") + assert "ubuntu ALL=(root) NOPASSWD:" in content + assert "/usr/bin/rsync" in content + assert "/bin/rsync" in content + assert "/usr/bin/chmod" in content + + +def test_build_nexus_files_sudoers_strips_user(): + content = build_nexus_files_sudoers(" deploy ") + assert content.startswith("# nexus-files:") + assert "deploy ALL=(root)" in content diff --git a/tests/test_rsync_elevation.py b/tests/test_rsync_elevation.py new file mode 100644 index 00000000..57528840 --- /dev/null +++ b/tests/test_rsync_elevation.py @@ -0,0 +1,78 @@ +"""Tests for rsync push sudo elevation (non-root auto_sudo policy).""" + +from __future__ import annotations + +import pytest + +from server.domain.models import Server +from server.utils.rsync_elevation import ( + RSYNC_SUDO_RECEIVER_PATH, + is_root_ssh_user, + rsync_elevation_log_hint, + rsync_receiver_path_for_push, +) + + +def test_root_ssh_skips_sudo_rsync(): + server = Server(name="t", domain="1.2.3.4", username="root") + assert is_root_ssh_user(server) is True + assert rsync_receiver_path_for_push(server) is None + assert rsync_elevation_log_hint(server) is None + + +def test_non_root_default_auto_sudo_uses_sudo_rsync(): + """Unset extra_attrs → normalize to auto_sudo → sudo rsync (Ubuntu/deploy 默认).""" + server = Server(name="t", domain="1.2.3.4", username="ubuntu") + assert rsync_receiver_path_for_push(server) == RSYNC_SUDO_RECEIVER_PATH + assert rsync_elevation_log_hint(server) == "rsync=sudo" + + +def test_non_root_always_sudo_uses_sudo_rsync(): + server = Server( + name="t", + domain="1.2.3.4", + username="deploy", + extra_attrs={"files_elevation": "always_sudo"}, + ) + assert rsync_receiver_path_for_push(server) == RSYNC_SUDO_RECEIVER_PATH + + +def test_non_root_off_disables_sudo_rsync(): + server = Server( + name="t", + domain="1.2.3.4", + username="deploy", + extra_attrs={"files_elevation": "off"}, + ) + assert rsync_receiver_path_for_push(server) is None + assert rsync_elevation_log_hint(server) is None + + +@pytest.mark.asyncio +async def test_rsync_push_passes_sudo_path_for_non_root(monkeypatch, tmp_path): + import asyncio + + from server.application.services import sync_engine_v2 as se + + source = tmp_path / "src" + source.mkdir() + (source / "a.txt").write_text("hi", encoding="utf-8") + + server = Server( + name="t", + domain="127.0.0.1", + username="ubuntu", + ssh_key_path="/nonexistent/key", + ) + + captured: list[str | None] = [] + + async def fake_execute(_server, _src, _dest, **kwargs): + captured.append(kwargs.get("rsync_receiver_path")) + return {"exit_code": 0, "stdout": "", "stderr": ""} + + monkeypatch.setattr(se, "_execute_rsync_push", fake_execute) + + result = await se._rsync_push(server, str(source), "/www/wwwroot/site") + assert captured == [RSYNC_SUDO_RECEIVER_PATH] + assert result["elevated"] is True