mirror of
https://github.com/netcccyun/dnsmgr.git
synced 2026-05-02 11:56:27 +02:00
```
feat(cloudflare): 添加 Cloudflare Tunnels 和增强功能支持 - 在 .gitignore 中添加 .ace-tool/ 忽略规则 - 更新 Cloudflare 配置项,添加详细的使用说明和 API 令牌认证支持 - 新增 Account ID 配置字段用于 Cloudflare Tunnels 功能 - 在账户管理页面添加 Tunnels 功能入口按钮 - 实现智能账户名称自动生成逻辑,优先使用关键认证字段 - 添加 Cloudflare 增强功能菜单项,仅对管理员可见 - 定义完整的 Cloudflare 相关路由,包括 hostnames、tunnels 等功能模块 ```
This commit is contained in:
252
.codex-tmp/cloudflare_service_smoke.php
Normal file
252
.codex-tmp/cloudflare_service_smoke.php
Normal 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;
|
||||
1
.codex-tmp/dns-panel-ref
Submodule
1
.codex-tmp/dns-panel-ref
Submodule
Submodule .codex-tmp/dns-panel-ref added at 0427f1f55f
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
/vendor
|
||||
*.log
|
||||
.env
|
||||
.ace-tool/
|
||||
|
||||
782
app/controller/Cloudflare.php
Normal file
782
app/controller/Cloudflare.php
Normal file
@@ -0,0 +1,782 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\BaseController;
|
||||
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'));
|
||||
View::assign('domainId', $context['domain']['id']);
|
||||
View::assign('domainName', $context['domain']['name']);
|
||||
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_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 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 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'] : [];
|
||||
$verificationStatus = trim((string)($ownership['http']['status'] ?? $ownership['txt']['status'] ?? $ownership['status'] ?? ''));
|
||||
$validationErrors = [];
|
||||
if (!empty($ssl['validation_errors']) && is_array($ssl['validation_errors'])) {
|
||||
foreach ($ssl['validation_errors'] as $item) {
|
||||
$validationErrors[] = trim((string)($item['message'] ?? $item));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => trim((string)($row['id'] ?? '')),
|
||||
'hostname' => trim((string)($row['hostname'] ?? '')),
|
||||
'custom_origin_server' => trim((string)($row['custom_origin_server'] ?? '')),
|
||||
'ssl_status' => trim((string)($ssl['status'] ?? '')),
|
||||
'ssl_method' => trim((string)($ssl['method'] ?? '')),
|
||||
'ssl_type' => trim((string)($ssl['type'] ?? '')),
|
||||
'verification_status' => $verificationStatus !== '' ? $verificationStatus : '-',
|
||||
'created_on' => trim((string)($row['created_at'] ?? $row['created_on'] ?? '')),
|
||||
'validation_errors' => implode(' | ', array_filter($validationErrors)),
|
||||
];
|
||||
}
|
||||
|
||||
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 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 ($hostname === $domainName || str_ends_with($hostname, '.' . $domainName)) {
|
||||
if (strlen($domainName) > $bestLength) {
|
||||
$best = $domain;
|
||||
$bestLength = strlen($domainName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
|
||||
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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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' => '使用代理服务器',
|
||||
|
||||
579
app/service/CloudflareEnhanceService.php
Normal file
579
app/service/CloudflareEnhanceService.php
Normal file
@@ -0,0 +1,579 @@
|
||||
<?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 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 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);
|
||||
}
|
||||
}
|
||||
265
app/view/cloudflare/hostnames.html
Normal file
265
app/view/cloudflare/hostnames.html
Normal file
@@ -0,0 +1,265 @@
|
||||
{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">
|
||||
<h3 class="panel-title">
|
||||
<a href="/record/{$domainId}" class="btn btn-sm btn-default pull-right" style="margin-top:-6px"><i class="fa fa-reply fa-fw"></i> 返回解析</a>
|
||||
Cloudflare增强 - {$domainName}
|
||||
</h3>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>×</span></button>
|
||||
<h4 class="modal-title">添加自定义主机名</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal" id="form-store">
|
||||
<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>
|
||||
</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">
|
||||
</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>
|
||||
{/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>
|
||||
$(document).ready(function(){
|
||||
updateToolbar();
|
||||
$("#form-store").bootstrapValidator();
|
||||
loadFallbackOrigin();
|
||||
$("#listTable").bootstrapTable({
|
||||
url: '/cloudflare/hostnames/data/{$domainId}',
|
||||
method: 'post',
|
||||
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;
|
||||
},
|
||||
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: '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>';
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
function formatStatus(value){
|
||||
var v = (value || '').toLowerCase();
|
||||
if(v === 'active' || v === 'active_deployed'){
|
||||
return '<span class="label label-success">'+htmlEscape(value)+'</span>';
|
||||
}
|
||||
if(v === 'pending' || v === 'pending_validation' || v === 'initializing'){
|
||||
return '<span class="label label-warning">'+htmlEscape(value || '-')+'</span>';
|
||||
}
|
||||
if(v){
|
||||
return '<span class="label label-danger">'+htmlEscape(value)+'</span>';
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function openAddDialog(){
|
||||
$("#form-store")[0].reset();
|
||||
$("#form-store").data("bootstrapValidator").resetForm();
|
||||
$("#modal-store").modal('show');
|
||||
}
|
||||
|
||||
function submitHostname(){
|
||||
$("#form-store").data("bootstrapValidator").validate();
|
||||
if(!$("#form-store").data("bootstrapValidator").isValid()){
|
||||
return;
|
||||
}
|
||||
var ii = layer.load(2);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/cloudflare/hostnames/add/{$domainId}',
|
||||
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();
|
||||
}else{
|
||||
layer.alert(res.msg, {icon: 2});
|
||||
}
|
||||
},
|
||||
error: function(){
|
||||
layer.close(ii);
|
||||
layer.alert('服务器错误', {icon: 2});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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});
|
||||
searchRefresh();
|
||||
}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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
</script>
|
||||
{/block}
|
||||
602
app/view/cloudflare/tunnels.html
Normal file
602
app/view/cloudflare/tunnels.html
Normal file
@@ -0,0 +1,602 @@
|
||||
{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>
|
||||
|
||||
<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="刷新 Tunnel 列表"><i class="fa fa-refresh"></i> 刷新</a>
|
||||
<a href="javascript:openTunnelDialog()" class="btn btn-success"><i class="fa fa-plus"></i> 创建 Tunnel</a>
|
||||
</form>
|
||||
|
||||
<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>×</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>×</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>×</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>×</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>×</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(){
|
||||
updateToolbar();
|
||||
$("#form-tunnel").bootstrapValidator();
|
||||
|
||||
$("#listTable").bootstrapTable({
|
||||
url: '/cloudflare/tunnels/data/{$accountId}',
|
||||
method: 'post',
|
||||
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 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
</script>
|
||||
{/block}
|
||||
@@ -69,6 +69,9 @@ $(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>';
|
||||
if(row.type === 'cloudflare'){
|
||||
html += ' <a href="/cloudflare/tunnels/'+row.id+'" class="btn btn-primary btn-xs">Tunnels</a>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
},
|
||||
@@ -96,4 +99,4 @@ function delItem(id) {
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{/block}
|
||||
{/block}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -183,6 +183,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' && request()->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">
|
||||
@@ -759,4 +760,4 @@ function htmlEscape(str) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
</script>
|
||||
{/block}
|
||||
{/block}
|
||||
|
||||
@@ -51,6 +51,28 @@ 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/delete/:id', 'cloudflare/hostnames_delete');
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user