提交 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
# ==================== 检测函数 ====================
# 检测 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服务状态
check_docker_service() {
local docker_status="未知"
# 首次调用时检测是否需要 sudo
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if command -v docker &> /dev/null; then
if docker ps &> /dev/null; then
if $DOCKER_CMD ps &> /dev/null; then
docker_status="运行中"
else
docker_status="无权限"
......@@ -63,7 +80,7 @@ check_container_status() {
# 获取所有容器
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
total_count=$(echo "$containers" | wc -l)
......@@ -87,7 +104,11 @@ check_single_container() {
local container_name=$1
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}_STATUS" "不存在"
return
......@@ -97,7 +118,7 @@ check_single_container() {
# 检查容器状态
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
output_result "CONTAINER_${container_key}_STATUS" "运行中"
......@@ -109,7 +130,7 @@ check_single_container() {
# 检查重启次数
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
output_result "CONTAINER_${container_key}_RESTART" "$restart_count"
......@@ -125,7 +146,7 @@ check_single_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
output_result "CONTAINER_${container_key}_IP" "$container_ip"
......@@ -133,7 +154,7 @@ check_single_container() {
# 获取端口映射
local ports
ports=$(docker port "$container_name" 2>/dev/null)
ports=$($DOCKER_CMD port "$container_name" 2>/dev/null)
if [ -n "$ports" ]; then
output_result "CONTAINER_${container_key}_PORTS" "$ports"
......@@ -142,13 +163,17 @@ check_single_container() {
# 检测容器资源使用
check_container_resources() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then
return
fi
# 获取运行中的容器资源使用情况
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
# 跳过标题行,处理每个容器
......@@ -180,6 +205,10 @@ check_container_resources() {
# 检测容器健康状态
check_container_health() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then
return
fi
......@@ -193,13 +222,13 @@ check_container_health() {
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
fi
# 检查健康状态
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
output_result "HEALTH_${container_name}" "$health_status"
......@@ -218,6 +247,10 @@ check_container_health() {
# 检测容器日志大小
check_container_logs() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then
return
fi
......@@ -225,10 +258,10 @@ check_container_logs() {
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
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
local size_bytes
......@@ -260,12 +293,16 @@ check_container_logs() {
# 检测Docker磁盘使用
check_docker_disk() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then
return
fi
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
# 解析磁盘使用情况
......@@ -289,12 +326,16 @@ check_docker_disk() {
# 检测Docker网络
check_docker_network() {
if [ -z "$DOCKER_CMD" ]; then
_detect_docker_sudo
fi
if ! command -v docker &> /dev/null; then
return
fi
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)) # 减去标题行
output_result "DOCKER_NETWORKS" "$network_count"
......
......@@ -87,6 +87,18 @@ def api_list_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'])
def api_create_target():
guard = _require_admin_json()
......
......@@ -103,6 +103,7 @@ def list_targets(role: str) -> list:
"port": t.get("port", 22),
"username": t.get("username", ""),
"has_password": bool(t.get("password_enc")),
"use_sudo": t.get("use_sudo", False), # 是否需要 sudo 提权
"built_in": t.get("built_in", False),
"container_overrides": t.get("container_overrides", {}),
"thresholds": t.get("thresholds", {}),
......@@ -148,6 +149,7 @@ def _build_view(t: dict) -> dict:
"port": t.get("port", 22),
"username": t.get("username", ""),
"has_password": bool(t.get("password_enc")),
"use_sudo": t.get("use_sudo", False), # 是否需要 sudo 提权
"built_in": t.get("built_in", False),
"container_overrides": t.get("container_overrides", {}),
"thresholds": t.get("thresholds", {}),
......@@ -201,6 +203,7 @@ def create_target(data: dict) -> dict:
"port": port,
"username": username,
"password_enc": encrypt_password(password),
"use_sudo": bool(data.get("use_sudo", False)), # 是否需要 sudo 提权
"container_overrides": _sanitize_overrides(data.get("container_overrides")),
"credential_overrides": _encrypt_creds(data.get("credential_overrides")),
"thresholds": _sanitize_thresholds(data.get("thresholds")),
......@@ -245,6 +248,9 @@ def update_target(target_id: str, data: dict) -> dict:
target["password_enc"] = encrypt_password(data["password"])
if "credential_overrides" in data:
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:
......@@ -354,6 +360,9 @@ def make_executor(target: dict, run_id: str):
password_enc = target.get("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 密码)
exe = SSHExecutor(
run_id=run_id,
......@@ -361,6 +370,7 @@ def make_executor(target: dict, run_id: str):
port=target.get("port", 22),
username=target["username"],
password=password,
use_sudo=use_sudo,
)
return exe
......
......@@ -5,7 +5,7 @@
<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-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-row { border-bottom: 1px solid var(--gray-100); font-size: 14px; }
.table-row:last-child { border-bottom: none; }
......@@ -74,7 +74,7 @@
<div class="table">
<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>
{% for t in targets %}
<div class="table-row">
......@@ -82,6 +82,7 @@
<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>{{ t.username or '-' }}</span>
<span>{{ '是' if t.use_sudo else '-' }}</span>
<div class="actions">
{% if t.type == 'remote' %}
<button class="btn-sm btn-edit" onclick="openEdit('{{ t.id }}')">编辑</button>
......@@ -120,6 +121,13 @@
<input type="password" id="f-password" placeholder="SSH 登录密码">
<div class="form-hint">密码将加密存储</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;">
<button class="btn-test" onclick="testConnection()">测试连接</button>
<div class="test-msg" id="test-msg"></div>
......@@ -144,14 +152,38 @@ function openCreate() {
document.getElementById('f-port').value = '22';
document.getElementById('f-username').value = '';
document.getElementById('f-password').value = '';
document.getElementById('f-use-sudo').checked = false;
document.getElementById('test-msg').textContent = '';
document.getElementById('modal').classList.add('show');
}
function openEdit(id) {
async function openEdit(id) {
editingId = id;
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');
}
......@@ -206,6 +238,7 @@ async function saveTarget() {
port: parseInt(document.getElementById('f-port').value) || 22,
username: document.getElementById('f-username').value,
password: document.getElementById('f-password').value,
use_sudo: document.getElementById('f-use-sudo').checked,
};
if (!data.name || !data.host || !data.username) {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论