feat(cloudflare): 添加自定义主机名编辑和刷新验证功能

- 新增 hostnames_update 方法用于更新自定义主机名的自定义源站配置
- 新增 hostnames_refresh 方法用于重新向 Cloudflare 发起验证请求
- 添加 extractCustomHostnameSslPayload 辅助方法处理 SSL 配置参数
- 完善 formatCustomHostnameRow 方法,增加更详细的验证状态信息展示
- 在前端界面添加编辑按钮和校验对话框,支持在线查看和刷新验证记录
- 优化自定义主机名列表页面,支持实时更新和状态显示
- 新增证书校验和所有权验证的详细信息展示界面
```
This commit is contained in:
luo-bo
2026-03-24 01:45:55 +08:00
parent 918bd872d9
commit 7d02f15fde
6 changed files with 1044 additions and 30 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,420 @@
var currentVerificationHostnameId = '';
$(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)
);
}
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 += 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 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 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;');
}

View File

@@ -57,6 +57,64 @@ class Cloudflare extends BaseController
}
}
public function hostnames_update()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$hostnameId = trim(input('post.hostname_id', '', 'trim'));
if ($hostnameId === '') {
throw new Exception('缺少 hostname_id');
}
$current = $context['service']->getCustomHostname($context['domain']['thirdid'], $hostnameId);
$hostname = trim((string)($current['hostname'] ?? ''));
$origin = trim(input('post.custom_origin_server', '', 'trim'));
if ($origin !== '') {
$this->validateCustomOrigin($origin);
}
$result = $context['service']->updateCustomHostname(
$context['domain']['thirdid'],
$hostnameId,
[
'custom_origin_server' => $origin !== '' ? $origin : null,
'ssl' => $this->extractCustomHostnameSslPayload($current),
]
);
$this->add_log($context['domain']['name'], '编辑自定义主机名', $hostname . ' -> ' . ($origin !== '' ? $origin : '清空源站'));
return json(['code' => 0, 'msg' => '更新自定义主机名成功', 'data' => $this->formatCustomHostnameRow($result)]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function hostnames_refresh()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$hostnameId = trim(input('post.hostname_id', '', 'trim'));
if ($hostnameId === '') {
throw new Exception('缺少 hostname_id');
}
$current = $context['service']->getCustomHostname($context['domain']['thirdid'], $hostnameId);
$hostname = trim((string)($current['hostname'] ?? $hostnameId));
$origin = trim((string)($current['custom_origin_server'] ?? ''));
$result = $context['service']->updateCustomHostname(
$context['domain']['thirdid'],
$hostnameId,
[
'custom_origin_server' => $origin !== '' ? $origin : null,
'ssl' => $this->extractCustomHostnameSslPayload($current),
]
);
$this->add_log($context['domain']['name'], '刷新自定义主机名校验', $hostname);
return json(['code' => 0, 'msg' => '已向 Cloudflare 重新发起校验', 'data' => $this->formatCustomHostnameRow($result)]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function hostnames_delete()
{
try {
@@ -570,28 +628,135 @@ class Cloudflare extends BaseController
}
}
private function extractCustomHostnameSslPayload(array $row): array
{
$ssl = isset($row['ssl']) && is_array($row['ssl']) ? $row['ssl'] : [];
$payload = [
'method' => trim((string)($ssl['method'] ?? 'http')),
'type' => trim((string)($ssl['type'] ?? 'dv')),
];
if ($payload['method'] === '') {
$payload['method'] = 'http';
}
if ($payload['type'] === '') {
$payload['type'] = 'dv';
}
if (!empty($ssl['bundle_method'])) {
$payload['bundle_method'] = trim((string)$ssl['bundle_method']);
}
if (!empty($ssl['certificate_authority'])) {
$payload['certificate_authority'] = trim((string)$ssl['certificate_authority']);
}
return $payload;
}
private function formatCustomHostnameRow(array $row): array
{
$ssl = isset($row['ssl']) && is_array($row['ssl']) ? $row['ssl'] : [];
$ownership = isset($row['ownership_verification']) && is_array($row['ownership_verification']) ? $row['ownership_verification'] : [];
$ownershipHttp = isset($row['ownership_verification_http']) && is_array($row['ownership_verification_http']) ? $row['ownership_verification_http'] : [];
$verificationStatus = trim((string)($ownership['http']['status'] ?? $ownership['txt']['status'] ?? $ownership['status'] ?? ''));
if ($verificationStatus === '' && (
trim((string)($ownership['name'] ?? '')) !== ''
|| trim((string)($ownership['value'] ?? '')) !== ''
|| trim((string)($ownershipHttp['http_url'] ?? '')) !== ''
|| trim((string)($ownershipHttp['http_body'] ?? '')) !== ''
)) {
$verificationStatus = 'pending';
}
$validationErrors = [];
if (!empty($row['verification_errors']) && is_array($row['verification_errors'])) {
foreach ($row['verification_errors'] as $item) {
$message = trim((string)($item['message'] ?? $item));
if ($message !== '') {
$validationErrors[] = $message;
}
}
}
if (!empty($ssl['validation_errors']) && is_array($ssl['validation_errors'])) {
foreach ($ssl['validation_errors'] as $item) {
$validationErrors[] = trim((string)($item['message'] ?? $item));
$message = trim((string)($item['message'] ?? $item));
if ($message !== '') {
$validationErrors[] = $message;
}
}
}
$sslValidationRecords = [];
if (!empty($ssl['validation_records']) && is_array($ssl['validation_records'])) {
foreach ($ssl['validation_records'] as $item) {
if (!is_array($item)) {
continue;
}
$sslValidationRecords[] = [
'status' => trim((string)($item['status'] ?? '')),
'txt_name' => trim((string)($item['txt_name'] ?? '')),
'txt_value' => trim((string)($item['txt_value'] ?? '')),
'cname_name' => trim((string)($item['cname_name'] ?? '')),
'cname_target' => trim((string)($item['cname_target'] ?? '')),
'http_url' => trim((string)($item['http_url'] ?? '')),
'http_body' => trim((string)($item['http_body'] ?? '')),
'emails' => !empty($item['emails']) && is_array($item['emails']) ? array_values(array_filter(array_map('strval', $item['emails']))) : [],
];
}
}
if (empty($sslValidationRecords) && (
trim((string)($ssl['txt_name'] ?? '')) !== ''
|| trim((string)($ssl['txt_value'] ?? '')) !== ''
|| trim((string)($ssl['cname_name'] ?? '')) !== ''
|| trim((string)($ssl['cname_target'] ?? '')) !== ''
|| trim((string)($ssl['http_url'] ?? '')) !== ''
|| trim((string)($ssl['http_body'] ?? '')) !== ''
)) {
$sslValidationRecords[] = [
'status' => trim((string)($ssl['status'] ?? '')),
'txt_name' => trim((string)($ssl['txt_name'] ?? '')),
'txt_value' => trim((string)($ssl['txt_value'] ?? '')),
'cname_name' => trim((string)($ssl['cname_name'] ?? '')),
'cname_target' => trim((string)($ssl['cname_target'] ?? '')),
'http_url' => trim((string)($ssl['http_url'] ?? '')),
'http_body' => trim((string)($ssl['http_body'] ?? '')),
'emails' => [],
];
}
$sslValidationStatuses = [];
foreach ($sslValidationRecords as $item) {
$status = trim((string)($item['status'] ?? ''));
if ($status !== '') {
$sslValidationStatuses[] = $status;
}
}
$sslValidationStatuses = array_values(array_unique(array_filter($sslValidationStatuses)));
$sslValidationStatus = count($sslValidationStatuses) > 0 ? implode(' / ', $sslValidationStatuses) : trim((string)($ssl['status'] ?? ''));
if ($sslValidationStatus === '') {
$sslValidationStatus = '-';
}
return [
'id' => trim((string)($row['id'] ?? '')),
'hostname' => trim((string)($row['hostname'] ?? '')),
'custom_origin_server' => trim((string)($row['custom_origin_server'] ?? '')),
'status' => trim((string)($row['status'] ?? '')),
'ssl_status' => trim((string)($ssl['status'] ?? '')),
'ssl_method' => trim((string)($ssl['method'] ?? '')),
'ssl_type' => trim((string)($ssl['type'] ?? '')),
'ssl_validation_status' => $sslValidationStatus,
'verification_status' => $verificationStatus !== '' ? $verificationStatus : '-',
'created_on' => trim((string)($row['created_at'] ?? $row['created_on'] ?? '')),
'validation_errors' => implode(' | ', array_filter($validationErrors)),
'validation_errors' => implode(' | ', array_values(array_unique(array_filter($validationErrors)))),
'ownership_verification' => [
'type' => trim((string)($ownership['type'] ?? '')),
'name' => trim((string)($ownership['name'] ?? '')),
'value' => trim((string)($ownership['value'] ?? '')),
'status' => $verificationStatus !== '' ? $verificationStatus : '-',
],
'ownership_verification_http' => [
'http_url' => trim((string)($ownershipHttp['http_url'] ?? '')),
'http_body' => trim((string)($ownershipHttp['http_body'] ?? '')),
],
'ssl_validation_records' => $sslValidationRecords,
];
}

View File

@@ -82,6 +82,15 @@ class CloudflareEnhanceService
}
}
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);
@@ -104,6 +113,22 @@ class CloudflareEnhanceService
}
}
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 {

View File

@@ -12,7 +12,7 @@
</div>
<div class="panel-body">
<div class="alert alert-info">
<strong>说明:</strong> 这里管理 Cloudflare 自定义主机名、证书状态与 Fallback Origin。
<strong>说明:</strong> 这里管理 Cloudflare 自定义主机名、证书状态、证书校验与 Fallback Origin。
</div>
<div class="well well-sm">
@@ -27,11 +27,12 @@
</div>
</div>
<form onsubmit="return searchSubmit()" method="GET" class="form-inline" id="searchToolbar">
<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:openAddDialog()" class="btn btn-success"><i class="fa fa-plus"></i> 添加自定义主机名</a>
</form>
<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>
@@ -44,20 +45,23 @@
<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">添加自定义主机名</h4>
<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>
@@ -69,6 +73,24 @@
</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>
@@ -77,57 +99,100 @@
<script src="/static/js/bootstrapValidator.min.js"></script>
<script src="/static/js/custom.js"></script>
<script>
var currentVerificationHostnameId = '';
$(document).ready(function(){
updateToolbar();
$("#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: function(res){
if(res.code !== 0){
layer.alert(res.msg || '获取自定义主机名失败', {icon: 2});
return {total: 0, rows: []};
}
return res;
},
responseHandler: hostnameResponseHandler,
columns: [
{field: 'hostname', title: '主机名'},
{field: 'custom_origin_server', title: '自定义源站', formatter: function(v){ return v || '-'; }},
{field: 'ssl_status', title: '证书状态', formatter: formatStatus},
{field: 'verification_status', title: '验证状态', formatter: function(v){ return v || '-'; }},
{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:deleteHostname(\''+row.id+'\', \''+htmlEscape(row.hostname)+'\')" class="btn btn-danger btn-xs">删除</a>';
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 = (value || '').toLowerCase();
if(v === 'active' || v === 'active_deployed'){
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'){
if(v === 'pending' || v === 'pending_validation' || v === 'initializing' || v === 'in_progress'){
return '<span class="label label-warning">'+htmlEscape(value || '-')+'</span>';
}
if(v){
if(v && v !== '-'){
return '<span class="label label-danger">'+htmlEscape(value)+'</span>';
}
return '-';
}
function openAddDialog(){
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").data("bootstrapValidator").resetForm();
$("#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');
}
@@ -136,18 +201,28 @@ function submitHostname(){
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: '/cloudflare/hostnames/add/{$domainId}',
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, {icon: 1, time: 1200});
searchRefresh();
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});
}
@@ -159,8 +234,191 @@ function submitHostname(){
});
}
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)
);
}
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 += 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 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 deleteHostname(id, hostname){
layer.confirm('确定要删除自定义主机名 '+hostname+' 吗?', {title: '提示', icon: 0}, function(){
layer.confirm('确定要删除自定义主机名 ' + hostname + ' 吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
@@ -172,7 +430,7 @@ function deleteHostname(id, hostname){
if(res.code === 0){
layer.closeAll();
layer.msg(res.msg, {icon: 1, time: 1000});
searchRefresh();
refreshHostnameList();
}else{
layer.alert(res.msg, {icon: 2});
}

View File

@@ -54,6 +54,8 @@ Route::group(function () {
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/fallback/get/:id', 'cloudflare/fallback_get');
Route::post('/cloudflare/fallback/set/:id', 'cloudflare/fallback_set');