Merge remote-tracking branch 'remotes/upstream/main'

This commit is contained in:
net909
2026-04-11 19:47:25 +08:00
18 changed files with 4449 additions and 16 deletions

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
function convertDomainToAscii($domain)
{
return (string)$domain;
}
function http_request($url, $data = null, $referer = null, $cookie = null, $headers = null, $proxy = false, $method = null, $timeout = 10): array
{
$method = strtoupper((string)($method ?: ($data !== null ? 'POST' : 'GET')));
$headerLines = [
'User-Agent: Codex-Hostname-Smoke/1.0',
];
$normalizedHeaders = [];
foreach ((array)$headers as $key => $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;

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
function convertDomainToAscii($domain)
{
return (string)$domain;
}
function http_request($url, $data = null, $referer = null, $cookie = null, $headers = null, $proxy = false, $method = null, $timeout = 10): array
{
$method = strtoupper((string)($method ?: ($data !== null ? 'POST' : 'GET')));
$headerLines = [
'User-Agent: Codex-Smoke-Test/1.0',
];
$normalizedHeaders = [];
foreach ((array)$headers as $key => $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;

View File

@@ -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 ''
+ '<a href="javascript:openEditDialog(\''+row.id+'\')" class="btn btn-info btn-xs">编辑</a> '
+ '<a href="javascript:openVerificationDialog(\''+row.id+'\')" class="btn btn-primary btn-xs">校验</a> '
+ '<a href="javascript:deleteHostname(\''+row.id+'\', \''+htmlEscape(row.hostname)+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
});
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 '<span class="label label-success">'+htmlEscape(value)+'</span>';
}
if(v === 'pending' || v === 'pending_validation' || v === 'initializing' || v === 'in_progress'){
return '<span class="label label-warning">'+htmlEscape(value || '-')+'</span>';
}
if(v && v !== '-'){
return '<span class="label label-danger">'+htmlEscape(value)+'</span>';
}
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 += '<div class="alert alert-info"><strong>说明:</strong> 下列值直接来自 Cloudflare 返回结果,可直接复制到 DNS、源站或验证目录中。点击“刷新校验”会重新向 Cloudflare 发起一次校验。</div>';
html += '<div class="row">';
html += '<div class="col-sm-4">'+renderSummaryCard('证书状态', formatStatusText(row.ssl_status))+'</div>';
html += '<div class="col-sm-4">'+renderSummaryCard('证书校验', formatStatusText(row.ssl_validation_status))+'</div>';
html += '<div class="col-sm-4">'+renderSummaryCard('所有权校验', formatStatusText(row.verification_status))+'</div>';
html += '</div>';
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 += '<div class="panel panel-default" style="margin-bottom:12px;">';
recordsHtml += '<div class="panel-heading"><strong>证书校验记录 #' + (i + 1) + '</strong><span class="pull-right">' + formatStatusText(item.status || '-') + '</span></div>';
recordsHtml += '<div class="panel-body">';
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 += '</div></div>';
}
html += renderSection('证书校验记录', recordsHtml);
}else{
html += '<div class="alert alert-warning">Cloudflare 当前尚未返回证书校验记录,请先等待状态进入 <code>pending_validation</code>,再点击“刷新校验”或稍后刷新列表。</div>';
}
if(row.validation_errors){
html += renderSection('错误信息', renderCopyTextarea('错误信息', row.validation_errors, false, 3));
}
$("#verificationContent").html(html);
}
function renderSummaryCard(title, value){
return '<div class="panel panel-default"><div class="panel-heading"><strong>' + htmlEscape(title) + '</strong></div><div class="panel-body">' + value + '</div></div>';
}
function renderSection(title, body){
return '<div class="panel panel-default"><div class="panel-heading"><strong>' + htmlEscape(title) + '</strong></div><div class="panel-body">' + body + '</div></div>';
}
function renderCopyInput(label, value, copyable){
var safeValue = String(value || '');
if(!safeValue){
return '';
}
var html = '<div class="form-group">';
html += '<label>' + htmlEscape(label) + '</label>';
if(copyable){
html += '<div class="input-group">';
html += '<input type="text" class="form-control" readonly value="' + htmlEscape(safeValue) + '">';
html += '<span class="input-group-btn"><button type="button" class="btn btn-default" data-copy="' + encodeURIComponent(safeValue) + '" onclick="copyEncodedValue(this)">复制</button></span>';
html += '</div>';
}else{
html += '<input type="text" class="form-control" readonly value="' + htmlEscape(safeValue) + '">';
}
html += '</div>';
return html;
}
function renderCopyTextarea(label, value, copyable, rows){
var safeValue = String(value || '');
if(!safeValue){
return '';
}
var html = '<div class="form-group">';
html += '<label>' + htmlEscape(label) + '</label>';
html += '<textarea class="form-control" rows="' + (rows || 3) + '" readonly>' + htmlEscape(safeValue) + '</textarea>';
if(copyable){
html += '<div class="text-right" style="margin-top:8px;"><button type="button" class="btn btn-default btn-xs" data-copy="' + encodeURIComponent(safeValue) + '" onclick="copyEncodedValue(this)">复制</button></div>';
}
html += '</div>';
return html;
}
function renderQuickAddTxtButton(name, value, label){
var txtName = String(name || '').trim();
var txtValue = String(value || '').trim();
if(!txtName || !txtValue){
return '';
}
return '<div class="text-right" style="margin-top:8px;margin-bottom:12px;"><button type="button" class="btn btn-success btn-xs" data-name="' + encodeURIComponent(txtName) + '" data-value="' + encodeURIComponent(txtValue) + '" onclick="quickAddTxtRecord(this)">' + htmlEscape(label || '快速添加 TXT') + '</button></div>';
}
function formatStatusText(value){
var text = value || '-';
if(text === '-'){
return '<span class="text-muted">-</span>';
}
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 = $('<textarea readonly></textarea>');
$('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 记录吗?<br><code>' + htmlEscape(fullName) + '</code>', {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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
/vendor
*.log
.env
.ace-tool/
/.codex-tmp/dns-panel-ref/

View File

@@ -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)
{

File diff suppressed because it is too large Load Diff

View File

@@ -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', '无权限');

View File

@@ -366,18 +366,19 @@ class DnsHelper
'cloudflare' => [
'name' => 'Cloudflare',
'icon' => 'cloudflare.ico',
'note' => '',
'note' => '如需使用 Cloudflare 增强与 Tunnels建议使用 <b>API令牌</b> 认证,并补充 <b>Account ID</b>。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' => '使用代理服务器',

View File

@@ -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;
}
}

View File

@@ -0,0 +1,604 @@
<?php
namespace app\service;
use Exception;
class CloudflareEnhanceService
{
private string $email = '';
private string $apiKey = '';
private int $auth = 0;
private bool $proxy = false;
private string $accountId = '';
private string $baseUrl = 'https://api.cloudflare.com/client/v4';
public function __construct(array $config = [])
{
$this->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);
}
}

View File

@@ -0,0 +1,701 @@
{extend name="common/layout" /}
{block name="title"}Cloudflare增强 - {$domainName}{/block}
{block name="main"}
<div class="row">
<div class="col-xs-12 center-block" style="float:none;">
<div class="panel panel-default panel-intro">
<div class="panel-heading">
<div class="clearfix">
<div class="pull-right" style="margin-top:-6px;max-width:100%;">
<div style="display:inline-block;width:300px;max-width:100%;vertical-align:middle;margin-right:6px;">
<select id="quickDomainSwitch" class="form-control">
{volist name="quickDomainOptions" id="item"}
<option value="{$item.id}"{if $item.id == $domainId} selected{/if}>{$item.text}</option>
{/volist}
</select>
</div>
<button type="button" class="btn btn-sm btn-primary" id="quickDomainSwitchBtn" style="vertical-align:middle;margin-right:6px;" onclick="return quickSwitchDomain('/cloudflare/hostnames/')"><i class="fa fa-random fa-fw"></i> 切换域名</button>
<a href="/record/{$domainId}" class="btn btn-sm btn-default" style="vertical-align:middle;"><i class="fa fa-reply fa-fw"></i> 返回解析</a>
</div>
<h3 class="panel-title" style="padding-top:4px;">Cloudflare增强 - {$domainName}</h3>
</div>
</div>
<div class="panel-body">
<div class="alert alert-info">
<strong>说明:</strong> 这里管理 Cloudflare 自定义主机名、证书状态、证书校验与 Fallback Origin。
</div>
<div class="well well-sm">
<div class="form-inline">
<div class="form-group" style="width:70%;max-width:720px;">
<label>Fallback Origin</label>
<input type="text" id="fallbackOrigin" class="form-control" style="width:80%;" placeholder="例如 origin.example.com">
</div>
<button type="button" class="btn btn-primary" onclick="saveFallbackOrigin()">保存</button>
<button type="button" class="btn btn-default" onclick="loadFallbackOrigin()">刷新</button>
<button type="button" class="btn btn-danger" onclick="clearFallbackOrigin()">清空</button>
</div>
</div>
<div class="clearfix" style="margin-bottom:15px;">
<div class="pull-left">
<a href="javascript:refreshHostnameList()" class="btn btn-default" title="刷新自定义主机名列表"><i class="fa fa-refresh"></i> 刷新</a>
<a href="javascript:openAddDialog()" class="btn btn-success"><i class="fa fa-plus"></i> 添加自定义主机名</a>
</div>
</div>
<table id="listTable"></table>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-store" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title" id="storeTitle">添加自定义主机名</h4>
</div>
<div class="modal-body">
<form class="form-horizontal" id="form-store">
<input type="hidden" name="hostname_id" value="">
<div class="form-group">
<label class="col-sm-3 control-label">主机名</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="hostname" placeholder="例如 app.example.com 或 *.example.com" required>
<p class="help-block" id="hostnameHint">创建后主机名不能直接改名,如需改名请删除后重建。</p>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">自定义源站</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="custom_origin_server" placeholder="可留空,例如 origin.example.com">
<p class="help-block">留空表示清空当前自定义源站,回退到 Fallback Origin 或默认源站逻辑。</p>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-white" data-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary" onclick="submitHostname()">保存</button>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-verification" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title" id="verificationTitle">证书校验</h4>
</div>
<div class="modal-body">
<div id="verificationContent"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick="refreshHostnameValidation()">刷新校验</button>
<button type="button" class="btn btn-white" data-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
{/block}
{block name="script"}
<script src="/static/js/layer/layer.js"></script>
<script src="/static/js/bootstrap-table-1.21.4.min.js"></script>
<script src="/static/js/bootstrap-table-page-jump-to-1.21.4.min.js"></script>
<script src="/static/js/bootstrapValidator.min.js"></script>
<script src="/static/js/select2-4.0.13.min.js"></script>
<script src="/static/js/select2-i18n-zh-CN-4.0.13.min.js"></script>
<script src="/static/js/custom.js?v=1005"></script>
<script>
var currentVerificationHostnameId = '';
$(document).ready(function(){
initDomainQuickSwitch({
buttonSelector: '',
currentId: '{$domainId}',
currentText: {$domainName|json_encode|raw},
type: 'cloudflare',
buildUrl: function(id){
return '/cloudflare/hostnames/' + id;
}
});
$("#form-store").bootstrapValidator();
loadFallbackOrigin();
$("#listTable").bootstrapTable({
url: '/cloudflare/hostnames/data/{$domainId}',
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 ''
+ '<a href="javascript:openEditDialog(\''+row.id+'\')" class="btn btn-info btn-xs">编辑</a> '
+ '<a href="javascript:openVerificationDialog(\''+row.id+'\')" class="btn btn-primary btn-xs">校验</a> '
+ '<a href="javascript:deleteHostname(\''+row.id+'\', \''+htmlEscape(row.hostname)+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
});
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 '<span class="label label-success">'+htmlEscape(value)+'</span>';
}
if(v === 'pending' || v === 'pending_validation' || v === 'initializing' || v === 'in_progress'){
return '<span class="label label-warning">'+htmlEscape(value || '-')+'</span>';
}
if(v && v !== '-'){
return '<span class="label label-danger">'+htmlEscape(value)+'</span>';
}
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/{$domainId}' : '/cloudflare/hostnames/add/{$domainId}';
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/{$domainId}',
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 += '<div class="alert alert-info"><strong>说明:</strong> 下列值直接来自 Cloudflare 返回结果,可直接复制到 DNS、源站或验证目录中。点击“刷新校验”会重新向 Cloudflare 发起一次校验。</div>';
html += '<div class="row">';
html += '<div class="col-sm-4">'+renderSummaryCard('证书状态', formatStatusText(row.ssl_status))+'</div>';
html += '<div class="col-sm-4">'+renderSummaryCard('证书校验', formatStatusText(row.ssl_validation_status))+'</div>';
html += '<div class="col-sm-4">'+renderSummaryCard('所有权校验', formatStatusText(row.verification_status))+'</div>';
html += '</div>';
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 += '<div class="panel panel-default" style="margin-bottom:12px;">';
recordsHtml += '<div class="panel-heading"><strong>证书校验记录 #' + (i + 1) + '</strong><span class="pull-right">' + formatStatusText(item.status || '-') + '</span></div>';
recordsHtml += '<div class="panel-body">';
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 += '</div></div>';
}
html += renderSection('证书校验记录', recordsHtml);
}else{
html += '<div class="alert alert-warning">Cloudflare 当前尚未返回证书校验记录,请先等待状态进入 <code>pending_validation</code>,再点击“刷新校验”或稍后刷新列表。</div>';
}
if(row.validation_errors){
html += renderSection('错误信息', renderCopyTextarea('错误信息', row.validation_errors, false, 3));
}
$("#verificationContent").html(html);
}
function renderSummaryCard(title, value){
return '<div class="panel panel-default"><div class="panel-heading"><strong>' + htmlEscape(title) + '</strong></div><div class="panel-body">' + value + '</div></div>';
}
function renderSection(title, body){
return '<div class="panel panel-default"><div class="panel-heading"><strong>' + htmlEscape(title) + '</strong></div><div class="panel-body">' + body + '</div></div>';
}
function renderCopyInput(label, value, copyable){
var safeValue = String(value || '');
if(!safeValue){
return '';
}
var html = '<div class="form-group">';
html += '<label>' + htmlEscape(label) + '</label>';
if(copyable){
html += '<div class="input-group">';
html += '<input type="text" class="form-control" readonly value="' + htmlEscape(safeValue) + '">';
html += '<span class="input-group-btn"><button type="button" class="btn btn-default" data-copy="' + encodeURIComponent(safeValue) + '" onclick="copyEncodedValue(this)">复制</button></span>';
html += '</div>';
}else{
html += '<input type="text" class="form-control" readonly value="' + htmlEscape(safeValue) + '">';
}
html += '</div>';
return html;
}
function renderCopyTextarea(label, value, copyable, rows){
var safeValue = String(value || '');
if(!safeValue){
return '';
}
var html = '<div class="form-group">';
html += '<label>' + htmlEscape(label) + '</label>';
html += '<textarea class="form-control" rows="' + (rows || 3) + '" readonly>' + htmlEscape(safeValue) + '</textarea>';
if(copyable){
html += '<div class="text-right" style="margin-top:8px;"><button type="button" class="btn btn-default btn-xs" data-copy="' + encodeURIComponent(safeValue) + '" onclick="copyEncodedValue(this)">复制</button></div>';
}
html += '</div>';
return html;
}
function renderQuickAddTxtButton(name, value, label){
var txtName = String(name || '').trim();
var txtValue = String(value || '').trim();
if(!txtName || !txtValue){
return '';
}
return '<div class="text-right" style="margin-top:8px;margin-bottom:12px;"><button type="button" class="btn btn-success btn-xs" data-name="' + encodeURIComponent(txtName) + '" data-value="' + encodeURIComponent(txtValue) + '" onclick="quickAddTxtRecord(this)">' + htmlEscape(label || '快速添加 TXT') + '</button></div>';
}
function formatStatusText(value){
var text = value || '-';
if(text === '-'){
return '<span class="text-muted">-</span>';
}
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 = $('<textarea readonly></textarea>');
$('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') || '');
resolveTxtRecordTargets(fullName, function(targets){
if(!targets.length){
layer.alert('系统中未找到与该 TXT 主机名对应的托管域名,请手动到解析页添加', {icon: 2});
return;
}
if(targets.length === 1){
confirmQuickAddTxtRecord(fullName, value, targets[0]);
return;
}
openTxtTargetPicker(fullName, value, targets);
});
}
function resolveTxtRecordTargets(fullName, callback){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/hostnames/txttargets/{$domainId}',
data: {hostname: fullName},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
var targets = res.data && $.isArray(res.data.candidates) ? res.data.candidates : [];
callback(targets);
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function openTxtTargetPicker(fullName, value, targets){
var html = '<div style="padding:16px 18px 6px;">';
html += '<div class="alert alert-warning" style="margin-bottom:12px;">检测到多个可用解析域名,请确认要写入哪个服务商。</div>';
html += '<div class="form-group"><label>TXT 主机名</label><div><code>' + htmlEscape(fullName) + '</code></div></div>';
html += '<div class="form-group"><label>TXT 值</label><textarea class="form-control" rows="3" readonly>' + htmlEscape(value) + '</textarea></div>';
html += '<form id="txtTargetPickerForm">';
for(var i = 0; i < targets.length; i++){
var target = targets[i] || {};
var providerName = target.account_type_name || target.account_type || '-';
var accountName = target.account_display_name || ('账户#' + (target.account_id || ''));
html += '<div class="radio" style="margin:0 0 12px;border:1px solid #e5e5e5;border-radius:4px;padding:10px 12px;">';
html += '<label style="display:block;padding-left:22px;">';
html += '<input type="radio" name="txtTarget" value="' + htmlEscape(String(target.domain_id || '')) + '"' + (i === 0 ? ' checked' : '') + '>';
html += '<strong>' + htmlEscape(target.domain_name || '-') + '</strong>';
if(target.is_current_domain){
html += ' <span class="label label-primary">当前页</span>';
}
html += '<div class="help-block" style="margin:8px 0 0;">';
html += '主机记录:<code>' + htmlEscape(target.record_name || '@') + '</code><br>';
html += '服务商:' + htmlEscape(providerName) + '<br>';
html += '账户:' + htmlEscape(accountName);
html += '</div>';
html += '</label></div>';
}
html += '</form></div>';
layer.open({
type: 1,
title: '选择解析服务商',
area: ['640px', 'auto'],
shadeClose: false,
content: html,
btn: ['添加 TXT', '取消'],
yes: function(index){
var selectedId = $('#txtTargetPickerForm input[name=txtTarget]:checked').val();
var target = findTxtTargetByDomainId(targets, selectedId);
if(!target){
layer.msg('请选择要写入的解析域名', {icon: 2});
return;
}
layer.close(index);
submitQuickAddTxtRecord(value, target);
}
});
}
function confirmQuickAddTxtRecord(fullName, value, target){
layer.confirm(buildQuickAddConfirmHtml(fullName, target), {title: '提示', icon: 0}, function(index){
layer.close(index);
submitQuickAddTxtRecord(value, target);
});
}
function buildQuickAddConfirmHtml(fullName, target){
var providerName = target.account_type_name || target.account_type || '-';
var accountName = target.account_display_name || ('账户#' + (target.account_id || ''));
return '确定要快速添加 TXT 记录吗?<br><br>'
+ 'TXT 主机名:<code>' + htmlEscape(fullName) + '</code><br>'
+ '解析域名:<code>' + htmlEscape(target.domain_name || '-') + '</code><br>'
+ '主机记录:<code>' + htmlEscape(target.record_name || '@') + '</code><br>'
+ '服务商:' + htmlEscape(providerName) + '<br>'
+ '账户:' + htmlEscape(accountName);
}
function submitQuickAddTxtRecord(value, target){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/record/add/' + target.domain_id,
data: {
name: target.record_name,
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 记录已添加到 ' + (target.domain_name || '-'), {icon: 1, time: 1400});
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function findTxtTargetByDomainId(targets, domainId){
var selected = String(domainId || '');
for(var i = 0; i < targets.length; i++){
var item = targets[i] || {};
if(String(item.domain_id || '') === selected){
return item;
}
}
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/{$domainId}',
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/{$domainId}',
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/{$domainId}',
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/{$domainId}',
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
</script>
{/block}

View File

@@ -0,0 +1,607 @@
{extend name="common/layout" /}
{block name="title"}Cloudflare Tunnels - {$accountName}{/block}
{block name="main"}
<div class="row">
<div class="col-xs-12 center-block" style="float:none;">
<div class="panel panel-default panel-intro">
<div class="panel-heading">
<h3 class="panel-title">
<a href="/account" class="btn btn-sm btn-default pull-right" style="margin-top:-6px"><i class="fa fa-reply fa-fw"></i> 返回账户</a>
Cloudflare Tunnels - {$accountName}
</h3>
</div>
<div class="panel-body">
<div class="alert alert-info">
<strong>Account ID</strong>{$cfAccountId}
<br>
这里管理 Tunnel 列表、公网主机名、CIDR 路由和主机名路由。公网主机名会自动同步为对应域名下的 CNAME。
</div>
<div class="clearfix" style="margin-bottom:15px;">
<div class="pull-left">
<a href="javascript:refreshTunnelList()" class="btn btn-default" title="刷新 Tunnel 列表"><i class="fa fa-refresh"></i> 刷新</a>
<a href="javascript:openTunnelDialog()" class="btn btn-success"><i class="fa fa-plus"></i> 创建 Tunnel</a>
</div>
</div>
<table id="listTable"></table>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-tunnel" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title">创建 Tunnel</h4>
</div>
<div class="modal-body">
<form class="form-horizontal" id="form-tunnel">
<div class="form-group">
<label class="col-sm-3 control-label">名称</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="name" placeholder="例如 edge-prod" required>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-white" data-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary" onclick="submitTunnel()">保存</button>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-token" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title">Tunnel Token</h4>
</div>
<div class="modal-body">
<div class="form-group">
<label>Tunnel</label>
<input type="text" class="form-control" id="tokenTunnelName" disabled>
</div>
<div class="form-group">
<label>Token</label>
<textarea id="tokenValue" class="form-control" rows="4" readonly></textarea>
</div>
<div class="form-group">
<label>启动命令</label>
<textarea id="tokenCommand" class="form-control" rows="3" readonly></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" onclick="copyTokenCommand()">复制启动命令</button>
<button type="button" class="btn btn-white" data-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-public" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title" id="publicTitle">公网主机名</h4>
</div>
<div class="modal-body">
<form class="form-inline" id="form-public">
<div class="form-group">
<input type="text" class="form-control" name="hostname" placeholder="hostname例如 app.example.com" style="width:240px;" required>
</div>
<div class="form-group">
<input type="text" class="form-control" name="service" placeholder="service例如 http://127.0.0.1:8080" style="width:260px;" required>
</div>
<div class="form-group">
<input type="text" class="form-control" name="path" placeholder="可留空,例如 /api/*" style="width:180px;">
</div>
<button type="button" class="btn btn-primary" onclick="savePublicHostname()">保存</button>
</form>
<hr>
<table id="publicTable"></table>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-cidr" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title" id="cidrTitle">CIDR 路由</h4>
</div>
<div class="modal-body">
<form class="form-inline" id="form-cidr">
<div class="form-group">
<input type="text" class="form-control" name="network" placeholder="例如 10.10.0.0/16" style="width:220px;" required>
</div>
<div class="form-group">
<input type="text" class="form-control" name="comment" placeholder="备注,可留空" style="width:240px;">
</div>
<button type="button" class="btn btn-primary" onclick="saveCidrRoute()">保存</button>
</form>
<hr>
<table id="cidrTable"></table>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-hostname-route" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title" id="hostnameRouteTitle">主机名路由</h4>
</div>
<div class="modal-body">
<form class="form-inline" id="form-hostname-route">
<div class="form-group">
<input type="text" class="form-control" name="hostname" placeholder="例如 internal.example.com" style="width:260px;" required>
</div>
<div class="form-group">
<input type="text" class="form-control" name="comment" placeholder="备注,可留空" style="width:240px;">
</div>
<button type="button" class="btn btn-primary" onclick="saveHostnameRoute()">保存</button>
</form>
<hr>
<table id="hostnameRouteTable"></table>
</div>
</div>
</div>
</div>
{/block}
{block name="script"}
<script src="/static/js/layer/layer.js"></script>
<script src="/static/js/bootstrap-table-1.21.4.min.js"></script>
<script src="/static/js/bootstrap-table-page-jump-to-1.21.4.min.js"></script>
<script src="/static/js/bootstrapValidator.min.js"></script>
<script src="/static/js/custom.js"></script>
<script>
var selectedTunnelId = '';
var selectedTunnelName = '';
$(document).ready(function(){
$("#form-tunnel").bootstrapValidator();
$("#listTable").bootstrapTable({
url: '/cloudflare/tunnels/data/{$accountId}',
method: 'post',
toolbar: '',
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'id',
responseHandler: tableResponseHandler,
columns: [
{field: 'name', title: '名称'},
{field: 'id', title: 'Tunnel ID'},
{field: 'status', title: '状态', formatter: tunnelStatusFormatter},
{field: 'connection_count', title: '连接数'},
{field: 'created_at', title: '创建时间', formatter: function(v){ return v || '-'; }},
{
field: 'action',
title: '操作',
formatter: function(value, row){
return ''
+ '<a href="javascript:showToken(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-info btn-xs">Token</a> '
+ '<a href="javascript:openPublicHostnames(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-primary btn-xs">公网主机名</a> '
+ '<a href="javascript:openCidrRoutes(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-warning btn-xs">CIDR</a> '
+ '<a href="javascript:openHostnameRoutes(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-success btn-xs">主机名路由</a> '
+ '<a href="javascript:deleteTunnel(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
$("#publicTable").bootstrapTable({
method: 'post',
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'hostname',
responseHandler: tableResponseHandler,
columns: [
{field: 'hostname', title: 'Hostname'},
{field: 'path', title: 'Path', formatter: function(v){ return v || '-'; }},
{field: 'service', title: 'Service'},
{field: 'zone_name', title: '匹配域名', formatter: function(v){ return v || '-'; }},
{
field: 'action',
title: '操作',
formatter: function(value, row){
return '<a href="javascript:deletePublicHostname(\''+escapeJs(row.hostname)+'\', \''+escapeJs(row.path || '')+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
$("#cidrTable").bootstrapTable({
method: 'post',
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'id',
responseHandler: tableResponseHandler,
columns: [
{field: 'network', title: 'CIDR'},
{field: 'comment', title: '备注', formatter: function(v){ return v || '-'; }},
{field: 'created_at', title: '创建时间', formatter: function(v){ return v || '-'; }},
{
field: 'action',
title: '操作',
formatter: function(value, row){
return '<a href="javascript:deleteCidrRoute(\''+row.id+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
$("#hostnameRouteTable").bootstrapTable({
method: 'post',
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'id',
responseHandler: tableResponseHandler,
columns: [
{field: 'hostname', title: 'Hostname'},
{field: 'comment', title: '备注', formatter: function(v){ return v || '-'; }},
{field: 'created_at', title: '创建时间', formatter: function(v){ return v || '-'; }},
{
field: 'action',
title: '操作',
formatter: function(value, row){
return '<a href="javascript:deleteHostnameRoute(\''+row.id+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
});
function tableResponseHandler(res){
if(res.code !== 0){
layer.alert(res.msg || '请求失败', {icon: 2});
return {total: 0, rows: []};
}
return res;
}
function refreshTunnelList(){
$("#listTable").bootstrapTable('refresh');
}
function tunnelStatusFormatter(value){
var v = (value || '').toLowerCase();
if(v === 'healthy' || v === 'active'){
return '<span class="label label-success">'+htmlEscape(value)+'</span>';
}
if(v === 'inactive' || v === 'down' || v === 'degraded'){
return '<span class="label label-warning">'+htmlEscape(value || '-')+'</span>';
}
return value ? '<span class="label label-default">'+htmlEscape(value)+'</span>' : '-';
}
function openTunnelDialog(){
$("#form-tunnel")[0].reset();
$("#form-tunnel").data("bootstrapValidator").resetForm();
$("#modal-tunnel").modal('show');
}
function submitTunnel(){
$("#form-tunnel").data("bootstrapValidator").validate();
if(!$("#form-tunnel").data("bootstrapValidator").isValid()){
return;
}
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/add/{$accountId}',
data: $("#form-tunnel").serialize(),
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
$("#modal-tunnel").modal('hide');
layer.msg(res.msg, {icon: 1, time: 1000});
$("#listTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function deleteTunnel(tunnelId, tunnelName){
layer.confirm('确定要删除 Tunnel '+tunnelName+' 吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/delete/{$accountId}',
data: {tunnel_id: tunnelId},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
layer.msg(res.msg, {icon: 1, time: 1000});
$("#listTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
});
}
function showToken(tunnelId, tunnelName){
$("#tokenTunnelName").val(tunnelName + ' [' + tunnelId + ']');
$("#tokenValue").val('');
$("#tokenCommand").val('');
$("#modal-token").modal('show');
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/token/{$accountId}',
data: {tunnel_id: tunnelId},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
var token = (res.data && res.data.token) ? res.data.token : '';
$("#tokenValue").val(token);
$("#tokenCommand").val('cloudflared tunnel run --token ' + token);
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function copyTokenCommand(){
copyPlainText($("#tokenCommand").val());
}
function openPublicHostnames(tunnelId, tunnelName){
selectedTunnelId = tunnelId;
selectedTunnelName = tunnelName;
$("#publicTitle").text('公网主机名 - ' + tunnelName);
$("#form-public")[0].reset();
$("#modal-public").modal('show');
$("#publicTable").bootstrapTable('refreshOptions', {
url: '/cloudflare/tunnels/publichostnames/data/{$accountId}',
queryParams: function(){ return {tunnel_id: selectedTunnelId}; }
});
}
function savePublicHostname(){
if(!selectedTunnelId){
layer.msg('请先选择 Tunnel');
return;
}
var ii = layer.load(2);
var data = $("#form-public").serializeArray();
data.push({name: 'tunnel_id', value: selectedTunnelId});
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/publichostnames/save/{$accountId}',
data: $.param(data),
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.msg(res.msg, {icon: 1, time: 1000});
$("#publicTable").bootstrapTable('refresh');
$("#listTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function deletePublicHostname(hostname, path){
layer.confirm('确定要删除公网主机名 '+hostname+' 吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/publichostnames/delete/{$accountId}',
data: {tunnel_id: selectedTunnelId, hostname: hostname, path: path},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
$("#modal-public").modal('show');
layer.msg(res.msg, {icon: 1, time: 1000});
$("#publicTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
});
}
function openCidrRoutes(tunnelId, tunnelName){
selectedTunnelId = tunnelId;
selectedTunnelName = tunnelName;
$("#cidrTitle").text('CIDR 路由 - ' + tunnelName);
$("#form-cidr")[0].reset();
$("#modal-cidr").modal('show');
$("#cidrTable").bootstrapTable('refreshOptions', {
url: '/cloudflare/tunnels/cidr/data/{$accountId}',
queryParams: function(){ return {tunnel_id: selectedTunnelId}; }
});
}
function saveCidrRoute(){
if(!selectedTunnelId){
layer.msg('请先选择 Tunnel');
return;
}
var ii = layer.load(2);
var data = $("#form-cidr").serializeArray();
data.push({name: 'tunnel_id', value: selectedTunnelId});
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/cidr/add/{$accountId}',
data: $.param(data),
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.msg(res.msg, {icon: 1, time: 1000});
$("#cidrTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function deleteCidrRoute(routeId){
layer.confirm('确定要删除该 CIDR 路由吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/cidr/delete/{$accountId}',
data: {tunnel_id: selectedTunnelId, route_id: routeId},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
$("#modal-cidr").modal('show');
layer.msg(res.msg, {icon: 1, time: 1000});
$("#cidrTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
});
}
function openHostnameRoutes(tunnelId, tunnelName){
selectedTunnelId = tunnelId;
selectedTunnelName = tunnelName;
$("#hostnameRouteTitle").text('主机名路由 - ' + tunnelName);
$("#form-hostname-route")[0].reset();
$("#modal-hostname-route").modal('show');
$("#hostnameRouteTable").bootstrapTable('refreshOptions', {
url: '/cloudflare/tunnels/hostnameroutes/data/{$accountId}',
queryParams: function(){ return {tunnel_id: selectedTunnelId}; }
});
}
function saveHostnameRoute(){
if(!selectedTunnelId){
layer.msg('请先选择 Tunnel');
return;
}
var ii = layer.load(2);
var data = $("#form-hostname-route").serializeArray();
data.push({name: 'tunnel_id', value: selectedTunnelId});
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/hostnameroutes/add/{$accountId}',
data: $.param(data),
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.msg(res.msg, {icon: 1, time: 1000});
$("#hostnameRouteTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function deleteHostnameRoute(routeId){
layer.confirm('确定要删除该主机名路由吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/hostnameroutes/delete/{$accountId}',
data: {tunnel_id: selectedTunnelId, route_id: routeId},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
$("#modal-hostname-route").modal('show');
layer.msg(res.msg, {icon: 1, time: 1000});
$("#hostnameRouteTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
});
}
function copyPlainText(text){
var temp = document.createElement('textarea');
temp.style.position = 'absolute';
temp.style.left = '-9999px';
temp.value = text || '';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
layer.msg('已复制到剪贴板', {icon: 1, time: 600});
}
function escapeJs(str){
return String(str || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function htmlEscape(str){
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
</script>
{/block}

View File

@@ -29,6 +29,7 @@
<script src="/static/js/bootstrap-table-page-jump-to-1.21.4.min.js"></script>
<script src="/static/js/custom.js"></script>
<script>
var userLevel = "{$user['level']|default=''}";
$(document).ready(function(){
updateToolbar();
const defaultPageSize = 15;
@@ -69,6 +70,10 @@ $(document).ready(function(){
title: '操作',
formatter: function(value, row, index) {
var html = '<a href="/account/edit?id='+row.id+'" class="btn btn-info btn-xs">编辑</a> <a href="javascript:delItem('+row.id+')" class="btn btn-danger btn-xs">删除</a> <a href="/domain?aid='+row.id+'" class="btn btn-default btn-xs">域名</a>';
var rowType = String(row.type || '').toLowerCase();
if(userLevel == '2' && rowType === 'cloudflare'){
html += ' <a href="/cloudflare/tunnels/'+row.id+'" class="btn btn-primary btn-xs">Tunnels</a>';
}
return html;
}
},
@@ -96,4 +101,4 @@ function delItem(id) {
});
}
</script>
{/block}
{/block}

View File

@@ -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;

View File

@@ -40,6 +40,7 @@
<label class="col-sm-3 control-label">输入域名</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="adddomain" placeholder="输入要新增的域名" value="">
<p class="help-block" id="addDomainHint" style="display:none;">腾讯云子域托管会先自动添加校验 TXT再向父域自动补 NS 委派。父域必须已添加到系统并具备写入权限。</p>
</div>
</div>
@@ -146,7 +147,7 @@
</div>
<button type="submit" class="btn btn-primary"><i class="fa fa-search"></i> 搜索</button>
<a href="javascript:searchClear()" class="btn btn-default" title="刷新域名列表"><i class="fa fa-refresh"></i> 刷新</a>
{if request()->user['level'] eq 2}<a href="javascript:addframe()" class="btn btn-success"><i class="fa fa-plus"></i> 添加</a>
{if $user['level'] eq 2}<a href="javascript:addframe()" class="btn btn-success"><i class="fa fa-plus"></i> 添加</a>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">批量操作 <span class="caret"></span></button>
<ul class="dropdown-menu"><li><a href="/domain/add">添加域名</a></li><li><a href="javascript:operation('editremark')">修改域名备注</a></li><li><a href="javascript:operation('opennotice')">开启到期提醒</a></li><li><a href="javascript:operation('closenotice')">关闭到期提醒</a></li><li><a href="javascript:operation('updateexpire')">刷新到期时间</a></li><li><a href="javascript:operation('delete')">删除域名</a></li><li role="separator" class="divider"></li><li><a href="javascript:operation('addrecord')">添加解析</a></li><li><a href="javascript:operation('editrecord')">修改解析</a></li></ul>
@@ -172,7 +173,7 @@
<script src="/static/js/select2-i18n-zh-CN-4.0.13.min.js"></script>
<script src="/static/js/custom.js"></script>
<script>
var userLevel = "{:request()->user['level']}";
var userLevel = "{$user['level']|default=''}";
$(document).ready(function(){
updateToolbar();
const defaultPageSize = getCookie('domain_pagesize') ? getCookie('domain_pagesize') : 15;
@@ -293,6 +294,10 @@ $(document).ready(function(){
title: '操作',
formatter: function(value, row, index) {
var html = '<a href="/record/'+row.id+'" class="btn btn-success btn-xs" onclick="loading()">解析</a>';
var rowType = String(row.type || '').toLowerCase();
if(userLevel == '2' && rowType === 'cloudflare'){
html += ' <a href="/cloudflare/hostnames/'+row.id+'" class="btn btn-primary btn-xs">CF增强</a>';
}
if(userLevel == '2'){
html += ' <a href="javascript:editframe('+row.id+')" class="btn btn-info btn-xs">配置</a>';
html += ' <a href="javascript:delItem('+row.id+')" class="btn btn-danger btn-xs">删除</a>';
@@ -316,6 +321,7 @@ $(document).ready(function(){
$("#form-store input[name=method][value=0]").prop('checked', true);
$("#domainSelect").show();
$("#domainInput").hide();
$("#addDomainHint").hide();
var add = $(this).find('option:selected').data('add');
if(add == '1'){
$("#methodSelect").show();
@@ -327,13 +333,20 @@ $(document).ready(function(){
})
$("#form-store input[name=method]").change(function(){
var value = $("#form-store input[name=method]:checked").val();
var type = String($("#form-store select[name=aid]").find('option:selected').data('type') || '');
if(value == '0'){
$("#domainSelect").show();
$("#domainInput").hide();
$("#addDomainHint").hide();
getDomainList();
}else{
$("#domainSelect").hide();
$("#domainInput").show();
if(type === '腾讯云'){
$("#addDomainHint").show();
}else{
$("#addDomainHint").hide();
}
$('#domainList').empty();
}
})

View File

@@ -167,7 +167,20 @@ td{overflow: hidden;text-overflow: ellipsis;white-space: nowrap;max-width:360px;
<div class="col-xs-12 center-block" style="float: none;">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{if request()->user['type'] eq 'user'}<a href="/domain" class="btn btn-sm btn-default pull-right" style="margin-top:-6px"><i class="fa fa-reply fa-fw"></i> 返回</a>{/if}{$domainName}</h3>
<div class="clearfix">
<div class="pull-right" style="margin-top:-6px;max-width:100%;">
<div style="display:inline-block;width:300px;max-width:100%;vertical-align:middle;margin-right:6px;">
<select id="quickDomainSwitch" class="form-control">
{volist name="quickDomainOptions" id="item"}
<option value="{$item.id}"{if $item.id == $domainId} selected{/if}>{$item.text}</option>
{/volist}
</select>
</div>
<button type="button" class="btn btn-sm btn-primary" id="quickDomainSwitchBtn" style="vertical-align:middle;margin-right:6px;" onclick="return quickSwitchDomain('/record/')"><i class="fa fa-random fa-fw"></i> 切换域名</button>
{if $user['type'] eq 'user'}<a href="/domain" class="btn btn-sm btn-default" style="vertical-align:middle;"><i class="fa fa-reply fa-fw"></i> 返回</a>{/if}
</div>
<h3 class="panel-title" style="padding-top:4px;">{$domainName}</h3>
</div>
</div>
<div class="panel-body">
@@ -183,6 +196,7 @@ td{overflow: hidden;text-overflow: ellipsis;white-space: nowrap;max-width:360px;
<button type="submit" class="btn btn-primary"><i class="fa fa-search"></i> 搜索</button>
<a href="javascript:searchClear()" class="btn btn-default" title="刷新解析记录列表"><i class="fa fa-refresh"></i> 刷新</a>
<a href="javascript:addframe()" class="btn btn-success"><i class="fa fa-plus"></i> 添加记录</a>
{if $dnsconfig.type=='cloudflare' && $user['level'] eq 2}<a href="/cloudflare/hostnames/{$domainId}" class="btn btn-primary"><i class="fa fa-cloud"></i> Cloudflare增强</a>{/if}
{if $dnsconfig.type=='aliyun'}<a href="/record/weight/{$domainId}" class="btn btn-default">权重配置</a>{/if}
{if $dnsconfig.type=='dnspod'}<a href="/record/alias/{$domainId}" class="btn btn-default">域名别名</a>{/if}
<div class="btn-group" role="group">
@@ -240,7 +254,9 @@ td{overflow: hidden;text-overflow: ellipsis;white-space: nowrap;max-width:360px;
<script src="/static/js/bootstrap-table-1.21.4.min.js"></script>
<script src="/static/js/bootstrap-table-page-jump-to-1.21.4.min.js"></script>
<script src="/static/js/bootstrapValidator.min.js"></script>
<script src="/static/js/custom.js?v=1003"></script>
<script src="/static/js/select2-4.0.13.min.js"></script>
<script src="/static/js/select2-i18n-zh-CN-4.0.13.min.js"></script>
<script src="/static/js/custom.js?v=1005"></script>
<script>
var recordLine = {$recordLine|json_encode|raw};
var dnsconfig = {$dnsconfig|json_encode|raw};
@@ -248,6 +264,16 @@ var defaultLine = recordLine[0].id;
var sidePagination = dnsconfig.page ? 'client' : 'server';
var showWeight = dnsconfig.weight;
$(document).ready(function(){
if(typeof initDomainQuickSwitch === 'function'){
initDomainQuickSwitch({
buttonSelector: '',
currentId: '{$domainId}',
currentText: {$domainName|json_encode|raw},
buildUrl: function(id){
return '/record/' + id;
}
});
}
updateToolbar();
let defaultPageSize = getCookie('record_pagesize') ? getCookie('record_pagesize') : 15;
const pageNumber = typeof window.$_GET['pageNumber'] != 'undefined' ? parseInt(window.$_GET['pageNumber']) : 1;
@@ -759,4 +785,4 @@ function htmlEscape(str) {
.replace(/'/g, '&#39;');
}
</script>
{/block}
{/block}

View File

@@ -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();
}
}
}

View File

@@ -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');