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, \
|
/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 可写站点目录。
|
||||||
@@ -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),
|
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 自动提权。"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 与白名单路径",
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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