141 lines
5.0 KiB
Python
141 lines
5.0 KiB
Python
|
|
"""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 与白名单路径",
|
|||
|
|
}
|