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 += '
说明: 下列值直接来自 Cloudflare 返回结果,可直接复制到 DNS、源站或验证目录中。点击“刷新校验”会重新向 Cloudflare 发起一次校验。
'; + html += '
'; + html += '
'+renderSummaryCard('证书状态', formatStatusText(row.ssl_status))+'
'; + html += '
'+renderSummaryCard('证书校验', formatStatusText(row.ssl_validation_status))+'
'; + html += '
'+renderSummaryCard('所有权校验', formatStatusText(row.verification_status))+'
'; + html += '
'; + + var ownership = row.ownership_verification || {}; + if(ownership.name || ownership.value){ + html += renderSection('所有权 TXT 校验', + renderCopyInput('记录类型', ownership.type || 'txt', false) + + renderCopyInput('TXT 名称', ownership.name || '', true) + + renderCopyTextarea('TXT 值', ownership.value || '', true, 3) + + renderQuickAddTxtButton(ownership.name || '', ownership.value || '', '快速添加所有权 TXT') + ); + } + + var ownershipHttp = row.ownership_verification_http || {}; + if(ownershipHttp.http_url || ownershipHttp.http_body){ + html += renderSection('所有权 HTTP 校验', + renderCopyTextarea('HTTP URL', ownershipHttp.http_url || '', true, 2) + + renderCopyTextarea('HTTP Body', ownershipHttp.http_body || '', true, 3) + ); + } + + var records = $.isArray(row.ssl_validation_records) ? row.ssl_validation_records : []; + if(records.length > 0){ + var recordsHtml = ''; + for(var i = 0; i < records.length; i++){ + var item = records[i] || {}; + var emails = $.isArray(item.emails) ? item.emails.join('\n') : ''; + recordsHtml += '
'; + recordsHtml += '
证书校验记录 #' + (i + 1) + '' + formatStatusText(item.status || '-') + '
'; + recordsHtml += '
'; + recordsHtml += renderCopyInput('TXT 名称', item.txt_name || '', true); + recordsHtml += renderCopyTextarea('TXT 值', item.txt_value || '', true, 3); + recordsHtml += renderQuickAddTxtButton(item.txt_name || '', item.txt_value || '', '快速添加 TXT'); + recordsHtml += renderCopyInput('CNAME 名称', item.cname_name || '', true); + recordsHtml += renderCopyTextarea('CNAME 目标', item.cname_target || '', true, 2); + recordsHtml += renderCopyTextarea('HTTP URL', item.http_url || '', true, 2); + recordsHtml += renderCopyTextarea('HTTP Body', item.http_body || '', true, 3); + recordsHtml += renderCopyTextarea('邮箱地址', emails, false, 2); + recordsHtml += '
'; + } + html += renderSection('证书校验记录', recordsHtml); + }else{ + html += '
Cloudflare 当前尚未返回证书校验记录,请先等待状态进入 pending_validation,再点击“刷新校验”或稍后刷新列表。
'; + } + + if(row.validation_errors){ + html += renderSection('错误信息', renderCopyTextarea('错误信息', row.validation_errors, false, 3)); + } + + $("#verificationContent").html(html); +} + +function renderSummaryCard(title, value){ + return '
' + htmlEscape(title) + '
' + value + '
'; +} + +function renderSection(title, body){ + return '
' + htmlEscape(title) + '
' + body + '
'; +} + +function renderCopyInput(label, value, copyable){ + var safeValue = String(value || ''); + if(!safeValue){ + return ''; + } + var html = '
'; + html += ''; + if(copyable){ + html += '
'; + html += ''; + html += ''; + html += '
'; + }else{ + html += ''; + } + html += '
'; + return html; +} + +function renderCopyTextarea(label, value, copyable, rows){ + var safeValue = String(value || ''); + if(!safeValue){ + return ''; + } + var html = '
'; + html += ''; + html += ''; + if(copyable){ + html += '
'; + } + html += '
'; + return html; +} + +function renderQuickAddTxtButton(name, value, label){ + var txtName = String(name || '').trim(); + var txtValue = String(value || '').trim(); + if(!txtName || !txtValue){ + return ''; + } + return '
'; +} + +function formatStatusText(value){ + var text = value || '-'; + if(text === '-'){ + return '-'; + } + return formatStatus(text); +} + +function copyEncodedValue(btn){ + copyText(decodeURIComponent($(btn).attr('data-copy') || '')); +} + +function copyText(text){ + var value = String(text || ''); + if(!value){ + layer.msg('没有可复制的内容'); + return; + } + if(navigator.clipboard && window.isSecureContext){ + navigator.clipboard.writeText(value).then(function(){ + layer.msg('已复制', {icon: 1, time: 1000}); + }).catch(function(){ + fallbackCopyText(value); + }); + return; + } + fallbackCopyText(value); +} + +function fallbackCopyText(text){ + var $temp = $(''); + $('body').append($temp); + $temp.val(text).select(); + try{ + document.execCommand('copy'); + layer.msg('已复制', {icon: 1, time: 1000}); + }catch(e){ + layer.alert('复制失败,请手动复制', {icon: 2}); + } + $temp.remove(); +} + +function quickAddTxtRecord(btn){ + var fullName = decodeURIComponent($(btn).attr('data-name') || ''); + var value = decodeURIComponent($(btn).attr('data-value') || ''); + var rr = convertFullHostnameToRecordName(fullName); + if(rr === null){ + layer.alert('TXT 记录名称与当前域名不匹配,无法自动添加,请手动到解析页添加', {icon: 2}); + return; + } + + layer.confirm('确定要快速添加 TXT 记录吗?
' + 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"} +
+
+
+
+
+
+
+ +
+ + 返回解析 +
+

Cloudflare增强 - {$domainName}

+
+
+
+
+ 说明: 这里管理 Cloudflare 自定义主机名、证书状态、证书校验与 Fallback Origin。 +
+ +
+
+
+ + +
+ + + +
+
+ + + +
+
+
+
+
+ + + + +{/block} +{block name="script"} + + + + + + + + +{/block} diff --git a/app/view/cloudflare/tunnels.html b/app/view/cloudflare/tunnels.html new file mode 100644 index 0000000..b512420 --- /dev/null +++ b/app/view/cloudflare/tunnels.html @@ -0,0 +1,607 @@ +{extend name="common/layout" /} +{block name="title"}Cloudflare Tunnels - {$accountName}{/block} +{block name="main"} +
+
+
+
+

+ 返回账户 + Cloudflare Tunnels - {$accountName} +

+
+
+
+ Account ID:{$cfAccountId} +
+ 这里管理 Tunnel 列表、公网主机名、CIDR 路由和主机名路由。公网主机名会自动同步为对应域名下的 CNAME。 +
+ + + +
+
+
+
+
+ + + + + + + + + + +{/block} +{block name="script"} + + + + + + +{/block} diff --git a/app/view/domain/account.html b/app/view/domain/account.html index aa6445c..2ff2a98 100644 --- a/app/view/domain/account.html +++ b/app/view/domain/account.html @@ -29,6 +29,7 @@ -{/block} \ No newline at end of file +{/block} diff --git a/app/view/domain/account_add.html b/app/view/domain/account_add.html index ec842d1..f961e0f 100644 --- a/app/view/domain/account_add.html +++ b/app/view/domain/account_add.html @@ -198,7 +198,11 @@ new Vue({ } }) this.set.config = JSON.stringify(this.config); - this.set.name = this.config[Object.keys(this.config)[0]]; + this.set.name = this.resolveAccountName(); + if(!this.set.name){ + layer.alert('账户名称自动生成失败,请至少填写一个有效的认证字段', {icon: 2}); + return; + } let loading = layer.msg('正在进行账户有效性检查', {icon: 16,shade: 0.1,time: 0}); $.ajax({ type: "POST", @@ -221,6 +225,29 @@ new Vue({ } }); }, + resolveAccountName(){ + var preferred = []; + if(this.set.type === 'cloudflare'){ + preferred = ['email', 'account_id', 'apikey']; + } + for(var i = 0; i < preferred.length; i++){ + var val = this.config[preferred[i]]; + if(typeof val === 'string' && this.trim(val)){ + return this.trim(val); + } + } + var keys = Object.keys(this.config); + for(var j = 0; j < keys.length; j++){ + var value = this.config[keys[j]]; + if(typeof value === 'string' && this.trim(value)){ + return this.trim(value); + } + if(typeof value === 'number'){ + return String(value); + } + } + return ''; + }, isShow(show){ if(typeof show == 'boolean' && show){ return show; diff --git a/app/view/domain/domain.html b/app/view/domain/domain.html index 4ec97ff..1646a3b 100644 --- a/app/view/domain/domain.html +++ b/app/view/domain/domain.html @@ -40,6 +40,7 @@
+
@@ -146,7 +147,7 @@ 刷新 - {if request()->user['level'] eq 2} 添加 + {if $user['level'] eq 2} 添加
@@ -172,7 +173,7 @@ - + + + -{/block} \ No newline at end of file +{/block} diff --git a/public/static/js/custom.js b/public/static/js/custom.js index 1b79d35..9847cc1 100644 --- a/public/static/js/custom.js +++ b/public/static/js/custom.js @@ -56,6 +56,110 @@ function updateQueryStr(obj){ history.replaceState({}, null, '?'+arr.join("&")); } +function initDomainQuickSwitch(options){ + if(typeof $ === 'undefined' || typeof $.fn.select2 === 'undefined'){ + return null; + } + var settings = $.extend({ + selectSelector: '#quickDomainSwitch', + buttonSelector: '#quickDomainSwitchBtn', + currentId: '', + currentText: '', + placeholder: '搜索域名后切换', + useAjax: false, + type: '', + limit: 10, + buildUrl: function(id){ + return '/record/' + id; + } + }, options || {}); + var $select = $(settings.selectSelector); + if(!$select.length){ + return null; + } + if(settings.currentId !== '' && settings.currentText !== '' && $select.find('option[value="' + settings.currentId + '"]').length === 0){ + $select.append(new Option(settings.currentText, settings.currentId, true, true)); + } + var select2Options = { + width: '100%', + language: 'zh-CN', + placeholder: settings.placeholder + }; + if(settings.useAjax){ + select2Options.ajax = { + url: '/domain/data', + type: 'post', + dataType: 'json', + delay: 250, + data: function(params) { + var page = params.page || 1; + return { + kw: params.term || '', + type: settings.type || '', + offset: settings.limit * (page - 1), + limit: settings.limit + }; + }, + processResults: function(data, params) { + params.page = params.page || 1; + var rows = $.isArray(data.rows) ? data.rows : []; + return { + results: $.map(rows, function(item){ + var text = item.name || ''; + if(item.typename){ + text += ' [' + item.typename + ']'; + } + return { + id: item.id, + text: text + }; + }), + pagination: { + more: (data.total || 0) > settings.limit * params.page + } + }; + }, + cache: true + }; + } + $select.select2(select2Options); + var navigate = function(){ + var targetId = $.trim($select.val()); + if(targetId === ''){ + if(typeof layer !== 'undefined'){ + layer.msg('请先选择域名'); + }else{ + alert('请先选择域名'); + } + return; + } + window.location.href = settings.buildUrl(targetId); + }; + if(settings.buttonSelector){ + $(settings.buttonSelector).off('click.domainSwitch').on('click.domainSwitch', navigate); + } + return { + navigate: navigate + }; +} + +function quickSwitchDomain(basePath){ + if(typeof $ === 'undefined'){ + return false; + } + var targetId = $.trim($('#quickDomainSwitch').val()); + if(targetId === ''){ + if(typeof layer !== 'undefined'){ + layer.msg('请先选择域名'); + }else{ + alert('请先选择域名'); + } + return false; + } + window.location.href = String(basePath || '') + targetId; + return false; +} + if (typeof $.fn.bootstrapTable !== "undefined") { $.fn.bootstrapTable.custom = { method: 'post', @@ -171,4 +275,4 @@ function delCookie(name) if(cval!=null){ document.cookie= name + "="+cval+";expires="+exp.toGMTString(); } -} \ No newline at end of file +} diff --git a/route/app.php b/route/app.php index d549a1b..7f83283 100644 --- a/route/app.php +++ b/route/app.php @@ -51,6 +51,31 @@ Route::group(function () { Route::get('/account/:action', 'domain/account_add'); Route::get('/account', 'domain/account'); + Route::get('/cloudflare/hostnames/:id', 'cloudflare/hostnames'); + Route::post('/cloudflare/hostnames/data/:id', 'cloudflare/hostnames_data'); + Route::post('/cloudflare/hostnames/add/:id', 'cloudflare/hostnames_add'); + Route::post('/cloudflare/hostnames/update/:id', 'cloudflare/hostnames_update'); + Route::post('/cloudflare/hostnames/refresh/:id', 'cloudflare/hostnames_refresh'); + Route::post('/cloudflare/hostnames/delete/:id', 'cloudflare/hostnames_delete'); + Route::post('/cloudflare/hostnames/txttargets/:id', 'cloudflare/hostnames_txt_targets'); + Route::post('/cloudflare/fallback/get/:id', 'cloudflare/fallback_get'); + Route::post('/cloudflare/fallback/set/:id', 'cloudflare/fallback_set'); + Route::post('/cloudflare/fallback/delete/:id', 'cloudflare/fallback_delete'); + Route::get('/cloudflare/tunnels/:id', 'cloudflare/tunnels'); + Route::post('/cloudflare/tunnels/data/:id', 'cloudflare/tunnels_data'); + Route::post('/cloudflare/tunnels/add/:id', 'cloudflare/tunnels_add'); + Route::post('/cloudflare/tunnels/delete/:id', 'cloudflare/tunnels_delete'); + Route::post('/cloudflare/tunnels/token/:id', 'cloudflare/tunnels_token'); + Route::post('/cloudflare/tunnels/publichostnames/data/:id', 'cloudflare/tunnels_public_hostnames_data'); + Route::post('/cloudflare/tunnels/publichostnames/save/:id', 'cloudflare/tunnels_public_hostnames_save'); + Route::post('/cloudflare/tunnels/publichostnames/delete/:id', 'cloudflare/tunnels_public_hostnames_delete'); + Route::post('/cloudflare/tunnels/cidr/data/:id', 'cloudflare/tunnels_cidr_data'); + Route::post('/cloudflare/tunnels/cidr/add/:id', 'cloudflare/tunnels_cidr_add'); + Route::post('/cloudflare/tunnels/cidr/delete/:id', 'cloudflare/tunnels_cidr_delete'); + Route::post('/cloudflare/tunnels/hostnameroutes/data/:id', 'cloudflare/tunnels_hostname_routes_data'); + Route::post('/cloudflare/tunnels/hostnameroutes/add/:id', 'cloudflare/tunnels_hostname_routes_add'); + Route::post('/cloudflare/tunnels/hostnameroutes/delete/:id', 'cloudflare/tunnels_hostname_routes_delete'); + Route::any('/domain/expirenotice', 'domain/expire_notice'); Route::post('/domain/updatedate', 'domain/update_date'); Route::post('/domain/data', 'domain/domain_data');