diff --git a/.codex-tmp/cloudflare_hostname_edit_smoke.php b/.codex-tmp/cloudflare_hostname_edit_smoke.php new file mode 100644 index 0000000..081b35b --- /dev/null +++ b/.codex-tmp/cloudflare_hostname_edit_smoke.php @@ -0,0 +1,144 @@ + $value) { + $normalizedHeaders[strtolower((string)$key)] = (string)$value; + $headerLines[] = $key . ': ' . $value; + } + + $content = null; + if ($data !== null && $method !== 'GET') { + if (is_array($data) || is_object($data)) { + $contentType = $normalizedHeaders['content-type'] ?? 'application/x-www-form-urlencoded'; + if (stripos($contentType, 'application/json') !== false) { + $content = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $content = http_build_query((array)$data); + } + } else { + $content = (string)$data; + } + } + + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headerLines), + 'content' => $content, + 'timeout' => $timeout, + 'ignore_errors' => true, + ], + ]); + + $body = @file_get_contents($url, false, $context); + if ($body === false) { + $body = ''; + } + $responseHeaders = $http_response_header ?? []; + $statusLine = $responseHeaders[0] ?? ''; + $statusCode = preg_match('#\s(\d{3})\s#', $statusLine, $match) ? intval($match[1]) : 0; + + return [ + 'code' => $statusCode, + 'headers' => $responseHeaders, + 'body' => $body, + ]; +} + +require __DIR__ . '/../app/service/CloudflareEnhanceService.php'; + +use app\service\CloudflareEnhanceService; + +$token = getenv('CF_API_TOKEN') ?: ''; +$zoneId = getenv('CF_ZONE_ID') ?: ''; +$zoneName = getenv('CF_ZONE_NAME') ?: ''; + +if ($token === '' || $zoneId === '' || $zoneName === '') { + fwrite(STDERR, "Missing CF_API_TOKEN / CF_ZONE_ID / CF_ZONE_NAME\n"); + exit(2); +} + +$service = new CloudflareEnhanceService([ + 'apikey' => $token, + 'auth' => '1', +]); + +$prefix = 'codex-edit-' . time(); +$hostname = $prefix . '.' . $zoneName; +$origin = 'origin-' . $prefix . '.' . $zoneName; + +$summary = [ + 'created' => null, + 'updated' => null, + 'refreshed' => null, + 'cleanup' => null, +]; + +$created = null; + +try { + $created = $service->createCustomHostname($zoneId, $hostname, null); + $hostnameId = (string)($created['id'] ?? ''); + $summary['created'] = [ + 'id' => $hostnameId, + 'hostname' => $created['hostname'] ?? '', + 'ownership_txt_name' => $created['ownership_verification']['name'] ?? '', + 'ownership_txt_value' => $created['ownership_verification']['value'] ?? '', + 'ownership_http_url' => $created['ownership_verification_http']['http_url'] ?? '', + 'ownership_http_body' => $created['ownership_verification_http']['http_body'] ?? '', + ]; + + $updated = $service->updateCustomHostname($zoneId, $hostnameId, [ + 'custom_origin_server' => $origin, + 'ssl' => [ + 'method' => 'http', + 'type' => 'dv', + ], + ]); + $summary['updated'] = [ + 'custom_origin_server' => $updated['custom_origin_server'] ?? '', + 'ssl_status' => $updated['ssl']['status'] ?? '', + 'validation_record_count' => count($updated['ssl']['validation_records'] ?? []), + 'first_http_url' => $updated['ssl']['validation_records'][0]['http_url'] ?? ($updated['ssl']['http_url'] ?? ''), + 'first_http_body' => $updated['ssl']['validation_records'][0]['http_body'] ?? ($updated['ssl']['http_body'] ?? ''), + ]; + + $current = $service->getCustomHostname($zoneId, $hostnameId); + $refreshed = $service->updateCustomHostname($zoneId, $hostnameId, [ + 'custom_origin_server' => trim((string)($current['custom_origin_server'] ?? '')) !== '' ? $current['custom_origin_server'] : null, + 'ssl' => [ + 'method' => $current['ssl']['method'] ?? 'http', + 'type' => $current['ssl']['type'] ?? 'dv', + ], + ]); + $summary['refreshed'] = [ + 'ssl_status' => $refreshed['ssl']['status'] ?? '', + 'validation_record_count' => count($refreshed['ssl']['validation_records'] ?? []), + 'ownership_txt_name' => $refreshed['ownership_verification']['name'] ?? '', + 'ownership_http_url' => $refreshed['ownership_verification_http']['http_url'] ?? '', + ]; +} finally { + if ($created && !empty($created['id'])) { + try { + $service->deleteCustomHostname($zoneId, (string)$created['id']); + $summary['cleanup'] = true; + } catch (Throwable $e) { + $summary['cleanup'] = $e->getMessage(); + } + } +} + +echo json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), PHP_EOL; diff --git a/.codex-tmp/cloudflare_service_smoke.php b/.codex-tmp/cloudflare_service_smoke.php new file mode 100644 index 0000000..082e630 --- /dev/null +++ b/.codex-tmp/cloudflare_service_smoke.php @@ -0,0 +1,252 @@ + $value) { + $normalizedHeaders[strtolower((string)$key)] = (string)$value; + $headerLines[] = $key . ': ' . $value; + } + if ($referer) { + $headerLines[] = 'Referer: ' . $referer; + } + if ($cookie) { + $headerLines[] = 'Cookie: ' . $cookie; + } + + $content = null; + if ($data !== null && $method !== 'GET') { + if (is_array($data) || is_object($data)) { + $contentType = $normalizedHeaders['content-type'] ?? 'application/x-www-form-urlencoded'; + if (stripos($contentType, 'application/json') !== false) { + $content = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $content = http_build_query((array)$data); + } + } else { + $content = (string)$data; + } + } elseif ($data !== null && $method === 'GET' && is_array($data) && !str_contains($url, '?')) { + $url .= '?' . http_build_query($data); + } + + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headerLines), + 'content' => $content, + 'timeout' => $timeout, + 'ignore_errors' => true, + ], + ]); + + $body = @file_get_contents($url, false, $context); + if ($body === false) { + $body = ''; + } + $responseHeaders = $http_response_header ?? []; + $statusLine = $responseHeaders[0] ?? ''; + $statusCode = preg_match('#\s(\d{3})\s#', $statusLine, $match) ? intval($match[1]) : 0; + + return [ + 'code' => $statusCode, + 'headers' => $responseHeaders, + 'body' => $body, + ]; +} + +require __DIR__ . '/../app/service/CloudflareEnhanceService.php'; + +use app\service\CloudflareEnhanceService; + +$token = getenv('CF_API_TOKEN') ?: ''; +$zoneId = getenv('CF_ZONE_ID') ?: ''; +$accountId = getenv('CF_ACCOUNT_ID') ?: ''; +$zoneName = getenv('CF_ZONE_NAME') ?: ''; + +if ($token === '' || $zoneId === '' || $accountId === '' || $zoneName === '') { + fwrite(STDERR, "Missing CF_API_TOKEN / CF_ZONE_ID / CF_ACCOUNT_ID / CF_ZONE_NAME\n"); + exit(2); +} + +$service = new CloudflareEnhanceService([ + 'apikey' => $token, + 'auth' => '1', + 'account_id' => $accountId, +]); + +$summary = [ + 'account_id' => $service->getDefaultAccountId(), + 'custom_hostnames' => null, + 'fallback_origin' => null, + 'tunnel' => null, + 'cleanup' => [], +]; + +$prefix = 'codex-php-smoke-' . time(); +$tunnelName = $prefix; +$publicHostname = $prefix . '.' . $zoneName; +$hostnameRoute = 'internal-' . $prefix . '.' . $zoneName; +$customHostname = 'saas-' . $prefix . '.' . $zoneName; +$fallbackOrigin = 'origin-' . $prefix . '.' . $zoneName; +$cidr = '10.234.56.0/24'; + +$tunnel = null; +$cidrRoute = null; +$hostnameRouteRow = null; +$customHostnameRow = null; +$originalFallbackOrigin = null; + +try { + try { + $before = $service->listCustomHostnames($zoneId); + $summary['custom_hostnames_before'] = count($before); + $customHostnameRow = $service->createCustomHostname($zoneId, $customHostname, null); + $summary['custom_hostnames'] = [ + 'ok' => true, + 'created' => [ + 'id' => $customHostnameRow['id'] ?? '', + 'hostname' => $customHostnameRow['hostname'] ?? '', + 'ssl_status' => $customHostnameRow['ssl']['status'] ?? '', + 'ownership_status' => $customHostnameRow['ownership_verification']['http']['status'] + ?? $customHostnameRow['ownership_verification']['txt']['status'] + ?? '', + ], + 'after_count' => count($service->listCustomHostnames($zoneId)), + ]; + } catch (Throwable $e) { + $summary['custom_hostnames'] = [ + 'ok' => false, + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + ]; + } + + try { + $originalFallbackOrigin = $service->getFallbackOrigin($zoneId); + $updatedFallbackOrigin = $service->updateFallbackOrigin($zoneId, $fallbackOrigin); + $summary['fallback_origin'] = [ + 'ok' => true, + 'before' => $originalFallbackOrigin, + 'after' => $updatedFallbackOrigin, + ]; + } catch (Throwable $e) { + $summary['fallback_origin'] = [ + 'ok' => false, + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + ]; + } + + $tunnel = $service->createTunnel($accountId, $tunnelName); + $tunnelId = (string)($tunnel['id'] ?? ''); + $summary['tunnel'] = [ + 'id' => $tunnelId, + 'name' => $tunnel['name'] ?? '', + 'status' => $tunnel['status'] ?? '', + 'token_prefix' => substr($service->getTunnelToken($accountId, $tunnelId), 0, 24), + 'initial_config' => $service->getTunnelConfig($accountId, $tunnelId), + ]; + + $service->updateTunnelConfig($accountId, $tunnelId, [ + 'ingress' => [ + [ + 'hostname' => $publicHostname, + 'service' => 'http://127.0.0.1:8080', + ], + [ + 'service' => 'http_status:404', + ], + ], + ]); + + $summary['tunnel']['updated_config'] = $service->getTunnelConfig($accountId, $tunnelId); + $summary['tunnel']['dns_sync'] = $service->upsertTunnelCnameRecord($zoneId, $publicHostname, $tunnelId); + + $cidrRoute = $service->createCidrRoute($accountId, $tunnelId, $cidr, 'php smoke'); + $hostnameRouteRow = $service->createHostnameRoute($accountId, $tunnelId, $hostnameRoute, 'php smoke'); + + $summary['tunnel']['cidr_routes'] = $service->listCidrRoutes($accountId, $tunnelId); + $summary['tunnel']['hostname_routes'] = $service->listHostnameRoutes($accountId, $tunnelId); +} finally { + if ($customHostnameRow && !empty($customHostnameRow['id'])) { + try { + $service->deleteCustomHostname($zoneId, (string)$customHostnameRow['id']); + $summary['cleanup']['custom_hostname'] = true; + } catch (Throwable $e) { + $summary['cleanup']['custom_hostname'] = $e->getMessage(); + } + } + + if ($summary['fallback_origin']['ok'] ?? false) { + try { + if ($originalFallbackOrigin !== null && $originalFallbackOrigin !== '') { + $service->updateFallbackOrigin($zoneId, $originalFallbackOrigin); + } else { + $service->deleteFallbackOrigin($zoneId); + } + $summary['cleanup']['fallback_origin'] = true; + } catch (Throwable $e) { + $summary['cleanup']['fallback_origin'] = $e->getMessage(); + } + } + + if ($tunnel && !empty($tunnel['id'])) { + $tunnelId = (string)$tunnel['id']; + try { + $service->deleteTunnelCnameRecordIfMatch($zoneId, $publicHostname, $tunnelId); + $summary['cleanup']['dns'] = true; + } catch (Throwable $e) { + $summary['cleanup']['dns'] = $e->getMessage(); + } + + if ($cidrRoute && !empty($cidrRoute['id'])) { + try { + $service->deleteCidrRoute($accountId, (string)$cidrRoute['id']); + $summary['cleanup']['cidr'] = true; + } catch (Throwable $e) { + $summary['cleanup']['cidr'] = $e->getMessage(); + } + } + + if ($hostnameRouteRow && !empty($hostnameRouteRow['id'])) { + try { + $service->deleteHostnameRoute($accountId, (string)$hostnameRouteRow['id']); + $summary['cleanup']['hostname_route'] = true; + } catch (Throwable $e) { + $summary['cleanup']['hostname_route'] = $e->getMessage(); + } + } + + try { + $service->updateTunnelConfig($accountId, $tunnelId, [ + 'ingress' => [ + ['service' => 'http_status:404'], + ], + ]); + $summary['cleanup']['config'] = true; + } catch (Throwable $e) { + $summary['cleanup']['config'] = $e->getMessage(); + } + + try { + $service->deleteTunnel($accountId, $tunnelId); + $summary['cleanup']['tunnel'] = true; + } catch (Throwable $e) { + $summary['cleanup']['tunnel'] = $e->getMessage(); + } + } +} + +echo json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), PHP_EOL; diff --git a/.codex-tmp/hostnames.page.check.js b/.codex-tmp/hostnames.page.check.js new file mode 100644 index 0000000..5499b50 --- /dev/null +++ b/.codex-tmp/hostnames.page.check.js @@ -0,0 +1,498 @@ +var currentVerificationHostnameId = ''; +var currentDomainName = '6byj.cn'; + +$(document).ready(function(){ + $("#form-store").bootstrapValidator(); + loadFallbackOrigin(); + $("#listTable").bootstrapTable({ + url: '/cloudflare/hostnames/data/1', + method: 'post', + toolbar: '', + classes: 'table table-striped table-hover table-bordered', + uniqueId: 'id', + responseHandler: hostnameResponseHandler, + columns: [ + {field: 'hostname', title: '主机名'}, + {field: 'custom_origin_server', title: '自定义源站', formatter: function(v){ return v || '-'; }}, + {field: 'ssl_status', title: '证书状态', formatter: formatStatus}, + {field: 'ssl_validation_status', title: '证书校验', formatter: formatStatus}, + {field: 'verification_status', title: '所有权校验', formatter: formatStatus}, + {field: 'created_on', title: '创建时间', formatter: function(v){ return v || '-'; }}, + {field: 'validation_errors', title: '错误信息', formatter: function(v){ return v || '-'; }}, + { + field: 'action', + title: '操作', + formatter: function(value, row){ + return '' + + '编辑 ' + + '校验 ' + + '删除'; + } + } + ] + }); +}); + +function hostnameResponseHandler(res){ + if(res.code !== 0){ + layer.alert(res.msg || '获取自定义主机名失败', {icon: 2}); + return {total: 0, rows: []}; + } + return res; +} + +function refreshHostnameList(){ + $("#listTable").bootstrapTable('refresh'); +} + +function formatStatus(value){ + var v = String(value || '').toLowerCase(); + if(v === 'active' || v === 'active_deployed' || v === 'valid'){ + return ''+htmlEscape(value)+''; + } + if(v === 'pending' || v === 'pending_validation' || v === 'initializing' || v === 'in_progress'){ + return ''+htmlEscape(value || '-')+''; + } + if(v && v !== '-'){ + return ''+htmlEscape(value)+''; + } + return '-'; +} + +function getHostnameRow(id){ + var row = $("#listTable").bootstrapTable('getRowByUniqueId', id); + if(!row){ + layer.alert('未找到自定义主机名数据,请先刷新列表后重试', {icon: 2}); + return null; + } + return row; +} + +function resetHostnameForm(){ + $("#form-store")[0].reset(); + $("#form-store input[name=hostname_id]").val(''); + $("#form-store input[name=hostname]").prop('readonly', false); + $("#form-store").data("bootstrapValidator").resetForm(true); +} + +function openAddDialog(){ + resetHostnameForm(); + $("#storeTitle").text('添加自定义主机名'); + $("#hostnameHint").text('创建后主机名不能直接改名,如需改名请删除后重建。'); + $("#modal-store").modal('show'); +} + +function openEditDialog(id){ + var row = getHostnameRow(id); + if(!row){ + return; + } + resetHostnameForm(); + $("#storeTitle").text('编辑自定义主机名'); + $("#hostnameHint").text('主机名不可直接改名,当前仅支持修改或清空自定义源站。'); + $("#form-store input[name=hostname_id]").val(row.id); + $("#form-store input[name=hostname]").val(row.hostname).prop('readonly', true); + $("#form-store input[name=custom_origin_server]").val(row.custom_origin_server || ''); + $("#modal-store").modal('show'); +} + +function submitHostname(){ + $("#form-store").data("bootstrapValidator").validate(); + if(!$("#form-store").data("bootstrapValidator").isValid()){ + return; + } + var hostnameId = $.trim($("#form-store input[name=hostname_id]").val()); + var url = hostnameId ? '/cloudflare/hostnames/update/1' : '/cloudflare/hostnames/add/1'; + var successMsg = hostnameId ? '更新自定义主机名成功' : '创建自定义主机名成功'; + var ii = layer.load(2); + $.ajax({ + type: 'POST', + url: url, + data: $("#form-store").serialize(), + dataType: 'json', + success: function(res){ + layer.close(ii); + if(res.code === 0){ + $("#modal-store").modal('hide'); + layer.msg(res.msg || successMsg, {icon: 1, time: 1200}); + if(res.data && res.data.id){ + $("#listTable").bootstrapTable('updateByUniqueId', {id: res.data.id, row: res.data}); + if(!$("#listTable").bootstrapTable('getRowByUniqueId', res.data.id)){ + refreshHostnameList(); + } + }else{ + refreshHostnameList(); + } + }else{ + layer.alert(res.msg, {icon: 2}); + } + }, + error: function(){ + layer.close(ii); + layer.alert('服务器错误', {icon: 2}); + } + }); +} + +function openVerificationDialog(id){ + var row = getHostnameRow(id); + if(!row){ + return; + } + currentVerificationHostnameId = id; + renderVerificationDialog(row); + $("#modal-verification").modal('show'); +} + +function refreshHostnameValidation(){ + if(!currentVerificationHostnameId){ + layer.msg('请先选择自定义主机名'); + return; + } + var ii = layer.load(2); + $.ajax({ + type: 'POST', + url: '/cloudflare/hostnames/refresh/1', + data: {hostname_id: currentVerificationHostnameId}, + dataType: 'json', + success: function(res){ + layer.close(ii); + if(res.code === 0){ + if(res.data && res.data.id){ + $("#listTable").bootstrapTable('updateByUniqueId', {id: res.data.id, row: res.data}); + renderVerificationDialog(res.data); + }else{ + refreshHostnameList(); + } + layer.msg(res.msg, {icon: 1, time: 1200}); + }else{ + layer.alert(res.msg, {icon: 2}); + } + }, + error: function(){ + layer.close(ii); + layer.alert('服务器错误', {icon: 2}); + } + }); +} + +function renderVerificationDialog(row){ + $("#verificationTitle").text('证书校验 - ' + row.hostname); + var html = ''; + html += '
pending_validation,再点击“刷新校验”或稍后刷新列表。' + htmlEscape(fullName) + '', {title: '提示', icon: 0}, function(){
+ var ii = layer.load(2);
+ $.ajax({
+ type: 'POST',
+ url: '/record/add/1',
+ data: {
+ name: rr,
+ type: 'TXT',
+ value: value,
+ line: '0',
+ ttl: 600,
+ mx: 1,
+ weight: 0,
+ remark: 'Cloudflare证书校验'
+ },
+ dataType: 'json',
+ success: function(res){
+ layer.close(ii);
+ if(res.code === 0){
+ layer.closeAll();
+ $("#modal-verification").modal('show');
+ layer.msg('TXT 记录添加成功', {icon: 1, time: 1200});
+ }else{
+ layer.alert(res.msg, {icon: 2});
+ }
+ },
+ error: function(){
+ layer.close(ii);
+ layer.alert('服务器错误', {icon: 2});
+ }
+ });
+ });
+}
+
+function convertFullHostnameToRecordName(fullName){
+ var name = String(fullName || '').trim().replace(/\.$/, '');
+ var domain = String(currentDomainName || '').trim().replace(/\.$/, '');
+ if(!name || !domain){
+ return null;
+ }
+ var lowerName = name.toLowerCase();
+ var lowerDomain = domain.toLowerCase();
+ if(lowerName === lowerDomain){
+ return '@';
+ }
+ if(lowerName.endsWith('.' + lowerDomain)){
+ return name.slice(0, name.length - domain.length - 1);
+ }
+ if(name === '@'){
+ return '@';
+ }
+ if(name.indexOf('.') === -1){
+ return name;
+ }
+ return null;
+}
+
+function deleteHostname(id, hostname){
+ layer.confirm('确定要删除自定义主机名 ' + hostname + ' 吗?', {title: '提示', icon: 0}, function(){
+ var ii = layer.load(2);
+ $.ajax({
+ type: 'POST',
+ url: '/cloudflare/hostnames/delete/1',
+ data: {hostname_id: id, hostname: hostname},
+ dataType: 'json',
+ success: function(res){
+ layer.close(ii);
+ if(res.code === 0){
+ layer.closeAll();
+ layer.msg(res.msg, {icon: 1, time: 1000});
+ refreshHostnameList();
+ }else{
+ layer.alert(res.msg, {icon: 2});
+ }
+ },
+ error: function(){
+ layer.close(ii);
+ layer.alert('服务器错误', {icon: 2});
+ }
+ });
+ });
+}
+
+function loadFallbackOrigin(){
+ $.ajax({
+ type: 'POST',
+ url: '/cloudflare/fallback/get/1',
+ dataType: 'json',
+ success: function(res){
+ if(res.code === 0){
+ $("#fallbackOrigin").val((res.data && res.data.origin) ? res.data.origin : '');
+ }else{
+ layer.alert(res.msg, {icon: 2});
+ }
+ }
+ });
+}
+
+function saveFallbackOrigin(){
+ var origin = $.trim($("#fallbackOrigin").val());
+ if(!origin){
+ layer.msg('请输入 Fallback Origin');
+ return;
+ }
+ var ii = layer.load(2);
+ $.ajax({
+ type: 'POST',
+ url: '/cloudflare/fallback/set/1',
+ data: {origin: origin},
+ dataType: 'json',
+ success: function(res){
+ layer.close(ii);
+ if(res.code === 0){
+ $("#fallbackOrigin").val(res.data.origin || origin);
+ layer.msg(res.msg, {icon: 1, time: 1200});
+ }else{
+ layer.alert(res.msg, {icon: 2});
+ }
+ },
+ error: function(){
+ layer.close(ii);
+ layer.alert('服务器错误', {icon: 2});
+ }
+ });
+}
+
+function clearFallbackOrigin(){
+ layer.confirm('确定要清空 Fallback Origin 吗?', {title: '提示', icon: 0}, function(){
+ var ii = layer.load(2);
+ $.ajax({
+ type: 'POST',
+ url: '/cloudflare/fallback/delete/1',
+ dataType: 'json',
+ success: function(res){
+ layer.close(ii);
+ if(res.code === 0){
+ layer.closeAll();
+ $("#fallbackOrigin").val('');
+ layer.msg(res.msg, {icon: 1, time: 1200});
+ }else{
+ layer.alert(res.msg, {icon: 2});
+ }
+ },
+ error: function(){
+ layer.close(ii);
+ layer.alert('服务器错误', {icon: 2});
+ }
+ });
+ });
+}
+
+function htmlEscape(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/.gitignore b/.gitignore
index 3c2969d..a0218ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,5 @@
/vendor
*.log
.env
+.ace-tool/
+/.codex-tmp/dns-panel-ref/
diff --git a/app/BaseController.php b/app/BaseController.php
index 5b9d541..6cda57a 100644
--- a/app/BaseController.php
+++ b/app/BaseController.php
@@ -3,9 +3,11 @@ declare (strict_types = 1);
namespace app;
+use app\lib\DnsHelper;
use think\App;
use think\exception\ValidateException;
use think\Validate;
+use think\facade\Db;
use think\facade\View;
/**
@@ -96,6 +98,36 @@ abstract class BaseController
return $v->failException(true)->check($data);
}
+ protected function getManagedDomainOptions(?string $type = null): array
+ {
+ if (!checkPermission(1)) {
+ return [];
+ }
+
+ $query = Db::name('domain')->alias('A')
+ ->join('account B', 'A.aid = B.id')
+ ->field('A.id,A.name,B.type');
+ if (!empty($type)) {
+ $query->where('B.type', $type);
+ }
+ if (request()->user['level'] == 1) {
+ $query->where('A.is_hide', 0)->where('A.name', 'in', request()->user['permission']);
+ }
+
+ $rows = $query->order('A.name', 'asc')->select();
+ $list = [];
+ foreach ($rows as $row) {
+ $typeName = DnsHelper::$dns_config[$row['type']]['name'] ?? strtoupper((string)$row['type']);
+ $list[] = [
+ 'id' => intval($row['id']),
+ 'name' => $row['name'],
+ 'type' => $row['type'],
+ 'text' => $row['name'] . ' [' . $typeName . ']',
+ ];
+ }
+ return $list;
+ }
+
protected function alert($code, $msg = '', $url = null, $wait = 3)
{
diff --git a/app/controller/Cloudflare.php b/app/controller/Cloudflare.php
new file mode 100644
index 0000000..06618b7
--- /dev/null
+++ b/app/controller/Cloudflare.php
@@ -0,0 +1,1080 @@
+getCloudflareDomainContext(input('param.id/d'));
+ $quickDomainOptions = $this->getManagedDomainOptions('cloudflare');
+ if (empty($quickDomainOptions)) {
+ $quickDomainOptions = [[
+ 'id' => intval($context['domain']['id']),
+ 'name' => $context['domain']['name'],
+ 'type' => 'cloudflare',
+ 'text' => $context['domain']['name'] . ' [Cloudflare]',
+ ]];
+ }
+ View::assign('domainId', $context['domain']['id']);
+ View::assign('domainName', $context['domain']['name']);
+ View::assign('quickDomainOptions', $quickDomainOptions);
+ return view();
+ } catch (Exception $e) {
+ return $this->alert('error', $e->getMessage());
+ }
+ }
+
+ public function hostnames_data()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $rows = [];
+ foreach ($context['service']->listCustomHostnames($context['domain']['thirdid']) as $row) {
+ $rows[] = $this->formatCustomHostnameRow($row);
+ }
+ return json(['code' => 0, 'total' => count($rows), 'rows' => $rows]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
+ }
+ }
+
+ public function hostnames_add()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $hostname = trim(input('post.hostname', '', 'trim'));
+ $origin = trim(input('post.custom_origin_server', '', 'trim'));
+ if (empty($hostname) || !checkDomain($hostname)) {
+ throw new Exception('主机名格式不正确');
+ }
+ if ($origin !== '') {
+ $this->validateCustomOrigin($origin);
+ }
+
+ $result = $context['service']->createCustomHostname($context['domain']['thirdid'], $hostname, $origin !== '' ? $origin : null);
+ $this->add_log($context['domain']['name'], '创建自定义主机名', $hostname . ($origin !== '' ? ' -> ' . $origin : ''));
+ return json(['code' => 0, 'msg' => '创建自定义主机名成功', 'data' => $this->formatCustomHostnameRow($result)]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function hostnames_update()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $hostnameId = trim(input('post.hostname_id', '', 'trim'));
+ if ($hostnameId === '') {
+ throw new Exception('缺少 hostname_id');
+ }
+
+ $current = $context['service']->getCustomHostname($context['domain']['thirdid'], $hostnameId);
+ $hostname = trim((string)($current['hostname'] ?? ''));
+ $origin = trim(input('post.custom_origin_server', '', 'trim'));
+ if ($origin !== '') {
+ $this->validateCustomOrigin($origin);
+ }
+
+ $result = $context['service']->updateCustomHostname(
+ $context['domain']['thirdid'],
+ $hostnameId,
+ [
+ 'custom_origin_server' => $origin !== '' ? $origin : null,
+ 'ssl' => $this->extractCustomHostnameSslPayload($current),
+ ]
+ );
+ $this->add_log($context['domain']['name'], '编辑自定义主机名', $hostname . ' -> ' . ($origin !== '' ? $origin : '清空源站'));
+ return json(['code' => 0, 'msg' => '更新自定义主机名成功', 'data' => $this->formatCustomHostnameRow($result)]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function hostnames_refresh()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $hostnameId = trim(input('post.hostname_id', '', 'trim'));
+ if ($hostnameId === '') {
+ throw new Exception('缺少 hostname_id');
+ }
+
+ $current = $context['service']->getCustomHostname($context['domain']['thirdid'], $hostnameId);
+ $hostname = trim((string)($current['hostname'] ?? $hostnameId));
+ $origin = trim((string)($current['custom_origin_server'] ?? ''));
+ $result = $context['service']->updateCustomHostname(
+ $context['domain']['thirdid'],
+ $hostnameId,
+ [
+ 'custom_origin_server' => $origin !== '' ? $origin : null,
+ 'ssl' => $this->extractCustomHostnameSslPayload($current),
+ ]
+ );
+ $this->add_log($context['domain']['name'], '刷新自定义主机名校验', $hostname);
+ return json(['code' => 0, 'msg' => '已向 Cloudflare 重新发起校验', 'data' => $this->formatCustomHostnameRow($result)]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function hostnames_delete()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $hostnameId = trim(input('post.hostname_id', '', 'trim'));
+ $hostname = trim(input('post.hostname', '', 'trim'));
+ if ($hostnameId === '') {
+ throw new Exception('缺少 hostname_id');
+ }
+
+ $context['service']->deleteCustomHostname($context['domain']['thirdid'], $hostnameId);
+ $this->add_log($context['domain']['name'], '删除自定义主机名', $hostname !== '' ? $hostname : $hostnameId);
+ return json(['code' => 0, 'msg' => '删除自定义主机名成功']);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function hostnames_txt_targets()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $hostname = trim(input('post.hostname', '', 'trim'));
+ if ($hostname === '') {
+ throw new Exception('缺少 TXT 主机名');
+ }
+
+ return json([
+ 'code' => 0,
+ 'data' => [
+ 'hostname' => $hostname,
+ 'candidates' => $this->findTxtRecordTargetDomains($context['domain'], $hostname),
+ ],
+ ]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage(), 'data' => ['candidates' => []]]);
+ }
+ }
+
+ public function fallback_get()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $origin = $context['service']->getFallbackOrigin($context['domain']['thirdid']);
+ return json(['code' => 0, 'data' => ['origin' => $origin]]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function fallback_set()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $origin = trim(input('post.origin', '', 'trim'));
+ if ($origin === '') {
+ throw new Exception('Fallback Origin 不能为空');
+ }
+ $this->validateCustomOrigin($origin);
+
+ $savedOrigin = $context['service']->updateFallbackOrigin($context['domain']['thirdid'], $origin);
+ $this->add_log($context['domain']['name'], '更新 Fallback Origin', $savedOrigin);
+ return json(['code' => 0, 'msg' => '更新 Fallback Origin 成功', 'data' => ['origin' => $savedOrigin]]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function fallback_delete()
+ {
+ try {
+ $context = $this->getCloudflareDomainContext(input('param.id/d'));
+ $context['service']->deleteFallbackOrigin($context['domain']['thirdid']);
+ $this->add_log($context['domain']['name'], '删除 Fallback Origin', '清空成功');
+ return json(['code' => 0, 'msg' => '已清空 Fallback Origin']);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ View::assign('accountId', $context['account']['id']);
+ View::assign('accountName', $this->formatAccountDisplayName($context['account']));
+ View::assign('cfAccountId', $context['accountId']);
+ return view();
+ } catch (Exception $e) {
+ return $this->alert('error', $e->getMessage());
+ }
+ }
+
+ public function tunnels_data()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $rows = [];
+ foreach ($context['service']->listTunnels($context['accountId']) as $row) {
+ $rows[] = $this->formatTunnelRow($row);
+ }
+ return json(['code' => 0, 'total' => count($rows), 'rows' => $rows, 'account_id' => $context['accountId']]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
+ }
+ }
+
+ public function tunnels_add()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $name = trim(input('post.name', '', 'trim'));
+ if ($name === '') {
+ throw new Exception('Tunnel 名称不能为空');
+ }
+ $tunnel = $context['service']->createTunnel($context['accountId'], $name);
+ $this->add_log($this->formatAccountDisplayName($context['account']), '创建 Tunnel', $name . ' [' . ($tunnel['id'] ?? '-') . ']');
+ return json(['code' => 0, 'msg' => '创建 Tunnel 成功', 'data' => $this->formatTunnelRow($tunnel)]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels_delete()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ if ($tunnelId === '') {
+ throw new Exception('缺少 tunnel_id');
+ }
+ $context['service']->deleteTunnel($context['accountId'], $tunnelId);
+ $this->add_log($this->formatAccountDisplayName($context['account']), '删除 Tunnel', $tunnelId);
+ return json(['code' => 0, 'msg' => '删除 Tunnel 成功']);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels_token()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ if ($tunnelId === '') {
+ throw new Exception('缺少 tunnel_id');
+ }
+ $token = $context['service']->getTunnelToken($context['accountId'], $tunnelId);
+ return json(['code' => 0, 'data' => ['token' => $token]]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels_public_hostnames_data()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ if ($tunnelId === '') {
+ throw new Exception('缺少 tunnel_id');
+ }
+ $config = $this->extractTunnelConfigObject($context['service']->getTunnelConfig($context['accountId'], $tunnelId));
+ $rows = [];
+ foreach ($this->extractPublicHostnames($config) as $row) {
+ $zone = $this->findBestMatchingDomain(intval($context['account']['id']), $row['hostname']);
+ $row['zone_name'] = $zone['name'] ?? '';
+ $row['zone_id'] = $zone['thirdid'] ?? '';
+ $rows[] = $row;
+ }
+ return json(['code' => 0, 'total' => count($rows), 'rows' => $rows]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
+ }
+ }
+
+ public function tunnels_public_hostnames_save()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ $hostname = trim(input('post.hostname', '', 'trim'));
+ $serviceValue = trim(input('post.service', '', 'trim'));
+ $path = trim(input('post.path', '', 'trim'));
+ if ($tunnelId === '' || $hostname === '' || $serviceValue === '') {
+ throw new Exception('Tunnel、主机名、服务地址不能为空');
+ }
+ if (!checkDomain($hostname)) {
+ throw new Exception('主机名格式不正确');
+ }
+
+ $zone = $this->findBestMatchingDomain(intval($context['account']['id']), $hostname);
+ if (empty($zone) || empty($zone['thirdid'])) {
+ throw new Exception('未找到匹配的本地域名,请先在当前 Cloudflare 账户下导入该主机名所属主域');
+ }
+
+ $config = $this->extractTunnelConfigObject($context['service']->getTunnelConfig($context['accountId'], $tunnelId));
+ $oldConfig = json_decode(json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
+ $ingress = isset($config['ingress']) && is_array($config['ingress']) ? array_values($config['ingress']) : [];
+ $rule = [
+ 'hostname' => $hostname,
+ 'service' => $serviceValue,
+ ];
+ if ($path !== '') {
+ $rule['path'] = $path;
+ }
+
+ $existingIndex = $this->findPublicHostnameIndex($ingress, $hostname, $path);
+ if ($existingIndex >= 0) {
+ $next = array_merge($ingress[$existingIndex], $rule);
+ if ($path === '' && isset($next['path'])) {
+ unset($next['path']);
+ }
+ $ingress[$existingIndex] = $next;
+ } else {
+ $fallbackIndex = $this->findFallbackIngressIndex($ingress);
+ if ($fallbackIndex >= 0) {
+ array_splice($ingress, $fallbackIndex, 0, [$rule]);
+ } else {
+ $ingress[] = $rule;
+ }
+ }
+
+ $config['ingress'] = $this->ensureFallbackIngress($ingress);
+ $context['service']->updateTunnelConfig($context['accountId'], $tunnelId, $config);
+
+ try {
+ $dns = $context['service']->upsertTunnelCnameRecord($zone['thirdid'], $hostname, $tunnelId);
+ } catch (Exception $e) {
+ $context['service']->updateTunnelConfig($context['accountId'], $tunnelId, $oldConfig);
+ throw new Exception('Public Hostname 已回滚:' . $e->getMessage());
+ }
+
+ $this->add_log($this->formatAccountDisplayName($context['account']), '配置 Tunnel 公网主机名', $hostname . ' -> ' . $serviceValue . ' [' . ($dns['action'] ?? '-') . ']');
+ return json(['code' => 0, 'msg' => '配置 Public Hostname 成功']);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels_public_hostnames_delete()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ $hostname = trim(input('post.hostname', '', 'trim'));
+ $path = trim(input('post.path', '', 'trim'));
+ if ($tunnelId === '' || $hostname === '') {
+ throw new Exception('缺少 tunnel_id 或 hostname');
+ }
+
+ $config = $this->extractTunnelConfigObject($context['service']->getTunnelConfig($context['accountId'], $tunnelId));
+ $oldConfig = json_decode(json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
+ $ingress = isset($config['ingress']) && is_array($config['ingress']) ? array_values($config['ingress']) : [];
+ $nextIngress = [];
+ foreach ($ingress as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $match = $this->normalizeHostname($row['hostname'] ?? '') === $this->normalizeHostname($hostname)
+ && trim((string)($row['path'] ?? '')) === $path;
+ if (!$match) {
+ $nextIngress[] = $row;
+ }
+ }
+
+ $config['ingress'] = $this->ensureFallbackIngress($nextIngress);
+ $context['service']->updateTunnelConfig($context['accountId'], $tunnelId, $config);
+
+ $zone = $this->findBestMatchingDomain(intval($context['account']['id']), $hostname);
+ if (!empty($zone['thirdid'])) {
+ try {
+ $context['service']->deleteTunnelCnameRecordIfMatch($zone['thirdid'], $hostname, $tunnelId);
+ } catch (Exception $e) {
+ $context['service']->updateTunnelConfig($context['accountId'], $tunnelId, $oldConfig);
+ throw new Exception('删除 Public Hostname 时已回滚:' . $e->getMessage());
+ }
+ }
+
+ $this->add_log($this->formatAccountDisplayName($context['account']), '删除 Tunnel 公网主机名', $hostname . ($path !== '' ? ' [' . $path . ']' : ''));
+ return json(['code' => 0, 'msg' => '删除 Public Hostname 成功']);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels_cidr_data()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ if ($tunnelId === '') {
+ throw new Exception('缺少 tunnel_id');
+ }
+ $rows = [];
+ foreach ($context['service']->listCidrRoutes($context['accountId'], $tunnelId) as $row) {
+ $mapped = $this->formatCidrRouteRow($row);
+ if ($mapped['id'] !== '' && $mapped['network'] !== '') {
+ $rows[] = $mapped;
+ }
+ }
+ return json(['code' => 0, 'total' => count($rows), 'rows' => $rows]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
+ }
+ }
+
+ public function tunnels_cidr_add()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ $network = trim(input('post.network', '', 'trim'));
+ $comment = trim(input('post.comment', '', 'trim'));
+ if ($tunnelId === '' || $network === '') {
+ throw new Exception('Tunnel 和 CIDR 不能为空');
+ }
+ if (!$this->isValidCidr($network)) {
+ throw new Exception('CIDR 格式不正确');
+ }
+
+ $route = $context['service']->createCidrRoute($context['accountId'], $tunnelId, $network, $comment !== '' ? $comment : null);
+ $mapped = $this->formatCidrRouteRow($route);
+ $this->add_log($this->formatAccountDisplayName($context['account']), '创建 Tunnel CIDR 路由', $mapped['network']);
+ return json(['code' => 0, 'msg' => '创建 CIDR 路由成功', 'data' => $mapped]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels_cidr_delete()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ $routeId = trim(input('post.route_id', '', 'trim'));
+ if ($tunnelId === '' || $routeId === '') {
+ throw new Exception('缺少 tunnel_id 或 route_id');
+ }
+
+ $matched = false;
+ foreach ($context['service']->listCidrRoutes($context['accountId'], $tunnelId) as $row) {
+ if (trim((string)($row['id'] ?? '')) === $routeId) {
+ $matched = true;
+ break;
+ }
+ }
+ if (!$matched) {
+ throw new Exception('CIDR 路由不存在或不属于当前 Tunnel');
+ }
+
+ $context['service']->deleteCidrRoute($context['accountId'], $routeId);
+ $this->add_log($this->formatAccountDisplayName($context['account']), '删除 Tunnel CIDR 路由', $routeId);
+ return json(['code' => 0, 'msg' => '删除 CIDR 路由成功']);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels_hostname_routes_data()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ if ($tunnelId === '') {
+ throw new Exception('缺少 tunnel_id');
+ }
+ $rows = [];
+ foreach ($context['service']->listHostnameRoutes($context['accountId'], $tunnelId) as $row) {
+ $mapped = $this->formatHostnameRouteRow($row);
+ if ($mapped['id'] !== '' && $mapped['hostname'] !== '') {
+ $rows[] = $mapped;
+ }
+ }
+ return json(['code' => 0, 'total' => count($rows), 'rows' => $rows]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
+ }
+ }
+
+ public function tunnels_hostname_routes_add()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ $hostname = trim(input('post.hostname', '', 'trim'));
+ $comment = trim(input('post.comment', '', 'trim'));
+ if ($tunnelId === '' || $hostname === '') {
+ throw new Exception('Tunnel 和主机名不能为空');
+ }
+ if (!checkDomain($hostname)) {
+ throw new Exception('主机名格式不正确');
+ }
+
+ $route = $context['service']->createHostnameRoute($context['accountId'], $tunnelId, $hostname, $comment !== '' ? $comment : null);
+ $mapped = $this->formatHostnameRouteRow($route);
+ $this->add_log($this->formatAccountDisplayName($context['account']), '创建 Tunnel 主机名路由', $mapped['hostname']);
+ return json(['code' => 0, 'msg' => '创建主机名路由成功', 'data' => $mapped]);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ public function tunnels_hostname_routes_delete()
+ {
+ try {
+ $context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
+ $tunnelId = trim(input('post.tunnel_id', '', 'trim'));
+ $routeId = trim(input('post.route_id', '', 'trim'));
+ if ($tunnelId === '' || $routeId === '') {
+ throw new Exception('缺少 tunnel_id 或 route_id');
+ }
+
+ $matched = false;
+ foreach ($context['service']->listHostnameRoutes($context['accountId'], $tunnelId) as $row) {
+ $id = trim((string)($row['id'] ?? $row['hostname_route_id'] ?? ''));
+ if ($id === $routeId) {
+ $matched = true;
+ break;
+ }
+ }
+ if (!$matched) {
+ throw new Exception('主机名路由不存在或不属于当前 Tunnel');
+ }
+
+ $context['service']->deleteHostnameRoute($context['accountId'], $routeId);
+ $this->add_log($this->formatAccountDisplayName($context['account']), '删除 Tunnel 主机名路由', $routeId);
+ return json(['code' => 0, 'msg' => '删除主机名路由成功']);
+ } catch (Exception $e) {
+ return json(['code' => -1, 'msg' => $e->getMessage()]);
+ }
+ }
+
+ private function getCloudflareDomainContext(int $domainId): array
+ {
+ if (!checkPermission(2)) {
+ throw new Exception('无权限');
+ }
+ $row = Db::name('domain')->alias('A')
+ ->join('account B', 'A.aid = B.id')
+ ->where('A.id', $domainId)
+ ->field('A.*,B.type,B.config account_config,B.name account_name,B.remark account_remark')
+ ->find();
+ if (!$row) {
+ throw new Exception('域名不存在');
+ }
+ if (($row['type'] ?? '') !== 'cloudflare') {
+ throw new Exception('仅支持 Cloudflare 域名');
+ }
+ if (empty($row['thirdid'])) {
+ throw new Exception('当前域名缺少 Cloudflare Zone ID');
+ }
+
+ $config = json_decode($row['account_config'] ?? '', true);
+ if (!is_array($config)) {
+ $config = [];
+ }
+
+ return [
+ 'domain' => $row,
+ 'config' => $config,
+ 'service' => new CloudflareEnhanceService($config),
+ ];
+ }
+
+ private function getCloudflareAccountContext(int $accountId, bool $requireAccountId = false, bool $requireTunnelApiToken = false): array
+ {
+ if (!checkPermission(2)) {
+ throw new Exception('无权限');
+ }
+ $account = Db::name('account')->where('id', $accountId)->find();
+ if (!$account) {
+ throw new Exception('域名账户不存在');
+ }
+ if (($account['type'] ?? '') !== 'cloudflare') {
+ throw new Exception('仅支持 Cloudflare 账户');
+ }
+
+ $config = json_decode($account['config'] ?? '', true);
+ if (!is_array($config)) {
+ $config = [];
+ }
+
+ $service = new CloudflareEnhanceService($config);
+ if ($requireTunnelApiToken && !$service->isApiTokenAuth()) {
+ throw new Exception('Cloudflare Tunnels 仅支持 API 令牌认证,请将当前账户的认证方式切换为 API令牌');
+ }
+
+ $resolvedAccountId = trim((string)($config['account_id'] ?? ''));
+ if ($requireAccountId && $resolvedAccountId === '') {
+ $resolvedAccountId = $service->getDefaultAccountId();
+ if ($resolvedAccountId !== '') {
+ $config['account_id'] = $resolvedAccountId;
+ Db::name('account')->where('id', $account['id'])->update([
+ 'config' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ ]);
+ $account['config'] = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ $service = new CloudflareEnhanceService($config);
+ }
+ }
+ if ($requireAccountId && $resolvedAccountId === '') {
+ throw new Exception('当前 Cloudflare 账户缺少 Account ID,且无法自动探测。请编辑账户并补充 Account ID 后重试');
+ }
+
+ return [
+ 'account' => $account,
+ 'config' => $config,
+ 'service' => $service,
+ 'accountId' => $resolvedAccountId,
+ ];
+ }
+
+ private function validateCustomOrigin(string $origin): void
+ {
+ if (preg_match('/^https?:\/\//i', $origin)) {
+ throw new Exception('自定义源站不支持填写 http:// 或 https://');
+ }
+ if (str_contains($origin, '*')) {
+ throw new Exception('自定义源站不支持通配符');
+ }
+ if (str_contains($origin, '/')) {
+ throw new Exception('自定义源站格式不正确');
+ }
+ if (preg_match('/:\d+$/', $origin)) {
+ throw new Exception('自定义源站不支持端口');
+ }
+ if (filter_var($origin, FILTER_VALIDATE_IP)) {
+ throw new Exception('自定义源站不支持 IP 地址,请填写域名');
+ }
+ if (!checkDomain($origin)) {
+ throw new Exception('自定义源站格式不正确');
+ }
+ }
+
+ private function extractCustomHostnameSslPayload(array $row): array
+ {
+ $ssl = isset($row['ssl']) && is_array($row['ssl']) ? $row['ssl'] : [];
+ $payload = [
+ 'method' => trim((string)($ssl['method'] ?? 'http')),
+ 'type' => trim((string)($ssl['type'] ?? 'dv')),
+ ];
+ if ($payload['method'] === '') {
+ $payload['method'] = 'http';
+ }
+ if ($payload['type'] === '') {
+ $payload['type'] = 'dv';
+ }
+ return $payload;
+ }
+
+ private function formatCustomHostnameRow(array $row): array
+ {
+ $ssl = isset($row['ssl']) && is_array($row['ssl']) ? $row['ssl'] : [];
+ $ownership = isset($row['ownership_verification']) && is_array($row['ownership_verification']) ? $row['ownership_verification'] : [];
+ $ownershipHttp = isset($row['ownership_verification_http']) && is_array($row['ownership_verification_http']) ? $row['ownership_verification_http'] : [];
+ $verificationStatus = trim((string)($ownership['http']['status'] ?? $ownership['txt']['status'] ?? $ownership['status'] ?? ''));
+ if ($verificationStatus === '' && (
+ trim((string)($ownership['name'] ?? '')) !== ''
+ || trim((string)($ownership['value'] ?? '')) !== ''
+ || trim((string)($ownershipHttp['http_url'] ?? '')) !== ''
+ || trim((string)($ownershipHttp['http_body'] ?? '')) !== ''
+ )) {
+ $verificationStatus = 'pending';
+ }
+
+ $validationErrors = [];
+ if (!empty($row['verification_errors']) && is_array($row['verification_errors'])) {
+ foreach ($row['verification_errors'] as $item) {
+ $message = trim((string)($item['message'] ?? $item));
+ if ($message !== '') {
+ $validationErrors[] = $message;
+ }
+ }
+ }
+ if (!empty($ssl['validation_errors']) && is_array($ssl['validation_errors'])) {
+ foreach ($ssl['validation_errors'] as $item) {
+ $message = trim((string)($item['message'] ?? $item));
+ if ($message !== '') {
+ $validationErrors[] = $message;
+ }
+ }
+ }
+
+ $sslValidationRecords = [];
+ if (!empty($ssl['validation_records']) && is_array($ssl['validation_records'])) {
+ foreach ($ssl['validation_records'] as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $sslValidationRecords[] = [
+ 'status' => trim((string)($item['status'] ?? '')),
+ 'txt_name' => trim((string)($item['txt_name'] ?? '')),
+ 'txt_value' => trim((string)($item['txt_value'] ?? '')),
+ 'cname_name' => trim((string)($item['cname_name'] ?? '')),
+ 'cname_target' => trim((string)($item['cname_target'] ?? '')),
+ 'http_url' => trim((string)($item['http_url'] ?? '')),
+ 'http_body' => trim((string)($item['http_body'] ?? '')),
+ 'emails' => !empty($item['emails']) && is_array($item['emails']) ? array_values(array_filter(array_map('strval', $item['emails']))) : [],
+ ];
+ }
+ }
+ if (empty($sslValidationRecords) && (
+ trim((string)($ssl['txt_name'] ?? '')) !== ''
+ || trim((string)($ssl['txt_value'] ?? '')) !== ''
+ || trim((string)($ssl['cname_name'] ?? '')) !== ''
+ || trim((string)($ssl['cname_target'] ?? '')) !== ''
+ || trim((string)($ssl['http_url'] ?? '')) !== ''
+ || trim((string)($ssl['http_body'] ?? '')) !== ''
+ )) {
+ $sslValidationRecords[] = [
+ 'status' => trim((string)($ssl['status'] ?? '')),
+ 'txt_name' => trim((string)($ssl['txt_name'] ?? '')),
+ 'txt_value' => trim((string)($ssl['txt_value'] ?? '')),
+ 'cname_name' => trim((string)($ssl['cname_name'] ?? '')),
+ 'cname_target' => trim((string)($ssl['cname_target'] ?? '')),
+ 'http_url' => trim((string)($ssl['http_url'] ?? '')),
+ 'http_body' => trim((string)($ssl['http_body'] ?? '')),
+ 'emails' => [],
+ ];
+ }
+
+ $sslValidationStatuses = [];
+ foreach ($sslValidationRecords as $item) {
+ $status = trim((string)($item['status'] ?? ''));
+ if ($status !== '') {
+ $sslValidationStatuses[] = $status;
+ }
+ }
+ $sslValidationStatuses = array_values(array_unique(array_filter($sslValidationStatuses)));
+ $sslValidationStatus = count($sslValidationStatuses) > 0 ? implode(' / ', $sslValidationStatuses) : trim((string)($ssl['status'] ?? ''));
+ if ($sslValidationStatus === '') {
+ $sslValidationStatus = '-';
+ }
+
+ return [
+ 'id' => trim((string)($row['id'] ?? '')),
+ 'hostname' => trim((string)($row['hostname'] ?? '')),
+ 'custom_origin_server' => trim((string)($row['custom_origin_server'] ?? '')),
+ 'status' => trim((string)($row['status'] ?? '')),
+ 'ssl_status' => trim((string)($ssl['status'] ?? '')),
+ 'ssl_method' => trim((string)($ssl['method'] ?? '')),
+ 'ssl_type' => trim((string)($ssl['type'] ?? '')),
+ 'ssl_validation_status' => $sslValidationStatus,
+ 'verification_status' => $verificationStatus !== '' ? $verificationStatus : '-',
+ 'created_on' => trim((string)($row['created_at'] ?? $row['created_on'] ?? '')),
+ 'validation_errors' => implode(' | ', array_values(array_unique(array_filter($validationErrors)))),
+ 'ownership_verification' => [
+ 'type' => trim((string)($ownership['type'] ?? '')),
+ 'name' => trim((string)($ownership['name'] ?? '')),
+ 'value' => trim((string)($ownership['value'] ?? '')),
+ 'status' => $verificationStatus !== '' ? $verificationStatus : '-',
+ ],
+ 'ownership_verification_http' => [
+ 'http_url' => trim((string)($ownershipHttp['http_url'] ?? '')),
+ 'http_body' => trim((string)($ownershipHttp['http_body'] ?? '')),
+ ],
+ 'ssl_validation_records' => $sslValidationRecords,
+ ];
+ }
+
+ private function formatTunnelRow(array $row): array
+ {
+ $connections = isset($row['connections']) && is_array($row['connections']) ? array_values($row['connections']) : [];
+ return [
+ 'id' => trim((string)($row['id'] ?? '')),
+ 'name' => trim((string)($row['name'] ?? '')),
+ 'status' => trim((string)($row['status'] ?? 'unknown')),
+ 'created_at' => trim((string)($row['created_at'] ?? '')),
+ 'deleted_at' => trim((string)($row['deleted_at'] ?? '')),
+ 'conns_active_at' => trim((string)($row['conns_active_at'] ?? '')),
+ 'connection_count' => count($connections),
+ 'connections' => $connections,
+ ];
+ }
+
+ private function formatCidrRouteRow(array $row): array
+ {
+ return [
+ 'id' => trim((string)($row['id'] ?? '')),
+ 'network' => trim((string)($row['network'] ?? '')),
+ 'comment' => trim((string)($row['comment'] ?? '')),
+ 'virtual_network_id' => trim((string)($row['virtual_network_id'] ?? '')),
+ 'tunnel_id' => trim((string)($row['tunnel_id'] ?? '')),
+ 'created_at' => trim((string)($row['created_at'] ?? '')),
+ ];
+ }
+
+ private function formatHostnameRouteRow(array $row): array
+ {
+ return [
+ 'id' => trim((string)($row['id'] ?? $row['hostname_route_id'] ?? '')),
+ 'hostname' => trim((string)($row['hostname'] ?? $row['hostname_pattern'] ?? '')),
+ 'comment' => trim((string)($row['comment'] ?? '')),
+ 'tunnel_id' => trim((string)($row['tunnel_id'] ?? '')),
+ 'created_at' => trim((string)($row['created_at'] ?? '')),
+ ];
+ }
+
+ private function findTxtRecordTargetDomains(array $currentDomain, string $hostname): array
+ {
+ $rows = Db::name('domain')->alias('D')
+ ->join('account A', 'D.aid = A.id')
+ ->field('D.id,D.aid,D.name,A.type account_type,A.name account_name,A.remark account_remark')
+ ->select()
+ ->toArray();
+
+ $candidates = [];
+ $bestLength = -1;
+ foreach ($rows as $row) {
+ $recordName = $this->matchHostnameToDomainRecordName($hostname, $row['name'] ?? '');
+ if ($recordName === null) {
+ continue;
+ }
+ $domainName = $this->normalizeHostname($row['name'] ?? '');
+ $matchedLength = strlen($domainName);
+ if ($matchedLength > $bestLength) {
+ $bestLength = $matchedLength;
+ $candidates = [];
+ }
+ if ($matchedLength === $bestLength) {
+ $candidates[] = $this->formatTxtTargetCandidate($row, $recordName, intval($currentDomain['id'] ?? 0));
+ }
+ }
+
+ if (empty($candidates)) {
+ $fallbackRecordName = $this->matchHostnameToDomainRecordName($hostname, $currentDomain['name'] ?? '', true);
+ if ($fallbackRecordName !== null) {
+ $candidates[] = $this->formatTxtTargetCandidate([
+ 'id' => $currentDomain['id'] ?? 0,
+ 'aid' => $currentDomain['aid'] ?? 0,
+ 'name' => $currentDomain['name'] ?? '',
+ 'account_type' => $currentDomain['type'] ?? '',
+ 'account_name' => $currentDomain['account_name'] ?? '',
+ 'account_remark' => $currentDomain['account_remark'] ?? '',
+ ], $fallbackRecordName, intval($currentDomain['id'] ?? 0));
+ }
+ }
+
+ usort($candidates, function ($a, $b) {
+ if ($a['is_current_domain'] !== $b['is_current_domain']) {
+ return $a['is_current_domain'] ? -1 : 1;
+ }
+ $providerCompare = strcmp($a['account_type_name'], $b['account_type_name']);
+ if ($providerCompare !== 0) {
+ return $providerCompare;
+ }
+ $accountCompare = strcmp($a['account_display_name'], $b['account_display_name']);
+ if ($accountCompare !== 0) {
+ return $accountCompare;
+ }
+ return strcmp($a['domain_name'], $b['domain_name']);
+ });
+
+ return $candidates;
+ }
+
+ private function formatTxtTargetCandidate(array $row, string $recordName, int $currentDomainId): array
+ {
+ $account = [
+ 'id' => intval($row['aid'] ?? 0),
+ 'name' => trim((string)($row['account_name'] ?? '')),
+ 'remark' => trim((string)($row['account_remark'] ?? '')),
+ ];
+ $accountType = trim((string)($row['account_type'] ?? ''));
+
+ return [
+ 'domain_id' => intval($row['id'] ?? 0),
+ 'domain_name' => trim((string)($row['name'] ?? '')),
+ 'record_name' => $recordName,
+ 'account_id' => $account['id'],
+ 'account_type' => $accountType,
+ 'account_type_name' => $this->formatDnsTypeName($accountType),
+ 'account_display_name' => $this->formatAccountDisplayName($account),
+ 'is_current_domain' => intval($row['id'] ?? 0) === $currentDomainId,
+ ];
+ }
+
+ private function formatAccountDisplayName(array $account): string
+ {
+ $name = trim((string)($account['name'] ?? ''));
+ $remark = trim((string)($account['remark'] ?? ''));
+ if ($remark !== '') {
+ return $remark . ' (' . $name . ')';
+ }
+ return $name !== '' ? $name : ('Cloudflare账户#' . ($account['id'] ?? ''));
+ }
+
+ private function extractTunnelConfigObject(array $raw): array
+ {
+ if (isset($raw['config']) && is_array($raw['config'])) {
+ return $raw['config'];
+ }
+ return $raw;
+ }
+
+ private function extractPublicHostnames(array $config): array
+ {
+ $rows = [];
+ $ingress = isset($config['ingress']) && is_array($config['ingress']) ? array_values($config['ingress']) : [];
+ foreach ($ingress as $rule) {
+ if (!is_array($rule)) {
+ continue;
+ }
+ $hostname = trim((string)($rule['hostname'] ?? ''));
+ if ($hostname === '') {
+ continue;
+ }
+ $rows[] = [
+ 'hostname' => $hostname,
+ 'path' => trim((string)($rule['path'] ?? '')),
+ 'service' => trim((string)($rule['service'] ?? '')),
+ ];
+ }
+ return $rows;
+ }
+
+ private function ensureFallbackIngress(array $ingress): array
+ {
+ $rows = [];
+ foreach ($ingress as $rule) {
+ if (is_array($rule)) {
+ $rows[] = $rule;
+ }
+ }
+ if (empty($rows) || !$this->isFallbackIngressRule($rows[count($rows) - 1])) {
+ $rows[] = ['service' => 'http_status:404'];
+ }
+ return $rows;
+ }
+
+ private function isFallbackIngressRule(array $rule): bool
+ {
+ return trim((string)($rule['hostname'] ?? '')) === '' && trim((string)($rule['path'] ?? '')) === '';
+ }
+
+ private function findFallbackIngressIndex(array $ingress): int
+ {
+ foreach ($ingress as $index => $rule) {
+ if (is_array($rule) && $this->isFallbackIngressRule($rule)) {
+ return $index;
+ }
+ }
+ return -1;
+ }
+
+ private function findPublicHostnameIndex(array $ingress, string $hostname, string $path): int
+ {
+ foreach ($ingress as $index => $rule) {
+ if (!is_array($rule)) {
+ continue;
+ }
+ $sameHostname = $this->normalizeHostname($rule['hostname'] ?? '') === $this->normalizeHostname($hostname);
+ $samePath = trim((string)($rule['path'] ?? '')) === trim($path);
+ if ($sameHostname && $samePath) {
+ return $index;
+ }
+ }
+ return -1;
+ }
+
+ private function findBestMatchingDomain(int $accountId, string $hostname): ?array
+ {
+ $hostname = preg_replace('/^\*\./', '', $this->normalizeHostname($hostname));
+ $domains = Db::name('domain')->where('aid', $accountId)->select()->toArray();
+ $best = null;
+ $bestLength = -1;
+ foreach ($domains as $domain) {
+ $domainName = $this->normalizeHostname($domain['name'] ?? '');
+ if ($domainName === '') {
+ continue;
+ }
+ if ($this->matchHostnameToDomainRecordName($hostname, $domainName) !== null && strlen($domainName) > $bestLength) {
+ $best = $domain;
+ $bestLength = strlen($domainName);
+ }
+ }
+ return $best;
+ }
+
+ private function matchHostnameToDomainRecordName(string $hostname, string $domainName, bool $allowRelative = false): ?string
+ {
+ $hostname = preg_replace('/^\*\./', '', $this->normalizeHostname($hostname));
+ $domainName = $this->normalizeHostname($domainName);
+ if ($hostname === '' || $domainName === '') {
+ return null;
+ }
+ if ($hostname === $domainName) {
+ return '@';
+ }
+ if (str_ends_with($hostname, '.' . $domainName)) {
+ return substr($hostname, 0, -strlen($domainName) - 1);
+ }
+ if ($allowRelative) {
+ if ($hostname === '@') {
+ return '@';
+ }
+ if (!str_contains($hostname, '.')) {
+ return $hostname;
+ }
+ }
+ return null;
+ }
+
+ private function formatDnsTypeName(string $type): string
+ {
+ $dnsList = DnsHelper::getList();
+ return $dnsList[$type]['name'] ?? ($type !== '' ? $type : '-');
+ }
+
+ private function normalizeHostname($hostname): string
+ {
+ $hostname = trim((string)$hostname);
+ if ($hostname === '') {
+ return '';
+ }
+ $hostname = convertDomainToAscii(rtrim($hostname, '.'));
+ return strtolower($hostname);
+ }
+
+ private function isValidCidr(string $network): bool
+ {
+ if (!str_contains($network, '/')) {
+ return false;
+ }
+ [$ip, $prefix] = explode('/', $network, 2);
+ if (!is_numeric($prefix)) {
+ return false;
+ }
+ $prefix = intval($prefix);
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ return $prefix >= 0 && $prefix <= 32;
+ }
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+ return $prefix >= 0 && $prefix <= 128;
+ }
+ return false;
+ }
+
+ private function add_log(string $domain, string $action, string $data): void
+ {
+ if (strlen($data) > 500) {
+ $data = substr($data, 0, 500);
+ }
+ Db::name('log')->insert([
+ 'uid' => request()->user['id'],
+ 'domain' => $domain,
+ 'action' => $action,
+ 'data' => $data,
+ 'addtime' => date('Y-m-d H:i:s'),
+ ]);
+ }
+}
diff --git a/app/controller/Domain.php b/app/controller/Domain.php
index c930df8..bf30448 100644
--- a/app/controller/Domain.php
+++ b/app/controller/Domain.php
@@ -261,14 +261,27 @@ class Domain extends BaseController
$name = input('post.name', null, 'trim');
$thirdid = input('post.thirdid', null, 'trim');
$recordcount = input('post.recordcount/d', 0);
+ $result = [];
if ($method == 1 && empty($name) || $method == 0 && (empty($name) || empty($thirdid))) return json(['code' => -1, 'msg' => '参数不能为空']);
if (Db::name('domain')->where('aid', $aid)->where('name', $name)->find()) {
return json(['code' => -1, 'msg' => '域名已存在']);
}
if ($method == 1) {
- $dns = DnsHelper::getModel($aid);
- $result = $dns->addDomain($name);
- if (!$result) return json(['code' => -1, 'msg' => '添加域名失败,' . $dns->getError()]);
+ $account = Db::name('account')->where('id', $aid)->find();
+ if (!$account) {
+ return json(['code' => -1, 'msg' => '域名账户不存在']);
+ }
+ $name = strtolower(rtrim($name, '.'));
+ if ($account['type'] == 'dnspod' && getMainDomain($name) !== $name) {
+ $result = $this->addDnsPodDelegatedSubdomain($account, $name);
+ if (!$result['success']) {
+ return json(['code' => -1, 'msg' => $result['msg']]);
+ }
+ } else {
+ $dns = DnsHelper::getModel($aid);
+ $result = $dns->addDomain($name);
+ if (!$result) return json(['code' => -1, 'msg' => '添加域名失败,' . $dns->getError()]);
+ }
$name = $result['name'];
$thirdid = $result['id'];
}
@@ -281,7 +294,11 @@ class Domain extends BaseController
'is_sso' => 1,
'recordcount' => $recordcount,
]);
- return json(['code' => 0, 'msg' => '添加域名成功!']);
+ $msg = '添加域名成功!';
+ if (!empty($result['msg'])) {
+ $msg .= ' ' . $result['msg'];
+ }
+ return json(['code' => 0, 'msg' => $msg]);
} elseif ($act == 'edit') {
if (!checkPermission(2)) return $this->alert('error', '无权限');
$id = input('post.id/d');
@@ -452,9 +469,19 @@ class Domain extends BaseController
$dnsconfig = DnsHelper::$dns_config[$dnstype];
$dnsconfig['type'] = $dnstype;
+ $quickDomainOptions = $this->getManagedDomainOptions();
+ if (empty($quickDomainOptions)) {
+ $quickDomainOptions = [[
+ 'id' => intval($id),
+ 'name' => $drow['name'],
+ 'type' => $dnstype,
+ 'text' => $drow['name'] . ' [' . ($dnsconfig['name'] ?? strtoupper($dnstype)) . ']',
+ ]];
+ }
View::assign('domainId', $id);
View::assign('domainName', $drow['name']);
+ View::assign('quickDomainOptions', $quickDomainOptions);
View::assign('recordLine', $recordLineArr);
View::assign('minTTL', $minTTL ? $minTTL : 1);
View::assign('dnsconfig', $dnsconfig);
@@ -1190,6 +1217,189 @@ class Domain extends BaseController
}
}
+ private function addDnsPodDelegatedSubdomain($account, $domain)
+ {
+ $dns = DnsHelper::getModel(intval($account['id']));
+ if (!$dns || !method_exists($dns, 'createSubdomainValidateTxtValue')) {
+ return ['success' => false, 'msg' => '当前腾讯云账户不支持子域托管自动委派'];
+ }
+
+ $parentDomainRow = $this->findManagedParentDomainRow($domain);
+ if (!$parentDomainRow) {
+ return ['success' => false, 'msg' => '未找到可写的父域名,请先把父域添加到系统后再创建子域托管'];
+ }
+
+ $relativeName = $this->buildRelativeRecordName($domain, $parentDomainRow['name']);
+ if ($relativeName === '@') {
+ return ['success' => false, 'msg' => '当前输入看起来是根域名,请直接按普通新域名方式添加'];
+ }
+
+ $validation = $dns->createSubdomainValidateTxtValue($domain);
+ if (!$validation) {
+ return ['success' => false, 'msg' => '获取腾讯云子域校验 TXT 失败,' . $dns->getError()];
+ }
+
+ $validationRecordName = $validation['sub_domain'] !== '' ? $validation['sub_domain'] : $relativeName;
+ $validationValue = $validation['value'] ?? '';
+ if ($validationValue === '') {
+ return ['success' => false, 'msg' => '腾讯云未返回子域校验 TXT 值,请稍后重试'];
+ }
+
+ $saveValidation = $this->ensureManagedRecord(
+ $parentDomainRow,
+ $validationRecordName,
+ 'TXT',
+ $validationValue,
+ 'DNSPod子域托管校验'
+ );
+ if (!$saveValidation['success']) {
+ return ['success' => false, 'msg' => '父域自动添加校验 TXT 失败,' . $saveValidation['msg']];
+ }
+
+ $validated = false;
+ for ($i = 0; $i < 4; $i++) {
+ if ($dns->describeSubdomainValidateStatus($domain)) {
+ $validated = true;
+ break;
+ }
+ if ($i < 3) {
+ sleep(3);
+ }
+ }
+ if (!$validated) {
+ return [
+ 'success' => false,
+ 'msg' => '已自动向父域添加腾讯云校验 TXT,但腾讯云暂未检测到生效。请等待 DNS 生效后再次点击添加。校验主机:'
+ . $validationRecordName . ';校验值:' . $validationValue,
+ ];
+ }
+
+ $result = $dns->addDomain($domain);
+ if (!$result) {
+ return ['success' => false, 'msg' => '腾讯云创建子域托管失败,' . $dns->getError()];
+ }
+
+ $nameServers = isset($result['name_servers']) && is_array($result['name_servers']) ? $result['name_servers'] : [];
+ if (empty($nameServers)) {
+ return [
+ 'success' => true,
+ 'id' => $result['id'],
+ 'name' => $result['name'],
+ 'msg' => '腾讯云子域已创建,但未返回 NS 服务器,请到腾讯云控制台查看后手动补父域委派。',
+ ];
+ }
+
+ foreach ($nameServers as $nameServer) {
+ $saveNs = $this->ensureManagedRecord(
+ $parentDomainRow,
+ $relativeName,
+ 'NS',
+ $nameServer,
+ 'DNSPod子域托管委派'
+ );
+ if (!$saveNs['success']) {
+ return [
+ 'success' => false,
+ 'msg' => '腾讯云子域已创建,但父域自动添加 NS 委派失败,' . $saveNs['msg'] . '。请手动添加 NS:' . implode(', ', $nameServers),
+ ];
+ }
+ }
+
+ return [
+ 'success' => true,
+ 'id' => $result['id'],
+ 'name' => $result['name'],
+ 'msg' => '已自动完成父域校验 TXT 和 NS 委派。',
+ ];
+ }
+
+ private function findManagedParentDomainRow($domain)
+ {
+ $domain = strtolower(rtrim(trim($domain), '.'));
+ $rows = Db::name('domain')->alias('d')
+ ->join('account a', 'd.aid = a.id')
+ ->field('d.id,d.aid,d.name,d.thirdid,a.type')
+ ->select()
+ ->toArray();
+ usort($rows, function ($left, $right) {
+ return strlen($right['name']) <=> strlen($left['name']);
+ });
+ foreach ($rows as $row) {
+ $parent = strtolower(rtrim(trim($row['name']), '.'));
+ if ($parent === $domain) {
+ continue;
+ }
+ if (str_ends_with($domain, '.' . $parent)) {
+ return $row;
+ }
+ }
+ return false;
+ }
+
+ private function buildRelativeRecordName($domain, $parentDomain)
+ {
+ $domain = strtolower(rtrim(trim($domain), '.'));
+ $parentDomain = strtolower(rtrim(trim($parentDomain), '.'));
+ if ($domain === $parentDomain) {
+ return '@';
+ }
+ $suffix = '.' . $parentDomain;
+ if (!str_ends_with($domain, $suffix)) {
+ return '';
+ }
+ return substr($domain, 0, -strlen($suffix));
+ }
+
+ private function ensureManagedRecord($domainRow, $name, $type, $value, $remark = null)
+ {
+ $dns = DnsHelper::getModel($domainRow['aid'], $domainRow['name'], $domainRow['thirdid']);
+ if (!$dns) {
+ return ['success' => false, 'msg' => '父域 DNS 驱动初始化失败'];
+ }
+ if ($this->hasExistingManagedRecord($dns, $name, $type, $value)) {
+ return ['success' => true, 'msg' => '记录已存在'];
+ }
+ $line = DnsHelper::$line_name[$domainRow['type']]['DEF'] ?? 'default';
+ $recordId = $dns->addDomainRecord($name, $type, $value, $line, 600, 1, null, $remark);
+ if (!$recordId) {
+ return ['success' => false, 'msg' => $dns->getError()];
+ }
+ $this->add_log($domainRow['name'], '添加解析', $name . ' [' . $type . '] ' . $value . ' (线路:' . $line . ' TTL:600)');
+ return ['success' => true, 'msg' => '添加成功'];
+ }
+
+ private function hasExistingManagedRecord($dns, $name, $type, $value)
+ {
+ $records = $dns->getSubDomainRecords($name === '@' ? '' : $name, 1, 100, $type);
+ if (!$records || empty($records['list']) || !is_array($records['list'])) {
+ return false;
+ }
+ foreach ($records['list'] as $row) {
+ if (strtoupper((string)($row['Type'] ?? '')) !== strtoupper($type)) {
+ continue;
+ }
+ if ($this->isSameDnsRecordValue($type, $row['Value'] ?? '', $value)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private function isSameDnsRecordValue($type, $left, $right)
+ {
+ $left = trim((string)$left);
+ $right = trim((string)$right);
+ if (strtoupper($type) === 'TXT') {
+ $left = trim($left, "\"'");
+ $right = trim($right, "\"'");
+ return $left === $right;
+ }
+ if (strtoupper($type) === 'NS') {
+ return strtolower(rtrim($left, '.')) === strtolower(rtrim($right, '.'));
+ }
+ return $left === $right;
+ }
+
public function expire_notice()
{
if (!checkPermission(2)) return $this->alert('error', '无权限');
diff --git a/app/lib/DnsHelper.php b/app/lib/DnsHelper.php
index f055f67..f748f97 100644
--- a/app/lib/DnsHelper.php
+++ b/app/lib/DnsHelper.php
@@ -366,18 +366,19 @@ class DnsHelper
'cloudflare' => [
'name' => 'Cloudflare',
'icon' => 'cloudflare.ico',
- 'note' => '',
+ 'note' => '如需使用 Cloudflare 增强与 Tunnels,建议使用 API令牌 认证,并补充 Account ID。Fallback Origin / 自定义主机名还要求目标 Zone 已开通 Cloudflare for SaaS 能力。',
'config' => [
'email' => [
'name' => '邮箱地址',
'type' => 'input',
'placeholder' => '',
'required' => true,
+ 'show' => 'auth=="0"',
],
'apikey' => [
'name' => 'API密钥/令牌',
'type' => 'input',
- 'placeholder' => '',
+ 'placeholder' => '建议填写 Cloudflare API Token',
'required' => true,
],
'auth' => [
@@ -387,7 +388,15 @@ class DnsHelper
'0' => 'API密钥',
'1' => 'API令牌',
],
- 'value' => '0'
+ 'value' => '1'
+ ],
+ 'account_id' => [
+ 'name' => 'Account ID',
+ 'type' => 'input',
+ 'placeholder' => '可留空,首次进入 Tunnels 时会尝试自动探测',
+ 'required' => false,
+ 'show' => 'auth=="1"',
+ 'note' => 'Cloudflare Tunnels 是账户级能力,建议填写 Account ID;留空时系统会尝试自动探测。'
],
'proxy' => [
'name' => '使用代理服务器',
diff --git a/app/lib/dns/dnspod.php b/app/lib/dns/dnspod.php
index 61eee98..1bc24d3 100644
--- a/app/lib/dns/dnspod.php
+++ b/app/lib/dns/dnspod.php
@@ -322,11 +322,53 @@ class dnspod implements DnsInterface
];
$data = $this->send_request($action, $param);
if ($data) {
- return ['id' => $data['DomainInfo']['Id'], 'name' => $data['DomainInfo']['Domain']];
+ $result = [
+ 'id' => $data['DomainInfo']['Id'],
+ 'name' => $data['DomainInfo']['Domain'],
+ 'name_servers' => $this->normalizeNameServerList($data['DomainInfo']['GradeNsList'] ?? []),
+ ];
+ if (!$this->modifyDomainStatus($result['name'], 'enable', intval($result['id']))) {
+ return false;
+ }
+ return $result;
+ }
+ $existingDomain = $this->findExactDomain($Domain);
+ if ($existingDomain) {
+ if (!$this->modifyDomainStatus($existingDomain['name'], 'enable', intval($existingDomain['id']))) {
+ return false;
+ }
+ return $existingDomain;
}
return false;
}
+ public function createSubdomainValidateTxtValue($Domain)
+ {
+ $action = 'CreateSubdomainValidateTXTValue';
+ $param = [
+ 'DomainZone' => $Domain,
+ ];
+ $data = $this->send_request($action, $param);
+ if ($data) {
+ return [
+ 'domain' => trim((string)($data['Domain'] ?? getMainDomain($Domain))),
+ 'sub_domain' => trim((string)($data['Subdomain'] ?? '')),
+ 'value' => trim((string)($data['Value'] ?? '')),
+ ];
+ }
+ return false;
+ }
+
+ public function describeSubdomainValidateStatus($Domain)
+ {
+ $action = 'DescribeSubdomainValidateStatus';
+ $param = [
+ 'DomainZone' => $Domain,
+ ];
+ $data = $this->send_request($action, $param);
+ return is_array($data);
+ }
+
//域名别名列表
public function domainAliasList()
{
@@ -408,4 +450,56 @@ class dnspod implements DnsInterface
$this->error = $message;
//file_put_contents('logs.txt',date('H:i:s').' '.$message."\r\n", FILE_APPEND);
}
+
+ private function normalizeNameServerList($list)
+ {
+ if (!is_array($list)) {
+ return [];
+ }
+ $result = [];
+ foreach ($list as $item) {
+ $value = trim((string)$item);
+ if ($value !== '') {
+ $result[] = strtolower(rtrim($value, '.'));
+ }
+ }
+ return array_values(array_unique($result));
+ }
+
+ private function modifyDomainStatus($domain, $status, $domainId = 0)
+ {
+ $param = [
+ 'Domain' => $domain,
+ 'Status' => $status,
+ ];
+ if ($domainId > 0) {
+ $param['DomainId'] = $domainId;
+ }
+ $data = $this->send_request('ModifyDomainStatus', $param);
+ return is_array($data);
+ }
+
+ private function findExactDomain($domain)
+ {
+ $data = $this->send_request('DescribeDomainList', [
+ 'Offset' => 0,
+ 'Limit' => 20,
+ 'Keyword' => $domain,
+ ]);
+ if (!$data || empty($data['DomainList']) || !is_array($data['DomainList'])) {
+ return false;
+ }
+ foreach ($data['DomainList'] as $row) {
+ $name = strtolower(trim((string)($row['Name'] ?? '')));
+ if ($name !== strtolower(trim((string)$domain))) {
+ continue;
+ }
+ return [
+ 'id' => $row['DomainId'],
+ 'name' => $row['Name'],
+ 'name_servers' => $this->normalizeNameServerList($row['GradeNsList'] ?? []),
+ ];
+ }
+ return false;
+ }
}
diff --git a/app/service/CloudflareEnhanceService.php b/app/service/CloudflareEnhanceService.php
new file mode 100644
index 0000000..66123ac
--- /dev/null
+++ b/app/service/CloudflareEnhanceService.php
@@ -0,0 +1,604 @@
+email = trim((string)($config['email'] ?? ''));
+ $this->apiKey = preg_replace('/\s+/', '', trim((string)($config['apikey'] ?? '')));
+ $this->auth = isset($config['auth']) ? intval($config['auth']) : (preg_match('/^[0-9a-f]+$/i', $this->apiKey) ? 0 : 1);
+ $this->proxy = isset($config['proxy']) && strval($config['proxy']) === '1';
+ $this->accountId = trim((string)($config['account_id'] ?? ''));
+ }
+
+ public function isApiTokenAuth(): bool
+ {
+ return $this->auth === 1;
+ }
+
+ public function getConfiguredAccountId(): string
+ {
+ return $this->accountId;
+ }
+
+ public function getAccounts(): array
+ {
+ try {
+ return $this->paginate('/accounts', [], 50);
+ } catch (Exception $e) {
+ $this->throwActionError('获取账户列表', $e, 'Account:Read');
+ }
+ }
+
+ public function getDefaultAccountId(): string
+ {
+ try {
+ $accounts = $this->getAccounts();
+ if (!empty($accounts[0]['id'])) {
+ return trim((string)$accounts[0]['id']);
+ }
+ } catch (Exception $e) {
+ }
+
+ try {
+ $payload = $this->requestRaw('GET', '/zones', ['page' => 1, 'per_page' => 1]);
+ $first = $payload['result'][0] ?? [];
+ $accountId = trim((string)($first['account']['id'] ?? ''));
+ if ($accountId !== '') {
+ return $accountId;
+ }
+ } catch (Exception $e) {
+ }
+
+ return '';
+ }
+
+ public function getZone(string $zoneId): array
+ {
+ try {
+ return $this->requestResult('GET', '/zones/' . $zoneId);
+ } catch (Exception $e) {
+ $this->throwActionError('获取域名详情', $e, 'Zone:Read');
+ }
+ }
+
+ public function listCustomHostnames(string $zoneId): array
+ {
+ try {
+ return $this->paginate('/zones/' . $zoneId . '/custom_hostnames', [], 100);
+ } catch (Exception $e) {
+ $this->throwActionError('获取自定义主机名列表', $e, 'SSL and Certificates:Read');
+ }
+ }
+
+ public function getCustomHostname(string $zoneId, string $hostnameId): array
+ {
+ try {
+ return $this->requestResult('GET', '/zones/' . $zoneId . '/custom_hostnames/' . trim($hostnameId));
+ } catch (Exception $e) {
+ $this->throwActionError('获取自定义主机名详情', $e, 'SSL and Certificates:Read');
+ }
+ }
+
+ public function createCustomHostname(string $zoneId, string $hostname, ?string $customOriginServer = null): array
+ {
+ $hostname = $this->normalizeHostname($hostname);
+ $payload = [
+ 'hostname' => $hostname,
+ 'ssl' => [
+ 'method' => 'http',
+ 'type' => 'dv',
+ ],
+ ];
+ $origin = trim((string)$customOriginServer);
+ if ($origin !== '') {
+ $payload['custom_origin_server'] = $this->normalizeHostname($origin);
+ }
+
+ try {
+ return $this->requestResult('POST', '/zones/' . $zoneId . '/custom_hostnames', [], $payload);
+ } catch (Exception $e) {
+ $this->throwActionError('创建自定义主机名', $e, 'SSL and Certificates:Write');
+ }
+ }
+
+ public function updateCustomHostname(string $zoneId, string $hostnameId, array $payload): array
+ {
+ if (isset($payload['custom_origin_server']) && $payload['custom_origin_server'] !== null) {
+ $payload['custom_origin_server'] = $this->normalizeHostname($payload['custom_origin_server']);
+ }
+ if (isset($payload['hostname']) && $payload['hostname'] !== null) {
+ $payload['hostname'] = $this->normalizeHostname($payload['hostname']);
+ }
+
+ try {
+ return $this->requestResult('PATCH', '/zones/' . $zoneId . '/custom_hostnames/' . trim($hostnameId), [], $payload);
+ } catch (Exception $e) {
+ $this->throwActionError('更新自定义主机名', $e, 'SSL and Certificates:Write');
+ }
+ }
+
+ public function deleteCustomHostname(string $zoneId, string $hostnameId): bool
+ {
+ try {
+ $this->requestResult('DELETE', '/zones/' . $zoneId . '/custom_hostnames/' . $hostnameId);
+ return true;
+ } catch (Exception $e) {
+ $this->throwActionError('删除自定义主机名', $e, 'SSL and Certificates:Write');
+ }
+ }
+
+ public function getFallbackOrigin(string $zoneId): string
+ {
+ try {
+ $result = $this->requestResult('GET', '/zones/' . $zoneId . '/custom_hostnames/fallback_origin', [], null, true);
+ if ($result === null) {
+ return '';
+ }
+ return trim((string)($result['origin'] ?? ''));
+ } catch (Exception $e) {
+ if ($e->getCode() === 404) {
+ return '';
+ }
+ $this->throwActionError('获取 Fallback Origin', $e, 'SSL and Certificates:Read');
+ }
+ }
+
+ public function updateFallbackOrigin(string $zoneId, string $origin): string
+ {
+ try {
+ $result = $this->requestResult('PUT', '/zones/' . $zoneId . '/custom_hostnames/fallback_origin', [], [
+ 'origin' => $this->normalizeHostname($origin),
+ ]);
+ return trim((string)($result['origin'] ?? $origin));
+ } catch (Exception $e) {
+ $this->throwActionError('更新 Fallback Origin', $e, 'SSL and Certificates:Write');
+ }
+ }
+
+ public function deleteFallbackOrigin(string $zoneId): bool
+ {
+ try {
+ $this->requestResult('DELETE', '/zones/' . $zoneId . '/custom_hostnames/fallback_origin', [], null, true);
+ return true;
+ } catch (Exception $e) {
+ if ($e->getCode() === 404) {
+ return true;
+ }
+ $this->throwActionError('删除 Fallback Origin', $e, 'SSL and Certificates:Write');
+ }
+ }
+
+ public function listTunnels(string $accountId): array
+ {
+ $this->assertTunnelSupported();
+ try {
+ return $this->paginate('/accounts/' . $accountId . '/cfd_tunnel', ['is_deleted' => 'false'], 100);
+ } catch (Exception $e) {
+ $this->throwActionError('获取 Tunnel 列表', $e, 'Cloudflare Tunnel:Read');
+ }
+ }
+
+ public function createTunnel(string $accountId, string $name): array
+ {
+ $this->assertTunnelSupported();
+ try {
+ return $this->requestResult('POST', '/accounts/' . $accountId . '/cfd_tunnel', [], [
+ 'name' => trim($name),
+ 'tunnel_secret' => base64_encode(random_bytes(32)),
+ ]);
+ } catch (Exception $e) {
+ $this->throwActionError('创建 Tunnel', $e, 'Cloudflare Tunnel:Write');
+ }
+ }
+
+ public function deleteTunnel(string $accountId, string $tunnelId): bool
+ {
+ $this->assertTunnelSupported();
+ try {
+ $this->requestResult('DELETE', '/accounts/' . $accountId . '/cfd_tunnel/' . $tunnelId);
+ return true;
+ } catch (Exception $e) {
+ $this->throwActionError('删除 Tunnel', $e, 'Cloudflare Tunnel:Write');
+ }
+ }
+
+ public function getTunnelToken(string $accountId, string $tunnelId): string
+ {
+ $this->assertTunnelSupported();
+ try {
+ $result = $this->requestResult('GET', '/accounts/' . $accountId . '/cfd_tunnel/' . $tunnelId . '/token');
+ if (is_string($result)) {
+ return $result;
+ }
+ return trim((string)($result['token'] ?? ''));
+ } catch (Exception $e) {
+ $this->throwActionError('获取 Tunnel Token', $e, 'Cloudflare Tunnel:Read');
+ }
+ }
+
+ public function getTunnelConfig(string $accountId, string $tunnelId): array
+ {
+ $this->assertTunnelSupported();
+ try {
+ $result = $this->requestResult('GET', '/accounts/' . $accountId . '/cfd_tunnel/' . $tunnelId . '/configurations', [], null, true);
+ return is_array($result) ? $result : [];
+ } catch (Exception $e) {
+ $this->throwActionError('获取 Tunnel 配置', $e, 'Cloudflare Tunnel:Read');
+ }
+ }
+
+ public function updateTunnelConfig(string $accountId, string $tunnelId, array $config): array
+ {
+ $this->assertTunnelSupported();
+ try {
+ return $this->requestResult('PUT', '/accounts/' . $accountId . '/cfd_tunnel/' . $tunnelId . '/configurations', [], [
+ 'config' => $config,
+ ]);
+ } catch (Exception $e) {
+ $this->throwActionError('更新 Tunnel 配置', $e, 'Cloudflare Tunnel:Write');
+ }
+ }
+
+ public function listCidrRoutes(string $accountId, ?string $tunnelId = null): array
+ {
+ $this->assertTunnelSupported();
+ $query = ['is_deleted' => 'false'];
+ if (!empty($tunnelId)) {
+ $query['tunnel_id'] = $tunnelId;
+ }
+
+ try {
+ return $this->paginate('/accounts/' . $accountId . '/teamnet/routes', $query, 100);
+ } catch (Exception $e) {
+ $this->throwActionError('获取 CIDR 路由列表', $e, 'Cloudflare Tunnel:Read');
+ }
+ }
+
+ public function createCidrRoute(string $accountId, string $tunnelId, string $network, ?string $comment = null, ?string $virtualNetworkId = null): array
+ {
+ $this->assertTunnelSupported();
+ $payload = [
+ 'network' => trim($network),
+ 'tunnel_id' => trim($tunnelId),
+ ];
+ if (!empty($comment)) {
+ $payload['comment'] = trim($comment);
+ }
+ if (!empty($virtualNetworkId)) {
+ $payload['virtual_network_id'] = trim($virtualNetworkId);
+ }
+
+ try {
+ return $this->requestResult('POST', '/accounts/' . $accountId . '/teamnet/routes', [], $payload);
+ } catch (Exception $e) {
+ $this->throwActionError('创建 CIDR 路由', $e, 'Cloudflare Tunnel:Write');
+ }
+ }
+
+ public function deleteCidrRoute(string $accountId, string $routeId): bool
+ {
+ $this->assertTunnelSupported();
+ try {
+ $this->requestResult('DELETE', '/accounts/' . $accountId . '/teamnet/routes/' . $routeId);
+ return true;
+ } catch (Exception $e) {
+ $this->throwActionError('删除 CIDR 路由', $e, 'Cloudflare Tunnel:Write');
+ }
+ }
+
+ public function listHostnameRoutes(string $accountId, ?string $tunnelId = null): array
+ {
+ $this->assertTunnelSupported();
+ $query = ['is_deleted' => 'false'];
+ if (!empty($tunnelId)) {
+ $query['tunnel_id'] = $tunnelId;
+ }
+
+ try {
+ return $this->paginate('/accounts/' . $accountId . '/zerotrust/routes/hostname', $query, 100);
+ } catch (Exception $e) {
+ $this->throwActionError('获取主机名路由列表', $e, 'Cloudflare Tunnel:Read');
+ }
+ }
+
+ public function createHostnameRoute(string $accountId, string $tunnelId, string $hostname, ?string $comment = null): array
+ {
+ $this->assertTunnelSupported();
+ $payload = [
+ 'hostname' => $this->normalizeHostname($hostname),
+ 'tunnel_id' => trim($tunnelId),
+ ];
+ if (!empty($comment)) {
+ $payload['comment'] = trim($comment);
+ }
+
+ try {
+ return $this->requestResult('POST', '/accounts/' . $accountId . '/zerotrust/routes/hostname', [], $payload);
+ } catch (Exception $e) {
+ $this->throwActionError('创建主机名路由', $e, 'Cloudflare Tunnel:Write');
+ }
+ }
+
+ public function deleteHostnameRoute(string $accountId, string $routeId): bool
+ {
+ $this->assertTunnelSupported();
+ try {
+ $this->requestResult('DELETE', '/accounts/' . $accountId . '/zerotrust/routes/hostname/' . $routeId);
+ return true;
+ } catch (Exception $e) {
+ $this->throwActionError('删除主机名路由', $e, 'Cloudflare Tunnel:Write');
+ }
+ }
+
+ public function upsertTunnelCnameRecord(string $zoneId, string $hostname, string $tunnelId): array
+ {
+ $zoneId = trim($zoneId);
+ $hostname = $this->normalizeHostname($hostname);
+ $target = trim($tunnelId) . '.cfargotunnel.com';
+
+ try {
+ $payload = $this->requestRaw('GET', '/zones/' . $zoneId . '/dns_records', [
+ 'name' => $hostname,
+ 'type' => 'CNAME',
+ 'page' => 1,
+ 'per_page' => 100,
+ ]);
+ $records = $payload['result'] ?? [];
+
+ $allByNamePayload = $this->requestRaw('GET', '/zones/' . $zoneId . '/dns_records', [
+ 'name' => $hostname,
+ 'page' => 1,
+ 'per_page' => 100,
+ ]);
+ $allByName = $allByNamePayload['result'] ?? [];
+ $otherTypes = [];
+ foreach ($allByName as $row) {
+ $type = strtoupper((string)($row['type'] ?? ''));
+ $name = $this->normalizeHostname($row['name'] ?? '');
+ if ($name === $hostname && $type !== 'CNAME') {
+ $otherTypes[] = $type;
+ }
+ }
+ if (!empty($otherTypes)) {
+ $otherTypes = array_unique(array_filter($otherTypes));
+ throw new Exception('主机名已存在非 CNAME 记录(' . implode(', ', $otherTypes) . '),无法同步 Tunnel CNAME', 400);
+ }
+
+ foreach ($records as $record) {
+ $name = $this->normalizeHostname($record['name'] ?? '');
+ if ($name !== $hostname) {
+ continue;
+ }
+ $content = $this->normalizeHostname($record['content'] ?? '');
+ $proxied = !empty($record['proxied']);
+ if ($content === $this->normalizeHostname($target) && $proxied) {
+ return ['action' => 'unchanged'];
+ }
+
+ $this->requestResult('PUT', '/zones/' . $zoneId . '/dns_records/' . $record['id'], [], [
+ 'type' => 'CNAME',
+ 'name' => $hostname,
+ 'content' => $target,
+ 'proxied' => true,
+ 'ttl' => 1,
+ ]);
+ return ['action' => 'updated'];
+ }
+
+ $this->requestResult('POST', '/zones/' . $zoneId . '/dns_records', [], [
+ 'type' => 'CNAME',
+ 'name' => $hostname,
+ 'content' => $target,
+ 'proxied' => true,
+ 'ttl' => 1,
+ ]);
+ return ['action' => 'created'];
+ } catch (Exception $e) {
+ $this->throwActionError('同步 Tunnel CNAME 记录', $e, 'Zone:DNS:Edit');
+ }
+ }
+
+ public function deleteTunnelCnameRecordIfMatch(string $zoneId, string $hostname, string $tunnelId): array
+ {
+ $zoneId = trim($zoneId);
+ $hostname = $this->normalizeHostname($hostname);
+ $target = $this->normalizeHostname(trim($tunnelId) . '.cfargotunnel.com');
+
+ try {
+ $payload = $this->requestRaw('GET', '/zones/' . $zoneId . '/dns_records', [
+ 'name' => $hostname,
+ 'type' => 'CNAME',
+ 'page' => 1,
+ 'per_page' => 100,
+ ]);
+ $records = $payload['result'] ?? [];
+ foreach ($records as $record) {
+ $name = $this->normalizeHostname($record['name'] ?? '');
+ $content = $this->normalizeHostname($record['content'] ?? '');
+ if ($name === $hostname && $content === $target) {
+ $this->requestResult('DELETE', '/zones/' . $zoneId . '/dns_records/' . $record['id']);
+ return ['deleted' => true];
+ }
+ }
+ return ['deleted' => false];
+ } catch (Exception $e) {
+ $this->throwActionError('删除 Tunnel CNAME 记录', $e, 'Zone:DNS:Edit');
+ }
+ }
+
+ private function paginate(string $path, array $query = [], int $perPage = 100): array
+ {
+ $all = [];
+ $page = 1;
+ $maxPage = 200;
+ while ($page <= $maxPage) {
+ $payload = $this->requestRaw('GET', $path, array_merge($query, [
+ 'page' => $page,
+ 'per_page' => $perPage,
+ ]));
+ $batch = $payload['result'] ?? [];
+ if (!is_array($batch)) {
+ $batch = [];
+ }
+ foreach ($batch as $item) {
+ $all[] = $item;
+ }
+
+ $totalPages = intval($payload['result_info']['total_pages'] ?? 0);
+ if ($totalPages > 0) {
+ if ($page >= $totalPages) {
+ break;
+ }
+ } elseif (count($batch) < $perPage || empty($batch)) {
+ break;
+ }
+ $page++;
+ }
+ return $all;
+ }
+
+ private function requestResult(string $method, string $path, array $query = [], ?array $body = null, bool $allowNotFound = false)
+ {
+ $payload = $this->requestRaw($method, $path, $query, $body, $allowNotFound);
+ if ($payload === null) {
+ return null;
+ }
+ return $payload['result'] ?? [];
+ }
+
+ private function requestRaw(string $method, string $path, array $query = [], ?array $body = null, bool $allowNotFound = false): ?array
+ {
+ $headers = $this->buildHeaders($body !== null);
+ $url = $this->baseUrl . $path;
+ if (!empty($query)) {
+ $url .= '?' . http_build_query($query);
+ }
+
+ $response = http_request(
+ $url,
+ $body,
+ null,
+ null,
+ $headers,
+ $this->proxy,
+ strtoupper($method),
+ 20
+ );
+
+ $status = intval($response['code'] ?? 0);
+ if ($allowNotFound && $status === 404) {
+ return null;
+ }
+
+ $payload = json_decode($response['body'] ?? '', true);
+ if (!is_array($payload)) {
+ throw new Exception('Cloudflare 返回数据解析失败', $status > 0 ? $status : 502);
+ }
+
+ if (($payload['success'] ?? false) !== true) {
+ if ($allowNotFound && $status === 404) {
+ return null;
+ }
+ $message = $this->extractErrorMessage($payload);
+ throw new Exception($message !== '' ? $message : 'Cloudflare API 请求失败', $status > 0 ? $status : 400);
+ }
+
+ return $payload;
+ }
+
+ private function buildHeaders(bool $json = false): array
+ {
+ if ($this->apiKey === '') {
+ throw new Exception('Cloudflare API 凭证为空', 400);
+ }
+
+ if ($this->auth === 1) {
+ $headers = [
+ 'Authorization' => 'Bearer ' . $this->apiKey,
+ ];
+ } else {
+ if ($this->email === '') {
+ throw new Exception('当前 Cloudflare 账户缺少邮箱地址,旧版 API Key 认证需要填写邮箱', 400);
+ }
+ $headers = [
+ 'X-Auth-Email' => $this->email,
+ 'X-Auth-Key' => $this->apiKey,
+ ];
+ }
+
+ if ($json) {
+ $headers['Content-Type'] = 'application/json';
+ }
+
+ return $headers;
+ }
+
+ private function assertTunnelSupported(): void
+ {
+ if (!$this->isApiTokenAuth()) {
+ throw new Exception('Cloudflare Tunnels 仅支持 API 令牌认证,请将当前账户的认证方式切换为 API令牌', 400);
+ }
+ }
+
+ private function normalizeHostname($hostname): string
+ {
+ $hostname = trim((string)$hostname);
+ if ($hostname === '') {
+ return '';
+ }
+ $hostname = rtrim($hostname, '.');
+ $hostname = convertDomainToAscii($hostname);
+ return strtolower($hostname);
+ }
+
+ private function extractErrorMessage(array $payload): string
+ {
+ if (!empty($payload['errors'][0]['message'])) {
+ return trim((string)$payload['errors'][0]['message']);
+ }
+ if (!empty($payload['messages'][0]['message'])) {
+ return trim((string)$payload['messages'][0]['message']);
+ }
+ if (!empty($payload['result']['message'])) {
+ return trim((string)$payload['result']['message']);
+ }
+ return '';
+ }
+
+ private function throwActionError(string $action, Exception $e, string $permissionHint = ''): void
+ {
+ $status = intval($e->getCode());
+ $message = trim($e->getMessage());
+
+ if ($status === 401) {
+ $message = 'Cloudflare 凭证无效或已过期,无法' . $action;
+ } elseif ($status === 403) {
+ $message = 'Cloudflare 权限不足,无法' . $action;
+ if ($permissionHint !== '') {
+ $message .= '。请确认 Token 具备 ' . $permissionHint . ' 权限';
+ }
+ } elseif ($status === 404 && $message === '') {
+ $message = $action . '失败:资源不存在';
+ } elseif ($status === 429) {
+ $message = 'Cloudflare API 请求过于频繁,暂时无法' . $action . ',请稍后重试';
+ } elseif ($status >= 500) {
+ $message = 'Cloudflare 服务暂时不可用,无法' . $action . ',请稍后重试';
+ } elseif ($message === '') {
+ $message = $action . '失败';
+ }
+
+ throw new Exception($message, $status > 0 ? $status : 400);
+ }
+}
diff --git a/app/view/cloudflare/hostnames.html b/app/view/cloudflare/hostnames.html
new file mode 100644
index 0000000..2555a6a
--- /dev/null
+++ b/app/view/cloudflare/hostnames.html
@@ -0,0 +1,701 @@
+{extend name="common/layout" /}
+{block name="title"}Cloudflare增强 - {$domainName}{/block}
+{block name="main"}
+