diff --git a/docs/audit/2026-06-12-psutil-install-sudo-fix.md b/docs/audit/2026-06-12-psutil-install-sudo-fix.md new file mode 100644 index 00000000..66c15930 --- /dev/null +++ b/docs/audit/2026-06-12-psutil-install-sudo-fix.md @@ -0,0 +1,17 @@ +# 审计 — psutil 安装 apt sudo 修复 + +**日期**:2026-06-12 +**Changelog**: `docs/changelog/2026-06-12-psutil-install-sudo-fix.md` + +## 范围 + +| 文件 | +|------| +| `server/utils/psutil_install.py` | +| `server/application/server_batch_common.py` | +| `tests/test_psutil_install.py` | + +## Step 3 / Closure / DoD + +- H1 PASS — 行为修复,无新 API +- DoD: pytest test_psutil_install 5 passed diff --git a/docs/changelog/2026-06-12-psutil-install-sudo-fix.md b/docs/changelog/2026-06-12-psutil-install-sudo-fix.md new file mode 100644 index 00000000..4642966a --- /dev/null +++ b/docs/changelog/2026-06-12-psutil-install-sudo-fix.md @@ -0,0 +1,19 @@ +# Changelog — psutil SSH 安装 apt sudo 修复 + +**日期**:2026-06-12 + +## 摘要 + +修复非 root SSH 用户点击「安装 psutil(SSH)」时 `apt-get` 无 sudo 导致 Permission denied。 + +## 变更 + +- `psutil_install` 脚本增加 `run_root()`:`apt/dnf/yum` 与系统 pip 在非 root 时走 `sudo` +- `sudo_wrap` sudoers 补充 `python3` / `pip3` 路径 +- 安装失败时返回中文提示(apt lock / 需免密 sudo) + +## 验证 + +```bash +.venv/bin/pytest tests/test_psutil_install.py -q +``` diff --git a/server/application/server_batch_common.py b/server/application/server_batch_common.py index aaee8d9e..c39039d7 100644 --- a/server/application/server_batch_common.py +++ b/server/application/server_batch_common.py @@ -45,6 +45,8 @@ def sudo_wrap(cmd: str, ssh_user: str) -> str: f"{ssh_user} ALL=(ALL) NOPASSWD: " "/usr/bin/systemctl, " "/usr/bin/apt-get, /usr/bin/yum, /usr/bin/dnf, /usr/bin/apk, " + "/usr/bin/python3, /usr/bin/python3.10, /usr/bin/python3.11, /usr/bin/python3.12, " + "/usr/bin/pip3, " "/usr/bin/rm, /usr/bin/mkdir, /usr/bin/cp, /usr/bin/tee, " "/usr/bin/curl, /usr/bin/fuser, /usr/bin/kill, /usr/bin/pkill, " f"{shlex.quote('/opt/nexus-agent/.venv/bin/pip')}*" diff --git a/server/utils/psutil_install.py b/server/utils/psutil_install.py index 41a858e1..ee89ebaa 100644 --- a/server/utils/psutil_install.py +++ b/server/utils/psutil_install.py @@ -2,13 +2,22 @@ from __future__ import annotations +import re + from server.application.server_batch_common import sudo_wrap from server.domain.models import Server from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command -# Try --user pip, then distro packages, then system pip. Verify with same python3 as watch probe. +# pip --user first (no root); package managers and system pip via run_root (sudo when non-root). PSUTIL_INSTALL_SHELL = r""" -set -e +run_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} + verify_psutil() { for py in python3 python3.12 python3.11 python3.10; do if command -v "$py" >/dev/null 2>&1 && "$py" -c "import psutil" 2>/dev/null; then @@ -18,7 +27,8 @@ verify_psutil() { done return 1 } -verify_psutil || true + +verify_psutil && exit 0 for py in python3 python3.12 python3.11 python3.10; do command -v "$py" >/dev/null 2>&1 || continue @@ -28,19 +38,25 @@ for py in python3 python3.12 python3.11 python3.10; do done if command -v apt-get >/dev/null 2>&1; then - apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-psutil \ - && verify_psutil && exit 0 + if run_root apt-get update -qq \ + && run_root env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-psutil; then + verify_psutil && exit 0 + fi fi if command -v dnf >/dev/null 2>&1; then - dnf install -y -q python3-psutil && verify_psutil && exit 0 + if run_root dnf install -y -q python3-psutil; then + verify_psutil && exit 0 + fi fi if command -v yum >/dev/null 2>&1; then - yum install -y -q python3-psutil && verify_psutil && exit 0 + if run_root yum install -y -q python3-psutil; then + verify_psutil && exit 0 + fi fi for py in python3 python3.12 python3.11 python3.10; do command -v "$py" >/dev/null 2>&1 || continue - if "$py" -m pip install -q psutil 2>/dev/null; then + if run_root "$py" -m pip install -q psutil 2>/dev/null; then verify_psutil && exit 0 fi done @@ -49,13 +65,40 @@ echo "ERROR: psutil install failed — pip/apt unavailable or permission denied" exit 1 """ +_APT_PERM_RE = re.compile( + r"lock file|permission denied|unable to lock|not allowed to execute", + re.I, +) + + +def _psutil_install_error_zh(stderr: str, stdout: str) -> str: + text = f"{stderr}\n{stdout}" + if _APT_PERM_RE.search(text): + return ( + "安装 psutil 需要 root 或免密 sudo(apt/pip)。" + "请改用 root SSH,或在子机配置免密 sudo 后重试;也可手动执行:" + "sudo apt-get install -y python3-psutil" + ) + if "sudo requires a password" in text.lower(): + return "sudo 需要密码,请配置免密 sudo 或使用 root SSH 登录" + line = "" + for chunk in (stderr, stdout): + for raw in reversed(chunk.splitlines()): + raw = raw.strip() + if raw and not raw.startswith("W:"): + line = raw[:300] + break + if line: + break + return line or "psutil 安装失败" + def build_psutil_install_cmd(*, ssh_user: str) -> str: return sudo_wrap(PSUTIL_INSTALL_SHELL.strip(), ssh_user) -async def install_psutil_via_ssh(server: Server, *, timeout: int = 90) -> dict[str, str | int]: - """Run remote install; returns {exit_code, stdout, stderr, already_installed?}.""" +async def install_psutil_via_ssh(server: Server, *, timeout: int = 120) -> dict[str, str | int | bool]: + """Run remote install; returns success, message, stdout/stderr.""" ssh_user = (server.username or "root").strip() cmd = build_psutil_install_cmd(ssh_user=ssh_user) result = await exec_ssh_command(server, cmd, timeout=timeout) @@ -71,11 +114,10 @@ async def install_psutil_via_ssh(server: Server, *, timeout: int = 90) -> dict[s "stderr": stderr[:500], "message": "psutil 已就绪", } - detail = stderr or stdout or f"exit {exit_code}" return { "success": False, "exit_code": exit_code, "stdout": stdout[:2000], "stderr": stderr[:500], - "message": detail[:300], + "message": _psutil_install_error_zh(stderr, stdout), } diff --git a/tests/test_psutil_install.py b/tests/test_psutil_install.py index a36ae0ab..f733df17 100644 --- a/tests/test_psutil_install.py +++ b/tests/test_psutil_install.py @@ -9,15 +9,17 @@ from server.utils.psutil_install import build_psutil_install_cmd, install_psutil def test_build_psutil_install_cmd_root(): cmd = build_psutil_install_cmd(ssh_user="root") + assert "run_root" in cmd assert "pip install --user -q psutil" in cmd assert "python3-psutil" in cmd - assert "verify_psutil" in cmd + assert "run_root apt-get" in cmd def test_build_psutil_install_cmd_non_root_wraps_sudo(): cmd = build_psutil_install_cmd(ssh_user="deploy") assert "sudoers.d/nexus-agent" in cmd - assert "pip install --user -q psutil" in cmd + assert "run_root apt-get" in cmd + assert 'sudo "$@"' in cmd @pytest.mark.asyncio @@ -43,4 +45,14 @@ async def test_install_psutil_via_ssh_failure(): ): result = await install_psutil_via_ssh(server) assert result["success"] is False - assert "permission denied" in result["message"] + assert result["message"] + + +def test_psutil_install_error_zh_apt_lock(): + from server.utils.psutil_install import _psutil_install_error_zh + + msg = _psutil_install_error_zh( + "E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied)", + "", + ) + assert "免密 sudo" in msg or "root" in msg