fix(zjmfmanager): 加固密码存储与主机开通链路安全
4 个文件变更
+482
-119
13172193298@163.com
| •app_plugins/zjmfmanager_reserve/bootstrap.php | +80 -17 |
| •app_plugins/zjmfmanager_reserve/lib/upstream.php | +116 -24 |
| •app_plugins/zjmfmanager_reserve/lib/zjmf.php | +237 -68 |
| •app_plugins/zjmfmanager_reserve/views/host.php | +49 -10 |
变更内容
diff --git a/app_plugins/zjmfmanager_reserve/bootstrap.php b/app_plugins/zjmfmanager_reserve/bootstrap.php
index 3401a92..99d918b 100644
--- a/app_plugins/zjmfmanager_reserve/bootstrap.php
+++ b/app_plugins/zjmfmanager_reserve/bootstrap.php
@@ -19,13 +19,21 @@ if (!defined('IN_CRONLITE')) {
require_once __DIR__ . '/lib/zjmf.php';
require_once __DIR__ . '/lib/upstream.php';
-// 确保插件数据表存在:修复历史版本安装时 install.sql 首段(注释 + CREATE TABLE)
-// 被 mnbt_plugin_run_sql_file 整体跳过导致缺表(如 MN_plugin_zjmf_supplier)的问题。
-// install.sql 全部为 IF NOT EXISTS 建表,幂等,可安全重复执行。
+// 确保插件数据表存在:用 option 标记一次性执行,避免每请求都跑一遍 install.sql。
+// 建表语句全部 IF NOT EXISTS,幂等;schema 版本变更时调大
+// ZJMF_SCHEMA_VERSION 即会重跑一次(对齐仓库插件 option 标记惯例)。
+define('ZJMF_SCHEMA_VERSION', '1');
static $zjmf_tables_ready = false;
if (!$zjmf_tables_ready && function_exists('mnbt_plugin_run_sql_file')) {
$zjmf_tables_ready = true;
- mnbt_plugin_run_sql_file(__DIR__ . '/install.sql');
+ $doneVer = function_exists('mnbt_plugin_option_get')
+ ? (string)mnbt_plugin_option_get('zjmfmanager_reserve', 'schema_version', '') : '';
+ if ($doneVer !== ZJMF_SCHEMA_VERSION) {
+ mnbt_plugin_run_sql_file(__DIR__ . '/install.sql');
+ if (function_exists('mnbt_plugin_option_set')) {
+ mnbt_plugin_option_set('zjmfmanager_reserve', 'schema_version', ZJMF_SCHEMA_VERSION);
+ }
+ }
}
mnbt_plugin_register('zjmfmanager_reserve', [
@@ -141,6 +149,11 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_save_supplier', function () {
if (mb_strlen($url) > 255) {
json_exit_error('站点 URL 过长');
}
+ // http:// 上游为明文传输,凭据可被截获,落一条管理员可见的告警日志
+ if ($url !== '' && stripos($url, 'http://') === 0) {
+ @error_log('[zjmfmanager_reserve] 警告:供应商「' . $name . '」使用不加密的'
+ . ' http:// 上游地址,API 凭据可能被明文传输,建议改用 https://');
+ }
if ($url !== '' && $username === '') {
json_exit_error('请填写 API 用户名');
}
@@ -162,7 +175,8 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_save_supplier', function () {
$status, $sort, $remark, $now];
if ($password !== '') {
$sql .= ", api_password=?";
- $args[] = $password;
+ // API 密钥加密入库(读取处 ZjmfUpstream::client 统一解密)
+ $args[] = zjmf_encrypt($password);
}
$sql .= " WHERE id=?";
$args[] = $id;
@@ -176,7 +190,7 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_save_supplier', function () {
(name, api_url, api_username, api_password, api_timeout,
markup_type, markup_value, status, sort, remark, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
- [$name, $url, $username, $password, $timeout, $markupType,
+ [$name, $url, $username, zjmf_encrypt($password), $timeout, $markupType,
$markupValue, $status, $sort, $remark, $now, $now]
);
if (!$ok) {
@@ -471,10 +485,9 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_assign_host', function () {
continue;
}
$order_id = (int)$order['order_id'];
- zjmf_order_fill_opened($order_id, 0, $upHostId, $account);
- zjmf_order_set_status($order_id, 'opened', '管理员指派');
- // 写本地主机映射(绑定该上游机器)
+ // 写本地主机映射(绑定该上游机器)——先建主机,成功后再标记订单
+ // opened,避免主机写入失败时订单停留在已开通的中间态
$hostId = zjmf_host_create([
'supplier_id' => (int)$supplier['id'],
'user_id' => (int)$user['id'],
@@ -489,6 +502,7 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_assign_host', function () {
'renew_date' => zjmf_normalize_date($renew),
]);
if ($hostId <= 0) {
+ zjmf_order_set_status($order_id, 'failed', '本地主机映射写入失败');
$results[] = [
'up_host_id' => $upHostId,
'domain' => (string)($upRow['domain'] ?? ''),
@@ -499,6 +513,10 @@ mnbt_register_ajax('admin', 'p_zjmf_admin_assign_host', function () {
continue;
}
+ // 主机映射写入成功后再回填订单并标记 opened
+ zjmf_order_fill_opened($order_id, 0, $upHostId, $account);
+ zjmf_order_set_status($order_id, 'opened', '管理员指派');
+
zjmf_log((int)$user['id'], (string)($order['order_no'] ?? ''), 'assign', 'success',
json_encode(['up_host_id' => $upHostId, 'assign_by' => 'admin'], JSON_UNESCAPED_UNICODE),
(int)$supplier['id']);
@@ -1042,8 +1060,8 @@ mnbt_register_route('GET', '/reserve/api/hosts', function ($params, $ctx) {
}
$hosts = [];
foreach (zjmf_host_list_by_user((int)$user['id']) as $h) {
- // 缺失上游主机 ID 时尝试从上游主机列表补齐(同一次请求只拉一次上游列表)
- $h = zjmf_backfill_host_upid($h);
+ // 注:缺失上游主机 ID 时的回填已收敛到开通流程内/管理端,
+ // 用户端 GET 请求不再触发任何自动写库
$supplier = zjmf_supplier_get((int)$h['supplier_id']);
$hosts[] = [
'id' => (int)$h['id'],
@@ -1092,11 +1110,10 @@ mnbt_register_route('GET', '/reserve/orders', function ($params, $ctx) {
mnbt_register_route('GET', '/reserve/hosts', function ($params, $ctx) {
$user = zjmf_require_user();
$hosts = zjmf_host_list_by_user((int)$user['id']);
- // 列表页顺带补齐缺失的上游主机 ID(同一次请求只拉一次上游列表)
+ // 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
+ // (回填上游主机 ID 已收敛到开通流程内/管理端,GET 请求不写库)
foreach ($hosts as $i => $h) {
- $hosts[$i] = zjmf_backfill_host_upid($h);
- // 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
- $hosts[$i]['renew_date'] = zjmf_normalize_date((string)$hosts[$i]['renew_date']);
+ $hosts[$i]['renew_date'] = zjmf_normalize_date((string)$h['renew_date']);
}
zjmf_render('hosts', [
'page_title' => '我的主机',
@@ -1115,9 +1132,8 @@ mnbt_register_route('GET', '/reserve/hosts/{host_id}', function ($params, $ctx)
return;
}
- // 缺失上游主机 ID 时尝试补齐(开通结算未解析出 ID 的历史数据)
- $host = zjmf_backfill_host_upid($host);
// 历史数据可能存了上游时间戳,统一归一化为 Y-m-d
+ // (回填上游主机 ID 已收敛到开通流程内/管理端,GET 请求不写库)
$host['renew_date'] = zjmf_normalize_date((string)$host['renew_date']);
// 实时信息(失败不致命,仅展示缓存)
@@ -1284,6 +1300,15 @@ mnbt_register_route('POST', '/reserve/api/host_action', function ($params, $ctx)
if ($status !== '') {
zjmf_host_update_cache((int)$host['id'], ['status' => $status]);
}
+ // 电源类操作后主动刷新上游真实状态回写:本地主机表无独立电源字段,
+ // 关机不写 suspend(避免缓存状态与上游 domainstatus 脱节),以上游为准
+ if (in_array($action, ['on', 'off', 'reboot'], true)) {
+ $fresh = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
+ if (!empty($fresh['ok']) && $fresh['status'] !== 'unknown'
+ && $fresh['status'] !== $host['status']) {
+ zjmf_host_update_cache((int)$host['id'], ['status' => $fresh['status']]);
+ }
+ }
if ($action === 'reset_password' && $extra['password'] !== '') {
global $DB, $date;
$now = $date ?: date('Y-m-d H:i:s');
@@ -1296,6 +1321,44 @@ mnbt_register_route('POST', '/reserve/api/host_action', function ($params, $ctx)
zjmf_json('ok', ['msg' => '操作成功']);
});
+// 主机密码查看:详情页默认仅展示掩码,用户点击"显示密码"后经本路由
+// 按需解密返回(每次查看记录操作日志),页面不再无条件渲染明文密码
+mnbt_register_route('POST', '/reserve/api/host_password', function ($params, $ctx) {
+ $user = zjmf_require_user();
+
+ $host_id = (int)($_POST['host_id'] ?? 0);
+ $host = zjmf_host_get_by_user((int)$user['id'], $host_id);
+ if (!$host) {
+ zjmf_json('主机不存在');
+ }
+
+ $password = zjmf_decrypt((string)$host['password']);
+ $source = 'local';
+ // 本地未存密码(历史数据)且可查上游时,回退拉取上游实时密码
+ if ($password === '' && (int)$host['up_host_id'] > 0) {
+ $supplier = zjmf_supplier_get((int)$host['supplier_id']);
+ if ($supplier) {
+ try {
+ $info = ZjmfUpstream::hostInfo($supplier, (int)$host['up_host_id']);
+ if (!empty($info['ok'])) {
+ $password = (string)($info['data']['password'] ?? '');
+ $source = 'upstream';
+ }
+ } catch (Throwable $e) {
+ // 上游查询失败按无密码处理
+ }
+ }
+ }
+
+ $hostOrder = zjmf_order_get((int)$host['order_id']);
+ zjmf_log((int)$user['id'], $hostOrder ? $hostOrder['order_no'] : '',
+ 'view_password', 'success',
+ json_encode(['host_id' => (int)$host['id'], 'source' => $source], JSON_UNESCAPED_UNICODE),
+ (int)$host['supplier_id']);
+
+ zjmf_json('ok', ['password' => $password]);
+});
+
/* ============================================================
* 升级(配置升级 / 产品升降级)
* ============================================================ */
diff --git a/app_plugins/zjmfmanager_reserve/lib/upstream.php b/app_plugins/zjmfmanager_reserve/lib/upstream.php
index 5690c51..a659b38 100644
--- a/app_plugins/zjmfmanager_reserve/lib/upstream.php
+++ b/app_plugins/zjmfmanager_reserve/lib/upstream.php
@@ -55,13 +55,30 @@ class ZjmfUpstream
$supplierId = (int)($supplier['id'] ?? 0);
$cacheDir = mnbt_plugin_path('zjmfmanager_reserve')
. 'runtime/cache/s' . $supplierId;
+ // JWT 缓存目录防 Web 直接访问:.htaccess 拒绝 + 空 index.html 防目录列举
+ if (!is_dir($cacheDir)) {
+ @mkdir($cacheDir, 0755, true);
+ }
+ $ht = $cacheDir . '/.htaccess';
+ if (!is_file($ht)) {
+ @file_put_contents($ht, "Deny from all\n");
+ }
+ $ix = $cacheDir . '/index.html';
+ if (!is_file($ix)) {
+ @file_put_contents($ix, '');
+ }
+ // 解密 API 密钥:新数据为 v2: AES 密文;历史数据为明文直取
+ // (不经 authcode 解密,避免把明文误当乱码密文解出无效值)
+ $apiPassword = strpos($password, 'v2:') === 0 ? zjmf_decrypt($password) : $password;
return new CubeFinanceClient([
'url' => $apiUrl,
'username' => $username,
- 'password' => $password,
+ 'password' => $apiPassword,
'timeout' => $t,
'cache_dir' => $cacheDir,
- 'verify_ssl' => false,
+ // TODO: install.sql 供应商表暂无 verify_ssl 配置字段,先默认强制
+ // 校验证书;后续加字段后改为按供应商配置读取(默认 true)
+ 'verify_ssl' => true,
]);
}
@@ -490,20 +507,26 @@ class ZjmfUpstream
try {
// 0. 清空购物车:该版本 settle(checkout=1) 会结算整辆购物车,
// 若残留历史测试商品会把多件一起结算开通(曾实测一次开出多台机器)。
- // 先清空保证本次结算只涉及刚添加的这一件商品。
+ // 清空失败时确认购物车为空或仅含本次商品才继续,否则中断开通,
+ // 避免残留项被一起结算。
try {
$client->cartClear();
} catch (CubeFinanceException $e) {
- // 清空失败不致命,继续尝试(可能购物车本就为空)
+ if (!self::cartSafeForSettle($client, $upProductId)) {
+ return ['ok' => false, 'msg' => '上游购物车清空失败且无法确认购物车为空,'
+ . '为避免残留商品被一起结算已中断本次开通:' . $e->getMessage()];
+ }
}
// 1. 添加产品至购物车(官方:POST /cart/add_to_shop)→ data.i 购物车位置
$upCycle = self::upstreamCycle((int)($order['supplier_id'] ?? 0), $upProductId, $cycle);
+ $genHost = (string)($extra['host'] ?? '') !== ''
+ ? (string)$extra['host'] : self::randHost();
$addParams = [
'pid' => $upProductId,
'billingcycle' => $upCycle,
'qty' => 1,
- 'host' => (string)($extra['host'] ?? self::randHost()),
+ 'host' => $genHost,
'password' => (string)($extra['password'] ?? self::randPassword()),
];
foreach (['configoption', 'customfield', 'serverid', 'os', 'currencyid'] as $k) {
@@ -575,8 +598,18 @@ class ZjmfUpstream
$invoiceId = self::findId($checkoutData);
$hostId = self::findHostId($checkoutData);
- // 3. 使用余额支付账单(官方:POST /apply_credit)
+ // 3. 使用余额支付账单(官方:POST /apply_credit)。
+ // 仅当账单确认已支付且已生成主机记录时才跳过支付,防止把结算
+ // 响应中其他 ID 误判为主机 ID 而漏付;确认失败则正常走支付。
+ $skipPay = false;
if ($invoiceId > 0 && $hostId <= 0) {
+ $paid = self::invoiceInfo($client, $invoiceId, 1);
+ if ($paid && self::isPaidStatus($paid['status']) && (int)$paid['host_id'] > 0) {
+ $skipPay = true;
+ $hostId = (int)$paid['host_id'];
+ }
+ }
+ if ($invoiceId > 0 && !$skipPay) {
$credit = $client->post('apply_credit', [
'invoiceid' => $invoiceId,
'use_credit' => 1,
@@ -586,6 +619,7 @@ class ZjmfUpstream
// 账单可能已被自动扣款,确认已支付后再继续
$info = self::invoiceInfo($client, $invoiceId, 1);
if (!$info || !self::isPaidStatus($info['status'])) {
+ // 支付未确认:明确失败(订单将被标 failed),不得继续当作开通成功
return ['ok' => false, 'msg' => self::respErr('上游余额支付失败', $credit)];
}
}
@@ -607,6 +641,8 @@ class ZjmfUpstream
'msg' => '开通成功',
'up_order_id' => $invoiceId,
'up_host_id' => $hostId,
+ // 本次开通使用的本地生成主机标识(host 参数,供确定性回填)
+ 'host' => $genHost,
'username' => $header['username'],
'password' => $header['password'],
'name' => $header['name'],
@@ -620,6 +656,8 @@ class ZjmfUpstream
'msg' => '上游订单已创建,但未返回主机 ID,请到上游后台核对',
'up_order_id' => $invoiceId,
'up_host_id' => 0,
+ // 同上,保存主机标识供回填确定性匹配
+ 'host' => $genHost,
'username' => '',
'password' => '',
'name' => (string)($order['product_name'] ?? ''),
@@ -1699,6 +1737,40 @@ class ZjmfUpstream
return $lastPid; // 仅 pid 匹配的最后一个
}
+ /**
+ * 购物车是否可安全结算:为空或仅含本次商品。
+ * 用于 cartClear 失败后的兜底确认;购物车数据拉取失败视为不可确认。
+ *
+ * @param CubeFinanceClient $client
+ * @param int $upProductId 本次开通的上游商品 ID
+ * @return bool true=可继续结算
+ */
+ protected static function cartSafeForSettle($client, $upProductId)
+ {
+ try {
+ $res = $client->cartGetShopData();
+ } catch (CubeFinanceException $e) {
+ return false; // 无法确认购物车内容,不可继续
+ }
+ if (!self::respOk($res)) {
+ return false;
+ }
+ $products = $res['data']['cart_products'] ?? null;
+ if (!is_array($products)) {
+ return false; // 结构异常,无法确认
+ }
+ foreach ($products as $p) {
+ if (!is_array($p)) {
+ continue;
+ }
+ $pid = (string)($p['productid'] ?? $p['pid'] ?? $p['id'] ?? '');
+ if ($pid !== (string)$upProductId) {
+ return false; // 含本次商品之外的项目,不可继续
+ }
+ }
+ return true;
+ }
+
/**
* 从加购响应中取购物车位置 data.i(兼容字符串 data、其他位置键、一层嵌套)。
*
@@ -1754,14 +1826,16 @@ class ZjmfUpstream
return in_array($st, [200, 1001], true);
}
- /** 组装上游失败详情(msg + data 截断),避免日志里只有泛化文案。 */
+ /** 组装上游失败详情(msg + data 截断),避免日志里只有泛化文案。
+ * data 中 password/pass/pwd/token/secret 等键的值脱敏后再入日志。 */
protected static function respErr($prefix, $res)
{
$msg = (string)($res['msg'] ?? '');
$data = $res['data'] ?? null;
$detail = '';
if (is_array($data) || is_scalar($data)) {
- $json = json_encode($data, JSON_UNESCAPED_UNICODE);
+ $safeData = is_array($data) ? self::maskSecrets($data) : $data;
+ $json = json_encode($safeData, JSON_UNESCAPED_UNICODE);
if (is_string($json)) {
$detail = ' data=' . self::truncate($json, 300);
}
@@ -1770,6 +1844,30 @@ class ZjmfUpstream
return $out !== '' ? $out : $prefix;
}
+ /** 递归脱敏:键名含 password/pass/pwd/token/secret 的值替换为 ***。 */
+ protected static function maskSecrets(array $data)
+ {
+ $out = [];
+ foreach ($data as $k => $v) {
+ $lk = strtolower((string)$k);
+ $sensitive = $lk !== '' && (
+ strpos($lk, 'password') !== false
+ || strpos($lk, 'pass') !== false
+ || strpos($lk, 'pwd') !== false
+ || strpos($lk, 'token') !== false
+ || strpos($lk, 'secret') !== false
+ );
+ if ($sensitive) {
+ $out[$k] = '***';
+ } elseif (is_array($v)) {
+ $out[$k] = self::maskSecrets($v);
+ } else {
+ $out[$k] = $v;
+ }
+ }
+ return $out;
+ }
+
/** 截断字符串(mb_substr 不可用时回退 substr)。 */
protected static function truncate($str, $len)
{
@@ -1810,13 +1908,15 @@ class ZjmfUpstream
return 0;
}
- /** 从 data 中找主机 ID(支持嵌套 host 与 hostid 数组)。 */
+ /** 从 data 中找主机 ID(支持嵌套 host 与 hostid 数组)。
+ * 不含 'id' 兜底键:结算响应 data.id 通常是账单/订单 ID,曾被误判
+ * 为主机 ID 导致跳过支付(M3 误判)。 */
protected static function findHostId($arr)
{
if (!is_array($arr)) {
return 0;
}
- foreach (['host_id', 'hostid', 'hid', 'id'] as $k) {
+ foreach (['host_id', 'hostid', 'hid'] as $k) {
if (isset($arr[$k])) {
$v = $arr[$k];
if (is_array($v)) {
@@ -1955,7 +2055,11 @@ class ZjmfUpstream
}
}
- /** 上游主机状态 → 本地展示状态(active/suspend/pending/terminated/unknown)。 */
+ /**
+ * 上游主机数据 → 本地展示状态(active/suspend/pending/terminated/unknown)。
+ * 状态映射委托统一实现 zjmf_map_upstream_status(lib/zjmf.php,主表),
+ * 无状态字段时用 qk 兜底(false 视为不可用)。
+ */
public static function mapHostStatus($data)
{
if (!is_array($data)) {
@@ -1963,19 +2067,7 @@ class ZjmfUpstream
}
$st = strtolower(trim((string)($data['status'] ?? $data['domainstatus'] ?? '')));
if ($st !== '') {
- if (in_array($st, ['active', 'on', 'true', 'completed', '运行中'], true)) {
- return 'active';
- }
- if (in_array($st, ['pending', 'wait', 'waiting', '待开通'], true)) {
- return 'pending';
- }
- if (in_array($st, ['suspended', 'suspend', 'paused', 'off', '已暂停'], true)) {
- return 'suspend';
- }
- if (in_array($st, ['cancelled', 'cancel', 'terminated', 'terminate', 'fraud', '已终止'], true)) {
- return 'terminated';
- }
- return 'unknown';
+ return zjmf_map_upstream_status($st);
}
// 无状态字段时用 qk 兜底(false 视为不可用)
$qk = $data['qk'] ?? null;
diff --git a/app_plugins/zjmfmanager_reserve/lib/zjmf.php b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
index 376525c..888bebd 100644
--- a/app_plugins/zjmfmanager_reserve/lib/zjmf.php
+++ b/app_plugins/zjmfmanager_reserve/lib/zjmf.php
@@ -128,22 +128,118 @@ function zjmf_json($code, $extra = [])
exit;
}
-/** 明文加密(authcode,用于上游主机密码入库)。 */
+/**
+ * 获取 AES-256 加密密钥(32 字节原始密钥,不可用时返回 null)。
+ * 来源优先级:
+ * 1. 环境变量 / 常量 MNBT_SECRET_KEY(任意字符串,sha256 派生 32 字节)
+ * 2. 站点根 runtime/zjmf/zjmf_secret.key 密钥文件(插件目录之外,
+ * runtime/ 为仓库既有数据目录惯例并带 .htaccess 禁止 Web 访问;
+ * 不存在时自动生成 64 hex 字符随机密钥并尝试 chmod 600)
+ * 两者均不可用时返回 null(调用方退回旧 authcode 并记日志)。
+ */
+function zjmf_secret_key_raw()
+{
+ static $cached = null;
+ if ($cached !== null) {
+ return $cached['key'] ?? null;
+ }
+ $cached = ['key' => null];
+ // 1. 环境变量 / 常量
+ $secret = getenv('MNBT_SECRET_KEY');
+ if ($secret === '' || $secret === false) {
+ $secret = defined('MNBT_SECRET_KEY') ? (string)constant('MNBT_SECRET_KEY') : '';
+ }
+ if ($secret !== '') {
+ $cached['key'] = hash('sha256', (string)$secret, true);
+ return $cached['key'];
+ }
+ // 2. 密钥文件(站点根 runtime/zjmf/,插件 data 目录之外)
+ $base = defined('ROOT') ? ROOT : dirname(dirname(dirname(__DIR__))) . '/';
+ $dir = $base . 'runtime/zjmf';
+ $file = $dir . '/zjmf_secret.key';
+ $secret = '';
+ if (is_file($file)) {
+ $secret = trim((string)@file_get_contents($file));
+ }
+ if ($secret === '') {
+ // 自动生成 64 hex 字符随机密钥并落盘
+ if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
+ @error_log('[zjmfmanager_reserve] 加密密钥目录创建失败:' . $dir);
+ return null;
+ }
+ // 目录防 Web 直接访问(.htaccess 拒绝 + 空 index.html 防目录列举)
+ $ht = $dir . '/.htaccess';
+ if (!is_file($ht)) {
+ @file_put_contents($ht, "Deny from all\n");
+ }
+ $ix = $dir . '/index.html';
+ if (!is_file($ix)) {
+ @file_put_contents($ix, '');
+ }
+ $secret = bin2hex(random_bytes(32));
+ if (@file_put_contents($file, $secret) === false) {
+ @error_log('[zjmfmanager_reserve] 加密密钥文件写入失败:' . $file
+ . ',退回旧 authcode 加密');
+ return null;
+ }
+ @chmod($file, 0600);
+ }
+ $cached['key'] = hash('sha256', $secret, true);
+ return $cached['key'];
+}
+
+/**
+ * 加密上游主机密码等敏感信息。
+ * 密钥可用时 AES-256-GCM,密文格式 'v2:' + base64(nonce.tag.ciphertext);
+ * 密钥不可用时退回旧 authcode(记日志)。
+ */
function zjmf_encrypt($plain)
{
- return authcode((string)$plain, 'ENCODE', SYS_KEY);
+ $plain = (string)$plain;
+ if ($plain === '') {
+ return '';
+ }
+ $key = zjmf_secret_key_raw();
+ if ($key !== null) {
+ $nonce = random_bytes(12);
+ $tag = '';
+ $cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
+ if ($cipher !== false) {
+ return 'v2:' . base64_encode($nonce . $tag . $cipher);
+ }
+ @error_log('[zjmfmanager_reserve] AES-256-GCM 加密失败,退回旧 authcode');
+ }
+ return authcode($plain, 'ENCODE', SYS_KEY);
}
/**
* 密文解密(防御式)。
- * authcode 解密分支在 PHP 8 下对乱码密文会执行「前10位 - time()」并抛
- * TypeError(Unsupported operand types: string - int),导致详情页 500。
- * 这里对空/过短密文直接返回,异常兜底为空串。
+ * 'v2:' 前缀走 AES-256-GCM(与 zjmf_encrypt 对称);无前缀走旧 authcode
+ * 解密以兼容存量数据。authcode 解密分支在 PHP 8 下对乱码密文会抛
+ * TypeError,这里对空/过短密文直接返回,异常兜底为空串。
*/
function zjmf_decrypt($cipher)
{
$cipher = (string)$cipher;
- if ($cipher === '' || strlen($cipher) <= 4) {
+ if ($cipher === '') {
+ return '';
+ }
+ if (strpos($cipher, 'v2:') === 0) {
+ $key = zjmf_secret_key_raw();
+ if ($key === null) {
+ return ''; // 密钥不可用(如密钥文件被删),无法解密
+ }
+ $raw = base64_decode(substr($cipher, 3), true);
+ if ($raw === false || strlen($raw) <= 12 + 16) {
+ return '';
+ }
+ $nonce = substr($raw, 0, 12);
+ $tag = substr($raw, 12, 16);
+ $ct = substr($raw, 28);
+ $plain = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
+ return $plain === false ? '' : $plain;
+ }
+ if (strlen($cipher) <= 4) {
return ''; // 未设置密码或非 authcode 密文
}
try {
@@ -207,8 +303,10 @@ function zjmf_cycles()
/**
* 渲染商品简介为规范的展示 HTML。
* 上游常见 `<li>CPU:4核</li> <li>内存:4G</li>...` 格式:
- * 解码实体 → 压缩标签间空白 → 外层包裹 <ul> 渲染成列表。
- * 非 <li> 内容(含管理员手写 HTML)仅解码实体后原样输出。
+ * 解码实体 → 白名单过滤标签 → 压缩标签间空白 → 外层包裹 <ul> 渲染成列表。
+ * 白名单:p/br/b/strong/em/i/ul/ol/li/a[href|target]/span,
+ * a 的 href 仅允许 http/https 协议,其余标签的全部属性(含 style/on*、
+ * script/iframe 等危险标签)一律剥除,防上游描述注入 XSS。
*/
function zjmf_render_description($raw)
{
@@ -223,6 +321,34 @@ function zjmf_render_description($raw)
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$i++;
} while ($html !== $prev && $i < 3);
+ // 第一步:仅保留白名单标签(script/iframe 等连同标签一并剥除)
+ $html = strip_tags(
+ $html,
+ '<p><br><b><strong><em><i><ul><ol><li><a><span>'
+ );
+ // 第二步:逐标签重建,白名单外的属性全部剥除;a 仅恢复安全的 href/target
+ $html = preg_replace_callback(
+ '/<([a-zA-Z0-9]+)((?:\s+[^<>]*?)?)(\/?)>/u',
+ function ($m) {
+ $tag = strtolower($m[1]);
+ $attrs = $m[2] ?? '';
+ $selfClose = $m[3] ?? '';
+ if ($tag === 'a') {
+ $out = '<a';
+ // href 仅 http/https 协议,杜绝 javascript:/data: 等协议注入
+ if (preg_match('/href\s*=\s*(?:"|\')?\s*((?:https?:\/\/)[^"\'\s>]+)/iu', $attrs, $am)) {
+ $out .= ' href="' . htmlspecialchars($am[1], ENT_QUOTES) . '"';
+ }
+ if (preg_match('/target\s*=\s*(?:"|\')?_blank(?:"|\')?/iu', $attrs)) {
+ $out .= ' target="_blank" rel="noopener"';
+ }
+ return $out . '>';
+ }
+ // 其余标签剥除全部属性(<br/>、<br> 统一输出为 <br>)
+ return '<' . $tag . '>';
+ },
+ $html
+ );
if (stripos($html, '<li') === false) {
return $html;
}
@@ -328,12 +454,13 @@ function zjmf_product_get_by_up($supplier_id, $up_product_id)
) ?: null;
}
-/** 上架商品列表(用户端,仅所属供应商启用时可见)。 */
+/** 上架商品列表(用户端,仅所属供应商启用时可见;带供应商名供分组展示)。 */
function zjmf_product_list_active()
{
global $DB;
return $DB->get_all_prepare(
- "SELECT p.* FROM MN_plugin_zjmf_product p
+ "SELECT p.*, s.name AS supplier_name
+ FROM MN_plugin_zjmf_product p
LEFT JOIN MN_plugin_zjmf_supplier s ON s.id = p.supplier_id
WHERE p.status=1 AND s.status=1
ORDER BY s.sort ASC, p.sort ASC, p.id ASC"
@@ -873,12 +1000,15 @@ function zjmf_normalize_date($val)
}
/**
- * 补齐本地主机缺失的上游主机 ID(up_host_id<=0 时)。
+ * 补齐本地主机缺失的上游主机 ID(up_host_id<=0 时),确定性匹配。
*
- * 开通/结算响应未能解析出主机 ID 时会落库 up_host_id=0,导致用户端
- * 卡片按钮不可用、详情页无法拉取实时信息。此函数通过上游
- * GET host/list(我的主机列表)按 产品名 + 开通日期 匹配回填。
- * 同一次请求内只拉取一次上游列表(进程内静态缓存)。
+ * 匹配键:开通流程保存在订单 order_params.up_host 的本地生成主机标识
+ * (upstream.php purchase() add_to_shop 时的 host 参数),与上游
+ * host/list 返回的 domain 精确相等才绑定;已带 up_host_id 的正常路径
+ * 不会进入本函数。不再做"产品名前缀 + 日期最近"的猜测匹配(曾发生
+ * 误绑上游他人主机的越权风险)。
+ * 匹配不到不写库(保持 up_host_id=0),仅记一条告警日志提示人工绑定。
+ * 本函数只应在开通流程内或管理端调用,用户端 GET 请求不得触发写库。
*
* @param array $host MN_plugin_zjmf_host 行
* @return array 回填后的主机行(未匹配则原样返回)
@@ -897,61 +1027,58 @@ function zjmf_backfill_host_upid($host)
if (!$supplier || (int)$supplier['status'] !== 1) {
return $host;
}
- // 进程内缓存:同一次请求(列表页/详情页)只向上游请求一次
- static $cache = [];
- if (!array_key_exists($supplierId, $cache)) {
- $res = ZjmfUpstream::hostList($supplier, ['orderby' => 'id', 'sort' => 'DESC']);
- $cache[$supplierId] = empty($res['ok']) ? [] : ($res['data']['list'] ?? []);
+ // 确定性匹配键:订单参数中保存的本地生成 host 标识
+ $wantDomain = '';
+ $orderNo = '';
+ $order = zjmf_order_get((int)($host['order_id'] ?? 0));
+ if ($order) {
+ $orderNo = (string)($order['order_no'] ?? '');
+ $params = json_decode((string)($order['order_params'] ?? ''), true);
+ if (is_array($params) && isset($params['up_host'])) {
+ $wantDomain = trim((string)$params['up_host']);
+ }
}
- $list = $cache[$supplierId];
- if (!is_array($list) || $list === []) {
+ if ($wantDomain === '') {
+ // 无匹配键(历史数据/指派单等):不猜测,提示人工绑定
+ zjmf_log((int)($host['user_id'] ?? 0), $orderNo, 'backfill', 'failed',
+ '主机 #' . (int)$host['id'] . ' 缺少上游主机 ID 且无可用的确定性匹配键,'
+ . '已放弃自动回填,请管理员人工绑定', $supplierId);
return $host;
}
- $name = (string)($host['name'] ?? '');
- $created = substr((string)($host['created_at'] ?? ''), 0, 10);
- $candidates = [];
- foreach ($list as $item) {
- if (!is_array($item)) {
- continue;
+ $res = ZjmfUpstream::hostList($supplier, ['orderby' => 'id', 'sort' => 'DESC']);
+ $list = empty($res['ok']) ? [] : ($res['data']['list'] ?? []);
+ $matched = null;
+ foreach ((array)$list as $item) {
+ if (is_array($item) && trim((string)($item['domain'] ?? '')) === $wantDomain) {
+ $matched = $item;
+ break;
}
- $pn = (string)($item['productname'] ?? '');
- if ($pn === '' || ($name !== '' && $pn !== $name
- && strpos($pn, $name) !== 0 && strpos($name, $pn) !== 0)) {
- continue;
- }
- $candidates[] = $item;
}
- if ($candidates === []) {
+ if (!$matched || (int)($matched['id'] ?? 0) <= 0) {
+ // 上游列表中无该 domain(主机可能仍在异步创建):不写库
+ zjmf_log((int)($host['user_id'] ?? 0), $orderNo, 'backfill', 'failed',
+ '主机 #' . (int)$host['id'] . ' 未能按 host=' . $wantDomain
+ . ' 在上游主机列表中确定性匹配,保持 up_host_id=0,请人工核对绑定', $supplierId);
return $host;
}
- // 多台同名主机时,按开通日期与本地创建日期最接近者匹配
- $best = $candidates[0];
- if ($created !== '') {
- $bestDiff = PHP_INT_MAX;
- $bestTime = strtotime($created) ?: 0;
- foreach ($candidates as $item) {
- // regdate 部分版本为 Unix 时间戳,先归一化为 Y-m-d 再比较
- $rd = zjmf_normalize_date((string)($item['regdate'] ?? ''));
- if ($rd === '') {
- continue;
- }
- $rdTime = strtotime($rd) ?: 0;
- $diff = $rdTime ? abs($rdTime - $bestTime) : PHP_INT_MAX;
- if ($diff < $bestDiff) {
- $bestDiff = $diff;
- $best = $item;
- }
- }
- }
- $upId = (int)($best['id'] ?? 0);
- if ($upId <= 0) {
+ $upId = (int)$matched['id'];
+ // 代码级防重:该上游主机已被其他本地主机绑定时不重复写库
+ $dup = $DB->get_row_prepare(
+ "SELECT id FROM MN_plugin_zjmf_host
+ WHERE supplier_id=? AND up_host_id=? AND id<>? LIMIT 1",
+ [$supplierId, $upId, (int)$host['id']]
+ );
+ if ($dup) {
+ zjmf_log((int)($host['user_id'] ?? 0), $orderNo, 'backfill', 'failed',
+ '上游主机 #' . $upId . ' 已被本地主机 #' . (int)$dup['id'] . ' 绑定,'
+ . '主机 #' . (int)$host['id'] . ' 放弃自动回填,请人工核对', $supplierId);
return $host;
}
$status = function_exists('zjmf_map_upstream_status')
- ? zjmf_map_upstream_status((string)($best['domainstatus'] ?? ''))
+ ? zjmf_map_upstream_status((string)($matched['domainstatus'] ?? ''))
: (string)($host['status'] ?? '');
// nextduedate 部分版本为 Unix 时间戳,统一归一化为 Y-m-d
- $renew = zjmf_normalize_date((string)($best['nextduedate'] ?? $host['renew_date'] ?? ''));
+ $renew = zjmf_normalize_date((string)($matched['nextduedate'] ?? $host['renew_date'] ?? ''));
$now = $date ?: date('Y-m-d H:i:s');
$DB->query_prepare(
"UPDATE MN_plugin_zjmf_host
@@ -964,20 +1091,25 @@ function zjmf_backfill_host_upid($host)
return $host;
}
-/** 上游 domainstatus → 本地展示状态(active/suspend/unknown)。 */
+/**
+ * 上游状态 → 本地展示状态(统一映射表,主实现)。
+ * ZjmfUpstream::mapHostStatus 委托调用本函数;除常规 domainstatus 外
+ * 补充 off/true/false/deleted/Unpaid 等分支(true/false/off 多见于
+ * DCIM/云主机开关机状态,deleted/Unpaid 见于部分上游版本)。
+ */
function zjmf_map_upstream_status($status)
{
$st = strtolower(trim((string)$status));
- if (in_array($st, ['active', 'completed', '运行中'], true)) {
+ if (in_array($st, ['active', 'on', 'true', 'completed', '运行中'], true)) {
return 'active';
}
- if (in_array($st, ['pending', 'wait', 'waiting', '待开通'], true)) {
+ if (in_array($st, ['pending', 'wait', 'waiting', 'unpaid', '待开通', '未付款'], true)) {
return 'pending';
}
- if (in_array($st, ['suspended', 'suspend', 'paused', '已暂停'], true)) {
+ if (in_array($st, ['suspended', 'suspend', 'paused', 'off', 'false', '已暂停', '已关机'], true)) {
return 'suspend';
}
- if (in_array($st, ['cancelled', 'cancel', 'terminated', 'terminate', 'fraud', '已终止'], true)) {
+ if (in_array($st, ['cancelled', 'cancel', 'terminated', 'terminate', 'deleted', 'delete', 'fraud', '已终止', '已删除'], true)) {
return 'terminated';
}
return 'unknown';
@@ -1005,8 +1137,11 @@ function zjmf_action_status($action)
{
$map = [
'on' => 'active',
- 'off' => 'suspend',
'reboot' => 'active',
+ // 关机不写 suspend:本地主机表无独立电源字段,写入 suspend 会与
+ // 上游真实状态(domainstatus 仍为 active)脱节,由调用方刷新上游
+ // 状态回写真实状态
+ 'off' => '',
];
return $map[$action] ?? '';
}
@@ -1092,11 +1227,25 @@ function zjmf_open_host($order_id)
$password = (string)($result['password'] ?? '');
$upOrderId = (int)($result['up_order_id'] ?? 0);
- // 回填订单
- zjmf_order_fill_opened($order_id, $upOrderId, $upHostId, $username);
- zjmf_order_set_status($order_id, 'opened', '主机已开通');
+ // 保存本次开通使用的本地生成主机标识(add_to_shop 的 host 参数),
+ // 供开通响应未带主机 ID 时的确定性回填匹配(S2,避免猜测匹配越权)
+ $genHost = trim((string)($result['host'] ?? ''));
+ if ($genHost !== '') {
+ $params = json_decode((string)($order['order_params'] ?? ''), true);
+ if (!is_array($params)) {
+ $params = [];
+ }
+ if ((string)($params['up_host'] ?? '') !== $genHost) {
+ $params['up_host'] = $genHost;
+ $DB->query_prepare(
+ "UPDATE MN_plugin_zjmf_order SET order_params=? WHERE id=?",
+ [json_encode($params, JSON_UNESCAPED_UNICODE), (int)$order_id]
+ );
+ }
+ }
- // 写主机映射
+ // 写主机映射(先建主机,成功后再标记订单 opened,避免中间态):
+ // 主机映射写入失败时订单保持 paid 可人工重试,不得标 opened
$hostId = zjmf_host_create([
'supplier_id' => (int)$order['supplier_id'],
'user_id' => (int)$order['user_id'],
@@ -1110,6 +1259,26 @@ function zjmf_open_host($order_id)
'status' => 'active',
'renew_date' => (string)($result['renew_date'] ?? ''),
]);
+ if ($hostId <= 0) {
+ @error_log('[zjmfmanager_reserve] host create failed, order stays paid: order_id='
+ . (int)$order_id . ' up_host_id=' . $upHostId);
+ zjmf_log((int)$order['user_id'], $order['order_no'], 'purchase', 'failed',
+ '上游开通成功但本地主机映射写入失败,订单保持已支付待人工处理'
+ . '(up_host_id=' . $upHostId . ')', (int)$order['supplier_id']);
+ return ['ok' => false, 'msg' => '上游开通成功但本地主机映射写入失败,请人工处理', 'host_id' => 0];
+ }
+
+ // 回填订单并标记 opened
+ zjmf_order_fill_opened($order_id, $upOrderId, $upHostId, $username);
+ zjmf_order_set_status($order_id, 'opened', '主机已开通');
+
+ // 开通响应未带主机 ID 时,在开通流程内同步做一次确定性回填(失败不阻断)
+ if ($upHostId <= 0) {
+ $hostRow = zjmf_host_get($hostId);
+ if ($hostRow) {
+ zjmf_backfill_host_upid($hostRow);
+ }
+ }
zjmf_log((int)$order['user_id'], $order['order_no'],
'purchase', 'success',
diff --git a/app_plugins/zjmfmanager_reserve/views/host.php b/app_plugins/zjmfmanager_reserve/views/host.php
index e390668..da80a1f 100644
--- a/app_plugins/zjmfmanager_reserve/views/host.php
+++ b/app_plugins/zjmfmanager_reserve/views/host.php
@@ -16,7 +16,8 @@ $os_list = $os_list ?? [];
$os_groups = $os_groups ?? [];
$os_error = $os_error ?? '';
-$password = zjmf_decrypt((string)$host['password']);
+// 密码默认掩码展示,点击"显示"后经 /reserve/api/host_password 按需解密
+// (服务端记日志),页面不再无条件渲染明文密码
$hasUpId = (int)$host['up_host_id'] > 0;
// 上游详情字段(label => [显示名, 分组])。价格 / 付款相关字段一律不展示。
@@ -30,7 +31,7 @@ $up_fields = [
'ip_num' => ['IP 数量', '网络信息'],
'port' => ['端口', '网络信息'],
'username' => ['服务器用户名', '账号信息'],
- 'password' => ['服务器密码', '账号信息'],
+ // 上游密码不在详情面板展示(脱敏收敛,仅在"显示密码"后经 AJAX 获取)
'regdate' => ['开通时间', '生命周期'],
'nextduedate' => ['到期时间', '生命周期'],
'domainstatus' => ['产品状态', '生命周期'],
@@ -75,10 +76,6 @@ if (is_array($info['data']) && $info['data'] !== []) {
if ($k === 'domainstatus') {
$v = zjmf_host_status_label(zjmf_map_upstream_status((string)$v));
}
- // 上游 password 为明文,直接展示(存本地库时才加密)
- if ($k === 'password') {
- $v = (string)$v === '' ? '(空)' : $v;
- }
if (is_array($v)) {
$v = json_encode($v, JSON_UNESCAPED_UNICODE);
}
@@ -253,8 +250,10 @@ ob_start();
<div class="zj-stat-value zj-mono"><?= htmlspecialchars($host['username'] ?: '-') ?></div>
</div>
<div class="zj-stat">
- <div class="zj-stat-label">密码</div>
- <div class="zj-stat-value zj-mono"><?= htmlspecialchars($password ?: '-') ?></div>
+ <div class="zj-stat-label">密码
+ <a href="javascript:;" id="zjf-pw-toggle" style="margin-left:6px;">显示</a>
+ </div>
+ <div class="zj-stat-value zj-mono" id="zjf-pw-value">••••••••</div>
</div>
<div class="zj-stat">
<div class="zj-stat-label">周期</div>
@@ -413,7 +412,7 @@ $taskTypeMap = ['0' => '重装系统', '1' => '救援系统', '2' => '重置密
<?php if (!empty($traffic['ok'])): ?>
<div class="zj-desc2"><?= zjmf_view_show_traffic($traffic['data']) ?></div>
<?php else: ?>
- <span class="zj-tip"><?= (($traffic['msg'] ?? '') ?: '流量查询失败') ?></span>
+ <span class="zj-tip"><?= htmlspecialchars((string)(($traffic['msg'] ?? '') ?: '流量查询失败')) ?></span>
<?php endif; ?>
</div>
</div>
@@ -498,9 +497,49 @@ $taskTypeMap = ['0' => '重装系统', '1' => '救援系统', '2' => '重置密
var reinstallPanel = document.getElementById('zjf-reinstall-panel');
var rescuePanel = document.getElementById('zjf-rescue-panel');
var osCount = <?= is_array($os_list) ? count($os_list) : 0 ?>;
- var osError = <?= json_encode((string)$os_error, JSON_UNESCAPED_UNICODE) ?>;
+ var osError = <?= json_encode((string)$os_error, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var pendingAction = '';
+ // 密码显示/隐藏:点击"显示"后经 AJAX 获取一次解密值,再次点击恢复掩码
+ var pwValue = document.getElementById('zjf-pw-value');
+ var pwToggle = document.getElementById('zjf-pw-toggle');
+ var pwFetched = '';
+ if (pwToggle) {
+ pwToggle.addEventListener('click', function () {
+ if (pwToggle.textContent === '隐藏') {
+ pwValue.textContent = '••••••••';
+ pwToggle.textContent = '显示';
+ return;
+ }
+ if (pwFetched !== '') {
+ pwValue.textContent = pwFetched;
+ pwToggle.textContent = '隐藏';
+ return;
+ }
+ pwToggle.textContent = '…';
+ var body = new URLSearchParams();
+ body.append('host_id', '<?= (int)$host['id'] ?>');
+ fetch('<?= zjmf_url('reserve/api/host_password') ?>', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: body.toString()
+ }).then(function (r) { return r.json(); }).then(function (res) {
+ if (res.code === 'ok' && typeof res.password !== 'undefined' && res.password !== '') {
+ pwFetched = res.password;
+ pwValue.textContent = pwFetched;
+ pwToggle.textContent = '隐藏';
+ } else {
+ pwValue.textContent = '-';
+ pwToggle.textContent = '显示';
+ show(res.msg || res.code || '密码获取失败', false);
+ }
+ }).catch(function () {
+ pwToggle.textContent = '显示';
+ show('网络错误,请重试', false);
+ });
+ });
+ }
+
function show(text, ok) {
msg.textContent = text;
msg.className = 'zj-msg zj-msg-show ' + (ok ? 'zj-msg-success' : 'zj-msg-error');