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, \ /bin/tar, /usr/bin/tar, \
/usr/bin/unzip, /usr/bin/zip, \ /usr/bin/unzip, /usr/bin/zip, \
/usr/bin/tee, /bin/cat, /usr/bin/cat, \ /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), admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service), service: ServerService = Depends(get_server_service),
): ):
"""Auto-configure passwordless sudoers for file manager on non-root servers. """Auto-configure passwordless sudoers for file manager + rsync push on non-root servers."""
from server.application.services.files_sudoers_service import (
Uses the stored SSH password to run `sudo -S` and write NEXUS_FILES_SUDOERS_PATH,
/etc/sudoers.d/nexus-files on the target server. install_nexus_files_sudoers,
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
server = await service.get_server(id) server = await service.get_server(id)
if not server: if not server:
@@ -1264,70 +1260,23 @@ async def setup_files_sudo(
if ssh_user == "root": if ssh_user == "root":
return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"} return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"}
if not server.password: outcome = await install_nexus_files_sudoers(server)
raise HTTPException( if outcome.get("action") == "already_ok":
status_code=400, return {
detail="该服务器未存储 SSH 密码,无法自动配置。请手动在目标机配置 sudoers。", "ok": True,
) "message": f"sudo -n rsync 已可用,无需重复配置({NEXUS_FILES_SUDOERS_PATH}",
}
plain_password = decrypt_value(server.password) if not outcome.get("ok"):
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:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"SSH 执行失败:{exc}", detail=outcome.get("detail") or f"sudoers 配置失败:{outcome.get('action')}",
) from exc )
finally:
if conn is not None:
await ssh_pool.release(server.id)
return { return {
"ok": True, "ok": True,
"message": ( "message": (
f"sudoers 已配置:{sudoers_path}" f"sudoers 已配置:{NEXUS_FILES_SUDOERS_PATH}"
"现在文件管理可使用 sudo -n 自动提权浏览/操作文件" "文件管理与文件推送可使用 sudo -n 自动提权。"
), ),
} }
+7 -6
View File
@@ -626,6 +626,7 @@ async def diagnose_push(
""" """
import shlex import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command 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.infrastructure.database.server_repo import ServerRepositoryImpl
from server.utils.sync_error_message import translate_sync_error_message from server.utils.sync_error_message import translate_sync_error_message
@@ -643,6 +644,7 @@ async def diagnose_push(
"path_exists": None, "path_exists": None,
"path_perms": None, "path_perms": None,
"path_writable": None, "path_writable": None,
"path_elevated": False,
"errors": [], "errors": [],
} }
@@ -699,16 +701,15 @@ async def diagnose_push(
# 4. Write test # 4. Write test
if result["path_exists"]: if result["path_exists"]:
try: try:
test_file = remote_join(dest, ".nexus_diag_test_$$") test_file = remote_join(dest, ".nexus_diag_test")
r = await exec_ssh_command( write_cmd = f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}"
server, r = await exec_ssh_command_with_fallback(server, write_cmd, timeout=10)
f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}",
timeout=10,
)
result["path_writable"] = r["exit_code"] == 0 result["path_writable"] = r["exit_code"] == 0
if r["exit_code"] != 0: if r["exit_code"] != 0:
stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or "" stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or ""
result["errors"].append(f"目标路径不可写: {stderr_zh}") result["errors"].append(f"目标路径不可写: {stderr_zh}")
elif r.get("elevated"):
result["path_elevated"] = True
except Exception as e: except Exception as e:
result["errors"].append(f"写入测试失败: {e}") result["errors"].append(f"写入测试失败: {e}")
result["path_writable"] = False 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=+,:-]+$") _RSYNC_CHMOD_RE = re.compile(r"^[DF0-9a-zA-Z=+,:-]+$")
def rsync_push_permission_summary() -> str | None: def rsync_push_permission_summary(server: Server | None = None) -> str | None:
"""Human/log snapshot of rsync --chown/--chmod applied on real pushes.""" """Human/log snapshot of rsync --chown/--chmod and elevation applied on real pushes."""
from server.config import settings from server.config import settings
from server.utils.rsync_elevation import rsync_elevation_log_hint
parts: list[str] = [] parts: list[str] = []
chown = (settings.RSYNC_PUSH_CHOWN or "").strip() chown = (settings.RSYNC_PUSH_CHOWN or "").strip()
@@ -54,6 +55,10 @@ def rsync_push_permission_summary() -> str | None:
parts.append(f"chown={chown}") parts.append(f"chown={chown}")
if chmod and _RSYNC_CHMOD_RE.fullmatch(chmod): if chmod and _RSYNC_CHMOD_RE.fullmatch(chmod):
parts.append(f"chmod={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 return " ".join(parts) if parts else None
@@ -185,7 +190,7 @@ class SyncEngineV2:
operator=operator, operator=operator,
status="running", status="running",
sync_mode=sync_mode, sync_mode=sync_mode,
push_permission=rsync_push_permission_summary(), push_permission=rsync_push_permission_summary(server),
started_at=datetime.now(timezone.utc), started_at=datetime.now(timezone.utc),
) )
async with _db_lock: async with _db_lock:
@@ -452,9 +457,36 @@ async def _rsync_push(
) -> dict: ) -> dict:
"""Execute rsync on Nexus host, pushing to target server via SSH. """Execute rsync on Nexus host, pushing to target server via SSH.
Handles both password auth (sshpass) and key auth (temp key file). Non-root servers with ``files_elevation`` not ``off`` use
Returns {exit_code, stdout, stderr}. ``--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 import shlex
ssh_user = server.username or "root" ssh_user = server.username or "root"
@@ -463,7 +495,6 @@ async def _rsync_push(
remote_dest = normalize_remote_abs_path(to_posix(target_path)) remote_dest = normalize_remote_abs_path(to_posix(target_path))
rsync_remote = f"{ssh_user}@{ssh_host}:{remote_dest}" rsync_remote = f"{ssh_user}@{ssh_host}:{remote_dest}"
# Build rsync args
args = ["rsync", "-az"] args = ["rsync", "-az"]
if sync_mode == "full": if sync_mode == "full":
args.append("--delete") args.append("--delete")
@@ -481,18 +512,17 @@ async def _rsync_push(
if not dry_run: if not dry_run:
args.extend(rsync_push_permission_args()) 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}" ssh_opts = f"ssh -o StrictHostKeyChecking=no -p {ssh_port}"
# Auth: prepare temp key file or sshpass
key_file = None key_file = None
env_override = None env_override = None
try: try:
if server.auth_method == "password" and server.password: if server.auth_method == "password" and server.password:
password = decrypt_value(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} env_override = {"SSHPASS": password}
args = ["sshpass", "-e"] + args args = ["sshpass", "-e"] + args
ssh_opts += " -o PubkeyAuthentication=no" ssh_opts += " -o PubkeyAuthentication=no"
@@ -512,11 +542,9 @@ async def _rsync_push(
return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"} return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"}
args.extend(["-e", ssh_opts]) args.extend(["-e", ssh_opts])
# source_path: Nexus 主机本地目录(生产环境为 Linux);保持原样供 rsync 读取
args.append(source_path.rstrip("/") + "/") args.append(source_path.rstrip("/") + "/")
args.append(rsync_remote.rstrip("/") + "/") args.append(rsync_remote.rstrip("/") + "/")
# Execute rsync on Nexus host
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*args, *args,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
@@ -33,17 +33,17 @@ async def probe_files_capability(server: Server) -> dict:
whitelist_probe = await exec_ssh_command( whitelist_probe = await exec_ssh_command(
server, 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, timeout=15,
) )
whitelist_ok = can_sudo_nopasswd and whitelist_probe.get("exit_code") == 0 whitelist_ok = can_sudo_nopasswd and whitelist_probe.get("exit_code") == 0
if can_sudo_nopasswd and whitelist_ok: if can_sudo_nopasswd and whitelist_ok:
message = "已配置免密 sudo,文件管理可自动提权" message = "已配置免密 sudo,文件管理与推送可自动提权"
elif can_sudo_nopasswd: elif can_sudo_nopasswd:
message = ( message = (
"可免密 sudo,但 chmod/chown 可能未列入白名单;" "可免密 sudo,但 chmod/chown/rsync 可能未列入白名单;"
"请参考仓库内 sudoers 示例补全" "文件推送至 www 目录会失败,请参考仓库内 sudoers 示例补全"
) )
else: else:
message = ( 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