From 7efb3e6eb5d47b2c6d9f11b1bb15e805a30dd8ee Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 10:34:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=B7=BB=E5=8A=A0=E5=9F=9F?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- check_config/indBXSXX_config.php | 40 --- dashboard.php | 447 ++++++++++++++++++++---- indBXSXX.php | 4 - jump.txt | 7 + lib/Cloak/CloudflareConfig.php | 1 + lib/Cloak/DomainProvisioningService.php | 72 +++- lib/Cloak/DomainRepository.php | 168 ++++++++- lib/Cloak/VisitorSimulationRunner.php | 7 +- lib/InstallSchema.php | 10 +- tools/verify_cloudflare_domain.php | 23 ++ tools/verify_domain_random.php | 90 +++++ 11 files changed, 728 insertions(+), 141 deletions(-) delete mode 100755 check_config/indBXSXX_config.php delete mode 100755 indBXSXX.php create mode 100644 tools/verify_domain_random.php diff --git a/check_config/indBXSXX_config.php b/check_config/indBXSXX_config.php deleted file mode 100755 index ff31949..0000000 --- a/check_config/indBXSXX_config.php +++ /dev/null @@ -1,40 +0,0 @@ - 'https://www.google.com/', -) ); - define('COSTM_IP_SCORE', '20251108_emoUAVF6S' ); - define('BLACK_LIST', '0' ); - define('AUTO_BLACK', 'OFF' ); - define('IS_VIRTUAL', '2' ); - define('AUTO_BLACK_TIMES', '5' ); - define('WHITE_PARAMS', '' ); - define('IPHONE_MODEL', 0 ); - - define('SHOW_SITE_MOBILE', 0 ); - define('SHOW_SITE_OUTPUT', 1 ); - define('WRITE_LOG', 1 ); - define('CLOAK_ZH_ON', 'ON'); - define('CLOAK_OS_ON', 'OFF'); - define('KEEP_PARAMS', 'OFF'); - define('CLOAK_REDIRECT_METHOD', 'curl'); - define('DB_ZP', 'https://worldhomenest.store'); - define('CLOAK_MOBILE_ON', 'OFF'); - define('CLOAK_V_REFERER', 'OFF'); - define('CLOAK_RISK_NUMBER', '68'); - define('CLOAK_RISK_ENHANCED', 'ON'); - define('CLOAK_URL_ARGS_TIMEOUT', '0'); - - define('CLOAK_AD_FB', 'OFF'); - define('CLOAK_AD_GOOGLE', 'ON'); - define('CLOAK_AD_TIKTOK', 'ON'); - define('CLOAK_AD_STRICT', 'OFF'); - define('CLOAK_AD_SPM_ID', 'Ss4yqlAnuG1V'); - - define('CLOAK_DEBUG_MODE', 'OFF'); - ?> \ No newline at end of file diff --git a/dashboard.php b/dashboard.php index caea975..ed1df95 100755 --- a/dashboard.php +++ b/dashboard.php @@ -191,7 +191,17 @@ if (!empty($_REQUEST["action"]) && $_REQUEST["action"] == "submit") { try { $pdoBind = DomainRepository::pdo(); DomainRepository::ensureTable($pdoBind); - $siteDomainId = (int) ($_REQUEST['site_domain_id'] ?? 0); + $siteDomainRaw = $_REQUEST['site_domain_id'] ?? '0'; + if ($siteDomainRaw === 'random') { + $existingId = DomainRepository::domainIdForConfig($pdoBind, $check_name); + if ($existingId > 0) { + $siteDomainId = $existingId; + } else { + $siteDomainId = DomainRepository::pickRandomDomainId($pdoBind); + } + } else { + $siteDomainId = (int) $siteDomainRaw; + } if ($siteDomainId > 0) { DomainRepository::bindConfigToDomain($pdoBind, $check_name, $siteDomainId); } else { @@ -215,10 +225,10 @@ function onoff_radio($name, $val, $label_on='开启', $label_off='关闭') { $on = ($val == 'ON') ? 'active' : ''; $off = ($val != 'ON') ? 'active' : ''; return '
-
'; @@ -256,6 +266,7 @@ body { background:#f0f2f5; font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-ser .topbar a:hover { color:#fff; } .topbar h1 { margin:0; font-size:1.25rem; font-weight:600; } .page-body { max-width:1100px; margin:32px auto; padding:0 16px; } +.page-body.page-body--list { width:90%; max-width:none; margin-left:auto; margin-right:auto; } .status-bar { display:flex; gap:12px; margin-bottom:20px; flex-wrap:wrap; } .status-badge { display:inline-flex; align-items:center; gap:6px; padding:6px 14px; border-radius:20px; font-size:.82rem; font-weight:500; } .status-ok { background:#d4edda; color:#155724; } @@ -326,6 +337,40 @@ body { background:#f0f2f5; font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-ser .domain-host-badge { display: inline-block; padding: 2px 8px; background: #e8f4fd; color: #1565c0; border-radius: 4px; font-size: .78rem; font-family: 'SF Mono', Consolas, monospace; } .config-name-cell strong { color: #2c3e50; font-size: .92rem; } .empty-hint { padding: 28px 20px; text-align: center; color: #8c98a4; font-size: .88rem; background: #fafbfc; border-radius: 0 0 10px 10px; } +.list-toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:10px; padding:12px 16px; border-bottom:1px solid #e9ecef; background:#fafbfc; } +.list-toolbar .search-form { display:flex; align-items:center; gap:8px; flex:1; min-width:220px; } +.list-toolbar .search-form input[type="search"] { max-width:280px; } +.list-pagination { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; padding:12px 16px; border-top:1px solid #e9ecef; background:#fff; font-size:.84rem; color:#6c757d; } +.list-pagination .pagination { margin:0; } +.config-table .col-check { width:36px; text-align:center; } +/* 表单开关:选中实心蓝,未选中蓝框蓝字 */ +.btn-group-toggle .btn.btn-outline-primary, +.btn-group-toggle .btn.btn-outline-secondary { + color: #1565c0; + border-color: #64b5f6; + background: #fff; + font-weight: 500; + min-width: 52px; +} +.btn-group-toggle .btn.btn-outline-primary:hover, +.btn-group-toggle .btn.btn-outline-secondary:hover { + color: #0d47a1; + background: #e3f2fd; + border-color: #42a5f5; +} +.btn-group-toggle .btn.btn-outline-primary.active, +.btn-group-toggle .btn.btn-outline-secondary.active, +.btn-group-toggle .btn.btn-outline-primary.active:hover, +.btn-group-toggle .btn.btn-outline-secondary.active:hover { + color: #fff !important; + background: linear-gradient(180deg, #2196f3 0%, #1565c0 100%) !important; + border-color: #1565c0 !important; + box-shadow: 0 1px 4px rgba(21, 101, 192, .35); +} +.btn-group-toggle .btn.btn-outline-primary:not(.active):focus, +.btn-group-toggle .btn.btn-outline-secondary:not(.active):focus { + box-shadow: 0 0 0 .15rem rgba(33, 150, 243, .25); +} @@ -355,45 +400,61 @@ if ($_SESSION['password'] != LOGIN_PASSWORD) { exit(); } -// 删除配置 -if (!empty($_REQUEST["action"]) && $_REQUEST["action"] == "delete_config" && !empty($_REQUEST["name"])) { - $name = trim($_REQUEST["name"]); - if ($name == "index") { - echo(""); - exit(); - } - $cfg = str_replace(".php", "", $name); - try { - DomainRepository::clearConfigBinding(DomainRepository::pdo(), $cfg); - } catch (Throwable $e) { - } - @unlink("check_config/{$cfg}_config.php"); - @unlink("{$cfg}.php"); - echo(""); - exit(); -} - // 站点域名管理 $cloakDomainMsg = ''; $cloakDomainErr = ''; $cloakCfEnabled = CloudflareConfig::isEnabled(); -if (!empty($_REQUEST['action']) && $_REQUEST['action'] === 'add_domain' && !empty($_POST['new_hostname'])) { - try { - $pdoDom = DomainRepository::pdo(); - $res = DomainProvisioningService::provisionOnAdd($pdoDom, $_POST['new_hostname']); - if ($res['ok']) { - $cloakDomainMsg = '域名「' . htmlspecialchars($res['hostname']) . '」已添加'; - if (!empty($res['nameservers'])) { - $nsHtml = implode('、', array_map('htmlspecialchars', $res['nameservers'])); - $cloakDomainMsg .= '。请到注册商将 NS 修改为:' . $nsHtml . ',然后点击「检测/刷新」。'; - } else { - $cloakDomainMsg .= ',请在编辑配置时关联此域名。'; +if (!empty($_REQUEST['action']) && $_REQUEST['action'] === 'add_domain') { + $hostText = trim((string) ($_POST['new_hostnames'] ?? $_POST['new_hostname'] ?? '')); + if ($hostText !== '') { + try { + $pdoDom = DomainRepository::pdo(); + $dnsMode = $_POST['dns_mode'] ?? 'auto'; + if (!in_array($dnsMode, ['auto', 'manual'], true)) { + $dnsMode = 'auto'; } - } else { - $cloakDomainErr = $res['error'] ?? '添加失败'; + if ($dnsMode === 'auto' && !$cloakCfEnabled) { + $cloakDomainErr = '自动模式需要先在 cong.php 配置 Cloudflare API Token 与 Account ID,或改选手动模式。'; + } else { + $res = DomainProvisioningService::provisionBatch($pdoDom, $hostText, $dnsMode); + if ($res['ok']) { + $addedNames = array_map(static function ($row) { + return $row['hostname'] ?? ''; + }, $res['added'] ?? []); + $addedNames = array_filter($addedNames); + $cloakDomainMsg = '已成功添加 ' . count($addedNames) . ' 个域名:' + . htmlspecialchars(implode('、', $addedNames)); + if (($res['dns_mode'] ?? '') === 'auto' && $cloakCfEnabled) { + $cloakDomainMsg .= '。请到注册商修改 Nameserver 后,在列表中点击「检测/刷新」。'; + } elseif (($res['dns_mode'] ?? '') === 'manual') { + $cloakDomainMsg .= '(手动解析模式,无需 Cloudflare 检测)。'; + } else { + $cloakDomainMsg .= ',请在编辑配置时关联此域名。'; + } + if (!empty($res['failed'])) { + $failParts = []; + foreach ($res['failed'] as $f) { + $failParts[] = ($f['hostname'] ?? '?') . '(' . ($f['error'] ?? '失败') . ')'; + } + $cloakDomainErr = count($res['failed']) . ' 个域名添加失败:' + . htmlspecialchars(implode(';', $failParts)); + } + } else { + $cloakDomainErr = $res['error'] ?? '添加失败'; + if (!empty($res['failed'])) { + $failParts = []; + foreach ($res['failed'] as $f) { + $failParts[] = ($f['hostname'] ?? '?') . '(' . ($f['error'] ?? '失败') . ')'; + } + $cloakDomainErr .= ':' . htmlspecialchars(implode(';', $failParts)); + } + } + } + } catch (Throwable $e) { + $cloakDomainErr = $e->getMessage(); } - } catch (Throwable $e) { - $cloakDomainErr = $e->getMessage(); + } else { + $cloakDomainErr = '请至少输入一个域名'; } } if (!empty($_REQUEST['action']) && $_REQUEST['action'] === 'check_domain') { @@ -535,20 +596,163 @@ function dash_build_ad_links($pf, $protocol, $host) return [['label' => '落地链接', 'url' => $url]]; } -// 遍历配置列表 -$products_file_ary = []; -$dd = opendir("check_config"); -while (false !== ($f = readdir($dd))) { - if ($f == "." || $f == "..") continue; - if (filetype("check_config/$f") == "dir" || $f == "index.php" || $f == "a.txt") continue; - $fname = str_replace("_config.php", "", $f); - $products_file_ary[] = $fname; - if (!file_exists($fname . ".php")) { - copy("tem_link.php", $fname . ".php"); +function dash_build_config_index() +{ + $items = []; + $dir = __DIR__ . '/check_config'; + $dh = @opendir($dir); + if (!$dh) { + return $items; } + while (false !== ($f = readdir($dh))) { + if ($f === '.' || $f === '..') { + continue; + } + $path = $dir . '/' . $f; + if (is_dir($path) || $f === 'index.php' || $f === 'a.txt') { + continue; + } + if (substr($f, -11) !== '_config.php') { + continue; + } + $name = str_replace('_config.php', '', $f); + if ($name === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $name)) { + continue; + } + $entryPhp = __DIR__ . '/' . $name . '.php'; + if (!file_exists($entryPhp)) { + copy(__DIR__ . '/tem_link.php', $entryPhp); + } + $items[] = [ + 'name' => $name, + 'mtime' => (int) @filemtime($path), + 'path' => $path, + ]; + } + closedir($dh); + usort($items, function ($a, $b) { + if ($a['mtime'] === $b['mtime']) { + return strcmp($a['name'], $b['name']); + } + return $b['mtime'] <=> $a['mtime']; + }); + + return $items; +} + +function dash_paginate_configs(array $all, $q, $page, $perPage) +{ + $q = trim((string) $q); + $page = max(1, (int) $page); + $perPage = max(1, (int) $perPage); + + if ($q !== '') { + $qLower = function_exists('mb_strtolower') ? mb_strtolower($q, 'UTF-8') : strtolower($q); + $all = array_values(array_filter($all, function ($row) use ($qLower) { + $name = (string) ($row['name'] ?? ''); + $hay = function_exists('mb_strtolower') ? mb_strtolower($name, 'UTF-8') : strtolower($name); + + return strpos($hay, $qLower) !== false; + })); + } + + $total = count($all); + $totalPages = $total > 0 ? (int) ceil($total / $perPage) : 1; + if ($page > $totalPages) { + $page = $totalPages; + } + $offset = ($page - 1) * $perPage; + + return [ + 'items' => array_slice($all, $offset, $perPage), + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + ]; +} + +function dash_delete_config($name) +{ + $name = dash_sanitize_config_name($name); + if ($name === '' || $name === 'index') { + return false; + } + try { + DomainRepository::clearConfigBinding(DomainRepository::pdo(), $name); + } catch (Throwable $e) { + } + @unlink(__DIR__ . "/check_config/{$name}_config.php"); + @unlink(__DIR__ . "/{$name}.php"); + + return true; +} + +function dash_list_query_params($q, $page) +{ + $params = []; + if (trim((string) $q) !== '') { + $params['q'] = trim((string) $q); + } + if ((int) $page > 1) { + $params['page'] = (int) $page; + } + + return $params; +} + +function dash_list_url($currentName, $q, $page) +{ + $params = dash_list_query_params($q, $page); + $qs = $params !== [] ? '?' . http_build_query($params) : ''; + + return $currentName . $qs; +} + +// 配置列表索引(按文件修改时间倒序) +$configListAll = dash_build_config_index(); +$listSearchQ = trim((string) ($_GET['q'] ?? '')); +$listPage = max(1, (int) ($_GET['page'] ?? 1)); +$listPerPage = 10; +$configPage = dash_paginate_configs($configListAll, $listSearchQ, $listPage, $listPerPage); +$products_file_ary = array_map(function ($row) { + return $row['name']; +}, $configListAll); + +// 删除 / 批量删除配置 +if (!empty($_REQUEST["action"]) && $_REQUEST["action"] == "delete_config" && !empty($_REQUEST["name"])) { + $name = trim($_REQUEST["name"]); + if ($name == "index") { + echo(""); + exit(); + } + dash_delete_config($name); + echo(""); + exit(); +} + +if (!empty($_REQUEST['action']) && $_REQUEST['action'] === 'batch_delete_config') { + $names = $_REQUEST['names'] ?? []; + if (!is_array($names)) { + $names = [$names]; + } + $deleted = 0; + $seen = []; + foreach ($names as $rawName) { + $name = dash_sanitize_config_name($rawName); + if ($name === '' || $name === 'index' || isset($seen[$name])) { + continue; + } + $seen[$name] = true; + if (dash_delete_config($name)) { + $deleted++; + } + } + $redirect = dash_list_url($current_name, $_POST['q'] ?? '', 1); + $msg = $deleted > 0 ? "已删除 {$deleted} 个配置" : '未删除任何配置'; + echo(""); + exit(); } -closedir($dd); -sort($products_file_ary); // 加载 IP 组(编辑页用) $blackGroups = $whiteGroups = []; @@ -566,17 +770,20 @@ $action = $_REQUEST['action'] ?? ''; $isEdit = in_array($action, ['edit_config', 'submit'], true); $cloakSiteDomains = []; +$domainConfigMap = []; $selectedDomainId = 0; $dashboardDefaultHost = DomainRepository::normalizeHostname($_SERVER['HTTP_HOST'] ?? ''); try { $pdoDash = $pdoDash ?? DomainRepository::pdo(); DomainRepository::ensureTable($pdoDash); $cloakSiteDomains = DomainRepository::listAll($pdoDash); + $domainConfigMap = DomainRepository::groupConfigsByDomainId($pdoDash); if ($isEdit) { $selectedDomainId = DomainRepository::domainIdForConfig($pdoDash, $check_name); } } catch (Throwable $e) { $cloakSiteDomains = []; + $domainConfigMap = []; } // 视图判断 @@ -594,7 +801,7 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def -
+
@@ -616,7 +823,7 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def echo '● Redis ' . ($redisOk ? '已连接' : '连接失败') . ''; } ?> - ● 配置数: + ● 配置数:(搜索「」) ● 已登记域名:
@@ -635,22 +842,40 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def
+
+
+ + + + 清除 + +
+
+
+ +
- - + + + - + - + + + +
配置名称绑定域名配置名称绑定域名 广告链接(广告后台填入的链接) 真实页链接(客户到达的链接)操作操作
暂无匹配的配置,请调整搜索关键词
+ + + + 默认 @@ -722,6 +952,35 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def
+ 1 || $configPage['total'] > 0): ?> +
+ / 页,共 + 1): ?> + + +
+ +
@@ -732,19 +991,37 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def
- Cloudflare 已启用:添加域名后将自动创建 Zone 并显示 Nameserver;请到注册商修改 NS 后点击「检测/刷新」。NS 生效后将自动配置 proxied A 记录、Flexible SSL 与 Always HTTPS。 - 源站仍需在宝塔添加站点并监听 (Flexible:访客↔CF 为 HTTPS,CF↔源站为 HTTP)。 + Cloudflare 已启用:选择「自动」时将创建 Zone 并配置 DNS/SSL;选择「手动」时仅本地登记(您已自行配置 DNS)。 + 源站仍需在宝塔添加站点并监听
- 使用前请先在宝塔完成:在宝塔面板 → 网站 → 添加站点(填写下方相同域名)→ 申请并开启 SSL 证书。完成后再在此添加域名并关联配置。 - 若在 cong.php 配置 Cloudflare API Token 与 Account ID,可自动创建 Zone 并管理 DNS/SSL。 + 提示:自动模式需先在 cong.php 配置 Cloudflare API Token 与 Account ID。若已自行完成 DNS 解析,请选择「手动」模式。 + 使用前请先在宝塔添加站点并开启 SSL。
-
+ - - +
+ + +
+
+ +
+ + +
+ + 自动:通过 Cloudflare 创建 Zone 并配置 DNS/SSL(默认)。 + 手动:仅登记域名,不调用 Cloudflare,无需检测/刷新。 + +
+
尚未添加域名。未添加时,广告链接将使用当前后台访问域名:
@@ -763,6 +1040,7 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def $cfLabel = CloudflareConfig::statusLabel($cfStatus); $badgeCls = 'secondary'; if ($cfStatus === 'pending_ns') { $badgeCls = 'warning'; } + elseif ($cfStatus === 'manual') { $badgeCls = 'secondary'; } elseif ($cfStatus === 'provisioning') { $badgeCls = 'info'; } elseif ($cfStatus === 'ready') { $badgeCls = 'success'; } elseif ($cfStatus === 'error') { $badgeCls = 'danger'; } @@ -775,10 +1053,13 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def ' . htmlspecialchars($dom['config_name']) . ''; - } else { + $boundConfigs = $domainConfigMap[(int) $dom['id']] ?? []; + if ($boundConfigs === []) { echo '未关联'; + } else { + foreach ($boundConfigs as $cfgName) { + echo '' . htmlspecialchars($cfgName) . ''; + } } ?> @@ -863,11 +1144,17 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def - + = 2 && ($isNewConfigDraft || $selectedDomainId === 0)): ?> + + + @@ -1009,10 +1296,10 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def
-
@@ -1200,10 +1487,10 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def 记录访客日志
-
@@ -1292,8 +1579,26 @@ $_d = function($name, $default) { return defined($name) ? constant($name) : $def } }); + $('#checkAllConfigs').on('change', function() { + $('.config-row-check').prop('checked', this.checked); + }); + $(document).on('change', '.config-row-check', function() { + var all = $('.config-row-check'); + var checked = all.filter(':checked').length; + $('#checkAllConfigs').prop('checked', all.length > 0 && checked === all.length); + }); + }); +function dashBatchDeleteConfirm() { + var checked = document.querySelectorAll('.config-row-check:checked'); + if (checked.length === 0) { + alert('请至少选择一个配置'); + return false; + } + return confirm('确定删除选中的 ' + checked.length + ' 个配置?此操作不可恢复。'); +} + function delUrlArgs() { if (!confirm('确认重置临时链接参数计时?')) return; window.location.href = '?action=delUrlArgs&check_name='; diff --git a/indBXSXX.php b/indBXSXX.php deleted file mode 100755 index 0c861a7..0000000 --- a/indBXSXX.php +++ /dev/null @@ -1,4 +0,0 @@ - '本地登记', + 'manual' => '手动解析', 'pending_ns' => '待检测', 'provisioning' => '配置中', 'ready' => '已生效', diff --git a/lib/Cloak/DomainProvisioningService.php b/lib/Cloak/DomainProvisioningService.php index f0aae5c..1b167e1 100755 --- a/lib/Cloak/DomainProvisioningService.php +++ b/lib/Cloak/DomainProvisioningService.php @@ -29,27 +29,33 @@ class DomainProvisioningService /** * 添加域名:本地入库 + 可选创建 Cloudflare Zone * - * @return array{ok:bool,error?:string,hostname?:string,id?:int,nameservers?:array,cf_status?:string} + * @param string $dnsMode auto|manual — manual 时不调用 Cloudflare + * @return array{ok:bool,error?:string,hostname?:string,id?:int,nameservers?:array,cf_status?:string,dns_mode?:string} */ - public static function provisionOnAdd(PDO $pdo, $hostname) + public static function provisionOnAdd(PDO $pdo, $hostname, $dnsMode = 'auto') { $add = DomainRepository::add($pdo, $hostname); if (!$add['ok']) { return $add; } - $id = (int) $add['id']; - $host = $add['hostname']; + $id = (int) $add['id']; + $host = $add['hostname']; + $dnsMode = strtolower(trim((string) $dnsMode)) === 'manual' ? 'manual' : 'auto'; - if (!CloudflareConfig::isEnabled()) { + if ($dnsMode === 'manual' || !CloudflareConfig::isEnabled()) { + $status = $dnsMode === 'manual' ? 'manual' : 'local'; DomainRepository::updateCloudflareMeta($pdo, $id, [ - 'cf_status' => 'local', + 'cf_status' => $status, + 'cf_error' => '', ]); + return [ - 'ok' => true, - 'id' => $id, - 'hostname' => $host, - 'cf_status' => 'local', + 'ok' => true, + 'id' => $id, + 'hostname' => $host, + 'cf_status' => $status, + 'dns_mode' => $dnsMode, 'nameservers' => [], ]; } @@ -84,10 +90,52 @@ class DomainProvisioningService 'id' => $id, 'hostname' => $host, 'cf_status' => 'pending_ns', + 'dns_mode' => 'auto', 'nameservers' => $ns, ]; } + /** + * 批量添加域名 + * + * @return array{ok:bool,error?:string,added?:array,failed?:array,dns_mode?:string} + */ + public static function provisionBatch(PDO $pdo, $hostnameText, $dnsMode = 'auto') + { + $hosts = DomainRepository::parseHostnameBatch($hostnameText); + if ($hosts === []) { + return ['ok' => false, 'error' => '请至少输入一个有效域名(每行一个)']; + } + + $dnsMode = strtolower(trim((string) $dnsMode)) === 'manual' ? 'manual' : 'auto'; + $added = []; + $failed = []; + foreach ($hosts as $host) { + $res = self::provisionOnAdd($pdo, $host, $dnsMode); + if (!empty($res['ok'])) { + $added[] = $res; + } else { + $failed[] = [ + 'hostname' => $host, + 'error' => $res['error'] ?? '添加失败', + ]; + } + } + + if ($added === []) { + $firstErr = $failed[0]['error'] ?? '添加失败'; + + return ['ok' => false, 'error' => $firstErr, 'added' => [], 'failed' => $failed, 'dns_mode' => $dnsMode]; + } + + return [ + 'ok' => true, + 'added' => $added, + 'failed' => $failed, + 'dns_mode' => $dnsMode, + ]; + } + /** * 手动检测 NS 是否生效,生效后配置 A 记录与 SSL * @@ -100,6 +148,10 @@ class DomainProvisioningService return ['ok' => false, 'message' => '域名不存在']; } + if (($row['cf_status'] ?? '') === 'manual') { + return ['ok' => false, 'message' => '该域名为手动解析模式,无需 Cloudflare 检测']; + } + if (!CloudflareConfig::isEnabled()) { return ['ok' => false, 'message' => '未配置 Cloudflare API,无法检测']; } diff --git a/lib/Cloak/DomainRepository.php b/lib/Cloak/DomainRepository.php index e94e4b3..872fd20 100755 --- a/lib/Cloak/DomainRepository.php +++ b/lib/Cloak/DomainRepository.php @@ -23,6 +23,50 @@ class DomainRepository ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" ); self::ensureCloudflareColumns($pdo); + self::ensureConfigDomainMapTable($pdo); + } + + /** + * 配置 → 域名(多配置可共用同一域名) + */ + public static function ensureConfigDomainMapTable(PDO $pdo) + { + $pdo->exec( + "CREATE TABLE IF NOT EXISTS `cloak_config_domain` ( + `config_name` varchar(64) NOT NULL COMMENT 'check_config 配置名', + `domain_id` int(11) UNSIGNED NOT NULL COMMENT 'cloak_site_domains.id', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`config_name`), + KEY `idx_domain_id` (`domain_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" + ); + self::migrateLegacyDomainBindings($pdo); + } + + /** + * 将 cloak_site_domains.config_name 旧数据迁入 cloak_config_domain(幂等) + */ + private static function migrateLegacyDomainBindings(PDO $pdo) + { + $st = $pdo->query( + "SELECT id, config_name FROM cloak_site_domains WHERE config_name IS NOT NULL AND config_name != ''" + ); + if (!$st) { + return; + } + $ins = $pdo->prepare( + 'INSERT IGNORE INTO cloak_config_domain (config_name, domain_id) VALUES (?, ?)' + ); + $clr = $pdo->prepare("UPDATE cloak_site_domains SET config_name = '' WHERE id = ?"); + while ($row = $st->fetch()) { + $cfg = trim((string) ($row['config_name'] ?? '')); + $id = (int) ($row['id'] ?? 0); + if ($cfg === '' || $id <= 0) { + continue; + } + $ins->execute([$cfg, $id]); + $clr->execute([$id]); + } } private static function ensureCloudflareColumns(PDO $pdo) @@ -86,6 +130,35 @@ class DomainRepository return $host; } + /** + * 从多行文本解析域名列表(每行一个,也支持逗号/分号分隔) + * + * @return string[] + */ + public static function parseHostnameBatch($text) + { + $text = str_replace(["\r\n", "\r"], "\n", trim((string) $text)); + if ($text === '') { + return []; + } + $parts = preg_split('/[\n,;]+/', $text); + $seen = []; + $out = []; + foreach ($parts as $part) { + $host = self::normalizeHostname(trim($part)); + if ($host === '' || isset($seen[$host])) { + continue; + } + if (!preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host)) { + continue; + } + $seen[$host] = true; + $out[] = $host; + } + + return $out; + } + public static function hostToConfigKey($host) { $host = self::normalizeHostname($host); @@ -171,8 +244,10 @@ class DomainRepository public static function deleteById(PDO $pdo, $id) { self::ensureTable($pdo); + $id = (int) $id; + $pdo->prepare('DELETE FROM cloak_config_domain WHERE domain_id = ?')->execute([$id]); $st = $pdo->prepare('DELETE FROM cloak_site_domains WHERE id = ?'); - $st->execute([(int) $id]); + $st->execute([$id]); return $st->rowCount() > 0; } @@ -184,35 +259,98 @@ class DomainRepository if ($configName === '' || $domainId <= 0) { return false; } - $pdo->prepare("UPDATE cloak_site_domains SET config_name = '' WHERE config_name = ?")->execute([$configName]); - $st = $pdo->prepare('UPDATE cloak_site_domains SET config_name = ? WHERE id = ?'); + if (self::findById($pdo, $domainId) === null) { + return false; + } + $st = $pdo->prepare( + 'INSERT INTO cloak_config_domain (config_name, domain_id) VALUES (?, ?) + ON DUPLICATE KEY UPDATE domain_id = VALUES(domain_id)' + ); $st->execute([$configName, $domainId]); - return $st->rowCount() > 0; + + return true; } public static function clearConfigBinding(PDO $pdo, $configName) { self::ensureTable($pdo); - $st = $pdo->prepare("UPDATE cloak_site_domains SET config_name = '' WHERE config_name = ?"); + $st = $pdo->prepare('DELETE FROM cloak_config_domain WHERE config_name = ?'); $st->execute([trim($configName)]); } public static function domainIdForConfig(PDO $pdo, $configName) { self::ensureTable($pdo); - $st = $pdo->prepare('SELECT id FROM cloak_site_domains WHERE config_name = ? ORDER BY id ASC LIMIT 1'); + $st = $pdo->prepare('SELECT domain_id FROM cloak_config_domain WHERE config_name = ? LIMIT 1'); $st->execute([trim($configName)]); $row = $st->fetch(); - return $row ? (int) $row['id'] : 0; + + return $row ? (int) $row['domain_id'] : 0; } public static function primaryHostnameForConfig(PDO $pdo, $configName) { self::ensureTable($pdo); - $st = $pdo->prepare('SELECT hostname FROM cloak_site_domains WHERE config_name = ? ORDER BY id ASC LIMIT 1'); + $st = $pdo->prepare( + 'SELECT d.hostname FROM cloak_config_domain m + INNER JOIN cloak_site_domains d ON d.id = m.domain_id + WHERE m.config_name = ? LIMIT 1' + ); $st->execute([trim($configName)]); $row = $st->fetch(); - return $row ? $row['hostname'] : ''; + + return $row ? (string) $row['hostname'] : ''; + } + + /** + * @return array + */ + public static function groupConfigsByDomainId(PDO $pdo) + { + self::ensureTable($pdo); + $st = $pdo->query('SELECT domain_id, config_name FROM cloak_config_domain ORDER BY config_name ASC'); + $map = []; + while ($row = $st->fetch()) { + $did = (int) ($row['domain_id'] ?? 0); + if ($did <= 0) { + continue; + } + $map[$did][] = (string) $row['config_name']; + } + + return $map; + } + + /** + * @return string[] + */ + public static function configNamesForDomainId(PDO $pdo, $domainId) + { + self::ensureTable($pdo); + $st = $pdo->prepare('SELECT config_name FROM cloak_config_domain WHERE domain_id = ? ORDER BY config_name ASC'); + $st->execute([(int) $domainId]); + $names = []; + while ($row = $st->fetch()) { + $names[] = (string) $row['config_name']; + } + + return $names; + } + + /** + * 从全部站点域名中随机选取一个(允许多配置共用同一域名) + */ + public static function pickRandomDomainId(PDO $pdo, $configName = '') + { + self::ensureTable($pdo); + unset($configName); + $all = self::listAll($pdo); + if ($all === []) { + return 0; + } + $pick = $all[array_rand($all)]; + + return (int) ($pick['id'] ?? 0); } public static function resolveConfigNameForHost($host, $baseDir = null) @@ -222,12 +360,15 @@ class DomainRepository if ($norm !== '') { try { $pdo = self::pdo(); + self::ensureTable($pdo); $row = self::findByHostname($pdo, $norm); - if ($row && !empty($row['config_name'])) { - $name = trim($row['config_name']); - if (file_exists($baseDir . '/check_config/' . $name . '_config.php')) { - return $name; + if ($row) { + $key = self::hostToConfigKey($norm); + if (file_exists($baseDir . '/check_config/' . $key . '_config.php')) { + return $key; } + + return 'index'; } } catch (Throwable $e) { } @@ -236,6 +377,7 @@ class DomainRepository return $key; } } + return 'index'; } } diff --git a/lib/Cloak/VisitorSimulationRunner.php b/lib/Cloak/VisitorSimulationRunner.php index 65c547d..d23bacf 100755 --- a/lib/Cloak/VisitorSimulationRunner.php +++ b/lib/Cloak/VisitorSimulationRunner.php @@ -157,8 +157,11 @@ class VisitorSimulationRunner $pdo = DomainRepository::pdo(); DomainRepository::ensureTable($pdo); $domRow = DomainRepository::findByHostname($pdo, $domain); - if ($domRow && !empty($domRow['config_name'])) { - $config = trim($domRow['config_name']); + if ($domRow) { + $names = DomainRepository::configNamesForDomainId($pdo, (int) ($domRow['id'] ?? 0)); + if (count($names) === 1) { + $config = $names[0]; + } } } catch (Throwable $e) { } diff --git a/lib/InstallSchema.php b/lib/InstallSchema.php index 37d7427..e663082 100755 --- a/lib/InstallSchema.php +++ b/lib/InstallSchema.php @@ -40,6 +40,14 @@ class InstallSchema KEY `idx_config_name` (`config_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci", + "CREATE TABLE IF NOT EXISTS `cloak_config_domain` ( + `config_name` varchar(64) NOT NULL COMMENT 'check_config 配置名', + `domain_id` int(11) UNSIGNED NOT NULL COMMENT 'cloak_site_domains.id', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`config_name`), + KEY `idx_domain_id` (`domain_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci", + "CREATE TABLE IF NOT EXISTS `visitor_logs` ( `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, `campagin_id` varchar(20) NOT NULL COMMENT '策略ID', @@ -101,7 +109,7 @@ class InstallSchema return [ 'ok' => true, - 'tables' => ['ip_groups', 'ip_list', 'cloak_site_domains', 'visitor_logs'], + 'tables' => ['ip_groups', 'ip_list', 'cloak_site_domains', 'cloak_config_domain', 'visitor_logs'], ]; } } diff --git a/tools/verify_cloudflare_domain.php b/tools/verify_cloudflare_domain.php index 4b6dbe4..a258e63 100755 --- a/tools/verify_cloudflare_domain.php +++ b/tools/verify_cloudflare_domain.php @@ -108,6 +108,29 @@ try { $del = DomainProvisioningService::deleteWithCloudflare($pdo, $domainId); $tests['delete_calls_zone'] = ($del['ok'] ?? false) === true && $mock->deleteCalled === true; $tests['delete_row_gone'] = DomainRepository::findById($pdo, $domainId) === null; + + // 手动模式:不创建 Zone + $hostManual = 'cf-manual-' . substr(md5((string) microtime(true)), 0, 8) . '.test'; + $addManual = DomainProvisioningService::provisionOnAdd($pdo, $hostManual, 'manual'); + $rowManual = DomainRepository::findById($pdo, (int) ($addManual['id'] ?? 0)); + $tests['add_manual_cf_status'] = ($addManual['ok'] ?? false) === true + && ($addManual['cf_status'] ?? '') === 'manual' + && ($rowManual['cf_status'] ?? '') === 'manual' + && trim((string) ($rowManual['cf_zone_id'] ?? '')) === ''; + $checkManual = DomainProvisioningService::checkAndProvision($pdo, (int) ($addManual['id'] ?? 0)); + $tests['check_manual_skipped'] = ($checkManual['ok'] ?? false) === false; + DomainRepository::deleteById($pdo, (int) ($addManual['id'] ?? 0)); + + // 批量添加 + $batchText = "batch-a.test\nbatch-b.test, batch-c.test"; + $batch = DomainProvisioningService::provisionBatch($pdo, $batchText, 'manual'); + $tests['batch_add_count'] = ($batch['ok'] ?? false) === true && count($batch['added'] ?? []) === 3; + foreach ($batch['added'] ?? [] as $item) { + DomainRepository::deleteById($pdo, (int) ($item['id'] ?? 0)); + } + + $parsed = DomainRepository::parseHostnameBatch("a.test\nb.test\nc.test,d.test"); + $tests['parse_hostname_batch'] = $parsed === ['a.test', 'b.test', 'c.test', 'd.test']; } catch (Throwable $e) { $tests['exception'] = false; $tests['error'] = $e->getMessage(); diff --git a/tools/verify_domain_random.php b/tools/verify_domain_random.php new file mode 100644 index 0000000..b3ca5e5 --- /dev/null +++ b/tools/verify_domain_random.php @@ -0,0 +1,90 @@ +#!/usr/bin/env php += 3) { + DomainRepository::bindConfigToDomain($pdo, 'cfg_one', $ids[2]); + DomainRepository::bindConfigToDomain($pdo, 'cfg_two', $ids[2]); + } + + $map = DomainRepository::groupConfigsByDomainId($pdo); + vassert('多配置可共用同一域名', count($map[$ids[2]] ?? []) === 2, json_encode($map[$ids[2]] ?? [])); + + $picked = DomainRepository::pickRandomDomainId($pdo); + vassert('pickRandom 返回有效 id', $picked > 0, (string) $picked); + vassert('pickRandom 从全部域名中选取', in_array($picked, $ids, true), 'picked=' . $picked); + + DomainRepository::bindConfigToDomain($pdo, 'cfg_three', $ids[0]); + DomainRepository::bindConfigToDomain($pdo, 'cfg_four', $ids[0]); + vassert('domainIdForConfig 读取映射', DomainRepository::domainIdForConfig($pdo, 'cfg_three') === $ids[0]); + vassert('primaryHostnameForConfig', DomainRepository::primaryHostnameForConfig($pdo, 'cfg_three') === $hostA); + + DomainRepository::clearConfigBinding($pdo, 'cfg_three'); + vassert('clearConfigBinding 仅清除指定配置', DomainRepository::domainIdForConfig($pdo, 'cfg_three') === 0); + vassert('clearConfigBinding 不影响同域其他配置', DomainRepository::domainIdForConfig($pdo, 'cfg_four') === $ids[0]); + + foreach ($ids as $id) { + DomainRepository::deleteById($pdo, $id); + } + vassert('deleteById 级联清理映射', DomainRepository::domainIdForConfig($pdo, 'cfg_two') === 0); + vassert('cleanup', DomainRepository::findById($pdo, $ids[0]) === null); +} catch (Throwable $e) { + echo "WARN: DB test skipped — " . $e->getMessage() . "\n"; +} + +$all = [ + ['id' => 1], + ['id' => 2], + ['id' => 3], +]; +vassert('offline 全部域名均可随机', count($all) === 3); + +$result = ['ok' => $failed === 0, 'passed' => $passed, 'failed' => $failed]; +echo json_encode($result, JSON_UNESCAPED_UNICODE) . "\n"; +exit($failed > 0 ? 1 : 0);