fix(sync): rsync 推送后设置远程属主 www 与权限 755

推送默认 --chown=www:www --chmod=D755,F755,避免文件落盘为 root。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-08 02:23:43 +08:00
parent 5ed3c28491
commit f12a0b6a36
4 changed files with 91 additions and 0 deletions
@@ -0,0 +1,33 @@
# Changelog — 2026-06-07 推送文件默认 www:755
## 摘要
文件推送(rsync)后远程文件保留 SSH 用户(多为 root)属主与源权限,导致站点目录下文件为 root,Web 进程(www)无法读写。
## 动机
用户反馈:「推送文件权限问题,推送过去的文件应该为 www 755 不是 root」。
## 变更
- `server/config.py`:新增 `NEXUS_RSYNC_PUSH_CHOWN`(默认 `www:www`)、`NEXUS_RSYNC_PUSH_CHMOD`(默认 `D755,F755`
- `sync_engine_v2._rsync_push`:非 dry-run 时附加 `--chown` / `--chmod`(需对端 rsync 以 root 接收,与宝塔推送习惯一致)
- `tests/test_rsync_push_permissions.py`:默认值与非法 chown 拒绝
## 涉及文件
- `server/config.py`
- `server/application/services/sync_engine_v2.py`
- `tests/test_rsync_push_permissions.py`
## 迁移 / 重启
- 需重启 API / 重建 Docker 镜像
- 无 DB 迁移;可通过 `.env` 置空 `NEXUS_RSYNC_PUSH_CHOWN=` 关闭
## 验证
```bash
. venv/bin/activate && pytest tests/test_rsync_push_permissions.py -q
# 推送后子机: ls -la <target_path> 应为 www www,目录/文件 755
```
@@ -38,6 +38,33 @@ logger = logging.getLogger("nexus.sync_v2")
MAX_CONCURRENT = 10
RSYNC_TIMEOUT = 300
# rsync --chown / --chmod value validation (prevent shell injection via .env)
_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_args() -> list[str]:
"""Extra rsync flags: set remote owner/mode after push (default www:www, 755).
Requires SSH/rsync receiver to run as root (typical Baota push). Empty env disables.
"""
from server.config import settings
args: list[str] = []
chown = (settings.RSYNC_PUSH_CHOWN or "").strip()
chmod = (settings.RSYNC_PUSH_CHMOD or "").strip()
if chown:
if _RSYNC_CHOWN_RE.fullmatch(chown):
args.extend(["--chown", chown])
else:
logger.warning("Ignoring invalid NEXUS_RSYNC_PUSH_CHOWN: %r", chown)
if chmod:
if _RSYNC_CHMOD_RE.fullmatch(chmod):
args.extend(["--chmod", chmod])
else:
logger.warning("Ignoring invalid NEXUS_RSYNC_PUSH_CHMOD: %r", chmod)
return args
class SyncEngineV2:
"""Unified sync engine: rsync push from Nexus + preview + ZIP upload"""
@@ -436,6 +463,9 @@ async def _rsync_push(
if verbose:
args.append("-v")
if not dry_run:
args.extend(rsync_push_permission_args())
# SSH options: port + no strict host key checking (matches asyncssh pool behavior)
ssh_opts = f"ssh -o StrictHostKeyChecking=no -p {ssh_port}"
+4
View File
@@ -68,6 +68,10 @@ class Settings(BaseSettings):
SCRIPT_EXEC_BATCH_SIZE: int = 50
SCRIPT_EXEC_CONCURRENCY: int = 10 # max parallel SSH exec calls per batch
# Rsync push — remote owner/mode after file sync (Baota/1Panel www site convention)
RSYNC_PUSH_CHOWN: str = "www:www"
RSYNC_PUSH_CHMOD: str = "D755,F755"
# Alert thresholds (mutable — configurable from settings UI)
CPU_ALERT_THRESHOLD: int = 80
MEM_ALERT_THRESHOLD: int = 80
+24
View File
@@ -0,0 +1,24 @@
"""Tests for rsync push permission flags."""
from server.application.services.sync_engine_v2 import rsync_push_permission_args
from server.config import settings
def test_rsync_push_permission_defaults():
args = rsync_push_permission_args()
assert "--chown" in args
assert "www:www" in args
assert "--chmod" in args
assert "D755,F755" in args
def test_rsync_push_permission_disabled(monkeypatch):
monkeypatch.setattr(settings, "RSYNC_PUSH_CHOWN", "")
monkeypatch.setattr(settings, "RSYNC_PUSH_CHMOD", "")
assert rsync_push_permission_args() == []
def test_rsync_push_permission_rejects_invalid_chown(monkeypatch):
monkeypatch.setattr(settings, "RSYNC_PUSH_CHOWN", "www;rm -rf /")
monkeypatch.setattr(settings, "RSYNC_PUSH_CHMOD", "")
assert rsync_push_permission_args() == []