2026-06-02 00:32:05 +08:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace app\common\service;
|
|
|
|
|
|
|
|
|
|
use fast\Http;
|
|
|
|
|
use think\Exception;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cloudflare API 服务层
|
|
|
|
|
*/
|
|
|
|
|
class CloudflareService
|
|
|
|
|
{
|
|
|
|
|
private const API_BASE = 'https://api.cloudflare.com/client/v4';
|
|
|
|
|
|
|
|
|
|
private string $apiToken;
|
|
|
|
|
private string $accountId;
|
|
|
|
|
private string $serverIp;
|
|
|
|
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
{
|
|
|
|
|
$config = config('cloudflare');
|
|
|
|
|
$this->apiToken = (string)($config['api_token'] ?? '');
|
|
|
|
|
$this->accountId = (string)($config['account_id'] ?? '');
|
|
|
|
|
$this->serverIp = trim((string)($config['server_ip'] ?? ''));
|
|
|
|
|
if ($this->apiToken === '' || $this->accountId === '') {
|
|
|
|
|
throw new Exception('Cloudflare API 凭证未配置,请在 .env 中设置 cloudflare.api_token 与 cloudflare.account_id');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 创建 Cloudflare Zone
|
|
|
|
|
*
|
|
|
|
|
* @param string $domain 根域名
|
|
|
|
|
* @return array{zone_id: string, name_servers: array, status: string}
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
public function createZone(string $domain): array
|
|
|
|
|
{
|
|
|
|
|
$body = [
|
|
|
|
|
'name' => $domain,
|
|
|
|
|
'account' => ['id' => $this->accountId],
|
|
|
|
|
'type' => 'full',
|
|
|
|
|
];
|
|
|
|
|
$response = $this->request('POST', '/zones', $body);
|
|
|
|
|
$result = $response['result'] ?? [];
|
|
|
|
|
return [
|
|
|
|
|
'zone_id' => (string)($result['id'] ?? ''),
|
|
|
|
|
'name_servers' => (array)($result['name_servers'] ?? []),
|
|
|
|
|
'status' => (string)($result['status'] ?? 'pending'),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
public function getZone(string $zoneId): array
|
|
|
|
|
{
|
|
|
|
|
$response = $this->request('GET', '/zones/' . $zoneId);
|
|
|
|
|
return (array)($response['result'] ?? []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
public function deleteZone(string $zoneId): bool
|
|
|
|
|
{
|
|
|
|
|
$this->request('DELETE', '/zones/' . $zoneId);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
public function listDnsRecords(string $zoneId): array
|
|
|
|
|
{
|
|
|
|
|
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], ['per_page' => 100]);
|
|
|
|
|
return (array)($response['result'] ?? []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-10 06:44:57 +08:00
|
|
|
* 聚合检测 Zone/NS/DNS 状态;NS 已验证且 Zone 已激活时,静默校验并修正 A 记录、Proxied、SSL、HTTPS
|
2026-06-02 00:32:05 +08:00
|
|
|
*
|
|
|
|
|
* @param array $row 域名记录
|
|
|
|
|
* @return array{zone_status: string, ns_status: string, dns_status: string, check_result: string}
|
|
|
|
|
*/
|
|
|
|
|
public function detectDomain(array $row): array
|
|
|
|
|
{
|
|
|
|
|
$domain = strtolower(trim((string)($row['domain'] ?? '')));
|
|
|
|
|
$zoneId = (string)($row['zone_id'] ?? '');
|
|
|
|
|
$expectedNs = $this->decodeNameservers($row['nameservers'] ?? '');
|
|
|
|
|
|
|
|
|
|
$zoneStatus = 'failed';
|
|
|
|
|
$nsStatus = 'pending';
|
|
|
|
|
$dnsStatus = 'pending';
|
|
|
|
|
$messages = [];
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$zone = $this->getZone($zoneId);
|
|
|
|
|
$cfStatus = (string)($zone['status'] ?? '');
|
|
|
|
|
if ($cfStatus === 'active') {
|
|
|
|
|
$zoneStatus = 'active';
|
|
|
|
|
$messages[] = 'Zone:已激活';
|
|
|
|
|
} elseif (in_array($cfStatus, ['pending', 'initializing'], true)) {
|
|
|
|
|
$zoneStatus = 'pending';
|
|
|
|
|
$messages[] = 'Zone:待激活';
|
|
|
|
|
} else {
|
|
|
|
|
$zoneStatus = 'failed';
|
|
|
|
|
$messages[] = 'Zone:异常(' . $cfStatus . ')';
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$zoneStatus = 'failed';
|
|
|
|
|
$messages[] = 'Zone:检测失败(' . $e->getMessage() . ')';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$currentNs = $this->getDomainNameservers($domain);
|
|
|
|
|
if ($currentNs === []) {
|
|
|
|
|
$nsStatus = 'pending';
|
|
|
|
|
$messages[] = 'NS:待验证(未查询到NS记录)';
|
|
|
|
|
} elseif ($this->compareNameservers($currentNs, $expectedNs)) {
|
|
|
|
|
$nsStatus = 'verified';
|
|
|
|
|
$messages[] = 'NS:已验证';
|
|
|
|
|
} else {
|
|
|
|
|
$nsStatus = 'pending';
|
|
|
|
|
$messages[] = 'NS:待验证(当前NS与Cloudflare不一致)';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($zoneStatus !== 'active') {
|
|
|
|
|
$dnsStatus = 'pending';
|
|
|
|
|
$messages[] = 'DNS:待创建(Zone未激活)';
|
|
|
|
|
} elseif ($nsStatus !== 'verified') {
|
|
|
|
|
$dnsStatus = 'pending';
|
|
|
|
|
$messages[] = 'DNS:待创建(NS未验证)';
|
|
|
|
|
} elseif ($this->serverIp === '') {
|
|
|
|
|
$dnsStatus = 'failed';
|
|
|
|
|
$messages[] = 'DNS:未配置server_ip';
|
|
|
|
|
} elseif (!filter_var($this->serverIp, FILTER_VALIDATE_IP)) {
|
|
|
|
|
$dnsStatus = 'failed';
|
|
|
|
|
$messages[] = 'DNS:server_ip格式无效';
|
|
|
|
|
} else {
|
2026-06-10 06:44:57 +08:00
|
|
|
if ($this->hasRootCnameConflict($zoneId, $domain)) {
|
|
|
|
|
$dnsStatus = 'failed';
|
|
|
|
|
$messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录';
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
$this->reconcileCloudflareConfig($zoneId, $domain, $this->serverIp);
|
2026-06-02 00:32:05 +08:00
|
|
|
if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) {
|
|
|
|
|
$dnsStatus = 'created';
|
|
|
|
|
$messages[] = 'DNS:已创建(A=' . $this->serverIp . ')';
|
|
|
|
|
} else {
|
|
|
|
|
$dnsStatus = 'pending';
|
|
|
|
|
$messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')';
|
|
|
|
|
}
|
2026-06-10 06:44:57 +08:00
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$dnsStatus = 'failed';
|
|
|
|
|
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
|
2026-06-02 00:32:05 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'zone_status' => $zoneStatus,
|
|
|
|
|
'ns_status' => $nsStatus,
|
|
|
|
|
'dns_status' => $dnsStatus,
|
|
|
|
|
'check_result' => implode('; ', $messages),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
private function listRootARecords(string $zoneId, string $domain): array
|
|
|
|
|
{
|
|
|
|
|
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
|
|
|
|
|
'type' => 'A',
|
|
|
|
|
'name' => $domain,
|
|
|
|
|
]);
|
|
|
|
|
return (array)($response['result'] ?? []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
private function hasRootCnameConflict(string $zoneId, string $domain): bool
|
|
|
|
|
{
|
|
|
|
|
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
|
|
|
|
|
'type' => 'CNAME',
|
|
|
|
|
'name' => $domain,
|
|
|
|
|
]);
|
|
|
|
|
return count((array)($response['result'] ?? [])) > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-10 06:44:57 +08:00
|
|
|
* NS 已验证且 Zone 已激活时:读取 Proxied / SSL / HTTPS / A 记录,与期望值不一致则修正
|
|
|
|
|
*
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
private function reconcileCloudflareConfig(string $zoneId, string $domain, string $ip): void
|
|
|
|
|
{
|
|
|
|
|
$this->ensureZoneEdgeSettings($zoneId);
|
|
|
|
|
$this->upsertRootARecord($zoneId, $domain, $ip);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 读取 Zone 单项设置值
|
|
|
|
|
*
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
private function getZoneSettingValue(string $zoneId, string $settingId): string
|
|
|
|
|
{
|
|
|
|
|
$response = $this->request('GET', '/zones/' . $zoneId . '/settings/' . $settingId);
|
|
|
|
|
return strtolower(trim((string)($response['result']['value'] ?? '')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* SSL/TLS=Flexible、Always Use HTTPS=开启;已与期望一致则跳过 PATCH
|
|
|
|
|
*
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
private function ensureZoneEdgeSettings(string $zoneId): void
|
|
|
|
|
{
|
|
|
|
|
if ($this->getZoneSettingValue($zoneId, 'ssl') !== 'flexible') {
|
|
|
|
|
$this->request('PATCH', '/zones/' . $zoneId . '/settings/ssl', [
|
|
|
|
|
'value' => 'flexible',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
if ($this->getZoneSettingValue($zoneId, 'always_use_https') !== 'on') {
|
|
|
|
|
$this->request('PATCH', '/zones/' . $zoneId . '/settings/always_use_https', [
|
|
|
|
|
'value' => 'on',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 创建或更新根域名 A 记录(IP 与 Proxied 与期望不一致时修正)
|
|
|
|
|
*
|
2026-06-02 00:32:05 +08:00
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
private function upsertRootARecord(string $zoneId, string $domain, string $ip): void
|
|
|
|
|
{
|
|
|
|
|
$records = $this->listRootARecords($zoneId, $domain);
|
|
|
|
|
if ($records === []) {
|
|
|
|
|
$this->request('POST', '/zones/' . $zoneId . '/dns_records', [
|
|
|
|
|
'type' => 'A',
|
|
|
|
|
'name' => $domain,
|
|
|
|
|
'content' => $ip,
|
|
|
|
|
'ttl' => 1,
|
2026-06-10 06:44:57 +08:00
|
|
|
'proxied' => true,
|
2026-06-02 00:32:05 +08:00
|
|
|
]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($records as $record) {
|
|
|
|
|
$recordId = (string)($record['id'] ?? '');
|
|
|
|
|
$content = (string)($record['content'] ?? '');
|
2026-06-10 06:44:57 +08:00
|
|
|
$proxied = (bool)($record['proxied'] ?? false);
|
|
|
|
|
if ($recordId === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($content === $ip && $proxied) {
|
2026-06-02 00:32:05 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [
|
|
|
|
|
'content' => $ip,
|
|
|
|
|
'ttl' => 1,
|
2026-06-10 06:44:57 +08:00
|
|
|
'proxied' => true,
|
2026-06-02 00:32:05 +08:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-10 06:44:57 +08:00
|
|
|
* 校验根域名 A 记录:content 指向 server_ip 且已开启 Proxy
|
|
|
|
|
*
|
2026-06-02 00:32:05 +08:00
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool
|
|
|
|
|
{
|
|
|
|
|
$records = $this->listRootARecords($zoneId, $domain);
|
|
|
|
|
foreach ($records as $record) {
|
2026-06-10 06:44:57 +08:00
|
|
|
$content = (string)($record['content'] ?? '');
|
|
|
|
|
$proxied = (bool)($record['proxied'] ?? false);
|
|
|
|
|
if ($content === $ip && $proxied) {
|
2026-06-02 00:32:05 +08:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @throws Exception
|
|
|
|
|
*/
|
|
|
|
|
private function request(string $method, string $path, array $body = [], array $query = []): array
|
|
|
|
|
{
|
|
|
|
|
$url = self::API_BASE . $path;
|
|
|
|
|
if ($query !== []) {
|
|
|
|
|
$url .= '?' . http_build_query($query);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$options = [
|
|
|
|
|
CURLOPT_HTTPHEADER => [
|
|
|
|
|
'Authorization: Bearer ' . $this->apiToken,
|
|
|
|
|
'Content-Type: application/json',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$payload = $body !== [] ? json_encode($body, JSON_UNESCAPED_UNICODE) : '';
|
|
|
|
|
|
|
|
|
|
if (strtoupper($method) === 'GET') {
|
|
|
|
|
$result = Http::sendRequest($url, [], 'GET', $options);
|
|
|
|
|
} elseif (strtoupper($method) === 'DELETE') {
|
|
|
|
|
$result = Http::sendRequest($url, $payload, 'DELETE', $options);
|
|
|
|
|
} else {
|
|
|
|
|
$result = Http::sendRequest($url, $payload, strtoupper($method), $options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$result['ret']) {
|
|
|
|
|
throw new Exception('Cloudflare API 请求失败: ' . ($result['msg'] ?? '未知错误'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decoded = json_decode((string)$result['msg'], true);
|
|
|
|
|
if (!is_array($decoded)) {
|
|
|
|
|
throw new Exception('Cloudflare API 响应解析失败');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($decoded['success'])) {
|
|
|
|
|
$errors = $decoded['errors'] ?? [];
|
|
|
|
|
$message = 'Cloudflare API 错误';
|
|
|
|
|
if (is_array($errors) && isset($errors[0]['message'])) {
|
|
|
|
|
$message = (string)$errors[0]['message'];
|
|
|
|
|
}
|
|
|
|
|
throw new Exception($message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $decoded;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function decodeNameservers($nameservers): array
|
|
|
|
|
{
|
|
|
|
|
if (is_array($nameservers)) {
|
|
|
|
|
return $nameservers;
|
|
|
|
|
}
|
|
|
|
|
if (is_string($nameservers) && $nameservers !== '') {
|
|
|
|
|
$decoded = json_decode($nameservers, true);
|
|
|
|
|
return is_array($decoded) ? $decoded : [];
|
|
|
|
|
}
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function getDomainNameservers(string $domain): array
|
|
|
|
|
{
|
|
|
|
|
$records = @dns_get_record($domain, DNS_NS);
|
|
|
|
|
if (!is_array($records)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
$list = [];
|
|
|
|
|
foreach ($records as $record) {
|
|
|
|
|
if (!empty($record['target'])) {
|
|
|
|
|
$list[] = $this->normalizeNs((string)$record['target']);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return array_values(array_unique($list));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function compareNameservers(array $current, array $expected): bool
|
|
|
|
|
{
|
|
|
|
|
if ($expected === []) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$currentNorm = array_map([$this, 'normalizeNs'], $current);
|
|
|
|
|
$expectedNorm = array_map([$this, 'normalizeNs'], $expected);
|
|
|
|
|
sort($currentNorm);
|
|
|
|
|
sort($expectedNorm);
|
|
|
|
|
return $currentNorm === $expectedNorm;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function normalizeNs(string $ns): string
|
|
|
|
|
{
|
|
|
|
|
return rtrim(strtolower(trim($ns)), '.');
|
|
|
|
|
}
|
|
|
|
|
}
|