diff --git a/alert_dashboard/docker-compose.yml b/alert_dashboard/docker-compose.yml
index 6024926..925f088 100644
--- a/alert_dashboard/docker-compose.yml
+++ b/alert_dashboard/docker-compose.yml
@@ -4,17 +4,19 @@ services:
volumes:
- ./www:/usr/share/nginx/html
- ./www/php-fpm/www.conf:/usr/local/etc/php-fpm.d/www.conf
- command: >
- sh -c "apk add --no-cache $PHPIZE_DEPS && docker-php-ext-install mysqli pdo_mysql && apk del $PHPIZE_DEPS && php-fpm"
+ - /var/run/docker.sock:/var/run/docker.sock
+ group_add:
+ - "994"
+ command: sh -c "apk add --no-cache docker-cli; addgroup -g 994 docker; addgroup www-data docker; docker-php-ext-install mysqli pdo_mysql; exec php-fpm"
nginx:
image: nginx:stable-alpine
ports:
- "8080:80"
volumes:
- - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
+ - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
+ - /var/run/docker.sock:/var/run/docker.sock
- ./www:/usr/share/nginx/html
depends_on:
- php
- # 使用nc探测tcp端口,不再用wget(wget不兼容fastcgi)
command: ["/bin/sh", "-c", "until nc -z php 9000; do echo '等待php-fpm端口就绪...'; sleep 1; done; nginx -g 'daemon off;'"]
diff --git a/alert_dashboard/docker-compose.yml.4 b/alert_dashboard/docker-compose.yml.4
new file mode 100644
index 0000000..bb1c1d8
--- /dev/null
+++ b/alert_dashboard/docker-compose.yml.4
@@ -0,0 +1,24 @@
+services:
+ php:
+ image: php:7.4-fpm-alpine
+ volumes:
+ - ./www:/usr/share/nginx/html
+ - ./www/php-fpm/www.conf:/usr/local/etc/php-fpm.d/www.conf
+ - /var/run/docker.sock:/var/run/docker.sock
+ group_add:
+ - "994"
+ command: >
+ sh -c "apk add --no-cache $PHPIZE_DEPS && docker-php-ext-install mysqli pdo_mysql && apk del $PHPIZE_DEPS && php-fpm"
+
+ nginx:
+ image: nginx:stable-alpine
+ ports:
+ - "8080:80"
+ volumes:
+ - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ./www:/usr/share/nginx/html
+ depends_on:
+ - php
+ # 使用nc探测tcp端口,不再用wget(wget不兼容fastcgi)
+ command: ["/bin/sh", "-c", "until nc -z php 9000; do echo '等待php-fpm端口就绪...'; sleep 1; done; nginx -g 'daemon off;'"]
diff --git a/alert_dashboard/webhook.log b/alert_dashboard/webhook.log
index 36c4f15..3dd535a 100644
--- a/alert_dashboard/webhook.log
+++ b/alert_dashboard/webhook.log
@@ -203,3 +203,91 @@ nohup: ignoring input
10.150.117.190 - - [07/Jul/2026 11:46:03] "[37mPOST /alert HTTP/1.1[0m" 200 -
10.150.117.190 - - [07/Jul/2026 12:12:58] "[37mPOST /alert HTTP/1.1[0m" 200 -
10.150.117.190 - - [07/Jul/2026 12:13:48] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 13:37:13] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 13:42:58] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 13:43:23] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 13:45:58] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 13:46:48] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 13:54:28] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 13:54:38] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:20:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:21:23] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:25:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:25:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:25:53] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:27:58] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:28:38] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:28:53] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:40:58] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:43:38] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:45:49] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 14:45:49] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [07/Jul/2026 17:32:51] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 09:56:28] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 09:57:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 09:59:18] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 09:59:23] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:08:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:08:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:09:03] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:09:28] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:10:23] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:10:38] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:15:28] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:17:38] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:18:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 10:19:03] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 11:11:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 11:19:53] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 11:26:28] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 11:26:38] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:25:58] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:26:18] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:35:13] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:36:03] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:40:43] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:42:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:21] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:36] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 13:52:36] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:09:28] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:09:48] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:02] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:02] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:02] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:02] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:12] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:12] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:12] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:12] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:12] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:12] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 14:15:17] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:33] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:18:48] "[37mPOST /alert HTTP/1.1[0m" 200 -
+10.150.117.190 - - [08/Jul/2026 15:19:03] "[37mPOST /alert HTTP/1.1[0m" 200 -
diff --git a/alert_dashboard/www/alert_list.php b/alert_dashboard/www/alert_list.php
index 408918a..94f2785 100644
--- a/alert_dashboard/www/alert_list.php
+++ b/alert_dashboard/www/alert_list.php
@@ -2,6 +2,7 @@
session_start();
header("Cache-Control: no-store, no-cache, must-revalidate");
header("Pragma: no-cache");
+require_once 'auth.php';
$expire = 1800;
if (empty($_SESSION['user_id']) || (time() - $_SESSION['login_time'] > $expire)) {
session_destroy();
@@ -80,6 +81,7 @@ td{padding:12px 10px;border-bottom:1px solid #F2F3F5;color:#1D2129;}
diff --git a/alert_dashboard/www/api/__pycache__/docker.cpython-36.pyc b/alert_dashboard/www/api/__pycache__/docker.cpython-36.pyc
new file mode 100644
index 0000000..03f306a
Binary files /dev/null and b/alert_dashboard/www/api/__pycache__/docker.cpython-36.pyc differ
diff --git a/alert_dashboard/www/api/docker.php b/alert_dashboard/www/api/docker.php
new file mode 100644
index 0000000..607471d
--- /dev/null
+++ b/alert_dashboard/www/api/docker.php
@@ -0,0 +1,165 @@
+ session_id(),
+ "session_data" => $_SESSION
+];
+
+$expire = 1800;
+if (empty($_SESSION['user_id']) || (time() - $_SESSION['login_time'] > $expire)) {
+ session_destroy();
+ echo json_encode([
+ "code"=>401,
+ "msg"=>"未登录",
+ "debug"=>$debug
+ ],JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+$user = $_SESSION['user_id'];
+$role = $_SESSION['role'];
+$ts = time();
+$rawSignStr = $user . "|" . $role . "|" . $ts;
+$sign = hash_hmac("sha256", $rawSignStr, $SECRET_RAW);
+$clientIp = $_SERVER['REMOTE_ADDR'];
+
+$act = $_POST['act'] ?? $_GET['act'] ?? 'list';
+$cid = $_POST['container_id'] ?? $_GET['container_id'] ?? '';
+$imageId = $_POST['image_id'] ?? $_GET['image_id'] ?? '';
+$lines = $_POST['lines'] ?? $_GET['lines'] ?? 200;
+$postRaw = file_get_contents("php://input");
+$apiBase = "http://10.150.117.190:8090";
+
+$authHeaders = [
+ "X-User: {$user}",
+ "X-Role: {$role}",
+ "X-Time: {$ts}",
+ "X-Sign: {$sign}",
+ "X-Client-Ip: {$clientIp}",
+ "Content-Type: application/json; charset=utf-8"
+];
+$headerStr = implode("\r\n", $authHeaders);
+
+$getMap = [
+ 'list' => '/list',
+ 'image_list' => '/image_list',
+ 'restart' => '/restart/%s',
+ 'stop' => '/stop/%s',
+ 'rm' => '/rm/%s',
+ 'remove_image' => '/remove_image/%s',
+ 'logs' => '/logs/%s/%d',
+ 'network' => '/network/%s',
+ 'stats' => '/stats/%s',
+ 'detail' => '/detail/%s'
+];
+
+$postMap = [
+ 'exec' => '/exec/%s',
+ 'create' => '/create',
+ 'bind_repo' => '/bind_repo',
+];
+
+if (isset($getMap[$act])) {
+ $path = $getMap[$act];
+ $url = '';
+ switch ($act) {
+ case 'restart':
+ case 'stop':
+ case 'rm':
+ case 'network':
+ case 'stats':
+ case 'detail':
+ if (empty($cid)) {
+ echo json_encode(["code"=>400,"msg"=>"缺少容器ID","debug"=>$debug],JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ $url = $apiBase . sprintf($path, urlencode($cid));
+ break;
+ case 'remove_image':
+ if (empty($imageId)) {
+ echo json_encode(["code"=>400,"msg"=>"缺少镜像ID","debug"=>$debug],JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ $url = $apiBase . sprintf($path, urlencode($imageId));
+ break;
+ case 'logs':
+ if (empty($cid)) {
+ echo json_encode(["code"=>400,"msg"=>"缺少容器ID","debug"=>$debug],JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ $url = $apiBase . sprintf($path, urlencode($cid), (int)$lines);
+ break;
+ default:
+ $url = $apiBase . $path;
+ break;
+ }
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => $headerStr,
+ 'timeout' => 30
+ ]
+ ]);
+ $resp = @file_get_contents($url, false, $ctx);
+ if ($resp === false) {
+ echo json_encode([
+ "code" => 503,
+ "msg" => "后端Docker接口连接失败,请检查服务是否启动",
+ "debug" => $debug
+ ], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ echo $resp;
+ exit;
+}
+
+if (isset($postMap[$act])) {
+ $path = $postMap[$act];
+ $url = '';
+ if ($act === 'exec') {
+ if (empty($cid)) {
+ echo json_encode(["code"=>400,"msg"=>"缺少容器ID","debug"=>$debug],JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ $url = $apiBase . sprintf($path, urlencode($cid));
+ } else {
+ $url = $apiBase . $path;
+ }
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => $headerStr,
+ 'content' => $postRaw,
+ 'timeout' => 30
+ ]
+ ]);
+ $resp = @file_get_contents($url, false, $ctx);
+ if ($resp === false) {
+ echo json_encode([
+ "code" => 503,
+ "msg" => "后端Docker接口连接失败,请检查服务是否启动",
+ "debug" => $debug
+ ], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ echo $resp;
+ exit;
+}
+
+echo json_encode([
+ "code"=>400,
+ "msg"=>"无效操作act参数",
+ "debug"=>$debug
+],JSON_UNESCAPED_UNICODE);
+exit;
diff --git a/alert_dashboard/www/api/docker_api.py b/alert_dashboard/www/api/docker_api.py
new file mode 100644
index 0000000..c925759
--- /dev/null
+++ b/alert_dashboard/www/api/docker_api.py
@@ -0,0 +1,752 @@
+import json
+import shlex
+import time
+import hashlib
+import hmac
+import docker
+from flask import Flask, jsonify, request
+from flask_socketio import SocketIO
+
+# 全局签名密钥,前后端统一,生产环境使用环境变量注入
+SIGN_SECRET = b"DockerAdminPlatform2026SecretKey123456789"
+# 签名有效期 300秒(5分钟)
+SIGN_EXPIRE = 300
+
+# 兼容新旧docker-py客户端
+try:
+ cli = docker.DockerClient(base_url='unix:///var/run/docker.sock')
+ api_cli = docker.APIClient(base_url='unix:///var/run/docker.sock')
+except AttributeError:
+ cli = docker.Client(base_url='unix:///var/run/docker.sock')
+ api_cli = docker.APIClient(base_url='unix:///var/run/docker.sock')
+
+app = Flask(__name__)
+app.config['SECRET_KEY'] = 'docker-terminal-secret-2026'
+
+# WebSocket心跳保活,防止Nginx/反向代理断连
+socketio = SocketIO(
+ app,
+ cors_allowed_origins="*",
+ async_mode="threading",
+ ping_timeout=60,
+ ping_interval=25
+)
+
+# 全局会话存储
+session_exec_map = {} # sid => TTY终端流信息
+log_task_map = {} # sid => {"cid": "", "running": True}
+ws_user_map = {} # 新增:sid 映射 WebSocket鉴权用户信息
+registry_config = {
+ "repo_name": "",
+ "repo_addr": "",
+ "repo_user": "",
+ "repo_pwd": ""
+}
+# 多系统shell兼容列表(Ubuntu/Alpine/标准Linux)
+SHELL_CANDIDATES = [
+ "/bin/bash",
+ "/usr/bin/bash",
+ "/bin/sh",
+ "/bin/ash"
+]
+
+# ===================== 工具函数1:docker socket兼容recv =====================
+def docker_recv(sock, size=1024):
+ try:
+ return sock.recv(size)
+ except Exception:
+ try:
+ return sock._sock.recv(size)
+ except Exception:
+ return b""
+
+# ===================== 工具函数2:操作审计日志(终端输入脱敏,不存明文密码) =====================
+def write_audit_log(user, role, action, target_name, target_id, result, client_ip):
+ """企业审计记录,可对接数据库docker_audit_log表"""
+ # 终端输入仅标记事件,不记录明文命令,规避密码泄露风险
+ if action == "terminal_input":
+ result = "command_entered"
+ log_item = {
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "user": user,
+ "role": role,
+ "action": action,
+ "container_name": target_name,
+ "container_id": target_id,
+ "result": result,
+ "client_ip": client_ip
+ }
+ # 生产环境替换为mysql insert语句
+ print(json.dumps(log_item, ensure_ascii=False))
+
+# ===================== 工具函数3:HTTP Header HMAC签名鉴权(原有,HTTP接口专用) =====================
+def verify_auth_sign():
+ """
+ 校验前端PHP生成的HMAC签名,防止伪造X-Role:admin
+ 请求Header要求:
+ X-User: 用户名
+ X-Role: admin/user/guest
+ X-Time: 当前时间戳(秒)
+ X-Sign: hmac-sha256签名
+ 返回 (user, role, client_ip, True/False)
+ """
+ user = request.headers.get("X-User", "anonymous")
+ role = request.headers.get("X-Role", "guest")
+ ts_str = request.headers.get("X-Time", "0")
+ sign = request.headers.get("X-Sign", "")
+ client_ip = request.headers.get("X-Client-Ip", request.remote_addr)
+ try:
+ ts = int(ts_str)
+ except ValueError:
+ return user, role, client_ip, False
+ # 校验签名有效期
+ now = int(time.time())
+ if abs(now - ts) > SIGN_EXPIRE:
+ return user, role, client_ip, False
+ # 校验HMAC签名
+ raw_data = f"{user}|{role}|{ts}".encode("utf-8")
+ calc_sign = hmac.new(SIGN_SECRET, raw_data, hashlib.sha256).hexdigest()
+ if calc_sign != sign:
+ return user, role, client_ip, False
+ return user, role, client_ip, True
+
+# ===================== 新增工具函数4:WebSocket连接专用鉴权函数 =====================
+def verify_socket_auth(auth):
+ user = auth.get("user", "")
+ role = auth.get("role", "")
+ ts_str = auth.get("ts", "0")
+ sign = auth.get("sign", "")
+ try:
+ ts = int(ts_str)
+ except:
+ return user, role, "", False
+ if abs(int(time.time()) - ts) > SIGN_EXPIRE:
+ return user, role, "", False
+ raw_data = f"{user}|{role}|{ts}".encode("utf-8")
+ calc_sign = hmac.new(
+ SIGN_SECRET,
+ raw_data,
+ hashlib.sha256
+ ).hexdigest()
+ if calc_sign != sign:
+ return user, role, "", False
+ return user, role, "", True
+
+# ===================== 工具函数5:环境变量脱敏,屏蔽密码/密钥 =====================
+def safe_env_filter(env_list):
+ safe_env = []
+ sensitive_keywords = ["PASS", "PASSWORD", "TOKEN", "SECRET", "KEY", "CREDENTIAL"]
+ for item in env_list:
+ equal_pos = item.find("=")
+ if equal_pos == -1:
+ safe_env.append(item)
+ continue
+ key = item[:equal_pos]
+ val = item[equal_pos+1:]
+ if any(word in key.upper() for word in sensitive_keywords):
+ safe_env.append(f"{key}=******")
+ else:
+ safe_env.append(item)
+ return safe_env
+
+# ===================== 全部HTTP业务接口 =====================
+# 1. 容器列表(端口预拼接字符串,前端直接渲染,无需formatPorts)
+@app.route("/list")
+def api_list():
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "list_container", "", "", "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ arr = []
+ for c in cli.containers.list(all=True):
+ img_name = c.image.tags[0] if c.image.tags else ""
+ port_str_arr = []
+ ports_raw = c.ports
+ for c_port, host_list in ports_raw.items():
+ if not host_list:
+ continue
+ for host_item in host_list:
+ host_port = host_item.get("HostPort", "")
+ port_str_arr.append(f"{host_port}:{c_port.split('/')[0]}")
+ port_display = ",".join(port_str_arr) if port_str_arr else "-"
+ arr.append({
+ "id": c.short_id,
+ "full_id": c.id,
+ "name": c.name,
+ "image": img_name,
+ "status": c.status,
+ "ports": port_display
+ })
+ write_audit_log(user, role, "list_container", "", "", "success", ip)
+ return jsonify({"code":0,"msg":"success","list":arr})
+
+# 2. 镜像列表
+@app.route("/image_list")
+def api_image_list():
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "list_image", "", "", "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ arr = []
+ for img in cli.images.list():
+ if not img.tags:
+ continue
+ full_tag = img.tags[0]
+ repo, tag = full_tag.split(":", 1)
+ size_mb = round(img.attrs["Size"] / 1024 / 1024, 1)
+ arr.append({
+ "repo": repo,
+ "tag": tag,
+ "id": img.short_id,
+ "full_id": img.id,
+ "size": f"{size_mb}MB",
+ "created": img.attrs["Created"]
+ })
+ write_audit_log(user, role, "list_image", "", "", "success", ip)
+ return jsonify({"code":0,"msg":"success","list":arr})
+
+# 3. 删除镜像
+@app.route("/remove_image/")
+def api_remove_image(image_id):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "remove_image", "", image_id, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "remove_image", "", image_id, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可删除镜像"})
+ try:
+ img = cli.images.get(image_id)
+ cli.images.remove(image_id, force=True)
+ write_audit_log(user, role, "remove_image", img.tags[0] if img.tags else "", image_id, "success", ip)
+ return jsonify({"code":0,"msg":"镜像已强制删除"})
+ except Exception as e:
+ write_audit_log(user, role, "remove_image", "", image_id, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 4. 重启容器
+@app.route("/restart/")
+def api_restart(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "restart", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "restart", "", cid, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可操作容器"})
+ try:
+ container = cli.containers.get(cid)
+ container.restart()
+ write_audit_log(user, role, "restart", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"容器已重启"})
+ except Exception as e:
+ write_audit_log(user, role, "restart", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 5. 停止容器
+@app.route("/stop/")
+def api_stop(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "stop", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "stop", "", cid, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可操作容器"})
+ try:
+ container = cli.containers.get(cid)
+ container.stop()
+ write_audit_log(user, role, "stop", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"容器已停止"})
+ except Exception as e:
+ write_audit_log(user, role, "stop", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 6. 删除容器
+@app.route("/rm/")
+def api_rm(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "rm", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "rm", "", cid, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可删除容器"})
+ try:
+ container = cli.containers.get(cid)
+ container.remove(force=True)
+ write_audit_log(user, role, "rm", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"容器已强制删除"})
+ except Exception as e:
+ write_audit_log(user, role, "rm", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 7. 一次性拉取日志(兼容旧页面单次查看)
+@app.route("/logs//")
+def api_logs(cid, tail_lines):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "read_log", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ try:
+ container = cli.containers.get(cid)
+ log_bytes = container.logs(tail=tail_lines)
+ log_str = log_bytes.decode("utf-8", errors="replace")
+ write_audit_log(user, role, "read_log", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"success","data":log_str})
+ except Exception as e:
+ write_audit_log(user, role, "read_log", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":""})
+
+# 8. 容器网络详情
+@app.route("/network/")
+def api_network(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "read_network", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ try:
+ container = cli.containers.get(cid)
+ info = container.attrs["NetworkSettings"]["Networks"]
+ write_audit_log(user, role, "read_network", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"success","data":json.dumps(info,indent=2)})
+ except Exception as e:
+ write_audit_log(user, role, "read_network", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":""})
+
+# 9. 短连接单次执行命令(旧弹窗兼容,非实时终端)
+@app.route("/exec/", methods=["POST"])
+def api_exec(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "single_exec", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "single_exec", "", cid, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可执行命令"})
+ try:
+ data = request.get_json()
+ cmd = data.get("cmd", "")
+ if not cmd:
+ return jsonify({"code":400,"msg":"命令不能为空","data":""})
+ cmd_list = shlex.split(cmd)
+ container = cli.containers.get(cid)
+ exec_obj = container.exec_run(cmd_list, stdout=True, stderr=True)
+ output = exec_obj.output.decode("utf-8", errors="replace")
+ write_audit_log(user, role, "single_exec", container.name, cid, "command_executed", ip)
+ return jsonify({"code":0,"msg":"success","data":output})
+ except Exception as e:
+ write_audit_log(user, role, "single_exec", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":""})
+
+# 10. 创建容器(端口标准格式 + 前端可配置CPU/内存)
+@app.route("/create", methods=["POST"])
+def api_create():
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "create_container", "", "", "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "create_container", "", "", "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可创建容器"})
+ try:
+ data = request.get_json()
+ name = data.get("name", "")
+ image = data.get("image")
+ cmd = data.get("cmd")
+ ports_input = data.get("ports", {})
+ # 修复:docker标准端口字典格式 {内部端口:宿主机端口}
+ port_bind = {}
+ for inner_port, host_port in ports_input.items():
+ port_bind[f"{inner_port}/tcp"] = int(host_port)
+ # 前端可配置资源限制,默认1核512M
+ cpu_core = int(data.get("cpu", 1))
+ memory_limit = data.get("memory", "512m")
+ nano_cpus = cpu_core * 1000000000
+ container = cli.containers.run(
+ image=image,
+ name=name if name else None,
+ command=cmd if cmd else None,
+ ports=port_bind,
+ detach=True,
+ restart_policy={"Name": "always"},
+ mem_limit=memory_limit,
+ nano_cpus=nano_cpus
+ )
+ write_audit_log(user, role, "create_container", container.name, container.id, "success", ip)
+ return jsonify({"code":0,"msg":f"容器创建成功,ID:{container.short_id}"})
+ except Exception as e:
+ write_audit_log(user, role, "create_container", "", "", f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 11. 登录私有镜像仓库
+@app.route("/bind_repo", methods=["POST"])
+def api_bind_repo():
+ global registry_config
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "repo_login", "", "", "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "repo_login", "", "", "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可登录镜像仓库"})
+ try:
+ data = request.get_json()
+ repo_name = data.get("repo_name","")
+ repo_addr = data.get("repo_addr")
+ repo_user = data.get("repo_user","")
+ repo_pwd = data.get("repo_pwd","")
+ api_cli.login(username=repo_user, password=repo_pwd, registry=repo_addr)
+ registry_config["repo_name"] = repo_name
+ registry_config["repo_addr"] = repo_addr
+ registry_config["repo_user"] = repo_user
+ registry_config["repo_pwd"] = repo_pwd
+ write_audit_log(user, role, "repo_login", repo_addr, "", "success", ip)
+ return jsonify({"code":0,"msg":"镜像仓库登录绑定成功"})
+ except Exception as e:
+ write_audit_log(user, role, "repo_login", repo_addr, "", f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 修复:stats接口online_cpus兜底兼容,无KeyError
+@app.route("/stats/")
+def api_stats(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "read_stats", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ try:
+ container = cli.containers.get(cid)
+ raw = container.stats(stream=False)
+ # CPU兼容兜底,不存在online_cpus自动计算
+ cpu_stats = raw["cpu_stats"]
+ online_cpus = cpu_stats.get("online_cpus", len(cpu_stats["cpu_usage"].get("percpu_usage", [])) or 1)
+ cpu_delta = cpu_stats["cpu_usage"]["total_usage"] - raw["precpu_stats"]["cpu_usage"]["total_usage"]
+ system_delta = cpu_stats["system_cpu_usage"] - raw["precpu_stats"]["system_cpu_usage"]
+ cpu_percent = 0.0
+ if system_delta > 0 and cpu_delta > 0:
+ cpu_percent = round((cpu_delta / system_delta) * online_cpus * 100, 1)
+ # 内存计算
+ mem_usage = raw["memory_stats"]["usage"] / 1024 / 1024
+ mem_limit = raw["memory_stats"]["limit"] / 1024 / 1024
+ mem_percent = round((mem_usage / mem_limit) * 100, 1) if mem_limit > 0 else 0
+ # 网络流量
+ net_rx = 0
+ net_tx = 0
+ for net in raw["networks"].values():
+ net_rx += net["rx_bytes"] / 1024 / 1024
+ net_tx += net["tx_bytes"] / 1024 / 1024
+ res = {
+ "cpu": cpu_percent,
+ "mem_mb": round(mem_usage, 1),
+ "mem_limit_mb": round(mem_limit, 1),
+ "mem_percent": mem_percent,
+ "net_rx_mb": round(net_rx, 1),
+ "net_tx_mb": round(net_tx, 1)
+ }
+ write_audit_log(user, role, "read_stats", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"success","data":res})
+ except Exception as e:
+ write_audit_log(user, role, "read_stats", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":{}})
+
+# 修复:detail接口ENV脱敏,多网卡IP兼容
+@app.route("/detail/")
+def api_detail(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "read_detail", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ try:
+ container = cli.containers.get(cid)
+ attrs = container.attrs
+ nets = attrs["NetworkSettings"]["Networks"]
+ ip_map = {}
+ for net_name, net_info in nets.items():
+ ip_map[net_name] = net_info.get("IPAddress", "")
+ # 敏感环境变量脱敏
+ raw_env = attrs["Config"].get("Env", [])
+ safe_env = safe_env_filter(raw_env)
+ res = {
+ "network_ips": ip_map,
+ "mounts": attrs["Mounts"],
+ "env": safe_env,
+ "created": attrs["Created"],
+ "entrypoint": attrs["Config"]["Entrypoint"],
+ "cmd": attrs["Config"]["Cmd"],
+ "restart_policy": attrs["HostConfig"]["RestartPolicy"]
+ }
+ write_audit_log(user, role, "read_detail", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"success","data":res})
+ except Exception as e:
+ write_audit_log(user, role, "read_detail", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":{}})
+
+# 根路由
+@app.route("/")
+def index():
+ return jsonify({"code":400,"msg":"无效操作","list":[],"data":""})
+
+# ===================== WebSocket 连接鉴权事件(新增) =====================
+@socketio.on("connect")
+def handle_connect(auth):
+ user, role, _, auth_ok = verify_socket_auth(auth)
+ if not auth_ok:
+ return False
+ ws_user_map[request.sid] = {
+ "user": user,
+ "role": role
+ }
+ print("WS Connected:", user, role)
+
+# ===================== WebSocket 交互式TTY实时终端 =====================
+def stream_terminal_output(sid, sock):
+ """后台异步读取docker终端输出,兼容两种socket接收方式"""
+ try:
+ while True:
+ chunk = docker_recv(sock, 1024)
+ if not chunk:
+ break
+ text = chunk.decode("utf-8", errors="replace")
+ socketio.emit("terminal_output", {"data": text}, room=sid)
+ except Exception:
+ pass
+ # 流结束自动清理会话
+ if sid in session_exec_map:
+ try:
+ session_exec_map[sid]["socket"].close()
+ except Exception:
+ pass
+ del session_exec_map[sid]
+
+@socketio.on("terminal_start")
+def handle_terminal_start(json_data):
+ sid = request.sid
+ # 新增调试输出
+ socketio.emit(
+ "terminal_output",
+ {
+ "data":"[DEBUG] terminal_start reached\r\n"
+ },
+ room=sid
+ )
+ # 替换原verify_auth_sign(),从ws_user_map读取身份
+ info = ws_user_map.get(request.sid)
+ if not info:
+ socketio.emit(
+ "terminal_output",
+ {"data":"身份认证失效\n"},
+ room=request.sid
+ )
+ return
+ user = info["user"]
+ role = info["role"]
+ client_ip = request.remote_addr
+
+ # 权限拦截:非管理员禁止打开交互式终端
+ if role != "admin":
+ socketio.emit("terminal_output", {"data": "Permission denied: 仅管理员可访问交互式终端\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", json_data.get("cid"), "denied", client_ip)
+ return
+
+ cid = json_data.get("cid")
+ if not cid:
+ socketio.emit("terminal_output", {"data": "错误:容器ID不能为空\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", "", "empty_cid", client_ip)
+ return
+
+ # 自动探测容器可用shell,兼容全版本docker-py(exec_create返回dict取Id)
+ target_shell = None
+ for shell in SHELL_CANDIDATES:
+ try:
+ ret = api_cli.exec_create(
+ container=cid,
+ cmd=[shell, "-c", "echo ok"]
+ )
+ test_exec_id = ret["Id"]
+ test_stream = api_cli.exec_start(test_exec_id)
+ test_stream.read()
+ target_shell = shell
+ break
+ except Exception:
+ continue
+ if not target_shell:
+ socketio.emit("terminal_output", {"data": "错误:容器内无可用shell(bash/sh/ash)\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", cid, "no_shell", client_ip)
+ return
+
+ try:
+ # 创建交互式TTY会话,统一提取Id兼容所有SDK版本
+ exec_ret = api_cli.exec_create(
+ container=cid,
+ cmd=target_shell,
+ stdin=True,
+ stdout=True,
+ stderr=True,
+ tty=True
+ )
+ exec_id = exec_ret["Id"]
+ sock = api_cli.exec_start(exec_id=exec_id, tty=True, socket=True, detach=False)
+ session_exec_map[sid] = {
+ "cid": cid,
+ "exec_id": exec_id,
+ "socket": sock
+ }
+ socketio.start_background_task(stream_terminal_output, sid, sock)
+ container_name = cli.containers.get(cid).name
+ write_audit_log(user, role, "terminal_start", container_name, cid, "success", client_ip)
+ except Exception as e:
+ socketio.emit("terminal_output", {"data": f"启动终端失败:{str(e)}\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", cid, f"fail:{str(e)}", client_ip)
+
+@socketio.on("terminal_input")
+def handle_terminal_input(json_data):
+ """前端输入字符写入容器标准输入,审计脱敏不记录明文"""
+ sid = request.sid
+ # 替换原verify_auth_sign()
+ info = ws_user_map.get(request.sid)
+ if not info:
+ return
+ user = info["user"]
+ role = info["role"]
+ client_ip = request.remote_addr
+
+ if sid not in session_exec_map:
+ socketio.emit("terminal_output", {"data": "终端会话已断开,请重新打开\n"}, room=sid)
+ return
+ input_text = json_data.get("data", "")
+ sock = session_exec_map[sid]["socket"]
+ sock.send(input_text.encode("utf-8"))
+ cid = session_exec_map[sid]["cid"]
+ # 审计仅标记事件,不再记录明文命令,规避密码泄露
+ write_audit_log(user, role, "terminal_input", "", cid, "command_entered", client_ip)
+
+@socketio.on("terminal_resize")
+def handle_terminal_resize(json_data):
+ """前端xterm窗口大小自适应"""
+ sid = request.sid
+ if sid not in session_exec_map:
+ return
+ exec_id = session_exec_map[sid]["exec_id"]
+ height = int(json_data.get("height", 24))
+ width = int(json_data.get("width", 80))
+ try:
+ api_cli.exec_resize(exec_id=exec_id, height=height, width=width)
+ except Exception:
+ pass
+
+# 新增主动关闭终端事件(解决切换容器残留TTY)
+@socketio.on("terminal_close")
+def handle_terminal_close():
+ sid = request.sid
+ info = ws_user_map.get(request.sid)
+ if not info:
+ return
+ user = info["user"]
+ role = info["role"]
+ client_ip = request.remote_addr
+
+ if sid in session_exec_map:
+ cid = session_exec_map[sid]["cid"]
+ try:
+ session_exec_map[sid]["socket"].close()
+ except Exception:
+ pass
+ del session_exec_map[sid]
+ write_audit_log(user, role, "terminal_close", "", cid, "manual_close", client_ip)
+
+@socketio.on("disconnect")
+def handle_disconnect():
+ """前端断开连接,释放docker socket、清理会话,防止句柄泄漏"""
+ sid = request.sid
+ # 清理ws_user_map会话
+ if sid in ws_user_map:
+ del ws_user_map[sid]
+ info = ws_user_map.get(sid)
+ if not info:
+ user = ""
+ role = ""
+ client_ip = request.remote_addr
+ else:
+ user = info["user"]
+ role = info["role"]
+ client_ip = request.remote_addr
+
+ # 清理TTY终端
+ if sid in session_exec_map:
+ cid = session_exec_map[sid]["cid"]
+ try:
+ session_exec_map[sid]["socket"].close()
+ except Exception:
+ pass
+ del session_exec_map[sid]
+ write_audit_log(user, role, "terminal_close", "", cid, "disconnect", client_ip)
+ # 清理实时日志标记,终止日志循环
+ if sid in log_task_map:
+ del log_task_map[sid]
+
+# ===================== WebSocket 实时流式日志 docker logs -f(生成器主动释放) =====================
+def stream_follow_log(sid, cid):
+ generator = None
+ try:
+ container = cli.containers.get(cid)
+ generator = container.logs(stream=True, follow=True)
+ log_task_map[sid] = {"cid": cid, "running": True}
+ for line in generator:
+ task_info = log_task_map.get(sid, {})
+ if not task_info.get("running"):
+ break
+ txt = line.decode("utf-8", errors="replace")
+ socketio.emit("log_output", {"data": txt}, room=sid)
+ except Exception:
+ pass
+ finally:
+ # 主动关闭日志生成器,释放docker daemon长连接
+ if generator is not None:
+ generator.close()
+ if sid in log_task_map:
+ del log_task_map[sid]
+
+@socketio.on("log_start")
+def handle_log_start(json_data):
+ sid = request.sid
+ # 替换verify_auth_sign,读取ws_user_map
+ info = ws_user_map.get(request.sid)
+ if not info:
+ socketio.emit("log_output", {"data": "身份校验失败"}, room=sid)
+ write_audit_log("", "", "log_start", "", "", "auth_failed", request.remote_addr)
+ return
+ user = info["user"]
+ role = info["role"]
+ client_ip = request.remote_addr
+
+ cid = json_data.get("cid")
+ if not cid:
+ socketio.emit("log_output", {"data": "容器ID不能为空"}, room=sid)
+ write_audit_log(user, role, "log_start", "", "", "empty_cid", client_ip)
+ return
+ if role != "admin":
+ socketio.emit("log_output", {"data": "权限不足,仅管理员可查看实时日志"}, room=sid)
+ write_audit_log(user, role, "log_start", "", cid, "denied", client_ip)
+ return
+ socketio.start_background_task(stream_follow_log, sid, cid)
+ container_name = cli.containers.get(cid).name
+ write_audit_log(user, role, "log_start", container_name, cid, "success", client_ip)
+
+@socketio.on("log_stop")
+def handle_log_stop():
+ sid = request.sid
+ info = ws_user_map.get(request.sid)
+ if not info:
+ return
+ user = info["user"]
+ role = info["role"]
+ client_ip = request.remote_addr
+
+ if sid in log_task_map:
+ log_task_map[sid]["running"] = False
+ cid = log_task_map[sid]["cid"]
+ write_audit_log(user, role, "log_stop", "", cid, "manual_close", client_ip)
+
+# 启动入口(eventlet异步,必须socketio.run)
+if __name__ == "__main__":
+ socketio.run(app, host="0.0.0.0", port=8090, debug=False, allow_unsafe_werkzeug=True)
diff --git a/alert_dashboard/www/api/docker_api.py.bak b/alert_dashboard/www/api/docker_api.py.bak
new file mode 100644
index 0000000..b607643
--- /dev/null
+++ b/alert_dashboard/www/api/docker_api.py.bak
@@ -0,0 +1,100 @@
+import json
+import docker
+from flask import Flask, jsonify
+
+# 兼容新旧版本
+try:
+ cli = docker.DockerClient(base_url='unix:///var/run/docker.sock')
+except AttributeError:
+ cli = docker.Client(base_url='unix:///var/run/docker.sock')
+
+app = Flask(__name__)
+
+# 容器列表
+@app.route("/list")
+def api_list():
+ arr = []
+ for c in cli.containers.list(all=True):
+ img_name = c.image.tags[0] if c.image.tags else ""
+ arr.append({
+ "id": c.short_id,
+ "full_id": c.id,
+ "name": c.name,
+ "image": img_name,
+ "status": c.status,
+ "ports": str(c.ports)
+ })
+ return jsonify({"code":0,"msg":"success","list":arr})
+
+# 镜像列表
+@app.route("/image_list")
+def api_image_list():
+ arr = []
+ for img in cli.images.list():
+ if not img.tags:
+ continue
+ full_tag = img.tags[0]
+ repo, tag = full_tag.split(":", 1)
+ size_mb = round(img.attrs["Size"] / 1024 / 1024, 1)
+ arr.append({
+ "repo": repo,
+ "tag": tag,
+ "id": img.short_id,
+ "full_id": img.id,
+ "size": f"{size_mb}MB",
+ "created": img.attrs["Created"]
+ })
+ return jsonify({"code":0,"msg":"success","list":arr})
+
+# 重启容器
+@app.route("/restart/")
+def api_restart(cid):
+ try:
+ cli.containers.get(cid).restart()
+ return jsonify({"code":0,"msg":"容器已重启"})
+ except Exception as e:
+ return jsonify({"code":500,"msg":str(e)})
+
+# 停止容器
+@app.route("/stop/")
+def api_stop(cid):
+ try:
+ cli.containers.get(cid).stop()
+ return jsonify({"code":0,"msg":"容器已停止"})
+ except Exception as e:
+ return jsonify({"code":500,"msg":str(e)})
+
+# 删除容器
+@app.route("/rm/")
+def api_rm(cid):
+ try:
+ cli.containers.get(cid).remove(force=True)
+ return jsonify({"code":0,"msg":"容器已强制删除"})
+ except Exception as e:
+ return jsonify({"code":500,"msg":str(e)})
+
+# 容器日志
+@app.route("/logs//")
+def api_logs(cid, tail_lines):
+ try:
+ log_bytes = cli.containers.get(cid).logs(tail=tail_lines)
+ log_str = log_bytes.decode("utf-8", errors="ignore")
+ return jsonify({"code":0,"msg":"success","data":log_str})
+ except Exception as e:
+ return jsonify({"code":500,"msg":str(e),"data":""})
+
+# 网络信息
+@app.route("/network/")
+def api_network(cid):
+ try:
+ info = cli.containers.get(cid).attrs["NetworkSettings"]["Networks"]
+ return jsonify({"code":0,"msg":"success","data":info})
+ except Exception as e:
+ return jsonify({"code":500,"msg":str(e),"data":""})
+
+@app.route("/")
+def index():
+ return jsonify({"code":400,"msg":"无效操作","list":[],"data":""})
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=8090, debug=False)
diff --git a/alert_dashboard/www/api/docker_api.py1 b/alert_dashboard/www/api/docker_api.py1
new file mode 100644
index 0000000..b514e30
--- /dev/null
+++ b/alert_dashboard/www/api/docker_api.py1
@@ -0,0 +1,669 @@
+# 【强制置顶】eventlet猴子补丁,必须放在所有导入最前面
+import json
+import shlex
+import time
+import hashlib
+import hmac
+import docker
+from flask import Flask, jsonify, request
+from flask_socketio import SocketIO
+
+# 全局签名密钥,前后端统一,生产环境使用环境变量注入
+SIGN_SECRET = b"DockerAdminPlatform2026SecretKey123456789"
+# 签名有效期 300秒(5分钟)
+SIGN_EXPIRE = 300
+
+# 兼容新旧docker-py客户端
+try:
+ cli = docker.DockerClient(base_url='unix:///var/run/docker.sock')
+ api_cli = docker.APIClient(base_url='unix:///var/run/docker.sock')
+except AttributeError:
+ cli = docker.Client(base_url='unix:///var/run/docker.sock')
+ api_cli = docker.APIClient(base_url='unix:///var/run/docker.sock')
+
+app = Flask(__name__)
+app.config['SECRET_KEY'] = 'docker-terminal-secret-2026'
+
+# WebSocket心跳保活,防止Nginx/反向代理断连
+socketio = SocketIO(
+ app,
+ cors_allowed_origins="*",
+ async_mode="threading",
+ ping_timeout=60,
+ ping_interval=25
+)
+
+# 全局会话存储
+session_exec_map = {} # sid => TTY终端流信息
+log_task_map = {} # sid => {"cid": "", "running": True}
+registry_config = {
+ "repo_name": "",
+ "repo_addr": "",
+ "repo_user": "",
+ "repo_pwd": ""
+}
+# 多系统shell兼容列表(Ubuntu/Alpine/标准Linux)
+SHELL_CANDIDATES = [
+ "/bin/bash",
+ "/usr/bin/bash",
+ "/bin/sh",
+ "/bin/ash"
+]
+
+# ===================== 工具函数1:docker socket兼容recv =====================
+def docker_recv(sock, size=1024):
+ try:
+ return sock.recv(size)
+ except Exception:
+ try:
+ return sock._sock.recv(size)
+ except Exception:
+ return b""
+
+# ===================== 工具函数2:操作审计日志(终端输入脱敏,不存明文密码) =====================
+def write_audit_log(user, role, action, target_name, target_id, result, client_ip):
+ """企业审计记录,可对接数据库docker_audit_log表"""
+ # 终端输入仅标记事件,不记录明文命令,规避密码泄露风险
+ if action == "terminal_input":
+ result = "command_entered"
+ log_item = {
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "user": user,
+ "role": role,
+ "action": action,
+ "container_name": target_name,
+ "container_id": target_id,
+ "result": result,
+ "client_ip": client_ip
+ }
+ # 生产环境替换为mysql insert语句
+ print(json.dumps(log_item, ensure_ascii=False))
+
+# ===================== 工具函数3:HMAC签名鉴权,防Header伪造 =====================
+def verify_auth_sign():
+ """
+ 校验前端PHP生成的HMAC签名,防止伪造X-Role:admin
+ 请求Header要求:
+ X-User: 用户名
+ X-Role: admin/user/guest
+ X-Time: 当前时间戳(秒)
+ X-Sign: hmac-sha256签名
+ 返回 (user, role, client_ip, True/False)
+ """
+ user = request.headers.get("X-User", "anonymous")
+ role = request.headers.get("X-Role", "guest")
+ ts_str = request.headers.get("X-Time", "0")
+ sign = request.headers.get("X-Sign", "")
+ client_ip = request.headers.get("X-Client-Ip", request.remote_addr)
+ try:
+ ts = int(ts_str)
+ except ValueError:
+ return user, role, client_ip, False
+ # 校验签名有效期
+ now = int(time.time())
+ if abs(now - ts) > SIGN_EXPIRE:
+ return user, role, client_ip, False
+ # 校验HMAC签名
+ raw_data = f"{user}|{role}|{ts}".encode("utf-8")
+ calc_sign = hmac.new(SIGN_SECRET, raw_data, hashlib.sha256).hexdigest()
+ if calc_sign != sign:
+ return user, role, client_ip, False
+ return user, role, client_ip, True
+
+# ===================== 工具函数4:环境变量脱敏,屏蔽密码/密钥 =====================
+def safe_env_filter(env_list):
+ safe_env = []
+ sensitive_keywords = ["PASS", "PASSWORD", "TOKEN", "SECRET", "KEY", "CREDENTIAL"]
+ for item in env_list:
+ equal_pos = item.find("=")
+ if equal_pos == -1:
+ safe_env.append(item)
+ continue
+ key = item[:equal_pos]
+ val = item[equal_pos+1:]
+ if any(word in key.upper() for word in sensitive_keywords):
+ safe_env.append(f"{key}=******")
+ else:
+ safe_env.append(item)
+ return safe_env
+
+# ===================== 全部HTTP业务接口 =====================
+# 1. 容器列表(端口预拼接字符串,前端直接渲染,无需formatPorts)
+@app.route("/list")
+def api_list():
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "list_container", "", "", "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ arr = []
+ for c in cli.containers.list(all=True):
+ img_name = c.image.tags[0] if c.image.tags else ""
+ port_str_arr = []
+ ports_raw = c.ports
+ for c_port, host_list in ports_raw.items():
+ if not host_list:
+ continue
+ for host_item in host_list:
+ host_port = host_item.get("HostPort", "")
+ port_str_arr.append(f"{host_port}:{c_port.split('/')[0]}")
+ port_display = ",".join(port_str_arr) if port_str_arr else "-"
+ arr.append({
+ "id": c.short_id,
+ "full_id": c.id,
+ "name": c.name,
+ "image": img_name,
+ "status": c.status,
+ "ports": port_display
+ })
+ write_audit_log(user, role, "list_container", "", "", "success", ip)
+ return jsonify({"code":0,"msg":"success","list":arr})
+
+# 2. 镜像列表
+@app.route("/image_list")
+def api_image_list():
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "list_image", "", "", "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ arr = []
+ for img in cli.images.list():
+ if not img.tags:
+ continue
+ full_tag = img.tags[0]
+ repo, tag = full_tag.split(":", 1)
+ size_mb = round(img.attrs["Size"] / 1024 / 1024, 1)
+ arr.append({
+ "repo": repo,
+ "tag": tag,
+ "id": img.short_id,
+ "full_id": img.id,
+ "size": f"{size_mb}MB",
+ "created": img.attrs["Created"]
+ })
+ write_audit_log(user, role, "list_image", "", "", "success", ip)
+ return jsonify({"code":0,"msg":"success","list":arr})
+
+# 3. 删除镜像
+@app.route("/remove_image/")
+def api_remove_image(image_id):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "remove_image", "", image_id, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "remove_image", "", image_id, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可删除镜像"})
+ try:
+ img = cli.images.get(image_id)
+ cli.images.remove(image_id, force=True)
+ write_audit_log(user, role, "remove_image", img.tags[0] if img.tags else "", image_id, "success", ip)
+ return jsonify({"code":0,"msg":"镜像已强制删除"})
+ except Exception as e:
+ write_audit_log(user, role, "remove_image", "", image_id, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 4. 重启容器
+@app.route("/restart/")
+def api_restart(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "restart", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "restart", "", cid, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可操作容器"})
+ try:
+ container = cli.containers.get(cid)
+ container.restart()
+ write_audit_log(user, role, "restart", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"容器已重启"})
+ except Exception as e:
+ write_audit_log(user, role, "restart", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 5. 停止容器
+@app.route("/stop/")
+def api_stop(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "stop", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "stop", "", cid, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可操作容器"})
+ try:
+ container = cli.containers.get(cid)
+ container.stop()
+ write_audit_log(user, role, "stop", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"容器已停止"})
+ except Exception as e:
+ write_audit_log(user, role, "stop", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 6. 删除容器
+@app.route("/rm/")
+def api_rm(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "rm", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "rm", "", cid, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可删除容器"})
+ try:
+ container = cli.containers.get(cid)
+ container.remove(force=True)
+ write_audit_log(user, role, "rm", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"容器已强制删除"})
+ except Exception as e:
+ write_audit_log(user, role, "rm", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 7. 一次性拉取日志(兼容旧页面单次查看)
+@app.route("/logs//")
+def api_logs(cid, tail_lines):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "read_log", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ try:
+ container = cli.containers.get(cid)
+ log_bytes = container.logs(tail=tail_lines)
+ log_str = log_bytes.decode("utf-8", errors="replace")
+ write_audit_log(user, role, "read_log", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"success","data":log_str})
+ except Exception as e:
+ write_audit_log(user, role, "read_log", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":""})
+
+# 8. 容器网络详情
+@app.route("/network/")
+def api_network(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "read_network", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ try:
+ container = cli.containers.get(cid)
+ info = container.attrs["NetworkSettings"]["Networks"]
+ write_audit_log(user, role, "read_network", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"success","data":json.dumps(info,indent=2)})
+ except Exception as e:
+ write_audit_log(user, role, "read_network", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":""})
+
+# 9. 短连接单次执行命令(旧弹窗兼容,非实时终端)
+@app.route("/exec/", methods=["POST"])
+def api_exec(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "single_exec", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "single_exec", "", cid, "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可执行命令"})
+ try:
+ data = request.get_json()
+ cmd = data.get("cmd", "")
+ if not cmd:
+ return jsonify({"code":400,"msg":"命令不能为空","data":""})
+ cmd_list = shlex.split(cmd)
+ container = cli.containers.get(cid)
+ exec_obj = container.exec_run(cmd_list, stdout=True, stderr=True)
+ output = exec_obj.output.decode("utf-8", errors="replace")
+ write_audit_log(user, role, "single_exec", container.name, cid, "command_executed", ip)
+ return jsonify({"code":0,"msg":"success","data":output})
+ except Exception as e:
+ write_audit_log(user, role, "single_exec", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":""})
+
+# 10. 创建容器(端口标准格式 + 前端可配置CPU/内存)
+@app.route("/create", methods=["POST"])
+def api_create():
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "create_container", "", "", "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "create_container", "", "", "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可创建容器"})
+ try:
+ data = request.get_json()
+ name = data.get("name", "")
+ image = data.get("image")
+ cmd = data.get("cmd")
+ ports_input = data.get("ports", {})
+ # 修复:docker标准端口字典格式 {内部端口:宿主机端口}
+ port_bind = {}
+ for inner_port, host_port in ports_input.items():
+ port_bind[f"{inner_port}/tcp"] = int(host_port)
+ # 前端可配置资源限制,默认1核512M
+ cpu_core = int(data.get("cpu", 1))
+ memory_limit = data.get("memory", "512m")
+ nano_cpus = cpu_core * 1000000000
+ container = cli.containers.run(
+ image=image,
+ name=name if name else None,
+ command=cmd if cmd else None,
+ ports=port_bind,
+ detach=True,
+ restart_policy={"Name": "always"},
+ mem_limit=memory_limit,
+ nano_cpus=nano_cpus
+ )
+ write_audit_log(user, role, "create_container", container.name, container.id, "success", ip)
+ return jsonify({"code":0,"msg":f"容器创建成功,ID:{container.short_id}"})
+ except Exception as e:
+ write_audit_log(user, role, "create_container", "", "", f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 11. 登录私有镜像仓库
+@app.route("/bind_repo", methods=["POST"])
+def api_bind_repo():
+ global registry_config
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "repo_login", "", "", "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ if role != "admin":
+ write_audit_log(user, role, "repo_login", "", "", "denied", ip)
+ return jsonify({"code":403,"msg":"权限不足,仅管理员可登录镜像仓库"})
+ try:
+ data = request.get_json()
+ repo_name = data.get("repo_name","")
+ repo_addr = data.get("repo_addr")
+ repo_user = data.get("repo_user","")
+ repo_pwd = data.get("repo_pwd","")
+ api_cli.login(username=repo_user, password=repo_pwd, registry=repo_addr)
+ registry_config["repo_name"] = repo_name
+ registry_config["repo_addr"] = repo_addr
+ registry_config["repo_user"] = repo_user
+ registry_config["repo_pwd"] = repo_pwd
+ write_audit_log(user, role, "repo_login", repo_addr, "", "success", ip)
+ return jsonify({"code":0,"msg":"镜像仓库登录绑定成功"})
+ except Exception as e:
+ write_audit_log(user, role, "repo_login", repo_addr, "", f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e)})
+
+# 修复:stats接口online_cpus兜底兼容,无KeyError
+@app.route("/stats/")
+def api_stats(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "read_stats", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ try:
+ container = cli.containers.get(cid)
+ raw = container.stats(stream=False)
+ # CPU兼容兜底,不存在online_cpus自动计算
+ cpu_stats = raw["cpu_stats"]
+ online_cpus = cpu_stats.get("online_cpus", len(cpu_stats["cpu_usage"].get("percpu_usage", [])) or 1)
+ cpu_delta = cpu_stats["cpu_usage"]["total_usage"] - raw["precpu_stats"]["cpu_usage"]["total_usage"]
+ system_delta = cpu_stats["system_cpu_usage"] - raw["precpu_stats"]["system_cpu_usage"]
+ cpu_percent = 0.0
+ if system_delta > 0 and cpu_delta > 0:
+ cpu_percent = round((cpu_delta / system_delta) * online_cpus * 100, 1)
+ # 内存计算
+ mem_usage = raw["memory_stats"]["usage"] / 1024 / 1024
+ mem_limit = raw["memory_stats"]["limit"] / 1024 / 1024
+ mem_percent = round((mem_usage / mem_limit) * 100, 1) if mem_limit > 0 else 0
+ # 网络流量
+ net_rx = 0
+ net_tx = 0
+ for net in raw["networks"].values():
+ net_rx += net["rx_bytes"] / 1024 / 1024
+ net_tx += net["tx_bytes"] / 1024 / 1024
+ res = {
+ "cpu": cpu_percent,
+ "mem_mb": round(mem_usage, 1),
+ "mem_limit_mb": round(mem_limit, 1),
+ "mem_percent": mem_percent,
+ "net_rx_mb": round(net_rx, 1),
+ "net_tx_mb": round(net_tx, 1)
+ }
+ write_audit_log(user, role, "read_stats", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"success","data":res})
+ except Exception as e:
+ write_audit_log(user, role, "read_stats", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":{}})
+
+# 修复:detail接口ENV脱敏,多网卡IP兼容
+@app.route("/detail/")
+def api_detail(cid):
+ user, role, ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ write_audit_log(user, role, "read_detail", "", cid, "auth_failed", ip)
+ return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
+ try:
+ container = cli.containers.get(cid)
+ attrs = container.attrs
+ nets = attrs["NetworkSettings"]["Networks"]
+ ip_map = {}
+ for net_name, net_info in nets.items():
+ ip_map[net_name] = net_info.get("IPAddress", "")
+ # 敏感环境变量脱敏
+ raw_env = attrs["Config"].get("Env", [])
+ safe_env = safe_env_filter(raw_env)
+ res = {
+ "network_ips": ip_map,
+ "mounts": attrs["Mounts"],
+ "env": safe_env,
+ "created": attrs["Created"],
+ "entrypoint": attrs["Config"]["Entrypoint"],
+ "cmd": attrs["Config"]["Cmd"],
+ "restart_policy": attrs["HostConfig"]["RestartPolicy"]
+ }
+ write_audit_log(user, role, "read_detail", container.name, cid, "success", ip)
+ return jsonify({"code":0,"msg":"success","data":res})
+ except Exception as e:
+ write_audit_log(user, role, "read_detail", "", cid, f"fail:{str(e)}", ip)
+ return jsonify({"code":500,"msg":str(e),"data":{}})
+
+# 根路由
+@app.route("/")
+def index():
+ return jsonify({"code":400,"msg":"无效操作","list":[],"data":""})
+
+# ===================== WebSocket 交互式TTY实时终端 =====================
+def stream_terminal_output(sid, sock):
+ """后台异步读取docker终端输出,兼容两种socket接收方式"""
+ try:
+ while True:
+ chunk = docker_recv(sock, 1024)
+ if not chunk:
+ break
+ text = chunk.decode("utf-8", errors="replace")
+ socketio.emit("terminal_output", {"data": text}, room=sid)
+ except Exception:
+ pass
+ # 流结束自动清理会话
+ if sid in session_exec_map:
+ try:
+ session_exec_map[sid]["socket"].close()
+ except Exception:
+ pass
+ del session_exec_map[sid]
+
+@socketio.on("terminal_start")
+def handle_terminal_start(json_data):
+ sid = request.sid
+ user, role, client_ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ socketio.emit("terminal_output", {"data": "Permission denied: 身份校验失败\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", json_data.get("cid"), "auth_failed", client_ip)
+ return
+ # 权限拦截:非管理员禁止打开交互式终端
+ if role != "admin":
+ socketio.emit("terminal_output", {"data": "Permission denied: 仅管理员可访问交互式终端\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", json_data.get("cid"), "denied", client_ip)
+ return
+
+ cid = json_data.get("cid")
+ if not cid:
+ socketio.emit("terminal_output", {"data": "错误:容器ID不能为空\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", "", "empty_cid", client_ip)
+ return
+
+ # 自动探测容器可用shell,兼容全版本docker-py(exec_create返回dict取Id)
+ target_shell = None
+ for shell in SHELL_CANDIDATES:
+ try:
+ ret = api_cli.exec_create(
+ container=cid,
+ cmd=[shell, "-c", "echo ok"]
+ )
+ test_exec_id = ret["Id"]
+ test_stream = api_cli.exec_start(test_exec_id)
+ test_stream.read()
+ target_shell = shell
+ break
+ except Exception:
+ continue
+ if not target_shell:
+ socketio.emit("terminal_output", {"data": "错误:容器内无可用shell(bash/sh/ash)\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", cid, "no_shell", client_ip)
+ return
+
+ try:
+ # 创建交互式TTY会话,统一提取Id兼容所有SDK版本
+ exec_ret = api_cli.exec_create(
+ container=cid,
+ cmd=target_shell,
+ stdin=True,
+ stdout=True,
+ stderr=True,
+ tty=True
+ )
+ exec_id = exec_ret["Id"]
+ sock = api_cli.exec_start(exec_id=exec_id, tty=True, socket=True, detach=False)
+ session_exec_map[sid] = {
+ "cid": cid,
+ "exec_id": exec_id,
+ "socket": sock
+ }
+ socketio.start_background_task(stream_terminal_output, sid, sock)
+ container_name = cli.containers.get(cid).name
+ write_audit_log(user, role, "terminal_start", container_name, cid, "success", client_ip)
+ except Exception as e:
+ socketio.emit("terminal_output", {"data": f"启动终端失败:{str(e)}\n"}, room=sid)
+ write_audit_log(user, role, "terminal_start", "", cid, f"fail:{str(e)}", client_ip)
+
+@socketio.on("terminal_input")
+def handle_terminal_input(json_data):
+ """前端输入字符写入容器标准输入,审计脱敏不记录明文"""
+ sid = request.sid
+ user, role, client_ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ socketio.emit("terminal_output", {"data": "身份校验失败\n"}, room=sid)
+ return
+ if sid not in session_exec_map:
+ socketio.emit("terminal_output", {"data": "终端会话已断开,请重新打开\n"}, room=sid)
+ return
+ input_text = json_data.get("data", "")
+ sock = session_exec_map[sid]["socket"]
+ sock.send(input_text.encode("utf-8"))
+ cid = session_exec_map[sid]["cid"]
+ # 审计仅标记事件,不再记录明文命令,规避密码泄露
+ write_audit_log(user, role, "terminal_input", "", cid, "command_entered", client_ip)
+
+@socketio.on("terminal_resize")
+def handle_terminal_resize(json_data):
+ """前端xterm窗口大小自适应"""
+ sid = request.sid
+ if sid not in session_exec_map:
+ return
+ exec_id = session_exec_map[sid]["exec_id"]
+ height = int(json_data.get("height", 24))
+ width = int(json_data.get("width", 80))
+ try:
+ api_cli.exec_resize(exec_id=exec_id, height=height, width=width)
+ except Exception:
+ pass
+
+# 新增主动关闭终端事件(解决切换容器残留TTY)
+@socketio.on("terminal_close")
+def handle_terminal_close():
+ sid = request.sid
+ user, role, client_ip, auth_ok = verify_auth_sign()
+ if sid in session_exec_map:
+ cid = session_exec_map[sid]["cid"]
+ try:
+ session_exec_map[sid]["socket"].close()
+ except Exception:
+ pass
+ del session_exec_map[sid]
+ write_audit_log(user, role, "terminal_close", "", cid, "manual_close", client_ip)
+
+@socketio.on("disconnect")
+def handle_disconnect():
+ """前端断开连接,释放docker socket、清理会话,防止句柄泄漏"""
+ sid = request.sid
+ user, role, client_ip, auth_ok = verify_auth_sign()
+ # 清理TTY终端
+ if sid in session_exec_map:
+ cid = session_exec_map[sid]["cid"]
+ try:
+ session_exec_map[sid]["socket"].close()
+ except Exception:
+ pass
+ del session_exec_map[sid]
+ write_audit_log(user, role, "terminal_close", "", cid, "disconnect", client_ip)
+ # 清理实时日志标记,终止日志循环
+ if sid in log_task_map:
+ del log_task_map[sid]
+
+# ===================== WebSocket 实时流式日志 docker logs -f(生成器主动释放) =====================
+def stream_follow_log(sid, cid):
+ generator = None
+ try:
+ container = cli.containers.get(cid)
+ generator = container.logs(stream=True, follow=True)
+ log_task_map[sid] = {"cid": cid, "running": True}
+ for line in generator:
+ task_info = log_task_map.get(sid, {})
+ if not task_info.get("running"):
+ break
+ txt = line.decode("utf-8", errors="replace")
+ socketio.emit("log_output", {"data": txt}, room=sid)
+ except Exception:
+ pass
+ finally:
+ # 主动关闭日志生成器,释放docker daemon长连接
+ if generator is not None:
+ generator.close()
+ if sid in log_task_map:
+ del log_task_map[sid]
+
+@socketio.on("log_start")
+def handle_log_start(json_data):
+ sid = request.sid
+ user, role, client_ip, auth_ok = verify_auth_sign()
+ if not auth_ok:
+ socketio.emit("log_output", {"data": "身份校验失败"}, room=sid)
+ write_audit_log(user, role, "log_start", "", "", "auth_failed", client_ip)
+ return
+ cid = json_data.get("cid")
+ if not cid:
+ socketio.emit("log_output", {"data": "容器ID不能为空"}, room=sid)
+ write_audit_log(user, role, "log_start", "", "", "empty_cid", client_ip)
+ return
+ if role != "admin":
+ socketio.emit("log_output", {"data": "权限不足,仅管理员可查看实时日志"}, room=sid)
+ write_audit_log(user, role, "log_start", "", cid, "denied", client_ip)
+ return
+ socketio.start_background_task(stream_follow_log, sid, cid)
+ container_name = cli.containers.get(cid).name
+ write_audit_log(user, role, "log_start", container_name, cid, "success", client_ip)
+
+@socketio.on("log_stop")
+def handle_log_stop():
+ sid = request.sid
+ user, role, client_ip, auth_ok = verify_auth_sign()
+ if sid in log_task_map:
+ log_task_map[sid]["running"] = False
+ cid = log_task_map[sid]["cid"]
+ write_audit_log(user, role, "log_stop", "", cid, "manual_close", client_ip)
+
+# 启动入口(eventlet异步,必须socketio.run)
+if __name__ == "__main__":
+ socketio.run(app, host="0.0.0.0", port=8090, debug=False, allow_unsafe_werkzeug=True)
diff --git a/alert_dashboard/www/api/docker_api.sh b/alert_dashboard/www/api/docker_api.sh
new file mode 100644
index 0000000..f6b244a
--- /dev/null
+++ b/alert_dashboard/www/api/docker_api.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+while true; do
+nc -l 127.0.0.1 9999 <400,"msg"=>"日期格式错误,请使用 Y-m-d,例如 2026-07-01"], JSON_UNESCAPED_UNICODE);
- exit;
+
+ $singleDate = $_GET['date'] ?? '';
+ $startDate = $_GET['start'] ?? '';
+ $endDate = $_GET['end'] ?? '';
+ $where = "";
+ $fileName = "";
+
+ if (!empty($startDate) && !empty($endDate)) {
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+ header("Content-Type:application/json;charset=utf-8");
+ echo json_encode(["code"=>400,"msg"=>"日期格式错误"], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ if ($startDate > $endDate) {
+ header("Content-Type:application/json;charset=utf-8");
+ echo json_encode(["code"=>400,"msg"=>"开始日期不能大于结束日期"], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ $where = "receive_time >= '$startDate 00:00:00' AND receive_time <= '$endDate 23:59:59'";
+ $fileName = "告警报表_{$startDate}_至_{$endDate}.csv";
+ } elseif (!empty($singleDate)) {
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $singleDate)) {
+ header("Content-Type:application/json;charset=utf-8");
+ echo json_encode(["code"=>400,"msg"=>"日期格式错误"], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+ $where = "DATE(receive_time) = '$singleDate'";
+ $fileName = "告警报表_{$singleDate}.csv";
+ } else {
+ $today = date("Y-m-d");
+ $where = "DATE(receive_time) = '$today'";
+ $fileName = "告警报表_今日_{$today}.csv";
}
+ // 调试:打印where条件,访问接口先看这个
+ // echo "WHERE条件:".$where;exit;
+
$sql = "SELECT alert_type,alert_name,instance,severity,starts_at,receive_time
- FROM alert_log WHERE DATE(receive_time) = '$targetDate' ORDER BY receive_time DESC";
+ FROM alert_log WHERE {$where} ORDER BY receive_time DESC";
$res = mysqli_query($conn, $sql);
ob_clean();
header("Content-Type: text/csv; charset=utf-8");
- header("Content-Disposition: attachment; filename=告警报表_" . $targetDate . ".csv");
+ header("Content-Disposition: attachment; filename={$fileName}");
echo "\xEF\xBB\xBF";
$header = ["告警状态", "告警名称", "实例", "级别", "故障开始时间", "接收时间"];
echo implode(",", $header) . "\r\n";
@@ -69,7 +99,6 @@ if ($act === "export_csv") {
mysqli_close($conn);
exit;
}
-
// 所有JSON接口统一返回头
header("Content-Type:application/json;charset=utf-8");
$conn = getDbConn();
diff --git a/alert_dashboard/www/api/panel.php b/alert_dashboard/www/api/panel.php
index 5483cf1..1ba07ee 100644
--- a/alert_dashboard/www/api/panel.php
+++ b/alert_dashboard/www/api/panel.php
@@ -322,13 +322,25 @@ button.normal:hover{
-
9. 导出告警CSV报表(支持历史日期)
-
-
+
9. 导出告警CSV报表
+
+
+
+
-
+
-
不选择日期默认导出今日告警;选择历史日期可下载过往报表
+
不选择日期默认导出今日告警;选择历史日期下载单日报表
+
+
+
+
+
+ ~
+
+
+
+
填写开始、结束日期,导出该时间段全部告警数据
@@ -397,8 +409,8 @@ function simpleReq(act){
})
}
-// 导出CSV 支持日历选择日期
-function exportCsv(){
+// 单日导出
+function exportCsvSingle(){
const dateVal = document.getElementById("csv_date").value.trim();
let url = `${apiUrl}?act=export_csv`;
if(dateVal){
@@ -406,6 +418,23 @@ function exportCsv(){
}
window.open(url,"_blank");
}
+
+// 区间导出
+function exportCsvRange(){
+ const startDate = document.getElementById("csv_start").value.trim();
+ const endDate = document.getElementById("csv_end").value.trim();
+ if(!startDate || !endDate){
+ alert("请同时选择开始日期和结束日期");
+ return;
+ }
+ if(startDate > endDate){
+ alert("开始日期不能晚于结束日期");
+ return;
+ }
+ let url = `${apiUrl}?act=export_csv`;
+ url += `&start=${encodeURIComponent(startDate)}&end=${encodeURIComponent(endDate)}`;
+ window.open(url,"_blank");
+}