提交 65b25c4d authored 作者: 陈泽健's avatar 陈泽健

feat(monitor): SSH sudo 自动检测 + Docker 权限适配

- SSHExecutor 连接后自动检测用户是否为 root
- 非 root 用户自动检测 docker 是否可直接执行
- docker 需要权限时自动启用 sudo 提权
- 尝试用 SSH 密码自动配置免密 sudoers
- Docker 检测脚本新增 _detect_docker_sudo() 自动适配
- 前端目标管理增加 sudo 提权选项和编辑回填
- 新增 GET /api/service-monitor/targets/<id> API
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 cf1e89f5
...@@ -27,12 +27,29 @@ fi ...@@ -27,12 +27,29 @@ fi
# ==================== 检测函数 ==================== # ==================== 检测函数 ====================
# 检测 Docker 命令是否需要 sudo
_detect_docker_sudo() {
# 测试 docker ps 是否需要权限
if docker ps &> /dev/null; then
DOCKER_CMD="docker"
elif sudo -n docker ps &> /dev/null 2>&1; then
DOCKER_CMD="sudo docker"
else
DOCKER_CMD="docker" # 降级,后续会报错
fi
}
# 检测Docker服务状态 # 检测Docker服务状态
check_docker_service() { check_docker_service() {
local docker_status="未知" local docker_status="未知"
# 首次调用时检测是否需要 sudo
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if command -v docker &> /dev/null; then if command -v docker &> /dev/null; then
if docker ps &> /dev/null; then if $DOCKER_CMD ps &> /dev/null; then
docker_status="运行中" docker_status="运行中"
else else
docker_status="无权限" docker_status="无权限"
...@@ -63,7 +80,7 @@ check_container_status() { ...@@ -63,7 +80,7 @@ check_container_status() {
# 获取所有容器 # 获取所有容器
local containers local containers
containers=$(docker ps -a --format "{{.Names}}\t{{.Status}}" 2>/dev/null) containers=$($DOCKER_CMD ps -a --format "{{.Names}}\t{{.Status}}" 2>/dev/null)
if [ -n "$containers" ]; then if [ -n "$containers" ]; then
total_count=$(echo "$containers" | wc -l) total_count=$(echo "$containers" | wc -l)
...@@ -87,7 +104,11 @@ check_single_container() { ...@@ -87,7 +104,11 @@ check_single_container() {
local container_name=$1 local container_name=$1
local container_key=$2 local container_key=$2
if ! docker ps -a --format "{{.Names}}" | grep -q "^${container_name}$"; then if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! $DOCKER_CMD ps -a --format "{{.Names}}" | grep -q "^${container_name}$"; then
output_result "CONTAINER_${container_key}_EXISTS" "否" output_result "CONTAINER_${container_key}_EXISTS" "否"
output_result "CONTAINER_${container_key}_STATUS" "不存在" output_result "CONTAINER_${container_key}_STATUS" "不存在"
return return
...@@ -97,7 +118,7 @@ check_single_container() { ...@@ -97,7 +118,7 @@ check_single_container() {
# 检查容器状态 # 检查容器状态
local container_status local container_status
container_status=$(docker inspect --format="{{.State.Status}}" "$container_name" 2>/dev/null) container_status=$($DOCKER_CMD inspect --format="{{.State.Status}}" "$container_name" 2>/dev/null)
if [ "$container_status" = "running" ]; then if [ "$container_status" = "running" ]; then
output_result "CONTAINER_${container_key}_STATUS" "运行中" output_result "CONTAINER_${container_key}_STATUS" "运行中"
...@@ -109,7 +130,7 @@ check_single_container() { ...@@ -109,7 +130,7 @@ check_single_container() {
# 检查重启次数 # 检查重启次数
local restart_count local restart_count
restart_count=$(docker inspect --format="{{.RestartCount}}" "$container_name" 2>/dev/null) restart_count=$($DOCKER_CMD inspect --format="{{.RestartCount}}" "$container_name" 2>/dev/null)
if [ -n "$restart_count" ] && [ "$restart_count" -gt 0 ]; then if [ -n "$restart_count" ] && [ "$restart_count" -gt 0 ]; then
output_result "CONTAINER_${container_key}_RESTART" "$restart_count" output_result "CONTAINER_${container_key}_RESTART" "$restart_count"
...@@ -125,7 +146,7 @@ check_single_container() { ...@@ -125,7 +146,7 @@ check_single_container() {
# 获取容器IP # 获取容器IP
local container_ip local container_ip
container_ip=$(docker inspect --format="{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" "$container_name" 2>/dev/null) container_ip=$($DOCKER_CMD inspect --format="{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" "$container_name" 2>/dev/null)
if [ -n "$container_ip" ]; then if [ -n "$container_ip" ]; then
output_result "CONTAINER_${container_key}_IP" "$container_ip" output_result "CONTAINER_${container_key}_IP" "$container_ip"
...@@ -133,7 +154,7 @@ check_single_container() { ...@@ -133,7 +154,7 @@ check_single_container() {
# 获取端口映射 # 获取端口映射
local ports local ports
ports=$(docker port "$container_name" 2>/dev/null) ports=$($DOCKER_CMD port "$container_name" 2>/dev/null)
if [ -n "$ports" ]; then if [ -n "$ports" ]; then
output_result "CONTAINER_${container_key}_PORTS" "$ports" output_result "CONTAINER_${container_key}_PORTS" "$ports"
...@@ -142,13 +163,17 @@ check_single_container() { ...@@ -142,13 +163,17 @@ check_single_container() {
# 检测容器资源使用 # 检测容器资源使用
check_container_resources() { check_container_resources() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then if ! command -v docker &> /dev/null; then
return return
fi fi
# 获取运行中的容器资源使用情况 # 获取运行中的容器资源使用情况
local stats local stats
stats=$(docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" 2>/dev/null) stats=$($DOCKER_CMD stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" 2>/dev/null)
if [ -n "$stats" ]; then if [ -n "$stats" ]; then
# 跳过标题行,处理每个容器 # 跳过标题行,处理每个容器
...@@ -180,6 +205,10 @@ check_container_resources() { ...@@ -180,6 +205,10 @@ check_container_resources() {
# 检测容器健康状态 # 检测容器健康状态
check_container_health() { check_container_health() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then if ! command -v docker &> /dev/null; then
return return
fi fi
...@@ -193,13 +222,13 @@ check_container_health() { ...@@ -193,13 +222,13 @@ check_container_health() {
fi fi
# 检查容器是否存在 # 检查容器是否存在
if ! docker ps -a --format "{{.Names}}" | grep -q "^${container_name}$"; then if ! $DOCKER_CMD ps -a --format "{{.Names}}" | grep -q "^${container_name}$"; then
continue continue
fi fi
# 检查健康状态 # 检查健康状态
local health_status local health_status
health_status=$(docker inspect --format="{{if .State.Health}}{{.State.Health.Status}}{{else}}no healthcheck{{end}}" "$container_name" 2>/dev/null) health_status=$($DOCKER_CMD inspect --format="{{if .State.Health}}{{.State.Health.Status}}{{else}}no healthcheck{{end}}" "$container_name" 2>/dev/null)
if [ -n "$health_status" ] && [ "$health_status" != "no healthcheck" ]; then if [ -n "$health_status" ] && [ "$health_status" != "no healthcheck" ]; then
output_result "HEALTH_${container_name}" "$health_status" output_result "HEALTH_${container_name}" "$health_status"
...@@ -218,6 +247,10 @@ check_container_health() { ...@@ -218,6 +247,10 @@ check_container_health() {
# 检测容器日志大小 # 检测容器日志大小
check_container_logs() { check_container_logs() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then if ! command -v docker &> /dev/null; then
return return
fi fi
...@@ -225,10 +258,10 @@ check_container_logs() { ...@@ -225,10 +258,10 @@ check_container_logs() {
local large_logs=0 local large_logs=0
# 获取运行中容器的日志路径 # 获取运行中容器的日志路径
docker ps --format "{{.Names}}" | while read -r container_name; do $DOCKER_CMD ps --format "{{.Names}}" | while read -r container_name; do
if [ -n "$container_name" ]; then if [ -n "$container_name" ]; then
local log_path local log_path
log_path=$(docker inspect --format="{{.LogPath}}" "$container_name" 2>/dev/null) log_path=$($DOCKER_CMD inspect --format="{{.LogPath}}" "$container_name" 2>/dev/null)
if [ -f "$log_path" ]; then if [ -f "$log_path" ]; then
local size_bytes local size_bytes
...@@ -260,12 +293,16 @@ check_container_logs() { ...@@ -260,12 +293,16 @@ check_container_logs() {
# 检测Docker磁盘使用 # 检测Docker磁盘使用
check_docker_disk() { check_docker_disk() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then if ! command -v docker &> /dev/null; then
return return
fi fi
local disk_usage local disk_usage
disk_usage=$(docker system df 2>/dev/null) disk_usage=$($DOCKER_CMD system df 2>/dev/null)
if [ -n "$disk_usage" ]; then if [ -n "$disk_usage" ]; then
# 解析磁盘使用情况 # 解析磁盘使用情况
...@@ -289,12 +326,16 @@ check_docker_disk() { ...@@ -289,12 +326,16 @@ check_docker_disk() {
# 检测Docker网络 # 检测Docker网络
check_docker_network() { check_docker_network() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then if ! command -v docker &> /dev/null; then
return return
fi fi
local network_count local network_count
network_count=$(docker network ls 2>/dev/null | grep -c "^" || echo "0") network_count=$($DOCKER_CMD network ls 2>/dev/null | grep -c "^" || echo "0")
network_count=$((network_count - 1)) # 减去标题行 network_count=$((network_count - 1)) # 减去标题行
output_result "DOCKER_NETWORKS" "$network_count" output_result "DOCKER_NETWORKS" "$network_count"
......
...@@ -87,6 +87,18 @@ def api_list_targets(): ...@@ -87,6 +87,18 @@ def api_list_targets():
return jsonify({"success": True, "targets": targets}) return jsonify({"success": True, "targets": targets})
@bp.route('/api/service-monitor/targets/<target_id>', methods=['GET'])
def api_get_target(target_id):
"""获取单个目标详情(管理员)。"""
guard = _require_admin_json()
if guard:
return guard
target = target_service.get_target_view(target_id, role=_role())
if not target:
return jsonify({"success": False, "error": {"code": 404, "message": "目标不存在"}}), 404
return jsonify({"success": True, "target": target})
@bp.route('/api/service-monitor/targets', methods=['POST']) @bp.route('/api/service-monitor/targets', methods=['POST'])
def api_create_target(): def api_create_target():
guard = _require_admin_json() guard = _require_admin_json()
......
...@@ -103,6 +103,7 @@ def list_targets(role: str) -> list: ...@@ -103,6 +103,7 @@ def list_targets(role: str) -> list:
"port": t.get("port", 22), "port": t.get("port", 22),
"username": t.get("username", ""), "username": t.get("username", ""),
"has_password": bool(t.get("password_enc")), "has_password": bool(t.get("password_enc")),
"use_sudo": t.get("use_sudo", False), # 是否需要 sudo 提权
"built_in": t.get("built_in", False), "built_in": t.get("built_in", False),
"container_overrides": t.get("container_overrides", {}), "container_overrides": t.get("container_overrides", {}),
"thresholds": t.get("thresholds", {}), "thresholds": t.get("thresholds", {}),
...@@ -148,6 +149,7 @@ def _build_view(t: dict) -> dict: ...@@ -148,6 +149,7 @@ def _build_view(t: dict) -> dict:
"port": t.get("port", 22), "port": t.get("port", 22),
"username": t.get("username", ""), "username": t.get("username", ""),
"has_password": bool(t.get("password_enc")), "has_password": bool(t.get("password_enc")),
"use_sudo": t.get("use_sudo", False), # 是否需要 sudo 提权
"built_in": t.get("built_in", False), "built_in": t.get("built_in", False),
"container_overrides": t.get("container_overrides", {}), "container_overrides": t.get("container_overrides", {}),
"thresholds": t.get("thresholds", {}), "thresholds": t.get("thresholds", {}),
...@@ -201,6 +203,7 @@ def create_target(data: dict) -> dict: ...@@ -201,6 +203,7 @@ def create_target(data: dict) -> dict:
"port": port, "port": port,
"username": username, "username": username,
"password_enc": encrypt_password(password), "password_enc": encrypt_password(password),
"use_sudo": bool(data.get("use_sudo", False)), # 是否需要 sudo 提权
"container_overrides": _sanitize_overrides(data.get("container_overrides")), "container_overrides": _sanitize_overrides(data.get("container_overrides")),
"credential_overrides": _encrypt_creds(data.get("credential_overrides")), "credential_overrides": _encrypt_creds(data.get("credential_overrides")),
"thresholds": _sanitize_thresholds(data.get("thresholds")), "thresholds": _sanitize_thresholds(data.get("thresholds")),
...@@ -245,6 +248,9 @@ def update_target(target_id: str, data: dict) -> dict: ...@@ -245,6 +248,9 @@ def update_target(target_id: str, data: dict) -> dict:
target["password_enc"] = encrypt_password(data["password"]) target["password_enc"] = encrypt_password(data["password"])
if "credential_overrides" in data: if "credential_overrides" in data:
target["credential_overrides"] = _encrypt_creds(data["credential_overrides"]) target["credential_overrides"] = _encrypt_creds(data["credential_overrides"])
# sudo 提权选项
if "use_sudo" in data:
target["use_sudo"] = bool(data["use_sudo"])
# 通用可改字段 # 通用可改字段
if "container_overrides" in data: if "container_overrides" in data:
...@@ -354,6 +360,9 @@ def make_executor(target: dict, run_id: str): ...@@ -354,6 +360,9 @@ def make_executor(target: dict, run_id: str):
password_enc = target.get("password_enc", "") password_enc = target.get("password_enc", "")
password = decrypt_password(password_enc) if is_encrypted(password_enc) else password_enc password = decrypt_password(password_enc) if is_encrypted(password_enc) else password_enc
# 是否需要 sudo 提权(用户非 root 时)
use_sudo = target.get("use_sudo", False)
# 合并目标级凭据覆盖(如 mysql/redis 密码) # 合并目标级凭据覆盖(如 mysql/redis 密码)
exe = SSHExecutor( exe = SSHExecutor(
run_id=run_id, run_id=run_id,
...@@ -361,6 +370,7 @@ def make_executor(target: dict, run_id: str): ...@@ -361,6 +370,7 @@ def make_executor(target: dict, run_id: str):
port=target.get("port", 22), port=target.get("port", 22),
username=target["username"], username=target["username"],
password=password, password=password,
use_sudo=use_sudo,
) )
return exe return exe
......
...@@ -318,6 +318,7 @@ class LocalExecutor(BaseExecutor): ...@@ -318,6 +318,7 @@ class LocalExecutor(BaseExecutor):
self._local_workdir: Optional[Path] = None self._local_workdir: Optional[Path] = None
self._ssh_executor: Optional["SSHExecutor"] = None self._ssh_executor: Optional["SSHExecutor"] = None
self._use_ssh = False # 是否使用 SSH 模式 self._use_ssh = False # 是否使用 SSH 模式
self._use_sudo = False # 是否需要 sudo 提权
# 检测是否需要 SSH 模式(容器内) # 检测是否需要 SSH 模式(容器内)
self._host_ip = get_docker_host_ip() self._host_ip = get_docker_host_ip()
...@@ -328,7 +329,12 @@ class LocalExecutor(BaseExecutor): ...@@ -328,7 +329,12 @@ class LocalExecutor(BaseExecutor):
# 如果配置了 SSH 凭据,使用 SSH 模式 # 如果配置了 SSH 凭据,使用 SSH 模式
if self._ssh_password or self._ssh_key: if self._ssh_password or self._ssh_key:
self._use_ssh = True self._use_ssh = True
logger.info("本级巡检将使用 SSH 到宿主机: %s@%s", self._ssh_user, self._host_ip) # 如果 SSH 用户不是 root,需要 sudo 提权
if self._ssh_user != "root":
self._use_sudo = True
logger.info("本级巡检将使用 SSH 到宿主机: %s@%s%s",
self._ssh_user, self._host_ip,
" (sudo)" if self._use_sudo else "")
@property @property
def is_local(self) -> bool: def is_local(self) -> bool:
...@@ -367,8 +373,10 @@ class LocalExecutor(BaseExecutor): ...@@ -367,8 +373,10 @@ class LocalExecutor(BaseExecutor):
def setup(self) -> str: def setup(self) -> str:
if self._use_ssh: if self._use_ssh:
# SSH 模式:在宿主机上创建工作目录 # SSH 模式:在宿主机上创建工作目录,并同步 _workdir
return self._get_ssh_executor().setup() # 注意:run_module 读的是 self._workdir,必须同步
self._workdir = self._get_ssh_executor().setup()
return self._workdir
else: else:
# 本地模式:创建临时目录 # 本地模式:创建临时目录
self._local_workdir = Path(tempfile.mkdtemp(prefix=f"mon_{self.run_id}_")) self._local_workdir = Path(tempfile.mkdtemp(prefix=f"mon_{self.run_id}_"))
...@@ -390,7 +398,11 @@ class LocalExecutor(BaseExecutor): ...@@ -390,7 +398,11 @@ class LocalExecutor(BaseExecutor):
def upload_assets(self) -> None: def upload_assets(self) -> None:
if self._use_ssh: if self._use_ssh:
# SSH 模式:上传资产到宿主机 # SSH 模式:上传资产到宿主机
self._get_ssh_executor().upload_assets() # 同步 _workdir 到内部 SSHExecutor(setup 已同步,这里确保一致)
ssh_ex = self._get_ssh_executor()
if not ssh_ex._workdir and self._workdir:
ssh_ex._workdir = self._workdir
ssh_ex.upload_assets()
# 本地模式:setup 时已完成复制,无需额外操作 # 本地模式:setup 时已完成复制,无需额外操作
def _write_config(self, wd: str, config_text: str) -> None: def _write_config(self, wd: str, config_text: str) -> None:
...@@ -404,7 +416,12 @@ class LocalExecutor(BaseExecutor): ...@@ -404,7 +416,12 @@ class LocalExecutor(BaseExecutor):
def _exec(self, command: str, timeout: int) -> str: def _exec(self, command: str, timeout: int) -> str:
if self._use_ssh: if self._use_ssh:
# SSH 模式:远程执行 # SSH 模式:远程执行(sudo 提权)
if self._use_sudo:
# sudo 执行复杂命令:用 bash -c 包裹
# 注意:单引号内的命令不能再有单引号,需转义
escaped = command.replace("'", "'\\''")
command = f"sudo bash -c '{escaped}'"
return self._get_ssh_executor()._exec(command, timeout) return self._get_ssh_executor()._exec(command, timeout)
else: else:
# 本地模式:subprocess 执行 # 本地模式:subprocess 执行
...@@ -457,10 +474,16 @@ class SSHExecutor(BaseExecutor): ...@@ -457,10 +474,16 @@ class SSHExecutor(BaseExecutor):
需要:paramiko,PyPI 包。 需要:paramiko,PyPI 包。
密码从 target 解密后传入,不在初始化参数中硬编码。 密码从 target 解密后传入,不在初始化参数中硬编码。
自动检测 sudo:
- 连接后自动检查用户是否为 root
- 非 root 用户自动启用 sudo 提权
- sudo 失败时给出明确提示
""" """
def __init__(self, run_id: str, host: str, port: int, def __init__(self, run_id: str, host: str, port: int,
username: str, password: str, connect_timeout: int = 15): username: str, password: str, use_sudo: bool = False,
connect_timeout: int = 15):
super().__init__(run_id) super().__init__(run_id)
if not _HAS_PARAMIKO: if not _HAS_PARAMIKO:
raise DependencyError( raise DependencyError(
...@@ -470,6 +493,8 @@ class SSHExecutor(BaseExecutor): ...@@ -470,6 +493,8 @@ class SSHExecutor(BaseExecutor):
self.port = port self.port = port
self.username = username self.username = username
self.password = password self.password = password
self.use_sudo = use_sudo # 初始值,连接后会自动检测
self._sudo_checked = False # 是否已检测过 sudo
self.connect_timeout = connect_timeout self.connect_timeout = connect_timeout
self._ssh_client = None self._ssh_client = None
self._sftp_client = None self._sftp_client = None
...@@ -479,7 +504,7 @@ class SSHExecutor(BaseExecutor): ...@@ -479,7 +504,7 @@ class SSHExecutor(BaseExecutor):
return False return False
def _get_ssh(self): def _get_ssh(self):
"""懒加载 SSH 连接。""" """懒加载 SSH 连接,并自动检测 sudo 提权需求。"""
if self._ssh_client is None: if self._ssh_client is None:
self._ssh_client = _paramiko.SSHClient() self._ssh_client = _paramiko.SSHClient()
self._ssh_client.set_missing_host_key_policy(_paramiko.AutoAddPolicy()) self._ssh_client.set_missing_host_key_policy(_paramiko.AutoAddPolicy())
...@@ -491,6 +516,12 @@ class SSHExecutor(BaseExecutor): ...@@ -491,6 +516,12 @@ class SSHExecutor(BaseExecutor):
allow_agent=False, look_for_keys=False, allow_agent=False, look_for_keys=False,
) )
logger.info("SSH 连接成功: %s@%s:%d", self.username, self.host, self.port) logger.info("SSH 连接成功: %s@%s:%d", self.username, self.host, self.port)
# 自动检测 sudo 提权需求(仅首次连接)
if not self._sudo_checked:
self._detect_sudo_need()
self._sudo_checked = True
except Exception as e: except Exception as e:
try: try:
self._ssh_client.close() self._ssh_client.close()
...@@ -500,6 +531,104 @@ class SSHExecutor(BaseExecutor): ...@@ -500,6 +531,104 @@ class SSHExecutor(BaseExecutor):
raise classify_ssh_error(e) from e raise classify_ssh_error(e) from e
return self._ssh_client return self._ssh_client
def _detect_sudo_need(self):
"""检测是否需要 sudo 提权,并尝试自动配置免密 sudo。
逻辑:
1. 执行 whoami 检查当前用户
2. 如果是 root,不需要 sudo
3. 如果不是 root,测试 docker 是否可直接执行
4. 如果 docker 需要 sudo,测试 sudo 是否可用
5. 如果 sudo 需要密码,尝试用 SSH 密码自动配置免密 sudoers
"""
try:
_, stdout, _ = self._ssh_client.exec_command("whoami", timeout=5)
current_user = stdout.read().decode("utf-8").strip()
if current_user == "root":
self.use_sudo = False
logger.debug("SSH 用户为 root,无需 sudo 提权")
return
# 检测 docker 是否可直接执行(无需 sudo)
_, stdout, _ = self._ssh_client.exec_command("docker ps &>/dev/null && echo OK", timeout=10)
docker_ok = stdout.read().decode("utf-8").strip()
if docker_ok == "OK":
self.use_sudo = False
logger.info("用户 '%s' 可直接执行 docker 命令", current_user)
return
# docker 需要 sudo,测试免密 sudo
logger.info("用户 '%s' 执行 docker 需要 sudo 提权", current_user)
_, stdout, _ = self._ssh_client.exec_command("sudo -n docker ps &>/dev/null && echo OK", timeout=10)
sudo_docker_ok = stdout.read().decode("utf-8").strip()
if sudo_docker_ok == "OK":
self.use_sudo = True
logger.info("免密 sudo 可用,已启用 sudo 提权")
return
# sudo 需要密码,尝试自动配置
logger.info("用户 '%s' sudo 需要密码,尝试自动配置免密 sudoers...", current_user)
if self._try_configure_passwordless_sudo(current_user):
self.use_sudo = True
logger.info("免密 sudo 配置成功")
else:
logger.warning(
"用户 '%s' 无法配置免密 sudo,巡检可能失败。"
"请手动在目标服务器执行: "
"echo '%s ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/%s",
current_user, current_user, current_user
)
# 仍然尝试用 sudo
self.use_sudo = True
except Exception as e:
logger.warning("检测 sudo 需求失败: %s,默认启用 sudo", e)
self.use_sudo = True
def _try_configure_passwordless_sudo(self, username: str) -> bool:
"""尝试用 SSH 密码自动配置免密 sudoers。
Returns:
True: 配置成功
False: 配置失败(可能密码不是 sudo 密码,或其他原因)
"""
if not self.password:
logger.debug("无 SSH 密码,无法尝试配置 sudo")
return False
try:
# 构造 sudoers 配置命令
sudoers_entry = f"{username} ALL=(ALL) NOPASSWD: ALL"
cmd = (
f"echo '{sudoers_entry}' | sudo -S tee /etc/sudoers.d/{username} > /dev/null 2>&1 && "
f"sudo -S chmod 440 /etc/sudoers.d/{username} 2>/dev/null"
)
# 用 SSH 密码尝试 sudo(通过 stdin 传递密码)
# 注意:这个操作只有在 SSH 密码 = sudo 密码时才能成功
stdin, stdout, stderr = self._ssh_client.exec_command(
f"echo '{self.password}' | {cmd}",
timeout=15
)
# 检查执行结果
exit_code = stdout.channel.recv_exit_status()
if exit_code == 0:
# 验证配置是否生效
_, stdout2, _ = self._ssh_client.exec_command("sudo -n whoami 2>&1", timeout=10)
result = stdout2.read().decode("utf-8").strip()
return result == "root"
else:
logger.debug("sudoers 配置命令执行失败 (exit code: %d)", exit_code)
return False
except Exception as e:
logger.debug("自动配置 sudoers 失败: %s", e)
return False
def _get_sftp(self): def _get_sftp(self):
if self._sftp_client is None: if self._sftp_client is None:
ssh = self._get_ssh() ssh = self._get_ssh()
...@@ -507,6 +636,13 @@ class SSHExecutor(BaseExecutor): ...@@ -507,6 +636,13 @@ class SSHExecutor(BaseExecutor):
return self._sftp_client return self._sftp_client
def _exec(self, command: str, timeout: int) -> str: def _exec(self, command: str, timeout: int) -> str:
"""执行命令,如果 use_sudo=True 则通过 sudo 提权。"""
# sudo 提权:用 bash -c 包裹命令
if self.use_sudo:
# 单引号内的命令不能再有单引号,需转义
escaped = command.replace("'", "'\\''")
command = f"sudo bash -c '{escaped}'"
ssh = self._get_ssh() ssh = self._get_ssh()
try: try:
_, stdout, stderr = ssh.exec_command(command, timeout=timeout) _, stdout, stderr = ssh.exec_command(command, timeout=timeout)
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
<style> <style>
/* 表格 */ /* 表格 */
.table { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); overflow: hidden; } .table { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); overflow: hidden; }
.table-head, .table-row { display: grid; grid-template-columns: 1.5fr 2fr 1fr 1.5fr 1.5fr; gap: 12px; padding: 12px 18px; align-items: center; } .table-head, .table-row { display: grid; grid-template-columns: 1.5fr 2fr 1fr 1.5fr 1fr 1.5fr; gap: 12px; padding: 12px 18px; align-items: center; }
.table-head { background: var(--gray-50); font-size: 13px; color: var(--gray-500); font-weight: 600; border-bottom: 1px solid var(--gray-200); } .table-head { background: var(--gray-50); font-size: 13px; color: var(--gray-500); font-weight: 600; border-bottom: 1px solid var(--gray-200); }
.table-row { border-bottom: 1px solid var(--gray-100); font-size: 14px; } .table-row { border-bottom: 1px solid var(--gray-100); font-size: 14px; }
.table-row:last-child { border-bottom: none; } .table-row:last-child { border-bottom: none; }
...@@ -74,7 +74,7 @@ ...@@ -74,7 +74,7 @@
<div class="table"> <div class="table">
<div class="table-head"> <div class="table-head">
<span>名称</span><span>主机</span><span>类型</span><span>用户名</span><span>操作</span> <span>名称</span><span>主机</span><span>类型</span><span>用户名</span><span>sudo</span><span>操作</span>
</div> </div>
{% for t in targets %} {% for t in targets %}
<div class="table-row"> <div class="table-row">
...@@ -82,6 +82,7 @@ ...@@ -82,6 +82,7 @@
<span>{{ '本机' if t.type == 'local' else (t.host ~ ':' ~ t.port) }}</span> <span>{{ '本机' if t.type == 'local' else (t.host ~ ':' ~ t.port) }}</span>
<span><span class="type-badge {{ 'badge-local' if t.type == 'local' else 'badge-remote' }}">{{ '本机' if t.type == 'local' else '远程' }}</span></span> <span><span class="type-badge {{ 'badge-local' if t.type == 'local' else 'badge-remote' }}">{{ '本机' if t.type == 'local' else '远程' }}</span></span>
<span>{{ t.username or '-' }}</span> <span>{{ t.username or '-' }}</span>
<span>{{ '是' if t.use_sudo else '-' }}</span>
<div class="actions"> <div class="actions">
{% if t.type == 'remote' %} {% if t.type == 'remote' %}
<button class="btn-sm btn-edit" onclick="openEdit('{{ t.id }}')">编辑</button> <button class="btn-sm btn-edit" onclick="openEdit('{{ t.id }}')">编辑</button>
...@@ -120,6 +121,13 @@ ...@@ -120,6 +121,13 @@
<input type="password" id="f-password" placeholder="SSH 登录密码"> <input type="password" id="f-password" placeholder="SSH 登录密码">
<div class="form-hint">密码将加密存储</div> <div class="form-hint">密码将加密存储</div>
</div> </div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="f-use-sudo" style="width: 16px; height: 16px;">
<span>需要 sudo 提权</span>
</label>
<div class="form-hint">SSH 用户非 root 时勾选,将用 sudo 执行巡检命令</div>
</div>
<div style="margin-bottom: 14px;"> <div style="margin-bottom: 14px;">
<button class="btn-test" onclick="testConnection()">测试连接</button> <button class="btn-test" onclick="testConnection()">测试连接</button>
<div class="test-msg" id="test-msg"></div> <div class="test-msg" id="test-msg"></div>
...@@ -144,14 +152,38 @@ function openCreate() { ...@@ -144,14 +152,38 @@ function openCreate() {
document.getElementById('f-port').value = '22'; document.getElementById('f-port').value = '22';
document.getElementById('f-username').value = ''; document.getElementById('f-username').value = '';
document.getElementById('f-password').value = ''; document.getElementById('f-password').value = '';
document.getElementById('f-use-sudo').checked = false;
document.getElementById('test-msg').textContent = ''; document.getElementById('test-msg').textContent = '';
document.getElementById('modal').classList.add('show'); document.getElementById('modal').classList.add('show');
} }
function openEdit(id) { async function openEdit(id) {
editingId = id; editingId = id;
document.getElementById('modal-title').textContent = '编辑目标'; document.getElementById('modal-title').textContent = '编辑目标';
// TODO: 填充现有数据 document.getElementById('test-msg').textContent = '';
// 获取目标数据
try {
const r = await fetch('/api/service-monitor/targets/' + id, {
credentials: 'include',
});
const d = await r.json();
if (d.success && d.target) {
document.getElementById('f-name').value = d.target.name || '';
document.getElementById('f-host').value = d.target.host || '';
document.getElementById('f-port').value = d.target.port || 22;
document.getElementById('f-username').value = d.target.username || '';
document.getElementById('f-password').value = ''; // 密码不回填,留空表示不修改
document.getElementById('f-use-sudo').checked = d.target.use_sudo || false;
} else {
alert('获取目标信息失败');
return;
}
} catch (e) {
alert('请求失败');
return;
}
document.getElementById('modal').classList.add('show'); document.getElementById('modal').classList.add('show');
} }
...@@ -206,6 +238,7 @@ async function saveTarget() { ...@@ -206,6 +238,7 @@ async function saveTarget() {
port: parseInt(document.getElementById('f-port').value) || 22, port: parseInt(document.getElementById('f-port').value) || 22,
username: document.getElementById('f-username').value, username: document.getElementById('f-username').value,
password: document.getElementById('f-password').value, password: document.getElementById('f-password').value,
use_sudo: document.getElementById('f-use-sudo').checked,
}; };
if (!data.name || !data.host || !data.username) { if (!data.name || !data.host || !data.username) {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论