fix(sync): rsync 推送非 root 自动 sudo 提权

文件推送与文件管理对齐:非 root 默认 auto_sudo 时使用 sudo -n rsync;
新增批量 sudoers 补丁脚本以补全 rsync 白名单。
This commit is contained in:
Nexus Agent
2026-06-11 23:20:45 +08:00
parent 0a5111a959
commit 84b388fae2
14 changed files with 637 additions and 90 deletions
@@ -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
```
@@ -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)。
+2 -1
View File
@@ -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
@@ -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 可写站点目录。
+153
View File
@@ -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())
+24
View File
@@ -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" "$@"
+16 -67
View File
@@ -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 自动提权。"
),
}
+7 -6
View File
@@ -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
@@ -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 与白名单路径",
}
+40 -12
View File
@@ -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,
@@ -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 = (
+37
View File
@@ -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"
+17
View File
@@ -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
+78
View File
@@ -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