From 80386d35bafda04b3ac7956e5105409d1c72debd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A2?= <2711452309@qq.com> Date: Wed, 10 Jun 2026 01:01:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E6=B5=8B=E8=AF=95LXC=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=9F=BA=E6=9C=AC=E6=AD=A3=E5=B8=B8=20KVM=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=9A=82=E6=9C=AA=E8=BF=9B=E8=A1=8C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mofang/README.md | 375 ++++++++++ Mofang/clicd.php | 1368 ++++++++++++++++++++++++++++++++++++ Mofang/clicd.zip | Bin 0 -> 23833 bytes Mofang/handlers/webssh.php | 384 ++++++++++ Mofang/templates/info.html | 378 ++++++++++ Mofang/templates/nat.html | 328 +++++++++ 6 files changed, 2833 insertions(+) create mode 100644 Mofang/README.md create mode 100644 Mofang/clicd.php create mode 100644 Mofang/clicd.zip create mode 100644 Mofang/handlers/webssh.php create mode 100644 Mofang/templates/info.html create mode 100644 Mofang/templates/nat.html diff --git a/Mofang/README.md b/Mofang/README.md new file mode 100644 index 0000000..23012d1 --- /dev/null +++ b/Mofang/README.md @@ -0,0 +1,375 @@ +# CLICD 魔方财务对接模块 + +这是用于智简魔方 / IDCSMART 的 CLICD 服务器模块。模块通过 CLICD API 完成实例开通、删除、开关机、重启、重装、改密、资源变更、流量重置、NAT 端口映射管理、实例信息展示和 WebSSH 入口。 + +## 文件结构 + +```text +clicd.php +README.md +handlers/ + webssh.php +templates/ + info.html + nat.html +``` + +安装时请保持目录结构不变,将整个 `clicd` 目录放入魔方服务器模块目录: + +```text +public/plugins/servers/clicd/ +``` + +## 服务器配置 + +在魔方后台添加服务器时,模块名称选择 `clicd`。 + +CLICD 面板地址建议使用 HTTPS: + +```text +主机名 = https://0.0.0.0:8999 +``` + +也可以拆分填写: + +```text +IP地址 = 0.0.0.0 +端口 = 8999 +secure = 开启 +``` + +API Key 可以填写在以下任意一个字段中: + +```text +Hash +密码 +``` + +模块请求 CLICD 时会同时携带: + +```text +X-API-Key: clicd_sk_xxxx +Authorization: Bearer clicd_sk_xxxx +Content-Type: application/json +``` + +## 产品配置项 + +| 字段 | 说明 | +| --- | --- | +| `virtualization` | 虚拟化类型,`lxc` 或 `kvm` | +| `template_id` | CLICD 模板 / 镜像 ID | +| `vcpu` | CPU 核心数 | +| `cpu_percent` | CPU 使用率限制,`0` 表示不额外限制 | +| `ram_mb` | 内存,单位 MB | +| `disk_gb` | 系统盘,单位 GB | +| `network_bw_mbps` | 带宽,单位 Mbps | +| `traffic_mode` | `total` 总流量,或 `in_out` 入 / 出分开 | +| `monthly_traffic_gb` | 月流量 GB | +| `traffic_in_gb` | 入站流量 GB,`in_out` 模式使用 | +| `traffic_out_gb` | 出站流量 GB,`in_out` 模式使用 | +| `io_speed_mbps` | 磁盘 IO 限制,`0` 表示不限制 | +| `port_mapping_count` | 开通时分配的 NAT 端口数量,最小 2 | +| `snapshot_limit` | 快照配额 | +| `extra_ports` | 额外映射的容器端口,逗号分隔,例如 `80,443` | +| `assign_ipv6` | 开通时是否自动分配 IPv6 | +| `sync_expiry` | 是否同步魔方到期时间到 CLICD | + +客户产品的 `domain` 会作为 CLICD 容器名称。模块会自动把不适合作为容器名的字符替换为 `-`。 + +## 开通后字段同步 + +开通、同步、重装、改密后,模块会从 CLICD 容器详情拉取最新信息并写回魔方主机表: + +| 魔方字段 | 写入内容 | +| --- | --- | +| `dedicatedip` | NAT 外网 IP,优先使用 API 返回的公网字段,否则使用服务器 IP | +| `username` | 固定写入 `root` | +| `password` | CLICD 返回的 SSH 密码,兼容魔方 `cmf_encrypt()` | +| `port` | CLICD 返回的 `ssh_port` | +| `domainstatus` | CLICD 状态为 `running` 时写 `Active`,否则写 `Suspended` | + +如果接口返回的密码是 `***` 这类脱敏值,模块不会覆盖魔方里已有密码。 + +## 客户区页面 + +模块提供两个客户区选项卡: + +```text +实例信息 +NAT转发 +``` + +客户区按钮提供: + +```text +WebSSH +``` + +## 实例信息 + +实例信息页展示: + +- 实例名称、运行状态、SSH 地址、IPv6 +- CPU、内存、负载、磁盘圆环状态 +- 月流量进度 +- CPU 使用率、内存使用、网络流量、磁盘 IO 图表 +- IPv4、SSH 端口、SSH 密码、资源配置、到期时间 + +图表数据通过客户区懒加载接口获取,不会强制刷新整个魔方页面。页面首次打开会加载一次数据,之后由用户选择是否自动刷新: + +```text +不刷新 +10 秒 +1 分钟 +5 分钟 +10 分钟 +``` + +也可以点击“立即刷新”手动刷新一次。当前 CLICD 用量接口返回的是实时值,不是历史数组;图表曲线由客户区前端持续采样生成。若需要打开页面立即显示历史曲线,需要 CLICD 额外提供历史指标接口。 + +流量显示支持智能单位,小流量会显示 B / KB / MB,大流量显示 GB,例如: + +```text +370.5 KB / 100 GB +``` + +模块会优先调用: + +```text +GET /api/v1/containers/{name}/usage +GET /api/v1/containers/{name}/traffic +``` + +如果 `/api/v1/containers/{name}/usage` 不可用,模块会在容器详情存在 `uuid` 时尝试兼容: + +```text +GET /api/containers/{uuid}/usage +``` + +已兼容的常见用量字段包括: + +```text +cpu_usage_pct +memory_usage_bytes +disk_usage_bytes +network_rx_bps +network_tx_bps +disk_read_bps +disk_write_bps +rx_used_bytes +tx_used_bytes +total_used_bytes +limit_gb +used_pct +``` + +## NAT 转发 + +NAT 转发是独立页面,支持: + +- 查看端口映射 +- 获取随机可用端口 +- 添加端口映射 +- 修改端口映射 +- 删除端口映射 + +删除端口映射时使用页面内确认弹窗,不使用浏览器自带确认框。 + +使用的 CLICD API: + +```text +GET /api/v1/containers/{id|uuid|name} +GET /api/v1/containers/{id}/random-port +POST /api/v1/containers/{id}/port-mappings +PUT /api/v1/containers/{id}/port-mappings/{index} +DELETE /api/v1/containers/{id}/port-mappings/{index} +``` + +添加 / 修改 NAT 映射时必须使用 JSON 请求体,例如: + +```json +{ + "container_port": 8080, + "host_port": 61320, + "protocol": "tcp", + "description": "HTTP" +} +``` + +## WebSSH + +WebSSH 按钮会调用: + +```text +POST /api/v1/ssh-ticket +``` + +请求体: + +```json +{ + "container_name": "example-vm" +} +``` + +接口返回 60 秒有效票据后,模块会打开本地 handler: + +```text +/plugins/servers/clicd/handlers/webssh.php +``` + +浏览器会从该页面直连 CLICD: + +```text +wss://0.0.0.0:8999/api/ssh?container=example-vm +Sec-WebSocket-Protocol: clicd-ticket.xxxxx +``` + +注意:WebSSH 受浏览器安全策略和 CLICD 后端 Origin 校验影响。魔方客户区通常是 HTTPS,因此 CLICD 面板也必须启用 HTTPS/WSS。请把魔方服务器配置里的 `主机名` 改为 `https://0.0.0.0:8999`,或把 `secure` 设为 `开启`。 + +新版 CLICD 已支持 WebSSH Origin 放行。部署时需要在 CLICD 后端把魔方财务客户区域名加入 WebSSH Origin 白名单,例如: + +```text +https://www.example.com +``` + +如果 WebSSH 页面显示 `WebSocket error`、`Disconnected code=1006`,但直接以 CLICD 自身 Origin 测试能返回 `101 Switching Protocols`,通常说明 CLICD 后端未放行魔方客户区域名的 WebSocket Origin。此时请检查 CLICD 的 WebSSH Origin 白名单配置;前端页面无法伪造浏览器 Origin。 + +## 支持的魔方操作 + +| 魔方操作 | CLICD API | +| --- | --- | +| 连接测试 | `GET /api/v1/dashboard` | +| 开通 | `POST /api/v1/containers` | +| 删除 | `DELETE /api/v1/containers/{name}/delete` | +| 开机 | `POST /api/v1/containers/{name}/start` | +| 关机 | `POST /api/v1/containers/{name}/stop` | +| 重启 | `POST /api/v1/containers/{name}/restart` | +| 重装 | `POST /api/v1/containers/{name}/reinstall` | +| 改密 | `POST /api/v1/containers/{name}/reset-password` | +| 重置流量 | `POST /api/v1/containers/{name}/traffic-reset` | +| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` | +| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` | +| 同步到期 | `PUT /api/v1/containers/{name}/expiry` | +| WebSSH | `POST /api/v1/ssh-ticket` | + +## 建议 API 权限 + +API Key 至少需要以下权限,具体名称以 CLICD 后端实际权限系统为准: + +```text +dashboard:read +container:read +container:create +container:power +container:delete +container:reinstall +container:password +container:traffic +container:resize +container:port +task:read +ssh-ticket:create +``` + +如果 API Key 使用 `*` 或 `admin:*`,通常可以覆盖上述权限。 + +## 建议先测试的 curl + +连接测试: + +```bash +curl -H "X-API-Key: clicd_sk_xxxx" \ + https://0.0.0.0:8999/api/v1/dashboard +``` + +容器详情: + +```bash +curl -H "X-API-Key: clicd_sk_xxxx" \ + https://0.0.0.0:8999/api/v1/containers/example-vm +``` + +资源用量: + +```bash +curl -H "X-API-Key: clicd_sk_xxxx" \ + https://0.0.0.0:8999/api/v1/containers/example-vm/usage +``` + +流量统计: + +```bash +curl -H "X-API-Key: clicd_sk_xxxx" \ + https://0.0.0.0:8999/api/v1/containers/example-vm/traffic +``` + +修改 NAT: + +```bash +curl --location --request PUT \ + "https://0.0.0.0:8999/api/v1/containers/10/port-mappings/1" \ + --header "X-API-Key: clicd_sk_xxxx" \ + --header "Authorization: Bearer clicd_sk_xxxx" \ + --header "Content-Type: application/json" \ + --data-raw '{"container_port":8081,"host_port":61320,"protocol":"tcp","description":"HTTP"}' +``` + +创建 WebSSH 票据: + +```bash +curl --location --request POST \ + "https://0.0.0.0:8999/api/v1/ssh-ticket" \ + --header "X-API-Key: clicd_sk_xxxx" \ + --header "Content-Type: application/json" \ + --data-raw '{"container_name":"example-vm"}' +``` + +## 常见问题 + +### NAT 修改不生效 + +确认请求体必须是 JSON,不要使用 `multipart/form-data`。正确请求头: + +```text +Content-Type: application/json +``` + +### 图表刚打开只有一条横线 + +CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。 + +### 流量显示为 0 + +旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。 + +### WebSSH 打不开或提示不安全 WebSocket + +请确认 CLICD 面板已经启用 HTTPS/WSS,并且魔方服务器配置使用 HTTPS: + +```text +server_host = https://0.0.0.0:8999 +``` + +如果仍然使用 `http://`,模块会生成 `ws://` 地址,HTTPS 客户区页面会被浏览器拦截。 + +如果 WSS 证书正常但仍返回 `Forbidden` 或浏览器显示 `code=1006`,请检查 CLICD 的 WebSSH Origin 白名单。新版 CLICD 已支持放行魔方财务域名,需要把魔方客户区访问域名完整加入白名单,例如: + +```text +https://www.example.com +``` + +注意需要填写浏览器实际访问魔方客户区时的协议和域名,`http` / `https`、带不带 `www` 都要与实际访问地址一致。 + +### 开通后魔方里的 IP、端口、密码不对 + +执行“同步状态”或重装 / 改密后,模块会重新拉取容器详情。请确认 CLICD 容器详情接口能返回: + +```text +ssh_port +ssh_password +status +``` + +公网 IP 优先使用 `nat_public_ip/public_ip/host_ip/external_ip/node_ip/nat_host` 等字段;如果接口没有返回,则使用魔方服务器配置的 IP。 diff --git a/Mofang/clicd.php b/Mofang/clicd.php new file mode 100644 index 0000000..7313aa5 --- /dev/null +++ b/Mofang/clicd.php @@ -0,0 +1,1368 @@ + date('Y-m-d H:i:s'), + 'message' => $message, + 'data' => $data, + ]; +} + +function clicd_json_response($payload) +{ + if (!headers_sent()) { + header('Content-Type: application/json; charset=utf-8'); + } + echo json_encode($payload, JSON_UNESCAPED_UNICODE); + exit; +} + +function clicd_MetaData() +{ + return [ + 'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5', + 'APIVersion' => '1.1', + 'HelpDoc' => 'https://github.com/MengMengCode/CLICD', + 'version' => '1.0.1', + ]; +} + +function clicd_ConfigOptions() +{ + return [ + ['type' => 'dropdown', 'name' => '虚拟化类型', 'description' => 'lxc 或 kvm', 'default' => 'lxc', 'key' => 'virtualization', 'options' => ['lxc' => 'LXC', 'kvm' => 'KVM']], + ['type' => 'text', 'name' => '镜像/模板 ID', 'description' => 'CLICD 模板 ID,例如 alpine-3.21、debian-bookworm、ubuntu-jammy 或已启用的 KVM 镜像 ID', 'default' => 'alpine-3.21', 'key' => 'template_id'], + ['type' => 'text', 'name' => 'CPU 核心', 'description' => 'vCPU 数量,KVM 必须为整数', 'default' => '1', 'key' => 'vcpu'], + ['type' => 'text', 'name' => 'CPU 百分比', 'description' => 'CPU 使用率限制,0 表示不额外限制', 'default' => '0', 'key' => 'cpu_percent'], + ['type' => 'text', 'name' => '内存 MB', 'description' => '容器内存,单位 MB', 'default' => '512', 'key' => 'ram_mb'], + ['type' => 'text', 'name' => '硬盘 GB', 'description' => '系统盘大小,单位 GB', 'default' => '5', 'key' => 'disk_gb'], + ['type' => 'text', 'name' => '带宽 Mbps', 'description' => '网络带宽限制,0 表示不限制', 'default' => '100', 'key' => 'network_bw_mbps'], + ['type' => 'dropdown', 'name' => '流量模式', 'description' => 'total=总流量,in_out=分别限制入/出方向', 'default' => 'total', 'key' => 'traffic_mode', 'options' => ['total' => '总流量', 'in_out' => '入/出分开']], + ['type' => 'text', 'name' => '月流量 GB', 'description' => 'total 模式下的月流量限制,0 表示不限制', 'default' => '100', 'key' => 'monthly_traffic_gb'], + ['type' => 'text', 'name' => '入站流量 GB', 'description' => 'in_out 模式下入站流量限制,0 表示不限制', 'default' => '0', 'key' => 'traffic_in_gb'], + ['type' => 'text', 'name' => '出站流量 GB', 'description' => 'in_out 模式下出站流量限制,0 表示不限制', 'default' => '0', 'key' => 'traffic_out_gb'], + ['type' => 'text', 'name' => 'IO 速度 MB/s', 'description' => '磁盘 IO 限制,0 表示不限制', 'default' => '0', 'key' => 'io_speed_mbps'], + ['type' => 'text', 'name' => 'NAT 端口数量', 'description' => '开通时分配的端口映射数量,最小 2', 'default' => '2', 'key' => 'port_mapping_count'], + ['type' => 'text', 'name' => '快照配额', 'description' => '每台实例允许保留的快照数量', 'default' => '3', 'key' => 'snapshot_limit'], + ['type' => 'text', 'name' => '额外端口', 'description' => '逗号分隔的容器端口,例如 80,443', 'default' => '', 'key' => 'extra_ports'], + ['type' => 'dropdown', 'name' => '自动 IPv6', 'description' => '开通时自动分配 IPv6', 'default' => 'false', 'key' => 'assign_ipv6', 'options' => ['true' => '启用', 'false' => '禁用']], + ['type' => 'dropdown', 'name' => '同步到期时间', 'description' => '开通/续费时把魔方到期日期同步到 CLICD,格式会转换为 YYYY-MM-DD', 'default' => 'true', 'key' => 'sync_expiry', 'options' => ['true' => '启用', 'false' => '禁用']], + ]; +} + +function clicd_base_url($params) +{ + if (!empty($params['server_host'])) { + return rtrim($params['server_host'], '/'); + } + + $host = $params['server_ip'] ?? $params['ip'] ?? ''; + $port = $params['port'] ?? ''; + $scheme = (!empty($params['secure']) && (string)$params['secure'] !== '0') ? 'https' : 'http'; + + if (stripos($host, 'http://') === 0 || stripos($host, 'https://') === 0) { + $base = rtrim($host, '/'); + } else { + $base = $scheme . '://' . $host; + } + + if ($port !== '' && strpos(parse_url($base, PHP_URL_HOST) ?: $base, ':') === false) { + $base .= ':' . $port; + } + + return rtrim($base, '/'); +} + +function clicd_api_key($params) +{ + foreach (['accesshash', 'server_password', 'password'] as $key) { + if (!empty($params[$key])) { + return trim($params[$key]); + } + } + return ''; +} + +function clicd_request($params, $endpoint, $data = [], $method = 'GET', $timeout = 30) +{ + $url = clicd_base_url($params) . $endpoint; + $apiKey = clicd_api_key($params); + $method = strtoupper($method); + + $curl = curl_init(); + $headers = [ + 'Content-Type: application/json', + 'X-API-Key: ' . $apiKey, + 'Authorization: Bearer ' . $apiKey, + ]; + + $options = [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_USERAGENT => 'Mofang-CLICD', + ]; + + if ($method !== 'GET' && $data !== null) { + $options[CURLOPT_POSTFIELDS] = json_encode($data, JSON_UNESCAPED_UNICODE); + } + + curl_setopt_array($curl, $options); + $body = curl_exec($curl); + $errno = curl_errno($curl); + $error = curl_error($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + clicd_debug('request', ['url' => $url, 'method' => $method, 'http_code' => $httpCode, 'errno' => $errno]); + + if ($errno) { + return ['success' => false, 'message' => 'CURL ERROR: ' . $error, '_http_code' => 0]; + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + return ['success' => false, 'message' => 'Invalid JSON response: ' . substr((string)$body, 0, 300), '_http_code' => $httpCode]; + } + + $decoded['_http_code'] = $httpCode; + return $decoded; +} + +function clicd_request_debug($params, $endpoint, $data = [], $method = 'GET', $timeout = 30) +{ + $started = microtime(true); + $res = clicd_request($params, $endpoint, $data, $method, $timeout); + return [ + 'response' => $res, + 'debug' => clicd_debug_entry('CLICD API request', [ + 'method' => strtoupper($method), + 'endpoint' => $endpoint, + 'payload' => $data, + 'http' => is_array($res) ? ($res['_http_code'] ?? null) : null, + 'success' => clicd_success($res), + 'message' => clicd_message($res, ''), + 'ms' => (int)round((microtime(true) - $started) * 1000), + ]), + ]; +} + +function clicd_success($res) +{ + if (!is_array($res)) { + return false; + } + if (isset($res['success'])) { + return (bool)$res['success']; + } + return isset($res['code']) && (int)$res['code'] >= 200 && (int)$res['code'] < 300; +} + +function clicd_message($res, $fallback = '操作失败') +{ + if (!is_array($res)) { + return $fallback; + } + return $res['message'] ?? $res['msg'] ?? $res['error'] ?? $fallback; +} + +function clicd_container_name($params) +{ + $name = $params['domain'] ?? ''; + if (is_array($name)) { + $name = reset($name); + } + $name = trim((string)$name); + if ($name === '') { + $name = 'host-' . ($params['hostid'] ?? time()); + } + $name = preg_replace('/[^A-Za-z0-9_.-]/', '-', $name); + return trim($name, '-.'); +} + +function clicd_public_host($params, $container = []) +{ + if (is_array($container)) { + foreach (['nat_public_ip', 'public_ip', 'host_ip', 'external_ip', 'node_ip', 'nat_host'] as $key) { + if (!empty($container[$key])) { + return trim((string)$container[$key]); + } + } + } + + foreach (['server_ip', 'ip'] as $key) { + if (!empty($params[$key])) { + $value = trim((string)$params[$key]); + if (stripos($value, 'http://') === 0 || stripos($value, 'https://') === 0) { + return parse_url($value, PHP_URL_HOST) ?: $value; + } + return $value; + } + } + + return parse_url(clicd_base_url($params), PHP_URL_HOST) ?: ''; +} + +function clicd_container_ssh_port($container) +{ + if (!is_array($container)) { + return ''; + } + foreach (['ssh_port', 'host_ssh_port', 'nat_ssh_port'] as $key) { + if (isset($container[$key]) && $container[$key] !== '') { + return (int)$container[$key]; + } + } + return ''; +} + +function clicd_container_password($container) +{ + if (!is_array($container)) { + return ''; + } + foreach (['ssh_password', 'password', 'root_password', 'default_password'] as $key) { + if (isset($container[$key]) && $container[$key] !== '') { + $password = trim((string)$container[$key]); + if ($password !== '' && !preg_match('/^\*+$/', $password)) { + return $password; + } + } + } + return ''; +} + +function clicd_store_password($password) +{ + $password = (string)$password; + if ($password === '') { + return ''; + } + return function_exists('cmf_encrypt') ? cmf_encrypt($password) : $password; +} + +function clicd_webssh_url($params, $ticket, $containerName) +{ + $baseUrl = rtrim(clicd_base_url($params), '/'); + $scheme = stripos($baseUrl, 'https://') === 0 ? 'wss' : 'ws'; + $host = parse_url($baseUrl, PHP_URL_HOST); + $port = parse_url($baseUrl, PHP_URL_PORT); + $wsBase = $scheme . '://' . $host . ($port ? ':' . $port : ''); + $wsUrl = $wsBase . '/api/ssh?container=' . rawurlencode((string)$containerName); + + $siteScheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $siteHost = $_SERVER['HTTP_HOST'] ?? ''; + $handler = ($siteHost !== '' ? $siteScheme . '://' . $siteHost : '') . '/plugins/servers/clicd/handlers/webssh.php'; + + return $handler + . '?ws=' . rawurlencode($wsUrl) + . '&protocol=' . rawurlencode('clicd-ticket.' . (string)$ticket) + . '&container=' . rawurlencode((string)$containerName); +} + +function clicd_bool_option($value, $default = false) +{ + if ($value === null || $value === '') { + return $default; + } + if (is_bool($value)) { + return $value; + } + return in_array(strtolower((string)$value), ['1', 'true', 'yes', 'on'], true); +} + +function clicd_int_option($options, $key, $default = 0) +{ + if (!isset($options[$key]) || $options[$key] === '') { + return $default; + } + return (int)$options[$key]; +} + +function clicd_float_option($options, $key, $default = 0) +{ + if (!isset($options[$key]) || $options[$key] === '') { + return $default; + } + return (float)$options[$key]; +} + +function clicd_number_value($value, $default = 0) +{ + if (is_numeric($value)) { + return (float)$value; + } + if (is_string($value) && preg_match('/-?\d+(?:\.\d+)?/', $value, $match)) { + return (float)$match[0]; + } + return $default; +} + +function clicd_pick_number($sources, $keys, $default = 0) +{ + foreach ($sources as $source) { + if (!is_array($source)) { + continue; + } + foreach ($keys as $key) { + if (array_key_exists($key, $source) && $source[$key] !== '' && $source[$key] !== null) { + return clicd_number_value($source[$key], $default); + } + } + } + return $default; +} + +function clicd_pct($value) +{ + $value = clicd_number_value($value, 0); + if ($value < 0) { + return 0; + } + if ($value > 100) { + return 100; + } + return round($value, 2); +} + +function clicd_bytes_to_gb($bytes) +{ + return round(clicd_number_value($bytes, 0) / 1073741824, 2); +} + +function clicd_bytes_to_mb($bytes) +{ + return round(clicd_number_value($bytes, 0) / 1048576, 2); +} + +function clicd_format_bytes($bytes) +{ + $value = clicd_number_value($bytes, 0); + if ($value >= 1073741824) { + return round($value / 1073741824, 2) . ' GB'; + } + if ($value >= 1048576) { + return round($value / 1048576, 2) . ' MB'; + } + if ($value >= 1024) { + return round($value / 1024, 2) . ' KB'; + } + return round($value, 2) . ' B'; +} + +function clicd_format_rate($bytesPerSecond) +{ + $value = clicd_number_value($bytesPerSecond, 0); + if ($value >= 1073741824) { + return round($value / 1073741824, 2) . ' GB/s'; + } + if ($value >= 1048576) { + return round($value / 1048576, 2) . ' MB/s'; + } + if ($value >= 1024) { + return round($value / 1024, 2) . ' KB/s'; + } + return round($value, 2) . ' B/s'; +} + +function clicd_extra_ports($value) +{ + if (empty($value)) { + return []; + } + $ports = []; + foreach (preg_split('/[,;\s]+/', (string)$value) as $port) { + $port = (int)trim($port); + if ($port > 0 && $port <= 65535) { + $ports[] = $port; + } + } + return array_values(array_unique($ports)); +} + +function clicd_expiry_from_params($params) +{ + $options = $params['configoptions'] ?? []; + if (!clicd_bool_option($options['sync_expiry'] ?? 'true', true)) { + return ''; + } + $raw = $params['nextduedate'] ?? ''; + if ($raw === '' || $raw === '0' || $raw === 0 || $raw === '0000-00-00' || $raw === '0000-00-00 00:00:00') { + return ''; + } + + $timestamp = 0; + if (is_numeric($raw)) { + $timestamp = (int)$raw; + if ($timestamp > 20000000000) { + $timestamp = (int)floor($timestamp / 1000); + } + } else { + $timestamp = strtotime((string)$raw); + } + + if ($timestamp === false || $timestamp <= time()) { + return ''; + } + + return date('Y-m-d', $timestamp); +} + +function clicd_container_payload($params) +{ + $options = $params['configoptions'] ?? []; + $trafficMode = $options['traffic_mode'] ?? 'total'; + + return [ + 'name' => clicd_container_name($params), + 'virtualization' => $options['virtualization'] ?? 'lxc', + 'template_id' => $options['template_id'] ?? '', + 'vcpu' => clicd_float_option($options, 'vcpu', 1), + 'cpu_percent' => clicd_int_option($options, 'cpu_percent', 0), + 'ram_mb' => clicd_int_option($options, 'ram_mb', 512), + 'disk_gb' => clicd_int_option($options, 'disk_gb', 5), + 'network_bw_mbps' => clicd_int_option($options, 'network_bw_mbps', 100), + 'monthly_traffic_gb' => clicd_int_option($options, 'monthly_traffic_gb', 100), + 'traffic_mode' => in_array($trafficMode, ['total', 'in_out'], true) ? $trafficMode : 'total', + 'traffic_in_gb' => clicd_int_option($options, 'traffic_in_gb', 0), + 'traffic_out_gb' => clicd_int_option($options, 'traffic_out_gb', 0), + 'io_speed_mbps' => clicd_int_option($options, 'io_speed_mbps', 0), + 'extra_ports' => clicd_extra_ports($options['extra_ports'] ?? ''), + 'port_mapping_count' => max(2, clicd_int_option($options, 'port_mapping_count', 2)), + 'snapshot_limit' => max(1, clicd_int_option($options, 'snapshot_limit', 3)), + 'assign_ipv6' => clicd_bool_option($options['assign_ipv6'] ?? 'false', false), + 'expires_at' => clicd_expiry_from_params($params), + ]; +} + +function clicd_find_container($params) +{ + $name = clicd_container_name($params); + return clicd_request($params, '/api/v1/containers/' . rawurlencode($name), [], 'GET'); +} + +function clicd_post_value($key, $default = '') +{ + if (function_exists('input')) { + $value = input('post.' . $key); + return $value === null ? $default : $value; + } + return $_POST[$key] ?? $default; +} + +function clicd_request_value($key, $default = '') +{ + if (function_exists('input')) { + $value = input('param.' . $key); + return $value === null ? $default : $value; + } + if (isset($_POST[$key])) { + return $_POST[$key]; + } + return $_GET[$key] ?? $default; +} + +function clicd_json_input() +{ + $raw = file_get_contents('php://input'); + $data = json_decode((string)$raw, true); + return is_array($data) ? $data : []; +} + +function clicd_param_value($data, $key, $default = '') +{ + if (is_array($data) && array_key_exists($key, $data)) { + return $data[$key]; + } + return clicd_request_value($key, $default); +} + +function clicd_container_api_id($params, &$container = null) +{ + $res = clicd_find_container($params); + if (clicd_success($res) && !empty($res['data']) && is_array($res['data'])) { + $container = $res['data']; + if (!empty($container['id'])) { + return (string)$container['id']; + } + if (!empty($container['uuid'])) { + return (string)$container['uuid']; + } + if (!empty($container['name'])) { + return (string)$container['name']; + } + } + + $container = []; + return clicd_container_name($params); +} + +function clicd_port_mappings_from_container($container) +{ + if (!is_array($container)) { + return []; + } + + foreach (['port_mappings', 'portMappings', 'nat', 'nat_list', 'NatList'] as $key) { + if (!empty($container[$key]) && is_array($container[$key])) { + return $container[$key]; + } + } + + return []; +} + +function clicd_normalize_port_mappings($mappings) +{ + if (!is_array($mappings)) { + return []; + } + + $result = []; + foreach ($mappings as $index => $mapping) { + if (!is_array($mapping)) { + continue; + } + $protocol = strtolower((string)($mapping['protocol'] ?? 'tcp')); + $result[] = [ + 'index' => is_numeric($index) ? (int)$index : $index, + 'host_port' => $mapping['host_port'] ?? '', + 'container_port' => $mapping['container_port'] ?? '', + 'protocol' => in_array($protocol, ['tcp', 'udp'], true) ? $protocol : 'tcp', + 'tcp_selected' => $protocol === 'udp' ? '' : 'selected', + 'udp_selected' => $protocol === 'udp' ? 'selected' : '', + 'description' => $mapping['description'] ?? '', + ]; + } + + return $result; +} + +function clicd_nat_post_action() +{ + $func = clicd_request_value('func', ''); + return strtolower(trim((string)$func)); +} + +function clicd_handle_nat_post($params) +{ + $action = clicd_nat_post_action(); + if ($action === '') { + return ['message' => '', 'mappings' => null]; + } + + if (!in_array($action, ['randomport', 'addnat', 'updatenat', 'deletenat'], true)) { + return ['message' => '', 'mappings' => null]; + } + + $map = [ + 'randomport' => 'clicd_randomPort', + 'addnat' => 'clicd_addNat', + 'updatenat' => 'clicd_updateNat', + 'deletenat' => 'clicd_deleteNat', + ]; + + if (!isset($map[$action]) || !function_exists($map[$action])) { + return ['message' => '', 'mappings' => null]; + } + + $res = call_user_func($map[$action], $params); + if (!is_array($res)) { + return ['message' => (string)$res, 'mappings' => null]; + } + + $ok = (($res['status'] ?? '') === 'success' || (int)($res['status'] ?? 0) === 200); + $prefix = $ok ? '成功: ' : '失败: '; + return [ + 'message' => $prefix . ($res['msg'] ?? '操作完成'), + 'mappings' => ($ok && isset($res['data']['port_mappings']) && is_array($res['data']['port_mappings'])) ? $res['data']['port_mappings'] : null, + ]; +} + +function clicd_nat_payload_from_post() +{ + return clicd_nat_payload_from_data(null); +} + +function clicd_nat_payload_from_data($data = null) +{ + $hostPort = (int)clicd_param_value($data, 'host_port', 0); + $containerPort = (int)clicd_param_value($data, 'container_port', 0); + $protocol = strtolower(trim((string)clicd_param_value($data, 'protocol', 'tcp'))); + $description = trim((string)clicd_param_value($data, 'description', '')); + + if ($hostPort < 1 || $hostPort > 65535) { + return ['error' => '公网端口必须在 1-65535 之间']; + } + if ($containerPort < 1 || $containerPort > 65535) { + return ['error' => '容器端口必须在 1-65535 之间']; + } + if (!in_array($protocol, ['tcp', 'udp'], true)) { + return ['error' => '协议只支持 tcp 或 udp']; + } + + return [ + 'container_port' => $containerPort, + 'host_port' => $hostPort, + 'protocol' => $protocol, + 'description' => $description, + ]; +} + +function clicd_nat_ajax($params) +{ + $input = clicd_json_input(); + $action = strtolower(trim((string)clicd_param_value($input, 'action', ''))); + $debug = [clicd_debug_entry('NAT ajax received', [ + 'action' => $action, + 'input' => $input, + 'query' => $_GET, + ])]; + + $container = []; + $containerId = clicd_container_api_id($params, $container); + $debug[] = clicd_debug_entry('Container resolved', [ + 'container_id' => $containerId, + 'container' => [ + 'id' => $container['id'] ?? null, + 'uuid' => $container['uuid'] ?? null, + 'name' => $container['name'] ?? null, + ], + ]); + + if (!in_array($action, ['random-port', 'add', 'update', 'delete'], true)) { + return ['status' => 'error', 'msg' => '未知 NAT 操作', 'debug' => $debug]; + } + + if ($action === 'random-port') { + $call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/random-port', [], 'GET', 30); + $debug[] = $call['debug']; + $res = $call['response']; + return clicd_success($res) + ? ['status' => 'success', 'msg' => '随机端口: ' . ($res['data']['port'] ?? ''), 'port' => $res['data']['port'] ?? '', 'debug' => $debug] + : ['status' => 'error', 'msg' => clicd_message($res, '获取随机端口失败'), 'debug' => $debug]; + } + + if ($action === 'delete') { + $index = clicd_param_value($input, 'index', ''); + if ($index === '' || !is_numeric($index) || (int)$index < 0) { + return ['status' => 'error', 'msg' => '端口映射索引错误', 'debug' => $debug]; + } + $endpoint = '/api/v1/containers/' . rawurlencode($containerId) . '/port-mappings/' . rawurlencode((string)(int)$index); + $call = clicd_request_debug($params, $endpoint, [], 'DELETE', 30); + $debug[] = $call['debug']; + $res = $call['response']; + } else { + $payload = clicd_nat_payload_from_data($input); + if (isset($payload['error'])) { + return ['status' => 'error', 'msg' => $payload['error'], 'debug' => $debug]; + } + + if ($action === 'add') { + $endpoint = '/api/v1/containers/' . rawurlencode($containerId) . '/port-mappings'; + $call = clicd_request_debug($params, $endpoint, $payload, 'POST', 30); + } else { + $index = clicd_param_value($input, 'index', ''); + if ($index === '' || !is_numeric($index) || (int)$index < 0) { + return ['status' => 'error', 'msg' => '端口映射索引错误', 'debug' => $debug]; + } + $endpoint = '/api/v1/containers/' . rawurlencode($containerId) . '/port-mappings/' . rawurlencode((string)(int)$index); + $call = clicd_request_debug($params, $endpoint, $payload, 'PUT', 30); + } + $debug[] = $call['debug']; + $res = $call['response']; + } + + if (!clicd_success($res)) { + return ['status' => 'error', 'msg' => clicd_message($res, 'NAT 操作失败'), 'debug' => $debug]; + } + + return [ + 'status' => 'success', + 'msg' => clicd_message($res, 'NAT 操作成功'), + 'port_mappings' => clicd_normalize_port_mappings($res['data'] ?? []), + 'debug' => $debug, + ]; +} + +function clicd_info_ajax($params) +{ + $debug = [clicd_debug_entry('Info ajax received', ['query' => $_GET])]; + $res = clicd_find_container($params); + if (!clicd_success($res) || empty($res['data']) || !is_array($res['data'])) { + return ['status' => 'error', 'msg' => clicd_message($res, '获取实例信息失败'), 'debug' => $debug]; + } + + $c = $res['data']; + $name = $c['name'] ?? clicd_container_name($params); + + $usageCall = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($name) . '/usage', [], 'GET', 30); + if (!clicd_success($usageCall['response']) && !empty($c['uuid'])) { + $usageCall = clicd_request_debug($params, '/api/containers/' . rawurlencode((string)$c['uuid']) . '/usage', [], 'GET', 30); + } + $trafficCall = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($name) . '/traffic', [], 'GET', 30); + $debug[] = $usageCall['debug']; + $debug[] = $trafficCall['debug']; + + $usageRes = $usageCall['response']; + $usage = clicd_success($usageRes) && isset($usageRes['data']) && is_array($usageRes['data']) ? $usageRes['data'] : []; + $trafficRes = $trafficCall['response']; + $traffic = clicd_success($trafficRes) && isset($trafficRes['data']) && is_array($trafficRes['data']) ? $trafficRes['data'] : []; + $options = $params['configoptions'] ?? []; + $sources = [$usage, $traffic, $c]; + + $rxBytes = clicd_pick_number($sources, ['rx_used_bytes', 'rx_bytes', 'traffic_used_rx', 'in_bytes', 'input_bytes', 'network_rx_bytes'], 0); + $txBytes = clicd_pick_number($sources, ['tx_used_bytes', 'tx_bytes', 'traffic_used_tx', 'out_bytes', 'output_bytes', 'network_tx_bytes'], 0); + $totalBytes = clicd_pick_number($sources, ['total_used_bytes', 'traffic_used_bytes'], 0); + if ($rxBytes <= 0 && $txBytes <= 0 && $totalBytes > 0) { + $txBytes = $totalBytes; + } + if ($rxBytes <= 0) { + $rxBytes = clicd_pick_number($sources, ['traffic_in_gb', 'in_gb', 'rx_gb'], 0) * 1073741824; + } + if ($txBytes <= 0) { + $txBytes = clicd_pick_number($sources, ['traffic_out_gb', 'out_gb', 'tx_gb'], 0) * 1073741824; + } + + $limitGB = clicd_pick_number([$traffic, $c, $options], ['monthly_traffic_gb', 'traffic_limit_gb', 'limit_gb'], 0); + $trafficUsedGB = round(($rxBytes + $txBytes) / 1073741824, 2); + $trafficPercent = $limitGB > 0 ? clicd_pct(($trafficUsedGB / $limitGB) * 100) : 0; + + $cpuPercent = clicd_pct(clicd_pick_number([$usage, $traffic], ['cpu_usage_pct', 'cpu_percent', 'cpu_usage_percent', 'cpu_usage', 'cpu'], 0)); + + $memoryUsedMB = clicd_pick_number($sources, ['memory_used_mb', 'mem_used_mb', 'ram_used_mb', 'memory_usage_mb'], 0); + if ($memoryUsedMB <= 0) { + $memoryUsedMB = clicd_bytes_to_mb(clicd_pick_number($sources, ['memory_usage_bytes', 'memory_used', 'mem_used', 'ram_used', 'memory_bytes'], 0)); + } + $memoryTotalMB = clicd_pick_number([$usage, $c, $options], ['memory_total_mb', 'mem_total_mb', 'ram_total_mb', 'ram_mb'], 0); + if ($memoryTotalMB <= 0) { + $memoryTotalMB = clicd_bytes_to_mb(clicd_pick_number($sources, ['memory_total', 'mem_total', 'ram_total'], 0)); + } + $memoryPercent = $memoryTotalMB > 0 ? clicd_pct(($memoryUsedMB / $memoryTotalMB) * 100) : 0; + + $vcpu = clicd_pick_number([$c, $options], ['vcpu', 'cpu', 'cores'], 1); + $loadPercent = clicd_pick_number([$usage, $traffic], ['load_percent', 'load_usage_percent'], -1); + if ($loadPercent < 0) { + $loadValue = clicd_pick_number([$usage, $traffic], ['load1', 'load', 'load_average'], 0); + $loadPercent = $vcpu > 0 ? ($loadValue / $vcpu) * 100 : 0; + } + $loadPercent = clicd_pct($loadPercent); + + $diskUsedGB = clicd_pick_number($sources, ['disk_used_gb', 'disk_usage_gb', 'storage_used_gb'], 0); + if ($diskUsedGB <= 0) { + $diskUsedGB = clicd_bytes_to_gb(clicd_pick_number($sources, ['disk_usage_bytes', 'disk_used', 'disk_usage', 'storage_used'], 0)); + } + $diskTotalGB = clicd_pick_number([$usage, $c, $options], ['disk_total_gb', 'storage_total_gb', 'disk_gb'], 0); + if ($diskTotalGB <= 0) { + $diskTotalGB = clicd_bytes_to_gb(clicd_pick_number($sources, ['disk_total', 'storage_total'], 0)); + } + $diskPercent = $diskTotalGB > 0 ? clicd_pct(($diskUsedGB / $diskTotalGB) * 100) : 0; + + $netInBps = clicd_pick_number($sources, ['rx_bps', 'in_bps', 'network_rx_bps', 'net_in_bps'], 0); + $netOutBps = clicd_pick_number($sources, ['tx_bps', 'out_bps', 'network_tx_bps', 'net_out_bps'], 0); + $diskReadBps = clicd_pick_number($sources, ['disk_read_bps', 'read_bps', 'io_read_bps'], 0); + $diskWriteBps = clicd_pick_number($sources, ['disk_write_bps', 'write_bps', 'io_write_bps'], 0); + + return [ + 'status' => 'success', + 'data' => [ + 'cpu_percent' => $cpuPercent, + 'cpu_detail' => $cpuPercent . '%', + 'mem_percent' => $memoryPercent, + 'mem_detail' => ($memoryTotalMB > 0 ? round($memoryUsedMB, 0) . ' / ' . round($memoryTotalMB, 0) . ' MB' : '-'), + 'load_percent' => $loadPercent, + 'load_detail' => $loadPercent . '%', + 'disk_percent' => $diskPercent, + 'disk_detail' => ($diskTotalGB > 0 ? round($diskUsedGB, 2) . ' / ' . round($diskTotalGB, 2) . ' GB' : '-'), + 'traffic_used' => $trafficUsedGB, + 'traffic_limit' => $limitGB, + 'traffic_in_gb' => round($rxBytes / 1073741824, 2), + 'traffic_out_gb' => round($txBytes / 1073741824, 2), + 'traffic_used_text' => clicd_format_bytes($rxBytes + $txBytes), + 'traffic_limit_text'=> $limitGB > 0 ? round($limitGB, 2) . ' GB' : '不限', + 'traffic_in_text' => clicd_format_bytes($rxBytes), + 'traffic_out_text' => clicd_format_bytes($txBytes), + 'traffic_percent'=> $trafficPercent, + 'net_in_bps' => round($netInBps, 2), + 'net_out_bps' => round($netOutBps, 2), + 'net_in_rate' => clicd_format_rate($netInBps), + 'net_out_rate' => clicd_format_rate($netOutBps), + 'disk_read_bps' => round($diskReadBps, 2), + 'disk_write_bps' => round($diskWriteBps, 2), + 'disk_read_rate' => clicd_format_rate($diskReadBps), + 'disk_write_rate'=> clicd_format_rate($diskWriteBps), + 'chart_time' => date('H:i:s'), + 'usage' => $usage, + ], + 'debug' => $debug, + ]; +} + +function clicd_update_host_from_container($params, $container) +{ + if (empty($params['hostid']) || !is_array($container)) { + return; + } + + $update = [ + 'domainstatus' => (($container['status'] ?? '') === 'running') ? 'Active' : 'Suspended', + 'username' => 'root', + 'dedicatedip' => clicd_public_host($params, $container), + ]; + + $sshPort = clicd_container_ssh_port($container); + if ($sshPort !== '') { + $update['port'] = $sshPort; + } + + $password = clicd_container_password($container); + if ($password !== '') { + $update['password'] = clicd_store_password($password); + } + + try { + Db::name('host')->where('id', $params['hostid'])->update($update); + } catch (\Exception $e) { + clicd_debug('host update failed', $e->getMessage()); + } +} + +function clicd_TestLink($params) +{ + $res = clicd_request($params, '/api/v1/dashboard', [], 'GET'); + return [ + 'status' => 200, + 'data' => [ + 'server_status' => clicd_success($res) ? 1 : 0, + 'msg' => clicd_success($res) ? '连接成功' : clicd_message($res, '连接失败'), + ], + ]; +} + +function clicd_CreateAccount($params) +{ + $exists = clicd_find_container($params); + if (clicd_success($exists)) { + return ['status' => 'error', 'msg' => '容器已存在,不能重复开通']; + } + + $payload = clicd_container_payload($params); + if (empty($payload['template_id'])) { + return ['status' => 'error', 'msg' => '产品配置缺少 template_id']; + } + + $res = clicd_request($params, '/api/v1/containers', $payload, 'POST', 120); + if (!clicd_success($res)) { + return ['status' => 'error', 'msg' => clicd_message($res, '开通失败')]; + } + + $detail = clicd_find_container($params); + if (clicd_success($detail) && isset($detail['data'])) { + clicd_update_host_from_container($params, $detail['data']); + } elseif (!empty($params['hostid'])) { + try { + Db::name('host')->where('id', $params['hostid'])->update([ + 'domainstatus' => 'Active', + 'username' => 'root', + 'dedicatedip' => clicd_public_host($params), + ]); + } catch (\Exception $e) { + return ['status' => 'error', 'msg' => '开通成功但同步魔方数据库失败: ' . $e->getMessage()]; + } + } + + return ['status' => 'success', 'msg' => clicd_message($res, '开通成功')]; +} + +function clicd_TerminateAccount($params) +{ + $name = clicd_container_name($params); + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/delete', [], 'DELETE', 60); + return clicd_success($res) + ? ['status' => 'success', 'msg' => clicd_message($res, '删除任务已提交')] + : ['status' => 'error', 'msg' => clicd_message($res, '删除失败')]; +} + +function clicd_action($params, $action, $successMsg, $timeout = 60) +{ + $name = clicd_container_name($params); + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/' . $action, [], 'POST', $timeout); + return clicd_success($res) + ? ['status' => 'success', 'msg' => clicd_message($res, $successMsg)] + : ['status' => 'error', 'msg' => clicd_message($res, $successMsg . '失败')]; +} + +function clicd_On($params) +{ + return clicd_action($params, 'start', '开机任务已提交'); +} + +function clicd_Off($params) +{ + return clicd_action($params, 'stop', '关机任务已提交'); +} + +function clicd_Reboot($params) +{ + return clicd_action($params, 'restart', '重启任务已提交'); +} + +function clicd_SuspendAccount($params) +{ + return clicd_Off($params); +} + +function clicd_UnsuspendAccount($params) +{ + return clicd_On($params); +} + +function clicd_Status($params) +{ + $res = clicd_find_container($params); + if (!clicd_success($res) || empty($res['data'])) { + return ['status' => 'error', 'msg' => clicd_message($res, '查询失败')]; + } + + $status = strtolower($res['data']['status'] ?? ''); + if ($status === 'running') { + return ['status' => 'success', 'data' => ['status' => 'on', 'des' => '运行中']]; + } + if ($status === 'stopped') { + return ['status' => 'success', 'data' => ['status' => 'off', 'des' => '已关机']]; + } + return ['status' => 'success', 'data' => ['status' => 'unknown', 'des' => $status ?: '未知']]; +} + +function clicd_Sync($params) +{ + $res = clicd_find_container($params); + if (!clicd_success($res) || empty($res['data'])) { + return ['status' => 'error', 'msg' => clicd_message($res, '同步失败')]; + } + clicd_update_host_from_container($params, $res['data']); + return ['status' => 'success', 'msg' => '同步成功']; +} + +function clicd_Reinstall($params) +{ + $templateId = $params['reinstall_os'] ?? ''; + if ($templateId === '') { + $templateId = ($params['configoptions']['template_id'] ?? ''); + } + if ($templateId === '') { + return ['status' => 'error', 'msg' => '缺少重装系统模板 ID']; + } + + $name = clicd_container_name($params); + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/reinstall', ['template_id' => $templateId], 'POST', 60); + if (clicd_success($res)) { + $detail = clicd_find_container($params); + if (clicd_success($detail) && isset($detail['data'])) { + clicd_update_host_from_container($params, $detail['data']); + } elseif (isset($res['data']) && is_array($res['data'])) { + clicd_update_host_from_container($params, $res['data']); + } + } + return clicd_success($res) + ? ['status' => 'success', 'msg' => clicd_message($res, '重装任务已提交')] + : ['status' => 'error', 'msg' => clicd_message($res, '重装失败')]; +} + +function clicd_CrackPassword($params, $new_pass) +{ + $name = clicd_container_name($params); + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/reset-password', ['password' => $new_pass], 'POST', 60); + if (!clicd_success($res)) { + return ['status' => 'error', 'msg' => clicd_message($res, '重置密码失败')]; + } + + $password = $res['data']['ssh_password'] ?? $res['data']['password'] ?? $new_pass; + if (!empty($params['hostid'])) { + try { + Db::name('host')->where('id', $params['hostid'])->update(['password' => clicd_store_password($password)]); + $detail = clicd_find_container($params); + if (clicd_success($detail) && isset($detail['data'])) { + clicd_update_host_from_container($params, $detail['data']); + } + } catch (\Exception $e) { + return ['status' => 'error', 'msg' => '密码重置成功但同步魔方数据库失败: ' . $e->getMessage()]; + } + } + + return ['status' => 'success', 'msg' => clicd_message($res, '密码重置成功')]; +} + +function clicd_TrafficReset($params) +{ + $name = clicd_container_name($params); + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/traffic-reset', [], 'POST', 30); + return clicd_success($res) + ? ['status' => 'success', 'msg' => clicd_message($res, '流量已重置')] + : ['status' => 'error', 'msg' => clicd_message($res, '流量重置失败')]; +} + +function clicd_randomPort($params) +{ + $container = []; + $containerId = clicd_container_api_id($params, $container); + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($containerId) . '/random-port', [], 'GET', 30); + if (!clicd_success($res)) { + return ['status' => 'error', 'msg' => clicd_message($res, '获取随机端口失败')]; + } + + $port = $res['data']['port'] ?? ''; + return ['status' => 200, 'msg' => $port ? '随机端口: ' . $port : '随机端口获取成功', 'data' => ['port' => $port]]; +} + +function clicd_addNat($params) +{ + $payload = clicd_nat_payload_from_post(); + if (isset($payload['error'])) { + return ['status' => 'error', 'msg' => $payload['error']]; + } + + $container = []; + $containerId = clicd_container_api_id($params, $container); + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($containerId) . '/port-mappings', $payload, 'POST', 30); + return clicd_success($res) + ? ['status' => 200, 'msg' => clicd_message($res, '端口映射添加成功'), 'data' => ['port_mappings' => clicd_normalize_port_mappings($res['data'] ?? [])]] + : ['status' => 'error', 'msg' => clicd_message($res, '端口映射添加失败')]; +} + +function clicd_updateNat($params) +{ + $index = clicd_request_value('index', ''); + if ($index === '' || !is_numeric($index) || (int)$index < 0) { + return ['status' => 'error', 'msg' => '端口映射索引错误']; + } + + $payload = clicd_nat_payload_from_post(); + if (isset($payload['error'])) { + return ['status' => 'error', 'msg' => $payload['error']]; + } + + $container = []; + $containerId = clicd_container_api_id($params, $container); + $endpoint = '/api/v1/containers/' . rawurlencode($containerId) . '/port-mappings/' . rawurlencode((string)(int)$index); + $res = clicd_request($params, $endpoint, $payload, 'PUT', 30); + return clicd_success($res) + ? ['status' => 200, 'msg' => clicd_message($res, '端口映射更新成功'), 'data' => ['port_mappings' => clicd_normalize_port_mappings($res['data'] ?? [])]] + : ['status' => 'error', 'msg' => clicd_message($res, '端口映射更新失败')]; +} + +function clicd_deleteNat($params) +{ + $index = clicd_request_value('index', ''); + if ($index === '' || !is_numeric($index) || (int)$index < 0) { + return ['status' => 'error', 'msg' => '端口映射索引错误']; + } + + $container = []; + $containerId = clicd_container_api_id($params, $container); + $endpoint = '/api/v1/containers/' . rawurlencode($containerId) . '/port-mappings/' . rawurlencode((string)(int)$index); + $res = clicd_request($params, $endpoint, [], 'DELETE', 30); + return clicd_success($res) + ? ['status' => 200, 'msg' => clicd_message($res, '端口映射删除成功'), 'data' => ['port_mappings' => clicd_normalize_port_mappings($res['data'] ?? [])]] + : ['status' => 'error', 'msg' => clicd_message($res, '端口映射删除失败')]; +} + +function clicd_natList($params) +{ + $res = clicd_find_container($params); + if (!clicd_success($res) || empty($res['data']) || !is_array($res['data'])) { + return ['status' => 'error', 'msg' => clicd_message($res, '获取 NAT 列表失败')]; + } + + return [ + 'status' => 200, + 'msg' => '获取成功', + 'data' => [ + 'port_mappings' => clicd_normalize_port_mappings(clicd_port_mappings_from_container($res['data'])), + 'debug' => [ + clicd_debug_entry('NatList', [ + 'container' => [ + 'id' => $res['data']['id'] ?? null, + 'uuid' => $res['data']['uuid'] ?? null, + 'name' => $res['data']['name'] ?? null, + ], + 'http' => $res['_http_code'] ?? null, + ]), + ], + ], + ]; +} + +function clicd_infoData($params) +{ + $data = clicd_info_ajax($params); + if (($data['status'] ?? '') !== 'success') { + return [ + 'status' => 200, + 'msg' => $data['msg'] ?? '流量统计暂不可用', + 'data' => [ + 'cpu_percent' => 0, + 'cpu_detail' => '-', + 'mem_percent' => 0, + 'mem_detail' => '-', + 'load_percent' => 0, + 'load_detail' => '-', + 'disk_percent' => 0, + 'disk_detail' => '-', + 'traffic_used' => '-', + 'traffic_limit' => '-', + 'traffic_in_gb' => '-', + 'traffic_out_gb' => '-', + 'traffic_used_text' => '-', + 'traffic_limit_text'=> '-', + 'traffic_in_text' => '-', + 'traffic_out_text' => '-', + 'traffic_percent'=> 0, + 'net_in_bps' => 0, + 'net_out_bps' => 0, + 'net_in_rate' => '0 B/s', + 'net_out_rate' => '0 B/s', + 'disk_read_bps' => 0, + 'disk_write_bps' => 0, + 'disk_read_rate' => '0 B/s', + 'disk_write_rate'=> '0 B/s', + 'chart_time' => date('H:i:s'), + 'debug' => $data['debug'] ?? [], + ], + ]; + } + + $data['data']['debug'] = $data['debug'] ?? []; + return ['status' => 200, 'msg' => '获取成功', 'data' => $data['data']]; +} + +function clicd_ChangePackage($params) +{ + $options = $params['configoptions'] ?? []; + $name = clicd_container_name($params); + + $resource = [ + 'vcpu' => clicd_float_option($options, 'vcpu', 0), + 'ram_mb' => clicd_int_option($options, 'ram_mb', 0), + 'io_speed_mbps' => clicd_int_option($options, 'io_speed_mbps', 0), + 'network_bw_mbps' => clicd_int_option($options, 'network_bw_mbps', 0), + ]; + $resource = array_filter($resource, function ($value) { + return $value !== 0 && $value !== 0.0; + }); + + if (!empty($resource)) { + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/resource-limit', $resource, 'PUT', 60); + if (!clicd_success($res)) { + return ['status' => 'error', 'msg' => clicd_message($res, '资源限制调整失败')]; + } + } + + $traffic = [ + 'traffic_mode' => $options['traffic_mode'] ?? 'total', + 'monthly_traffic_gb' => clicd_int_option($options, 'monthly_traffic_gb', 0), + 'traffic_in_gb' => clicd_int_option($options, 'traffic_in_gb', 0), + 'traffic_out_gb' => clicd_int_option($options, 'traffic_out_gb', 0), + ]; + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/traffic-limit', $traffic, 'PUT', 30); + if (!clicd_success($res)) { + return ['status' => 'error', 'msg' => clicd_message($res, '流量限制调整失败')]; + } + + $expiresAt = clicd_expiry_from_params($params); + if ($expiresAt !== '') { + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/expiry', ['expires_at' => $expiresAt], 'PUT', 30); + if (!clicd_success($res)) { + return ['status' => 'error', 'msg' => clicd_message($res, '到期时间同步失败')]; + } + } + + return ['status' => 'success', 'msg' => '配置变更成功']; +} + +function clicd_Renew($params) +{ + $expiresAt = clicd_expiry_from_params($params); + if ($expiresAt === '') { + return ['status' => 'success', 'msg' => '未启用到期时间同步']; + } + $name = clicd_container_name($params); + $res = clicd_request($params, '/api/v1/containers/' . rawurlencode($name) . '/expiry', ['expires_at' => $expiresAt], 'PUT', 30); + return clicd_success($res) + ? ['status' => 'success', 'msg' => '续费到期时间同步成功'] + : ['status' => 'error', 'msg' => clicd_message($res, '续费同步失败')]; +} + +function clicd_AdminButton($params) +{ + if (empty($params['domain'])) { + return []; + } + return [ + 'Sync' => '同步状态', + 'TrafficReset' => '重置流量', + ]; +} + +function clicd_ClientButton($params) +{ + if (empty($params['domain'])) { + return []; + } + return [ + 'webssh' => [ + 'place' => 'console', + 'name' => 'WebSSH', + ], + ]; +} + +function clicd_webssh($params) +{ + $containerName = clicd_container_name($params); + $res = clicd_request($params, '/api/v1/ssh-ticket', ['container_name' => $containerName], 'POST', 30); + if (!clicd_success($res)) { + return ['status' => 'error', 'msg' => clicd_message($res, 'WebSSH ticket create failed')]; + } + + $ticket = $res['data']['ticket'] ?? ''; + if ($ticket === '') { + return ['status' => 'error', 'msg' => 'WebSSH ticket is empty']; + } + + $url = clicd_webssh_url($params, $ticket, $containerName); + $jsUrl = json_encode($url, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return [ + 'status' => 'success', + 'msg' => "WebSSH started", + ]; +} + +function clicd_AllowFunction() +{ + return [ + 'client' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh'], + 'admin' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh'], + ]; +} + +function clicd_ClientArea($params) +{ + return [ + 'info' => ['name' => '实例信息'], + 'nat' => ['name' => 'NAT转发'], + ]; +} + +function clicd_ClientAreaOutput($params, $key) +{ + $func = strtolower(trim((string)clicd_request_value('func', ''))); + if ($func === 'natajax') { + clicd_json_response(clicd_nat_ajax($params)); + } + if ($func === 'infoajax') { + clicd_json_response(clicd_info_ajax($params)); + } + + if (!in_array($key, ['info', 'nat'], true)) { + return ''; + } + + $res = clicd_find_container($params); + if (!clicd_success($res) || empty($res['data'])) { + return '获取实例信息失败: ' . clicd_message($res, '未知错误'); + } + + $c = $res['data']; + + if ($key === 'nat') { + $operation = clicd_handle_nat_post($params); + $operationMsg = $operation['message'] ?? ''; + $postMappings = $operation['mappings'] ?? null; + if ($operationMsg !== '') { + $res = clicd_find_container($params); + $c = clicd_success($res) && !empty($res['data']) && is_array($res['data']) ? $res['data'] : $c; + } + $mappings = $postMappings !== null ? $postMappings : clicd_port_mappings_from_container($c); + + return [ + 'template' => 'templates/nat.html', + 'vars' => [ + 'container' => $c, + 'container_name'=> $c['name'] ?? clicd_container_name($params), + 'ssh_port' => $c['ssh_port'] ?? '', + 'server_ip' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST), + 'nat_host' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST), + 'operation_msg' => $operationMsg, + 'service_id' => clicd_request_value('id', $params['hostid'] ?? ''), + 'area_key' => 'nat', + 'port_mappings' => clicd_normalize_port_mappings($mappings), + ], + ]; + } + + $initialRxBytes = (int)($c['traffic_used_rx'] ?? $c['rx_bytes'] ?? 0); + $initialTxBytes = (int)($c['traffic_used_tx'] ?? $c['tx_bytes'] ?? 0); + $initialTrafficUsed = ($initialRxBytes || $initialTxBytes) ? round(($initialRxBytes + $initialTxBytes) / 1073741824, 2) : '-'; + $initialTrafficIn = $initialRxBytes ? round($initialRxBytes / 1073741824, 2) : '-'; + $initialTrafficOut = $initialTxBytes ? round($initialTxBytes / 1073741824, 2) : '-'; + $initialTrafficLimit = isset($c['monthly_traffic_gb']) && $c['monthly_traffic_gb'] !== '' ? $c['monthly_traffic_gb'] : '-'; + $initialTrafficUsedText = ($initialRxBytes || $initialTxBytes) ? clicd_format_bytes($initialRxBytes + $initialTxBytes) : '-'; + $initialTrafficInText = $initialRxBytes ? clicd_format_bytes($initialRxBytes) : '-'; + $initialTrafficOutText = $initialTxBytes ? clicd_format_bytes($initialTxBytes) : '-'; + $initialTrafficLimitText = is_numeric($initialTrafficLimit) ? round((float)$initialTrafficLimit, 2) . ' GB' : '-'; + $options = $params['configoptions'] ?? []; + + return [ + 'template' => 'templates/info.html', + 'vars' => [ + 'container' => $c, + 'status_text' => (($c['status'] ?? '') === 'running') ? '运行中' : '已关机', + 'server_ip' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST), + 'ssh_host' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST), + 'ssh_port' => $c['ssh_port'] ?? '', + 'ssh_password' => $c['ssh_password'] ?? '', + 'ipv4' => $c['ip'] ?? '', + 'ipv6' => $c['ipv6'] ?? '', + 'vcpu' => $c['vcpu'] ?? ($options['vcpu'] ?? ''), + 'ram_mb' => $c['ram_mb'] ?? ($options['ram_mb'] ?? ''), + 'disk_gb' => $c['disk_gb'] ?? ($options['disk_gb'] ?? ''), + 'bandwidth' => $c['network_bw_mbps'] ?? ($options['network_bw_mbps'] ?? ''), + 'traffic_used' => $initialTrafficUsed, + 'traffic_limit' => $initialTrafficLimit, + 'traffic_in_gb' => $initialTrafficIn, + 'traffic_out_gb' => $initialTrafficOut, + 'traffic_used_text' => $initialTrafficUsedText, + 'traffic_limit_text'=> $initialTrafficLimitText, + 'traffic_in_text' => $initialTrafficInText, + 'traffic_out_text' => $initialTrafficOutText, + 'expires_at' => $c['expires_at'] ?? '', + 'service_id' => clicd_request_value('id', $params['hostid'] ?? ''), + 'area_key' => 'info', + ], + ]; +} diff --git a/Mofang/clicd.zip b/Mofang/clicd.zip new file mode 100644 index 0000000000000000000000000000000000000000..66426cc6270bdef0ba73ab5219b0c273a589ede6 GIT binary patch literal 23833 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 A5&!@I literal 0 HcmV?d00001 diff --git a/Mofang/handlers/webssh.php b/Mofang/handlers/webssh.php new file mode 100644 index 0000000..887308a --- /dev/null +++ b/Mofang/handlers/webssh.php @@ -0,0 +1,384 @@ + + + + + + + WebSSH + + + +
+ + WebSSH + + 发送模式 + + + S0 R0 + + +
+
正在连接...
+
+ + +
+ + + diff --git a/Mofang/templates/info.html b/Mofang/templates/info.html new file mode 100644 index 0000000..d6d27b9 --- /dev/null +++ b/Mofang/templates/info.html @@ -0,0 +1,378 @@ + + +
+
+
+
实例名称
+
{$container.name|default='-'}
+
+
+
运行状态
+
{$status_text|default='-'}
+
+
+
SSH 地址
+
{$ssh_host|default='-'}:{$ssh_port|default='-'}
+
+
+
IPv6
+
{$ipv6|default='-'}
+
+
+ +
+
+ 状态 + + 更新于 - + + + +
+
+
+
0%
+
+
CPU
+
-
+
+
+
+
0%
+
+
内存
+
-
+
+
+
+
0%
+
+
负载
+
-
+
+
+
+
0%
+
+
磁盘
+
-
+
+
+
+ +
+
+
月流量
+
{$traffic_used_text|default='-'} / {$traffic_limit_text|default='-'}
+
+
+
+
入站 {$traffic_in_text|default='-'}
+
出站 {$traffic_out_text|default='-'}
+
+
+
+ +
+
统计信息
+
+
+
CPU 使用率 -
+ +
+
+
内存使用 -
+ +
+
+
网络流量 0 B/s / 0 B/s
+ +
+
+
磁盘 IO 0 B/s / 0 B/s
+ +
+
+
+ +
+
实例信息
+ + + + + + + + + + + + + + + + + + + + + + +
IPv4{$ipv4|default='-'}用户名root
SSH 端口{$ssh_port|default='-'}SSH 密码{$ssh_password|default='-'}
CPU{$vcpu|default='-'} 核内存{$ram_mb|default='-'} MB
硬盘{$disk_gb|default='-'} GB带宽{$bandwidth|default='-'} Mbps
到期时间{$expires_at|default='-'}
+
+ +
+
+ + diff --git a/Mofang/templates/nat.html b/Mofang/templates/nat.html new file mode 100644 index 0000000..9ba76b6 --- /dev/null +++ b/Mofang/templates/nat.html @@ -0,0 +1,328 @@ + + +
+
+ +
+
+
实例名称
+
{$container_name}
+
+
+
公网地址
+
{$nat_host}
+
+
+
SSH 端口
+
{$ssh_port}
+
+
+ +
添加端口映射
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
现有端口映射
+
+ {if condition="empty($port_mappings)"} +
暂无端口映射
+ {else/} + {foreach name="port_mappings" item="mapping"} +
+
+
+ +
{$mapping.index}
+
+
+ +
{$nat_host}:{$mapping.host_port}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/foreach} + {/if} +
+ +

+    
+
+
确认删除
+
确认删除该端口映射?
+
+ + +
+
+
+
+ +