fix(sync): rsync 推送非 root 自动 sudo 提权
文件推送与文件管理对齐:非 root 默认 auto_sudo 时使用 sudo -n rsync; 新增批量 sudoers 补丁脚本以补全 rsync 白名单。
This commit is contained in:
@@ -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)。
|
||||
@@ -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 可写站点目录。
|
||||
@@ -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())
|
||||
@@ -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
@@ -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 自动提权。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 与白名单路径",
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user