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/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} +
+ +

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