Files
dnsmgr/app/controller/Cloudflare.php
luo-bo 7670d5a387 ```
feat(cloudflare): 添加TXT记录目标域名选择功能

- 新增hostnames_txt_targets接口用于查找TXT记录的目标域名候选列表
- 实现findTxtRecordTargetDomains方法用于匹配最合适的解析域名
- 添加matchHostnameToDomainRecordName方法用于主机名与域名匹配逻辑
- 在前端页面中集成TXT记录快速添加的域名选择弹窗功能
- 更新域名快速切换功能,增加onclick事件处理
- 升级静态资源版本号以确保缓存更新
```
2026-04-02 03:33:10 +08:00

1081 lines
46 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\controller;
use app\BaseController;
use app\lib\DnsHelper;
use app\service\CloudflareEnhanceService;
use Exception;
use think\facade\Db;
use think\facade\View;
class Cloudflare extends BaseController
{
public function hostnames()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$quickDomainOptions = $this->getManagedDomainOptions('cloudflare');
if (empty($quickDomainOptions)) {
$quickDomainOptions = [[
'id' => intval($context['domain']['id']),
'name' => $context['domain']['name'],
'type' => 'cloudflare',
'text' => $context['domain']['name'] . ' [Cloudflare]',
]];
}
View::assign('domainId', $context['domain']['id']);
View::assign('domainName', $context['domain']['name']);
View::assign('quickDomainOptions', $quickDomainOptions);
return view();
} catch (Exception $e) {
return $this->alert('error', $e->getMessage());
}
}
public function hostnames_data()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$rows = [];
foreach ($context['service']->listCustomHostnames($context['domain']['thirdid']) as $row) {
$rows[] = $this->formatCustomHostnameRow($row);
}
return json(['code' => 0, 'total' => count($rows), 'rows' => $rows]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
}
}
public function hostnames_add()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$hostname = trim(input('post.hostname', '', 'trim'));
$origin = trim(input('post.custom_origin_server', '', 'trim'));
if (empty($hostname) || !checkDomain($hostname)) {
throw new Exception('主机名格式不正确');
}
if ($origin !== '') {
$this->validateCustomOrigin($origin);
}
$result = $context['service']->createCustomHostname($context['domain']['thirdid'], $hostname, $origin !== '' ? $origin : null);
$this->add_log($context['domain']['name'], '创建自定义主机名', $hostname . ($origin !== '' ? ' -> ' . $origin : ''));
return json(['code' => 0, 'msg' => '创建自定义主机名成功', 'data' => $this->formatCustomHostnameRow($result)]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function hostnames_update()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$hostnameId = trim(input('post.hostname_id', '', 'trim'));
if ($hostnameId === '') {
throw new Exception('缺少 hostname_id');
}
$current = $context['service']->getCustomHostname($context['domain']['thirdid'], $hostnameId);
$hostname = trim((string)($current['hostname'] ?? ''));
$origin = trim(input('post.custom_origin_server', '', 'trim'));
if ($origin !== '') {
$this->validateCustomOrigin($origin);
}
$result = $context['service']->updateCustomHostname(
$context['domain']['thirdid'],
$hostnameId,
[
'custom_origin_server' => $origin !== '' ? $origin : null,
'ssl' => $this->extractCustomHostnameSslPayload($current),
]
);
$this->add_log($context['domain']['name'], '编辑自定义主机名', $hostname . ' -> ' . ($origin !== '' ? $origin : '清空源站'));
return json(['code' => 0, 'msg' => '更新自定义主机名成功', 'data' => $this->formatCustomHostnameRow($result)]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function hostnames_refresh()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$hostnameId = trim(input('post.hostname_id', '', 'trim'));
if ($hostnameId === '') {
throw new Exception('缺少 hostname_id');
}
$current = $context['service']->getCustomHostname($context['domain']['thirdid'], $hostnameId);
$hostname = trim((string)($current['hostname'] ?? $hostnameId));
$origin = trim((string)($current['custom_origin_server'] ?? ''));
$result = $context['service']->updateCustomHostname(
$context['domain']['thirdid'],
$hostnameId,
[
'custom_origin_server' => $origin !== '' ? $origin : null,
'ssl' => $this->extractCustomHostnameSslPayload($current),
]
);
$this->add_log($context['domain']['name'], '刷新自定义主机名校验', $hostname);
return json(['code' => 0, 'msg' => '已向 Cloudflare 重新发起校验', 'data' => $this->formatCustomHostnameRow($result)]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function hostnames_delete()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$hostnameId = trim(input('post.hostname_id', '', 'trim'));
$hostname = trim(input('post.hostname', '', 'trim'));
if ($hostnameId === '') {
throw new Exception('缺少 hostname_id');
}
$context['service']->deleteCustomHostname($context['domain']['thirdid'], $hostnameId);
$this->add_log($context['domain']['name'], '删除自定义主机名', $hostname !== '' ? $hostname : $hostnameId);
return json(['code' => 0, 'msg' => '删除自定义主机名成功']);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function hostnames_txt_targets()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$hostname = trim(input('post.hostname', '', 'trim'));
if ($hostname === '') {
throw new Exception('缺少 TXT 主机名');
}
return json([
'code' => 0,
'data' => [
'hostname' => $hostname,
'candidates' => $this->findTxtRecordTargetDomains($context['domain'], $hostname),
],
]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage(), 'data' => ['candidates' => []]]);
}
}
public function fallback_get()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$origin = $context['service']->getFallbackOrigin($context['domain']['thirdid']);
return json(['code' => 0, 'data' => ['origin' => $origin]]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function fallback_set()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$origin = trim(input('post.origin', '', 'trim'));
if ($origin === '') {
throw new Exception('Fallback Origin 不能为空');
}
$this->validateCustomOrigin($origin);
$savedOrigin = $context['service']->updateFallbackOrigin($context['domain']['thirdid'], $origin);
$this->add_log($context['domain']['name'], '更新 Fallback Origin', $savedOrigin);
return json(['code' => 0, 'msg' => '更新 Fallback Origin 成功', 'data' => ['origin' => $savedOrigin]]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function fallback_delete()
{
try {
$context = $this->getCloudflareDomainContext(input('param.id/d'));
$context['service']->deleteFallbackOrigin($context['domain']['thirdid']);
$this->add_log($context['domain']['name'], '删除 Fallback Origin', '清空成功');
return json(['code' => 0, 'msg' => '已清空 Fallback Origin']);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
View::assign('accountId', $context['account']['id']);
View::assign('accountName', $this->formatAccountDisplayName($context['account']));
View::assign('cfAccountId', $context['accountId']);
return view();
} catch (Exception $e) {
return $this->alert('error', $e->getMessage());
}
}
public function tunnels_data()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$rows = [];
foreach ($context['service']->listTunnels($context['accountId']) as $row) {
$rows[] = $this->formatTunnelRow($row);
}
return json(['code' => 0, 'total' => count($rows), 'rows' => $rows, 'account_id' => $context['accountId']]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
}
}
public function tunnels_add()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$name = trim(input('post.name', '', 'trim'));
if ($name === '') {
throw new Exception('Tunnel 名称不能为空');
}
$tunnel = $context['service']->createTunnel($context['accountId'], $name);
$this->add_log($this->formatAccountDisplayName($context['account']), '创建 Tunnel', $name . ' [' . ($tunnel['id'] ?? '-') . ']');
return json(['code' => 0, 'msg' => '创建 Tunnel 成功', 'data' => $this->formatTunnelRow($tunnel)]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels_delete()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
if ($tunnelId === '') {
throw new Exception('缺少 tunnel_id');
}
$context['service']->deleteTunnel($context['accountId'], $tunnelId);
$this->add_log($this->formatAccountDisplayName($context['account']), '删除 Tunnel', $tunnelId);
return json(['code' => 0, 'msg' => '删除 Tunnel 成功']);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels_token()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
if ($tunnelId === '') {
throw new Exception('缺少 tunnel_id');
}
$token = $context['service']->getTunnelToken($context['accountId'], $tunnelId);
return json(['code' => 0, 'data' => ['token' => $token]]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels_public_hostnames_data()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
if ($tunnelId === '') {
throw new Exception('缺少 tunnel_id');
}
$config = $this->extractTunnelConfigObject($context['service']->getTunnelConfig($context['accountId'], $tunnelId));
$rows = [];
foreach ($this->extractPublicHostnames($config) as $row) {
$zone = $this->findBestMatchingDomain(intval($context['account']['id']), $row['hostname']);
$row['zone_name'] = $zone['name'] ?? '';
$row['zone_id'] = $zone['thirdid'] ?? '';
$rows[] = $row;
}
return json(['code' => 0, 'total' => count($rows), 'rows' => $rows]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
}
}
public function tunnels_public_hostnames_save()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
$hostname = trim(input('post.hostname', '', 'trim'));
$serviceValue = trim(input('post.service', '', 'trim'));
$path = trim(input('post.path', '', 'trim'));
if ($tunnelId === '' || $hostname === '' || $serviceValue === '') {
throw new Exception('Tunnel、主机名、服务地址不能为空');
}
if (!checkDomain($hostname)) {
throw new Exception('主机名格式不正确');
}
$zone = $this->findBestMatchingDomain(intval($context['account']['id']), $hostname);
if (empty($zone) || empty($zone['thirdid'])) {
throw new Exception('未找到匹配的本地域名,请先在当前 Cloudflare 账户下导入该主机名所属主域');
}
$config = $this->extractTunnelConfigObject($context['service']->getTunnelConfig($context['accountId'], $tunnelId));
$oldConfig = json_decode(json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
$ingress = isset($config['ingress']) && is_array($config['ingress']) ? array_values($config['ingress']) : [];
$rule = [
'hostname' => $hostname,
'service' => $serviceValue,
];
if ($path !== '') {
$rule['path'] = $path;
}
$existingIndex = $this->findPublicHostnameIndex($ingress, $hostname, $path);
if ($existingIndex >= 0) {
$next = array_merge($ingress[$existingIndex], $rule);
if ($path === '' && isset($next['path'])) {
unset($next['path']);
}
$ingress[$existingIndex] = $next;
} else {
$fallbackIndex = $this->findFallbackIngressIndex($ingress);
if ($fallbackIndex >= 0) {
array_splice($ingress, $fallbackIndex, 0, [$rule]);
} else {
$ingress[] = $rule;
}
}
$config['ingress'] = $this->ensureFallbackIngress($ingress);
$context['service']->updateTunnelConfig($context['accountId'], $tunnelId, $config);
try {
$dns = $context['service']->upsertTunnelCnameRecord($zone['thirdid'], $hostname, $tunnelId);
} catch (Exception $e) {
$context['service']->updateTunnelConfig($context['accountId'], $tunnelId, $oldConfig);
throw new Exception('Public Hostname 已回滚:' . $e->getMessage());
}
$this->add_log($this->formatAccountDisplayName($context['account']), '配置 Tunnel 公网主机名', $hostname . ' -> ' . $serviceValue . ' [' . ($dns['action'] ?? '-') . ']');
return json(['code' => 0, 'msg' => '配置 Public Hostname 成功']);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels_public_hostnames_delete()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
$hostname = trim(input('post.hostname', '', 'trim'));
$path = trim(input('post.path', '', 'trim'));
if ($tunnelId === '' || $hostname === '') {
throw new Exception('缺少 tunnel_id 或 hostname');
}
$config = $this->extractTunnelConfigObject($context['service']->getTunnelConfig($context['accountId'], $tunnelId));
$oldConfig = json_decode(json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
$ingress = isset($config['ingress']) && is_array($config['ingress']) ? array_values($config['ingress']) : [];
$nextIngress = [];
foreach ($ingress as $row) {
if (!is_array($row)) {
continue;
}
$match = $this->normalizeHostname($row['hostname'] ?? '') === $this->normalizeHostname($hostname)
&& trim((string)($row['path'] ?? '')) === $path;
if (!$match) {
$nextIngress[] = $row;
}
}
$config['ingress'] = $this->ensureFallbackIngress($nextIngress);
$context['service']->updateTunnelConfig($context['accountId'], $tunnelId, $config);
$zone = $this->findBestMatchingDomain(intval($context['account']['id']), $hostname);
if (!empty($zone['thirdid'])) {
try {
$context['service']->deleteTunnelCnameRecordIfMatch($zone['thirdid'], $hostname, $tunnelId);
} catch (Exception $e) {
$context['service']->updateTunnelConfig($context['accountId'], $tunnelId, $oldConfig);
throw new Exception('删除 Public Hostname 时已回滚:' . $e->getMessage());
}
}
$this->add_log($this->formatAccountDisplayName($context['account']), '删除 Tunnel 公网主机名', $hostname . ($path !== '' ? ' [' . $path . ']' : ''));
return json(['code' => 0, 'msg' => '删除 Public Hostname 成功']);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels_cidr_data()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
if ($tunnelId === '') {
throw new Exception('缺少 tunnel_id');
}
$rows = [];
foreach ($context['service']->listCidrRoutes($context['accountId'], $tunnelId) as $row) {
$mapped = $this->formatCidrRouteRow($row);
if ($mapped['id'] !== '' && $mapped['network'] !== '') {
$rows[] = $mapped;
}
}
return json(['code' => 0, 'total' => count($rows), 'rows' => $rows]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
}
}
public function tunnels_cidr_add()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
$network = trim(input('post.network', '', 'trim'));
$comment = trim(input('post.comment', '', 'trim'));
if ($tunnelId === '' || $network === '') {
throw new Exception('Tunnel 和 CIDR 不能为空');
}
if (!$this->isValidCidr($network)) {
throw new Exception('CIDR 格式不正确');
}
$route = $context['service']->createCidrRoute($context['accountId'], $tunnelId, $network, $comment !== '' ? $comment : null);
$mapped = $this->formatCidrRouteRow($route);
$this->add_log($this->formatAccountDisplayName($context['account']), '创建 Tunnel CIDR 路由', $mapped['network']);
return json(['code' => 0, 'msg' => '创建 CIDR 路由成功', 'data' => $mapped]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels_cidr_delete()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
$routeId = trim(input('post.route_id', '', 'trim'));
if ($tunnelId === '' || $routeId === '') {
throw new Exception('缺少 tunnel_id 或 route_id');
}
$matched = false;
foreach ($context['service']->listCidrRoutes($context['accountId'], $tunnelId) as $row) {
if (trim((string)($row['id'] ?? '')) === $routeId) {
$matched = true;
break;
}
}
if (!$matched) {
throw new Exception('CIDR 路由不存在或不属于当前 Tunnel');
}
$context['service']->deleteCidrRoute($context['accountId'], $routeId);
$this->add_log($this->formatAccountDisplayName($context['account']), '删除 Tunnel CIDR 路由', $routeId);
return json(['code' => 0, 'msg' => '删除 CIDR 路由成功']);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels_hostname_routes_data()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
if ($tunnelId === '') {
throw new Exception('缺少 tunnel_id');
}
$rows = [];
foreach ($context['service']->listHostnameRoutes($context['accountId'], $tunnelId) as $row) {
$mapped = $this->formatHostnameRouteRow($row);
if ($mapped['id'] !== '' && $mapped['hostname'] !== '') {
$rows[] = $mapped;
}
}
return json(['code' => 0, 'total' => count($rows), 'rows' => $rows]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage(), 'total' => 0, 'rows' => []]);
}
}
public function tunnels_hostname_routes_add()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
$hostname = trim(input('post.hostname', '', 'trim'));
$comment = trim(input('post.comment', '', 'trim'));
if ($tunnelId === '' || $hostname === '') {
throw new Exception('Tunnel 和主机名不能为空');
}
if (!checkDomain($hostname)) {
throw new Exception('主机名格式不正确');
}
$route = $context['service']->createHostnameRoute($context['accountId'], $tunnelId, $hostname, $comment !== '' ? $comment : null);
$mapped = $this->formatHostnameRouteRow($route);
$this->add_log($this->formatAccountDisplayName($context['account']), '创建 Tunnel 主机名路由', $mapped['hostname']);
return json(['code' => 0, 'msg' => '创建主机名路由成功', 'data' => $mapped]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
public function tunnels_hostname_routes_delete()
{
try {
$context = $this->getCloudflareAccountContext(input('param.id/d'), true, true);
$tunnelId = trim(input('post.tunnel_id', '', 'trim'));
$routeId = trim(input('post.route_id', '', 'trim'));
if ($tunnelId === '' || $routeId === '') {
throw new Exception('缺少 tunnel_id 或 route_id');
}
$matched = false;
foreach ($context['service']->listHostnameRoutes($context['accountId'], $tunnelId) as $row) {
$id = trim((string)($row['id'] ?? $row['hostname_route_id'] ?? ''));
if ($id === $routeId) {
$matched = true;
break;
}
}
if (!$matched) {
throw new Exception('主机名路由不存在或不属于当前 Tunnel');
}
$context['service']->deleteHostnameRoute($context['accountId'], $routeId);
$this->add_log($this->formatAccountDisplayName($context['account']), '删除 Tunnel 主机名路由', $routeId);
return json(['code' => 0, 'msg' => '删除主机名路由成功']);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
private function getCloudflareDomainContext(int $domainId): array
{
if (!checkPermission(2)) {
throw new Exception('无权限');
}
$row = Db::name('domain')->alias('A')
->join('account B', 'A.aid = B.id')
->where('A.id', $domainId)
->field('A.*,B.type,B.config account_config,B.name account_name,B.remark account_remark')
->find();
if (!$row) {
throw new Exception('域名不存在');
}
if (($row['type'] ?? '') !== 'cloudflare') {
throw new Exception('仅支持 Cloudflare 域名');
}
if (empty($row['thirdid'])) {
throw new Exception('当前域名缺少 Cloudflare Zone ID');
}
$config = json_decode($row['account_config'] ?? '', true);
if (!is_array($config)) {
$config = [];
}
return [
'domain' => $row,
'config' => $config,
'service' => new CloudflareEnhanceService($config),
];
}
private function getCloudflareAccountContext(int $accountId, bool $requireAccountId = false, bool $requireTunnelApiToken = false): array
{
if (!checkPermission(2)) {
throw new Exception('无权限');
}
$account = Db::name('account')->where('id', $accountId)->find();
if (!$account) {
throw new Exception('域名账户不存在');
}
if (($account['type'] ?? '') !== 'cloudflare') {
throw new Exception('仅支持 Cloudflare 账户');
}
$config = json_decode($account['config'] ?? '', true);
if (!is_array($config)) {
$config = [];
}
$service = new CloudflareEnhanceService($config);
if ($requireTunnelApiToken && !$service->isApiTokenAuth()) {
throw new Exception('Cloudflare Tunnels 仅支持 API 令牌认证,请将当前账户的认证方式切换为 API令牌');
}
$resolvedAccountId = trim((string)($config['account_id'] ?? ''));
if ($requireAccountId && $resolvedAccountId === '') {
$resolvedAccountId = $service->getDefaultAccountId();
if ($resolvedAccountId !== '') {
$config['account_id'] = $resolvedAccountId;
Db::name('account')->where('id', $account['id'])->update([
'config' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$account['config'] = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$service = new CloudflareEnhanceService($config);
}
}
if ($requireAccountId && $resolvedAccountId === '') {
throw new Exception('当前 Cloudflare 账户缺少 Account ID且无法自动探测。请编辑账户并补充 Account ID 后重试');
}
return [
'account' => $account,
'config' => $config,
'service' => $service,
'accountId' => $resolvedAccountId,
];
}
private function validateCustomOrigin(string $origin): void
{
if (preg_match('/^https?:\/\//i', $origin)) {
throw new Exception('自定义源站不支持填写 http:// 或 https://');
}
if (str_contains($origin, '*')) {
throw new Exception('自定义源站不支持通配符');
}
if (str_contains($origin, '/')) {
throw new Exception('自定义源站格式不正确');
}
if (preg_match('/:\d+$/', $origin)) {
throw new Exception('自定义源站不支持端口');
}
if (filter_var($origin, FILTER_VALIDATE_IP)) {
throw new Exception('自定义源站不支持 IP 地址,请填写域名');
}
if (!checkDomain($origin)) {
throw new Exception('自定义源站格式不正确');
}
}
private function extractCustomHostnameSslPayload(array $row): array
{
$ssl = isset($row['ssl']) && is_array($row['ssl']) ? $row['ssl'] : [];
$payload = [
'method' => trim((string)($ssl['method'] ?? 'http')),
'type' => trim((string)($ssl['type'] ?? 'dv')),
];
if ($payload['method'] === '') {
$payload['method'] = 'http';
}
if ($payload['type'] === '') {
$payload['type'] = 'dv';
}
return $payload;
}
private function formatCustomHostnameRow(array $row): array
{
$ssl = isset($row['ssl']) && is_array($row['ssl']) ? $row['ssl'] : [];
$ownership = isset($row['ownership_verification']) && is_array($row['ownership_verification']) ? $row['ownership_verification'] : [];
$ownershipHttp = isset($row['ownership_verification_http']) && is_array($row['ownership_verification_http']) ? $row['ownership_verification_http'] : [];
$verificationStatus = trim((string)($ownership['http']['status'] ?? $ownership['txt']['status'] ?? $ownership['status'] ?? ''));
if ($verificationStatus === '' && (
trim((string)($ownership['name'] ?? '')) !== ''
|| trim((string)($ownership['value'] ?? '')) !== ''
|| trim((string)($ownershipHttp['http_url'] ?? '')) !== ''
|| trim((string)($ownershipHttp['http_body'] ?? '')) !== ''
)) {
$verificationStatus = 'pending';
}
$validationErrors = [];
if (!empty($row['verification_errors']) && is_array($row['verification_errors'])) {
foreach ($row['verification_errors'] as $item) {
$message = trim((string)($item['message'] ?? $item));
if ($message !== '') {
$validationErrors[] = $message;
}
}
}
if (!empty($ssl['validation_errors']) && is_array($ssl['validation_errors'])) {
foreach ($ssl['validation_errors'] as $item) {
$message = trim((string)($item['message'] ?? $item));
if ($message !== '') {
$validationErrors[] = $message;
}
}
}
$sslValidationRecords = [];
if (!empty($ssl['validation_records']) && is_array($ssl['validation_records'])) {
foreach ($ssl['validation_records'] as $item) {
if (!is_array($item)) {
continue;
}
$sslValidationRecords[] = [
'status' => trim((string)($item['status'] ?? '')),
'txt_name' => trim((string)($item['txt_name'] ?? '')),
'txt_value' => trim((string)($item['txt_value'] ?? '')),
'cname_name' => trim((string)($item['cname_name'] ?? '')),
'cname_target' => trim((string)($item['cname_target'] ?? '')),
'http_url' => trim((string)($item['http_url'] ?? '')),
'http_body' => trim((string)($item['http_body'] ?? '')),
'emails' => !empty($item['emails']) && is_array($item['emails']) ? array_values(array_filter(array_map('strval', $item['emails']))) : [],
];
}
}
if (empty($sslValidationRecords) && (
trim((string)($ssl['txt_name'] ?? '')) !== ''
|| trim((string)($ssl['txt_value'] ?? '')) !== ''
|| trim((string)($ssl['cname_name'] ?? '')) !== ''
|| trim((string)($ssl['cname_target'] ?? '')) !== ''
|| trim((string)($ssl['http_url'] ?? '')) !== ''
|| trim((string)($ssl['http_body'] ?? '')) !== ''
)) {
$sslValidationRecords[] = [
'status' => trim((string)($ssl['status'] ?? '')),
'txt_name' => trim((string)($ssl['txt_name'] ?? '')),
'txt_value' => trim((string)($ssl['txt_value'] ?? '')),
'cname_name' => trim((string)($ssl['cname_name'] ?? '')),
'cname_target' => trim((string)($ssl['cname_target'] ?? '')),
'http_url' => trim((string)($ssl['http_url'] ?? '')),
'http_body' => trim((string)($ssl['http_body'] ?? '')),
'emails' => [],
];
}
$sslValidationStatuses = [];
foreach ($sslValidationRecords as $item) {
$status = trim((string)($item['status'] ?? ''));
if ($status !== '') {
$sslValidationStatuses[] = $status;
}
}
$sslValidationStatuses = array_values(array_unique(array_filter($sslValidationStatuses)));
$sslValidationStatus = count($sslValidationStatuses) > 0 ? implode(' / ', $sslValidationStatuses) : trim((string)($ssl['status'] ?? ''));
if ($sslValidationStatus === '') {
$sslValidationStatus = '-';
}
return [
'id' => trim((string)($row['id'] ?? '')),
'hostname' => trim((string)($row['hostname'] ?? '')),
'custom_origin_server' => trim((string)($row['custom_origin_server'] ?? '')),
'status' => trim((string)($row['status'] ?? '')),
'ssl_status' => trim((string)($ssl['status'] ?? '')),
'ssl_method' => trim((string)($ssl['method'] ?? '')),
'ssl_type' => trim((string)($ssl['type'] ?? '')),
'ssl_validation_status' => $sslValidationStatus,
'verification_status' => $verificationStatus !== '' ? $verificationStatus : '-',
'created_on' => trim((string)($row['created_at'] ?? $row['created_on'] ?? '')),
'validation_errors' => implode(' | ', array_values(array_unique(array_filter($validationErrors)))),
'ownership_verification' => [
'type' => trim((string)($ownership['type'] ?? '')),
'name' => trim((string)($ownership['name'] ?? '')),
'value' => trim((string)($ownership['value'] ?? '')),
'status' => $verificationStatus !== '' ? $verificationStatus : '-',
],
'ownership_verification_http' => [
'http_url' => trim((string)($ownershipHttp['http_url'] ?? '')),
'http_body' => trim((string)($ownershipHttp['http_body'] ?? '')),
],
'ssl_validation_records' => $sslValidationRecords,
];
}
private function formatTunnelRow(array $row): array
{
$connections = isset($row['connections']) && is_array($row['connections']) ? array_values($row['connections']) : [];
return [
'id' => trim((string)($row['id'] ?? '')),
'name' => trim((string)($row['name'] ?? '')),
'status' => trim((string)($row['status'] ?? 'unknown')),
'created_at' => trim((string)($row['created_at'] ?? '')),
'deleted_at' => trim((string)($row['deleted_at'] ?? '')),
'conns_active_at' => trim((string)($row['conns_active_at'] ?? '')),
'connection_count' => count($connections),
'connections' => $connections,
];
}
private function formatCidrRouteRow(array $row): array
{
return [
'id' => trim((string)($row['id'] ?? '')),
'network' => trim((string)($row['network'] ?? '')),
'comment' => trim((string)($row['comment'] ?? '')),
'virtual_network_id' => trim((string)($row['virtual_network_id'] ?? '')),
'tunnel_id' => trim((string)($row['tunnel_id'] ?? '')),
'created_at' => trim((string)($row['created_at'] ?? '')),
];
}
private function formatHostnameRouteRow(array $row): array
{
return [
'id' => trim((string)($row['id'] ?? $row['hostname_route_id'] ?? '')),
'hostname' => trim((string)($row['hostname'] ?? $row['hostname_pattern'] ?? '')),
'comment' => trim((string)($row['comment'] ?? '')),
'tunnel_id' => trim((string)($row['tunnel_id'] ?? '')),
'created_at' => trim((string)($row['created_at'] ?? '')),
];
}
private function findTxtRecordTargetDomains(array $currentDomain, string $hostname): array
{
$rows = Db::name('domain')->alias('D')
->join('account A', 'D.aid = A.id')
->field('D.id,D.aid,D.name,A.type account_type,A.name account_name,A.remark account_remark')
->select()
->toArray();
$candidates = [];
$bestLength = -1;
foreach ($rows as $row) {
$recordName = $this->matchHostnameToDomainRecordName($hostname, $row['name'] ?? '');
if ($recordName === null) {
continue;
}
$domainName = $this->normalizeHostname($row['name'] ?? '');
$matchedLength = strlen($domainName);
if ($matchedLength > $bestLength) {
$bestLength = $matchedLength;
$candidates = [];
}
if ($matchedLength === $bestLength) {
$candidates[] = $this->formatTxtTargetCandidate($row, $recordName, intval($currentDomain['id'] ?? 0));
}
}
if (empty($candidates)) {
$fallbackRecordName = $this->matchHostnameToDomainRecordName($hostname, $currentDomain['name'] ?? '', true);
if ($fallbackRecordName !== null) {
$candidates[] = $this->formatTxtTargetCandidate([
'id' => $currentDomain['id'] ?? 0,
'aid' => $currentDomain['aid'] ?? 0,
'name' => $currentDomain['name'] ?? '',
'account_type' => $currentDomain['type'] ?? '',
'account_name' => $currentDomain['account_name'] ?? '',
'account_remark' => $currentDomain['account_remark'] ?? '',
], $fallbackRecordName, intval($currentDomain['id'] ?? 0));
}
}
usort($candidates, function ($a, $b) {
if ($a['is_current_domain'] !== $b['is_current_domain']) {
return $a['is_current_domain'] ? -1 : 1;
}
$providerCompare = strcmp($a['account_type_name'], $b['account_type_name']);
if ($providerCompare !== 0) {
return $providerCompare;
}
$accountCompare = strcmp($a['account_display_name'], $b['account_display_name']);
if ($accountCompare !== 0) {
return $accountCompare;
}
return strcmp($a['domain_name'], $b['domain_name']);
});
return $candidates;
}
private function formatTxtTargetCandidate(array $row, string $recordName, int $currentDomainId): array
{
$account = [
'id' => intval($row['aid'] ?? 0),
'name' => trim((string)($row['account_name'] ?? '')),
'remark' => trim((string)($row['account_remark'] ?? '')),
];
$accountType = trim((string)($row['account_type'] ?? ''));
return [
'domain_id' => intval($row['id'] ?? 0),
'domain_name' => trim((string)($row['name'] ?? '')),
'record_name' => $recordName,
'account_id' => $account['id'],
'account_type' => $accountType,
'account_type_name' => $this->formatDnsTypeName($accountType),
'account_display_name' => $this->formatAccountDisplayName($account),
'is_current_domain' => intval($row['id'] ?? 0) === $currentDomainId,
];
}
private function formatAccountDisplayName(array $account): string
{
$name = trim((string)($account['name'] ?? ''));
$remark = trim((string)($account['remark'] ?? ''));
if ($remark !== '') {
return $remark . ' (' . $name . ')';
}
return $name !== '' ? $name : ('Cloudflare账户#' . ($account['id'] ?? ''));
}
private function extractTunnelConfigObject(array $raw): array
{
if (isset($raw['config']) && is_array($raw['config'])) {
return $raw['config'];
}
return $raw;
}
private function extractPublicHostnames(array $config): array
{
$rows = [];
$ingress = isset($config['ingress']) && is_array($config['ingress']) ? array_values($config['ingress']) : [];
foreach ($ingress as $rule) {
if (!is_array($rule)) {
continue;
}
$hostname = trim((string)($rule['hostname'] ?? ''));
if ($hostname === '') {
continue;
}
$rows[] = [
'hostname' => $hostname,
'path' => trim((string)($rule['path'] ?? '')),
'service' => trim((string)($rule['service'] ?? '')),
];
}
return $rows;
}
private function ensureFallbackIngress(array $ingress): array
{
$rows = [];
foreach ($ingress as $rule) {
if (is_array($rule)) {
$rows[] = $rule;
}
}
if (empty($rows) || !$this->isFallbackIngressRule($rows[count($rows) - 1])) {
$rows[] = ['service' => 'http_status:404'];
}
return $rows;
}
private function isFallbackIngressRule(array $rule): bool
{
return trim((string)($rule['hostname'] ?? '')) === '' && trim((string)($rule['path'] ?? '')) === '';
}
private function findFallbackIngressIndex(array $ingress): int
{
foreach ($ingress as $index => $rule) {
if (is_array($rule) && $this->isFallbackIngressRule($rule)) {
return $index;
}
}
return -1;
}
private function findPublicHostnameIndex(array $ingress, string $hostname, string $path): int
{
foreach ($ingress as $index => $rule) {
if (!is_array($rule)) {
continue;
}
$sameHostname = $this->normalizeHostname($rule['hostname'] ?? '') === $this->normalizeHostname($hostname);
$samePath = trim((string)($rule['path'] ?? '')) === trim($path);
if ($sameHostname && $samePath) {
return $index;
}
}
return -1;
}
private function findBestMatchingDomain(int $accountId, string $hostname): ?array
{
$hostname = preg_replace('/^\*\./', '', $this->normalizeHostname($hostname));
$domains = Db::name('domain')->where('aid', $accountId)->select()->toArray();
$best = null;
$bestLength = -1;
foreach ($domains as $domain) {
$domainName = $this->normalizeHostname($domain['name'] ?? '');
if ($domainName === '') {
continue;
}
if ($this->matchHostnameToDomainRecordName($hostname, $domainName) !== null && strlen($domainName) > $bestLength) {
$best = $domain;
$bestLength = strlen($domainName);
}
}
return $best;
}
private function matchHostnameToDomainRecordName(string $hostname, string $domainName, bool $allowRelative = false): ?string
{
$hostname = preg_replace('/^\*\./', '', $this->normalizeHostname($hostname));
$domainName = $this->normalizeHostname($domainName);
if ($hostname === '' || $domainName === '') {
return null;
}
if ($hostname === $domainName) {
return '@';
}
if (str_ends_with($hostname, '.' . $domainName)) {
return substr($hostname, 0, -strlen($domainName) - 1);
}
if ($allowRelative) {
if ($hostname === '@') {
return '@';
}
if (!str_contains($hostname, '.')) {
return $hostname;
}
}
return null;
}
private function formatDnsTypeName(string $type): string
{
$dnsList = DnsHelper::getList();
return $dnsList[$type]['name'] ?? ($type !== '' ? $type : '-');
}
private function normalizeHostname($hostname): string
{
$hostname = trim((string)$hostname);
if ($hostname === '') {
return '';
}
$hostname = convertDomainToAscii(rtrim($hostname, '.'));
return strtolower($hostname);
}
private function isValidCidr(string $network): bool
{
if (!str_contains($network, '/')) {
return false;
}
[$ip, $prefix] = explode('/', $network, 2);
if (!is_numeric($prefix)) {
return false;
}
$prefix = intval($prefix);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $prefix >= 0 && $prefix <= 32;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return $prefix >= 0 && $prefix <= 128;
}
return false;
}
private function add_log(string $domain, string $action, string $data): void
{
if (strlen($data) > 500) {
$data = substr($data, 0, 500);
}
Db::name('log')->insert([
'uid' => request()->user['id'],
'domain' => $domain,
'action' => $action,
'data' => $data,
'addtime' => date('Y-m-d H:i:s'),
]);
}
}