仰望星辰工作室

better-staridc-MNBT

better-staridc-MNBT/ docker/ajax.php 18.6 KB · 436 行 原始文件
Z zfhsh first commit 1 天前
1<?php
2/**
3 * Docker 控制台 AJAX 分发
4 * 入口:POST docker/ajax.php?gn=<操作>
5 * 认证:docker_token(login/logout 除外,login 需已生成 CSRF token)
6 */
7include __DIR__ . '/head.php';
8@header('Content-Type: application/json; charset=UTF-8');
9mnbt_csrf_validate_request();
10
11// 兼容两种传参:默认主题把 gn 放在 URL 查询串($_GET),SPA 放在 POST body($_POST)
12$gn = $_GET['gn'] ?? $_POST['gn'] ?? '';
13
14// —— 登录 / 登出(无需 docker 登录态)——
15if ($gn === 'login') {
16 $username = daddslashes($_POST['username'] ?? '');
17 $password = (string)($_POST['password'] ?? '');
18 if ($username === '' || $password === '') {
19 docker_json(100, '账号或密码不能为空');
20 }
21 $row = $DB->get_row_prepare("SELECT * FROM MN_docker_user WHERE username=? limit 1", [$username]);
22 if (!$row) {
23 docker_json(100, '账号或密码错误');
24 }
25 if (!docker_auth_password_verify($password, $row['password_hash'])) {
26 docker_json(100, '账号或密码错误');
27 }
28 if ($row['qk'] === 'paused') {
29 docker_json(100, '该账户已被暂停,请联系管理员');
30 }
31 if ($row['qk'] === 'expired' || $row['qk'] === 'pruned') {
32 docker_json(100, '该账户已到期,请联系管理员续费');
33 }
34 if ($row['datae'] !== '0000-00-00' && strtotime($date) - strtotime($row['datae']) > 0) {
35 $DB->query_prepare("UPDATE MN_docker_user SET qk='expired', expired_at=? WHERE id=?", [$row['datae'], $row['id']]);
36 docker_json(100, '该账户已到期,请联系管理员续费');
37 }
38 docker_auth_login($row['id'], $row['password_hash']);
39 mnbt_log($username, 'Docker登录', 'Docker 控制台登录成功', '登录成功', $DB);
40 docker_json(200, '登录成功');
41}
42if ($gn === 'logout') {
43 mnbt_log(docker_auth_current()['username'] ?? '未知', 'Docker登录', 'Docker 控制台登出', '登出', $DB);
44 docker_auth_logout();
45 docker_json(200, '已登出');
46}
47
48// —— 以下操作均需 docker 登录态 ——
49$me = docker_auth_require();
50if (!$me) {
51 docker_json(401, '请先登录');
52}
53list($bt, $nodeInfo) = docker_user_node($me);
54if (!$bt) {
55 docker_json(100, '所属节点不存在,请联系管理员');
56}
57
58// —— 我的容器(单容器隔离)——
59// 宝塔 get_list 的 ports 字段恒为空数组,端口/IP/参数信息实际来自 get_installed_apps
60if ($gn === 'my_container') {
61 $res = $bt->installed_apps();
62 $container = docker_find_my_installed_app($me, $res);
63 if ($container === false) {
64 // 节点查询失败:不清零、不改库,保留用户原数据,直接返回错误
65 docker_json(100, '节点暂时不可用,请稍后刷新');
66 }
67 // 同步容器状态($container 为 null 表示节点返回成功但列表中无该应用,此时才允许清零)
68 if ($container) {
69 $status = strtolower((string)($container['status'] ?? ''));
70 $mapped = 'running';
71 if (strpos($status, 'exit') !== false || strpos($status, 'stop') !== false) $mapped = 'stopped';
72 elseif (strpos($status, 'creat') !== false) $mapped = 'creating';
73 elseif (strpos($status, 'run') !== false) $mapped = 'running';
74 $cid = (string)($container['container_id'] ?? '');
75 $cid = substr($cid, 0, 64);
76 if ($me['container_id'] !== $cid || $me['container_status'] !== $mapped) {
77 $DB->query_prepare("UPDATE MN_docker_user SET container_id=?, container_status=? WHERE id=?", [$cid, $mapped, $me['id']]);
78 // 同步到 $me,确保返回给前端的是最新状态(否则前端会一直看到 creating)
79 $me['container_id'] = $cid;
80 $me['container_status'] = $mapped;
81 }
82 // 磁盘用量采集:从容器安装路径获取磁盘占用大小
83 $containerPath = (string)($container['path'] ?? '');
84 if ($containerPath !== '') {
85 $sizeResult = $bt->get_path_size($containerPath);
86 $diskSize = (int)($sizeResult['size'] ?? 0);
87 if ($diskSize > 0) {
88 $DB->query_prepare("UPDATE MN_docker_user SET disk_usage=?, disk_usage_at=? WHERE id=?", [$diskSize, $date, $me['id']]);
89 $me['disk_usage'] = $diskSize;
90 $me['disk_usage_at'] = $date;
91 }
92 // 磁盘超限自动停机:超过配额且容器正在运行
93 $plan = docker_user_plan($me);
94 $diskMax = $plan ? (int)$plan['disk_max'] : 0;
95 if ($diskMax > 0 && $diskSize > $diskMax * 1048576 && $mapped === 'running') {
96 $cid = (string)($container['container_id'] ?? '');
97 $cname = ltrim((string)($container['name'] ?? ($container['Names'][0] ?? '')), '/');
98 if ($cid !== '') {
99 $bt->container_stop($cid, $cname);
100 $DB->query_prepare("UPDATE MN_docker_user SET container_status='stopped' WHERE id=?", [$me['id']]);
101 $me['container_status'] = 'stopped';
102 $mapped = 'stopped';
103 mnbt_log($me['username'], 'Docker容器', '磁盘超限自动停机:用量 ' . round($diskSize / 1048576, 1) . 'MB / 配额 ' . $diskMax . 'MB', '已停机', $DB);
104 }
105 }
106 }
107 } else if ($me['container_status'] === 'running' || $me['container_status'] === 'stopped') {
108 // 应用列表中找不到,可能已被删除
109 $DB->query_prepare("UPDATE MN_docker_user SET container_id=NULL, container_status='none', service_name=NULL, disk_usage=0, disk_usage_at=NULL WHERE id=?", [$me['id']]);
110 $me['container_id'] = null;
111 $me['container_status'] = 'none';
112 $me['service_name'] = null;
113 $me['disk_usage'] = 0;
114 $me['disk_usage_at'] = null;
115 $container = null;
116 }
117 // 优先用 server_ip(节点外网 IP),否则回退到 host_ip
118 $containerIp = $container['server_ip'] ?? ($container['host_ip'] ?? '');
119 docker_json(200, 'ok', [
120 'container' => $container,
121 'me' => array_merge($me, ['password_hash' => null]),
122 'plan' => docker_user_plan($me),
123 // 节点 IP 优先取容器返回的 server_ip(最准确),否则回退到 MN_docker_node.btip
124 'node' => [
125 'btip' => $containerIp ?: ($nodeInfo['btip'] ?? ''),
126 'ptl' => $nodeInfo['ptl'] ?? 'false',
127 ],
128 ]);
129}
130
131// —— 容器启停重启(仅操作自己的容器)——
132if (in_array($gn, ['container_start', 'container_stop', 'container_restart'], true)) {
133 if (empty($me['container_id'])) {
134 docker_json(100, '您还没有容器,请先在应用商店创建');
135 }
136 // 启动前复查磁盘配额:已超限则拒绝启动(disk_max=0 表示不限制;超限自动停机见 my_container)
137 if ($gn === 'container_start') {
138 $plan = docker_user_plan($me);
139 $diskMax = $plan ? (int)$plan['disk_max'] : 0;
140 if ($diskMax > 0 && (int)($me['disk_usage'] ?? 0) > $diskMax * 1048576) {
141 docker_json(100, '磁盘用量已超配额(' . round((int)$me['disk_usage'] / 1048576, 1) . 'MB / ' . $diskMax . 'MB),请先清理磁盘后再启动');
142 }
143 }
144 $res = $bt->container_list();
145 $container = docker_find_my_container($me, $res);
146 if (!$container) {
147 docker_json(100, '未找到您的容器,可能已被删除');
148 }
149 $cid = $container['id'] ?? ($container['Id'] ?? '');
150 $cname = ltrim($container['name'] ?? ($container['Names'][0] ?? ''), '/');
151 $map = ['container_start' => 'container_start', 'container_stop' => 'container_stop', 'container_restart' => 'container_restart'];
152 $r = $bt->{$map[$gn]}($cid, $cname);
153 docker_json(($r['status'] ?? $r['code'] ?? false) ? 200 : 100, $r['msg'] ?? ($r['message'] ?? '操作完成'), ['raw' => $r]);
154}
155
156// —— 删除容器(卸载应用)——
157if ($gn === 'container_remove') {
158 if (empty($me['service_name'])) {
159 docker_json(100, '您还没有容器,请先在应用商店创建');
160 }
161 // 从 installed_apps 获取宝塔安装 ID(remove_app 的 id 参数,非 Docker container_id)
162 $apps = $bt->installed_apps();
163 $myApp = docker_find_my_installed_app($me, $apps);
164 $appId = (string)($myApp['id'] ?? '');
165 if ($appId === '') {
166 docker_json(100, '未找到应用安装 ID,请刷新后重试', ['raw' => $myApp]);
167 }
168 $r = $bt->app_remove(['id' => $appId, 'delete_data' => '0']);
169 $ok = ($r['status'] ?? false) || (($r['code'] ?? 1) === 0);
170 if ($ok) {
171 $DB->query_prepare("UPDATE MN_docker_user SET container_id=NULL, container_status='none', service_name=NULL, app_name=NULL, container_spec=NULL, disk_usage=0, disk_usage_at=NULL WHERE id=?", [$me['id']]);
172 mnbt_log($me['username'], 'Docker容器', '删除容器 ' . $me['service_name'] . ' (id=' . $appId . ')', '删除成功', $DB);
173 docker_json(200, '容器已删除');
174 }
175 docker_json(100, '删除失败:' . ($r['msg'] ?? ($r['message'] ?? '未知错误')), ['raw' => $r]);
176}
177
178// —— 镜像列表 ——
179if ($gn === 'image_list') {
180 $r = $bt->image_list();
181 docker_json(200, 'ok', ['data' => $r]);
182}
183
184// —— 存储卷列表 ——
185if ($gn === 'volume_list') {
186 $r = $bt->volume_list();
187 docker_json(200, 'ok', ['data' => $r]);
188}
189
190// —— Compose 模板 + 项目 ——
191if ($gn === 'compose_list') {
192 $tpl = $bt->template_list();
193 $proj = $bt->project_list();
194 docker_json(200, 'ok', ['templates' => $tpl, 'projects' => $proj]);
195}
196
197// —— 应用商店列表 ——
198if ($gn === 'app_list') {
199 $r = $bt->app_list();
200 docker_json(200, 'ok', ['data' => $r]);
201}
202
203// —— 单个应用详情(前端按 appname 过滤)——
204if ($gn === 'app_detail') {
205 $appname = daddslashes($_POST['appname'] ?? '');
206 if ($appname === '') docker_json(100, '缺少应用名');
207 $r = $bt->app_list();
208 $list = $r['data'] ?? $r;
209 $found = null;
210 if (is_array($list)) {
211 foreach ($list as $app) {
212 if (($app['appname'] ?? '') === $appname) { $found = $app; break; }
213 }
214 }
215 if (!$found) docker_json(100, '应用不存在');
216 docker_json(200, 'ok', ['app' => $found]);
217}
218
219// —— 依赖查询 ——
220if ($gn === 'app_dependence') {
221 $appname = daddslashes($_POST['appname'] ?? '');
222 $r = $bt->app_dependence($appname);
223 docker_json(200, 'ok', ['data' => $r]);
224}
225
226// —— 创建应用(开通容器)单容器模型 + 配额校验 ——
227if ($gn === 'app_create') {
228 // 单容器:已有容器则拒绝
229 if (!empty($me['container_id']) || !empty($me['service_name'])) {
230 docker_json(100, '每个账户仅允许创建一个容器,如需重建请联系管理员先删除现有容器');
231 }
232 $app_name = daddslashes($_POST['app_name'] ?? '');
233 $m_version = daddslashes($_POST['m_version'] ?? '');
234 $s_version = daddslashes($_POST['s_version'] ?? '');
235 // s_version 允许为空:部分应用(如 frps)只有 m_version=latest 无子版本
236 // 宝塔后端会用 m_version 作为镜像 tag;若强制 s_version='0' 会拼出 latest.0 无效 tag
237 if ($app_name === '' || $m_version === '') {
238 docker_json(100, '应用参数不完整');
239 }
240 // 配额校验
241 $plan = docker_user_plan($me);
242 $cpu_max = $plan ? (float)$plan['cpu_max'] : 1;
243 $mem_max = $plan ? (float)$plan['mem_max'] : 512;
244 $cpus = (int)($_POST['cpus'] ?? 0);
245 $memory_limit = (int)($_POST['memory_limit'] ?? 0);
246 // 0 = 不限制;宝塔后端用 int() 转换,必须整数
247 if ($cpus < 0) $cpus = 0;
248 if ($memory_limit < 0) $memory_limit = 0;
249 if ($cpus > $cpu_max) $cpus = (int)floor($cpu_max);
250 if ($memory_limit > $mem_max) $memory_limit = (int)floor($mem_max);
251
252 // 组装 service_name(唯一,前缀 mnbt_ + 用户名净化)
253 $cleanUser = preg_replace('/[^a-z0-9]/', '', strtolower($me['username']));
254 if ($cleanUser === '') $cleanUser = 'u' . $me['id'];
255 $service_name = 'mnbt_' . substr($cleanUser, 0, 20);
256
257 // 收集应用专属参数(透传 env/field 中除通用参数外的值)
258 $builtins = ['app_name' => 1, 'service_name' => 1, 'm_version' => 1, 's_version' => 1, 'cpus' => 1, 'memory_limit' => 1, 'allow_access' => 1, 'gn' => 1, 'MNBT_CSRF_TOKEN' => 1];
259 $params = [
260 'app_name' => $app_name,
261 'service_name' => $service_name,
262 'm_version' => $m_version,
263 's_version' => $s_version,
264 'allow_access' => (string)($_POST['allow_access'] ?? '1'),
265 'cpus' => (string)$cpus,
266 'memory_limit' => (string)$memory_limit,
267 ];
268 foreach ($_POST as $k => $v) {
269 if (!isset($builtins[$k]) && is_string($v)) {
270 $params[daddslashes($k)] = daddslashes($v);
271 }
272 }
273
274 // —— 端口冲突检测:检查请求的端口是否已被同节点其他容器占用 ——
275 $requestedPorts = [];
276 foreach ($params as $k => $v) {
277 // 识别端口参数:key 含 _port 或值看起来像端口号(1-65535 的纯数字字符串)
278 if (is_string($v) && ctype_digit($v) && (int)$v >= 1 && (int)$v <= 65535) {
279 $requestedPorts[(int)$v] = $k;
280 }
281 }
282 if (!empty($requestedPorts)) {
283 $installed = $bt->installed_apps();
284 $existingPorts = [];
285 $list = $installed['data'] ?? $installed;
286 if (is_array($list)) {
287 foreach ($list as $app) {
288 $appPorts = $app['port'] ?? [];
289 foreach ($appPorts as $p) {
290 $existingPorts[(int)$p] = ($app['service_name'] ?? '') . '/' . ($app['apptitle'] ?? $app['appname'] ?? '');
291 }
292 }
293 }
294 $conflicts = [];
295 foreach ($requestedPorts as $port => $key) {
296 if (isset($existingPorts[$port])) {
297 $conflicts[] = $port . '(参数 ' . $key . ',已被 ' . $existingPorts[$port] . ' 占用)';
298 }
299 }
300 if (!empty($conflicts)) {
301 mnbt_log($me['username'], 'Docker容器', '创建容器 ' . $app_name . ' 端口冲突:' . implode('; ', $conflicts), '创建失败', $DB);
302 docker_json(100, '端口冲突:' . implode(';', $conflicts) . '。请返回应用商店,系统将自动分配新端口。');
303 }
304 }
305
306 $r = $bt->app_create($params);
307 $ok = ($r['status'] ?? false) || ((($r['code'] ?? 1) === 0) && (($r['status'] ?? false) === true));
308 if ($ok) {
309 $spec = ['app_name' => $app_name, 'm_version' => $m_version, 's_version' => $s_version, 'cpus' => $cpus, 'memory_limit' => $memory_limit, 'params' => $params];
310 $DB->query_prepare("UPDATE MN_docker_user SET service_name=?, app_name=?, container_spec=?, container_status='creating' WHERE id=?", [$service_name, $app_name, json_encode($spec, JSON_UNESCAPED_UNICODE), $me['id']]);
311 mnbt_log($me['username'], 'Docker容器', '创建容器 ' . $app_name . ' (' . $service_name . ')', '创建中', $DB);
312 docker_json(200, '应用创建请求已提交,请耐心等待 1-5 分钟初始化', ['service_name' => $service_name]);
313 }
314 docker_json(100, '创建失败:' . ($r['msg'] ?? ($r['message'] ?? '未知错误')), ['raw' => $r]);
315}
316
317// —— 反向代理管理 ——
318// 获取容器端口列表(供反向代理页面选择)
319if ($gn === 'container_ports') {
320 $res = $bt->installed_apps();
321 $container = docker_find_my_installed_app($me, $res);
322 $ports = [];
323 if ($container) {
324 $rawPorts = $container['port'] ?? [];
325 foreach ($rawPorts as $p) {
326 $ports[] = (int)$p;
327 }
328 }
329 sort($ports);
330 docker_json(200, 'ok', ['ports' => $ports]);
331}
332
333if ($gn === 'proxy_list') {
334 $px = docker_user_proxy($me);
335 if (!$px) docker_json(100, '所属节点不可用');
336 $r = $px->proxy_list(1, 200);
337 $list = $r['data'] ?? $r;
338 if (!is_array($list)) $list = [];
339 // 归属过滤:remark 带 "用户名|" 前缀的代理只返回本人的(宝塔列表项备注在 ps 字段,兼容 remark);
340 // 无前缀的存量代理无法归属,保持旧行为原样返回(存量兼容,归属标记见 proxy_create)
341 $username = (string)$me['username'];
342 $filtered = [];
343 foreach ($list as $item) {
344 if (!is_array($item)) { $filtered[] = $item; continue; }
345 $itemRemark = (string)($item['ps'] ?? $item['remark'] ?? '');
346 if ($itemRemark === '' || strpos($itemRemark, $username . '|') === 0) {
347 $filtered[] = $item;
348 }
349 }
350 docker_json(200, 'ok', ['data' => $filtered]);
351}
352
353if ($gn === 'proxy_create') {
354 $px = docker_user_proxy($me);
355 if (!$px) docker_json(100, '所属节点不可用');
356 // 配额检查(只统计本人名下的代理)
357 $plan = docker_user_plan($me);
358 $proxyMax = $plan ? (int)$plan['proxy_max'] : 0;
359 if ($proxyMax > 0) {
360 $existing = $px->proxy_list(1, 200);
361 $existingList = $existing['data'] ?? $existing;
362 $count = 0;
363 if (is_array($existingList)) {
364 foreach ($existingList as $item) {
365 if (is_array($item) && strpos((string)($item['ps'] ?? $item['remark'] ?? ''), $me['username'] . '|') === 0) $count++;
366 }
367 }
368 if ($count >= $proxyMax) {
369 docker_json(100, '反向代理数量已达上限(' . $proxyMax . '个),请先删除后再添加');
370 }
371 }
372 $domains = daddslashes($_POST['domains'] ?? '');
373 $port = (int)($_POST['port'] ?? 0);
374 $proto = daddslashes($_POST['proto'] ?? 'http');
375 $ip = daddslashes($_POST['ip'] ?? '127.0.0.1');
376 $proxy_path = daddslashes($_POST['proxy_path'] ?? '/');
377 if ($domains === '' || $port <= 0) {
378 docker_json(100, '域名和容器端口不能为空');
379 }
380 // ip 基本校验:必须为合法 IP 或 host:port 格式,拒绝空值与特殊字符
381 if ($ip === '' || !preg_match('/^[a-zA-Z0-9.\-]+(:[0-9]{1,5})?$/', $ip)) {
382 docker_json(100, '代理目标 IP 格式不正确');
383 }
384 // 代理目标,IP 默认 127.0.0.1(容器与本机同机部署),协议由用户选择
385 $proto = ($proto === 'https') ? 'https' : 'http';
386 $proxy_pass = $proto . '://' . $ip . ':' . $port;
387 // 归属标记:remark 以 "用户名|" 前缀标识代理归属,供列表过滤与删除校验使用
388 // (存量兼容:此前创建的代理 remark 无该前缀,无法归属,列表/删除保持旧行为)
389 $remark = $me['username'] . '|' . daddslashes($_POST['remark'] ?? '');
390 $r = $px->proxy_create([
391 'domains' => $domains,
392 'proxy_pass' => $proxy_pass,
393 'proxy_path' => $proxy_path ?: '/',
394 'remark' => $remark,
395 ]);
396 $ok = ($r['status'] ?? false) || (($r['code'] ?? 1) === 0);
397 if ($ok) {
398 mnbt_log($me['username'], 'Docker代理', '创建反向代理 ' . $domains . ' → ' . $proxy_pass, '创建成功', $DB);
399 docker_json(200, '反向代理创建成功');
400 }
401 docker_json(100, '创建失败:' . ($r['msg'] ?? ($r['message'] ?? '未知错误')), ['raw' => $r]);
402}
403
404if ($gn === 'proxy_delete') {
405 $px = docker_user_proxy($me);
406 if (!$px) docker_json(100, '所属节点不可用');
407 $id = (int)($_POST['id'] ?? 0);
408 $site_name = daddslashes($_POST['site_name'] ?? '');
409 if ($id <= 0 || $site_name === '') docker_json(100, '参数错误');
410 // 归属校验:remark 带 "用户名|" 前缀的代理只有本人才允许删除;
411 // 无前缀的存量代理无法归属,保持旧行为允许删除(存量兼容,归属标记见 proxy_create)
412 $target = null;
413 $r0 = $px->proxy_list(1, 200);
414 $l0 = $r0['data'] ?? $r0;
415 if (is_array($l0)) {
416 foreach ($l0 as $item) {
417 if (is_array($item) && (int)($item['id'] ?? 0) === $id) { $target = $item; break; }
418 }
419 }
420 if ($target !== null) {
421 $targetRemark = (string)($target['ps'] ?? $target['remark'] ?? '');
422 // 有归属标记且不属于本人 → 拒绝;无标记的存量代理无法归属,保持旧行为放行
423 if ($targetRemark !== '' && strpos($targetRemark, $me['username'] . '|') !== 0) {
424 docker_json(100, '无权操作:该反向代理不属于当前账户');
425 }
426 }
427 $r = $px->proxy_delete($id, $site_name);
428 $ok = ($r['status'] ?? false) || (($r['code'] ?? 1) === 0);
429 if ($ok) {
430 mnbt_log($me['username'], 'Docker代理', '删除反向代理 #' . $id . ' ' . $site_name, '删除成功', $DB);
431 docker_json(200, '反向代理已删除');
432 }
433 docker_json(100, '删除失败:' . ($r['msg'] ?? ($r['message'] ?? '未知错误')), ['raw' => $r]);
434}
435
436docker_json(100, '未知操作');