域名管理
This commit is contained in:
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
-- 域名管理模块表结构
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `fa_domain`;
|
||||||
|
CREATE TABLE `fa_domain` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||||
|
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
|
||||||
|
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '根域名',
|
||||||
|
`full_url` varchar(512) NOT NULL DEFAULT '' COMMENT '完整链接',
|
||||||
|
`zone_id` varchar(64) NOT NULL DEFAULT '' COMMENT 'Cloudflare Zone ID',
|
||||||
|
`nameservers` text COMMENT 'CF分配的NS(JSON)',
|
||||||
|
`zone_status` enum('pending','active','failed') NOT NULL DEFAULT 'pending' COMMENT 'Zone状态',
|
||||||
|
`ns_status` enum('pending','verified','failed') NOT NULL DEFAULT 'pending' COMMENT 'NS状态',
|
||||||
|
`dns_status` enum('pending','created','failed') NOT NULL DEFAULT 'pending' COMMENT 'DNS状态',
|
||||||
|
`check_time` bigint(16) DEFAULT NULL COMMENT '检测时间',
|
||||||
|
`check_result` varchar(2000) DEFAULT '' COMMENT '最近检测结果',
|
||||||
|
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `domain` (`domain`),
|
||||||
|
KEY `admin_id` (`admin_id`),
|
||||||
|
KEY `zone_status` (`zone_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='域名管理表';
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
Regular → Executable
Executable
+247
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller;
|
||||||
|
|
||||||
|
use app\admin\model\Domain as DomainModel;
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use app\common\service\CloudflareService;
|
||||||
|
use app\common\service\DomainDetectRateLimitService;
|
||||||
|
use think\Db;
|
||||||
|
use think\Exception;
|
||||||
|
use think\exception\PDOException;
|
||||||
|
use think\exception\ValidateException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 域名管理
|
||||||
|
*
|
||||||
|
* @icon fa fa-globe
|
||||||
|
* @remark 域名接入Cloudflare,新增后请在详情查看NS并到注册商修改
|
||||||
|
*/
|
||||||
|
class Domain extends Backend
|
||||||
|
{
|
||||||
|
/** @var DomainModel */
|
||||||
|
protected $model = null;
|
||||||
|
|
||||||
|
protected $searchFields = 'domain';
|
||||||
|
protected $dataLimit = 'personal';
|
||||||
|
protected $modelValidate = true;
|
||||||
|
protected $modelSceneValidate = true;
|
||||||
|
|
||||||
|
public function _initialize()
|
||||||
|
{
|
||||||
|
parent::_initialize();
|
||||||
|
$this->model = new DomainModel();
|
||||||
|
$this->view->assign('zoneStatusList', $this->model->getZoneStatusList());
|
||||||
|
$this->view->assign('nsStatusList', $this->model->getNsStatusList());
|
||||||
|
$this->view->assign('dnsStatusList', $this->model->getDnsStatusList());
|
||||||
|
$this->assignconfig('zoneStatusList', $this->model->getZoneStatusList());
|
||||||
|
$this->assignconfig('nsStatusList', $this->model->getNsStatusList());
|
||||||
|
$this->assignconfig('dnsStatusList', $this->model->getDnsStatusList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁止编辑
|
||||||
|
*/
|
||||||
|
public function edit($ids = null)
|
||||||
|
{
|
||||||
|
$this->error(__('Domain cannot be edited after creation'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁止批量操作
|
||||||
|
*/
|
||||||
|
public function multi($ids = '')
|
||||||
|
{
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加域名并创建 Cloudflare Zone
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
return $this->view->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
$domain = DomainModel::normalizeDomain((string)($params['domain'] ?? ''));
|
||||||
|
$params['domain'] = $domain;
|
||||||
|
|
||||||
|
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
||||||
|
$params[$this->dataLimitField] = $this->auth->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = false;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||||
|
$this->model->validateFailException()->validate($validate, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DomainModel::where('domain', $domain)->find()) {
|
||||||
|
throw new ValidateException(__('Domain already exists'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$cloudflare = new CloudflareService();
|
||||||
|
$zone = $cloudflare->createZone($domain);
|
||||||
|
|
||||||
|
$params['full_url'] = 'https://' . $domain;
|
||||||
|
$params['zone_id'] = $zone['zone_id'];
|
||||||
|
$params['nameservers'] = $zone['name_servers'];
|
||||||
|
$params['zone_status'] = DomainModel::mapCloudflareZoneStatus($zone['status']);
|
||||||
|
$params['ns_status'] = 'pending';
|
||||||
|
$params['dns_status'] = 'pending';
|
||||||
|
$params['check_result'] = '';
|
||||||
|
|
||||||
|
$result = $this->model->allowField(true)->save($params);
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error(__('No rows were inserted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(__('Domain submitted, please check NS in detail page and update at registrar'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 域名详情
|
||||||
|
*/
|
||||||
|
public function detail($ids = null)
|
||||||
|
{
|
||||||
|
$row = $this->model->get($ids);
|
||||||
|
if (!$row) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
if (!$this->checkDataLimit($row)) {
|
||||||
|
$this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $row->toArray();
|
||||||
|
$data['nameservers_list'] = $row->getNameserversArray();
|
||||||
|
$this->view->assign('row', $data);
|
||||||
|
return $this->view->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测域名状态
|
||||||
|
*/
|
||||||
|
public function detect($ids = null)
|
||||||
|
{
|
||||||
|
if (!$this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->model->get($ids);
|
||||||
|
if (!$row) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
if (!$this->checkDataLimit($row)) {
|
||||||
|
$this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DomainDetectRateLimitService())->assertCanDetect((int)$this->auth->id, (int)$row['id']);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cloudflare = new CloudflareService();
|
||||||
|
$detect = $cloudflare->detectDomain($row->toArray());
|
||||||
|
$row->save([
|
||||||
|
'zone_status' => $detect['zone_status'],
|
||||||
|
'ns_status' => $detect['ns_status'],
|
||||||
|
'dns_status' => $detect['dns_status'],
|
||||||
|
'check_time' => time(),
|
||||||
|
'check_result' => $detect['check_result'],
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(__('Detection completed'), null, $row->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除(需输入完整域名二次确认)
|
||||||
|
*/
|
||||||
|
public function del($ids = '')
|
||||||
|
{
|
||||||
|
if (!$this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $ids ?: $this->request->post('ids', '');
|
||||||
|
$confirmDomain = trim((string)$this->request->post('confirm_domain', ''));
|
||||||
|
if ($ids === '' || $confirmDomain === '') {
|
||||||
|
$this->error(__('Please enter the full domain to confirm deletion'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$pk = $this->model->getPk();
|
||||||
|
$list = $this->model->where($pk, 'in', $ids)->select();
|
||||||
|
if (!$list || count($list) === 0) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
foreach ($list as $item) {
|
||||||
|
if (!$this->checkDataLimit($item)) {
|
||||||
|
throw new Exception(__('You have no permission'));
|
||||||
|
}
|
||||||
|
if (DomainModel::normalizeDomain($confirmDomain) !== DomainModel::normalizeDomain((string)$item['domain'])) {
|
||||||
|
throw new Exception(__('Domain confirmation does not match'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($item['zone_id'])) {
|
||||||
|
$cloudflare = new CloudflareService();
|
||||||
|
$cloudflare->deleteZone((string)$item['zone_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count += $item->delete();
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
$this->error(__('No rows were deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param DomainModel $row
|
||||||
|
*/
|
||||||
|
protected function checkDataLimit($row): bool
|
||||||
|
{
|
||||||
|
if (!$this->dataLimit) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (!is_array($adminIds)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return in_array((int)$row[$this->dataLimitField], $adminIds, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Domain' => '域名',
|
||||||
|
'Full_url' => '完整链接',
|
||||||
|
'Zone_status' => 'Zone状态',
|
||||||
|
'Ns_status' => 'NameServer状态',
|
||||||
|
'Dns_status' => 'DNS状态',
|
||||||
|
'Nameservers' => 'NameServer',
|
||||||
|
'Check_time' => '检测时间',
|
||||||
|
'Check_result' => '最近检测结果',
|
||||||
|
'Createtime' => '创建时间',
|
||||||
|
'Zone pending' => '待激活',
|
||||||
|
'Zone active' => '已激活',
|
||||||
|
'Zone failed' => '异常',
|
||||||
|
'NS pending' => '待验证',
|
||||||
|
'NS verified' => '已验证',
|
||||||
|
'NS failed' => '验证失败',
|
||||||
|
'DNS pending' => '待创建',
|
||||||
|
'DNS created' => '已创建',
|
||||||
|
'DNS failed' => '异常',
|
||||||
|
'Detect' => '检测',
|
||||||
|
'Detection completed' => '检测完成',
|
||||||
|
'Domain input tips title' => '填写说明',
|
||||||
|
'Domain input tips line1' => '请输入根域名,比如:nihao.com,不要填写 www,且不支持二级子域名。',
|
||||||
|
'Domain input tips line2' => '不支持中国域名注册商注册的域名,比如阿里云、腾讯云等注册商;也不支持中国域名后缀,包括 cn、com.cn、net.cn、org.cn 等。',
|
||||||
|
'Domain guide title' => '接入指引',
|
||||||
|
'Domain guide content' => '提交后,请在详情中查看 Cloudflare 实际分配的 NS,并到域名注册商处修改。',
|
||||||
|
'Domain submitted, please check NS in detail page and update at registrar' => '域名已提交,请在详情中查看 Cloudflare 分配的 NS,并到注册商处修改',
|
||||||
|
'Domain cannot be edited after creation' => '域名创建后不可编辑',
|
||||||
|
'Domain already exists' => '该域名已存在',
|
||||||
|
'Please enter the full domain to confirm deletion' => '请输入完整域名以确认删除',
|
||||||
|
'Domain confirmation does not match' => '输入的域名与记录不一致,删除已取消',
|
||||||
|
'Delete domain confirm prompt' => '请输入完整域名以确认删除',
|
||||||
|
'Delete domain confirm mismatch' => '域名输入不一致,请重新确认',
|
||||||
|
];
|
||||||
Executable
+189
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\model;
|
||||||
|
|
||||||
|
use think\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 域名管理模型
|
||||||
|
*/
|
||||||
|
class Domain extends Model
|
||||||
|
{
|
||||||
|
/** @var string 中国域名后缀黑名单 */
|
||||||
|
public const CHINA_SUFFIX_BLACKLIST = [
|
||||||
|
'cn', 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', 'ac.cn', 'mil.cn',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $name = 'domain';
|
||||||
|
protected $autoWriteTimestamp = 'integer';
|
||||||
|
protected $createTime = 'createtime';
|
||||||
|
protected $updateTime = 'updatetime';
|
||||||
|
protected $deleteTime = false;
|
||||||
|
|
||||||
|
protected $append = [
|
||||||
|
'zone_status_text',
|
||||||
|
'ns_status_text',
|
||||||
|
'dns_status_text',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getZoneStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('Zone pending'),
|
||||||
|
'active' => __('Zone active'),
|
||||||
|
'failed' => __('Zone failed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNsStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('NS pending'),
|
||||||
|
'verified' => __('NS verified'),
|
||||||
|
'failed' => __('NS failed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDnsStatusList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('DNS pending'),
|
||||||
|
'created' => __('DNS created'),
|
||||||
|
'failed' => __('DNS failed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getZoneStatusTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$value = $value ?: ($data['zone_status'] ?? '');
|
||||||
|
$list = $this->getZoneStatusList();
|
||||||
|
return $list[$value] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNsStatusTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$value = $value ?: ($data['ns_status'] ?? '');
|
||||||
|
$list = $this->getNsStatusList();
|
||||||
|
return $list[$value] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDnsStatusTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$value = $value ?: ($data['dns_status'] ?? '');
|
||||||
|
$list = $this->getDnsStatusList();
|
||||||
|
return $list[$value] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNameserversAttr($value): string
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return json_encode(array_values($value), JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function getNameserversArray(): array
|
||||||
|
{
|
||||||
|
$value = $this->getAttr('nameservers');
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeDomain(string $domain): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidRootDomain(string $domain): bool
|
||||||
|
{
|
||||||
|
$domain = self::normalizeDomain($domain);
|
||||||
|
if ($domain === '' || strlen($domain) > 253) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (strpos($domain, 'www.') === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i', $domain)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (self::isChinaSuffix($domain)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$label = self::getDomainLabelWithoutSuffix($domain);
|
||||||
|
if ($label === '' || strpos($label, '.') !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isChinaSuffix(string $domain): bool
|
||||||
|
{
|
||||||
|
$domain = self::normalizeDomain($domain);
|
||||||
|
foreach (self::CHINA_SUFFIX_BLACKLIST as $suffix) {
|
||||||
|
if ($domain === $suffix || self::endsWith($domain, '.' . $suffix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getMatchedSuffix(string $domain): string
|
||||||
|
{
|
||||||
|
$domain = self::normalizeDomain($domain);
|
||||||
|
$suffixes = array_merge(self::CHINA_SUFFIX_BLACKLIST, ['co.uk', 'com', 'net', 'org', 'io', 'co', 'me', 'info', 'biz']);
|
||||||
|
usort($suffixes, function ($a, $b) {
|
||||||
|
return strlen($b) - strlen($a);
|
||||||
|
});
|
||||||
|
foreach ($suffixes as $suffix) {
|
||||||
|
if ($domain === $suffix || self::endsWith($domain, '.' . $suffix)) {
|
||||||
|
return $suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getDomainLabelWithoutSuffix(string $domain): string
|
||||||
|
{
|
||||||
|
$domain = self::normalizeDomain($domain);
|
||||||
|
$suffix = self::getMatchedSuffix($domain);
|
||||||
|
if ($suffix === '') {
|
||||||
|
$pos = strrpos($domain, '.');
|
||||||
|
return $pos === false ? $domain : substr($domain, 0, $pos);
|
||||||
|
}
|
||||||
|
$suffixWithDot = '.' . $suffix;
|
||||||
|
if (self::endsWith($domain, $suffixWithDot)) {
|
||||||
|
return substr($domain, 0, -strlen($suffixWithDot));
|
||||||
|
}
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mapCloudflareZoneStatus(string $status): string
|
||||||
|
{
|
||||||
|
if ($status === 'active') {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
if (in_array($status, ['pending', 'initializing'], true)) {
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function endsWith(string $haystack, string $needle): bool
|
||||||
|
{
|
||||||
|
if ($needle === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return substr($haystack, -strlen($needle)) === $needle;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\validate;
|
||||||
|
|
||||||
|
use app\admin\model\Domain as DomainModel;
|
||||||
|
use think\Validate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 域名验证器
|
||||||
|
*/
|
||||||
|
class Domain extends Validate
|
||||||
|
{
|
||||||
|
protected $rule = [
|
||||||
|
'domain' => 'require|checkDomain',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $message = [
|
||||||
|
'domain.require' => '请输入根域名',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $scene = [
|
||||||
|
'add' => ['domain'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $value
|
||||||
|
* @return bool|string
|
||||||
|
*/
|
||||||
|
protected function checkDomain($value)
|
||||||
|
{
|
||||||
|
$domain = DomainModel::normalizeDomain((string)$value);
|
||||||
|
if (!DomainModel::isValidRootDomain($domain)) {
|
||||||
|
return '请输入合法根域名,不要填写 www 或二级子域名,且不支持中国域名后缀';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
|
||||||
|
{:token()}
|
||||||
|
|
||||||
|
<div class="alert alert-warning-light">
|
||||||
|
<p><strong>{:__('Domain input tips title')}</strong></p>
|
||||||
|
<p>{:__('Domain input tips line1')}</p>
|
||||||
|
<p>{:__('Domain input tips line2')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info-light">
|
||||||
|
<p><strong>{:__('Domain guide title')}</strong></p>
|
||||||
|
<p>{:__('Domain guide content')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="c-domain" class="control-label col-xs-12 col-sm-2">{:__('Domain')}:</label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<input id="c-domain" data-rule="required" class="form-control" name="row[domain]" type="text" value="" placeholder="nihao.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||||
|
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
<style>
|
||||||
|
.table-domain-detail tr td { word-break: break-all; }
|
||||||
|
.domain-ns-list { margin: 0; padding-left: 18px; }
|
||||||
|
.domain-ns-list li { margin-bottom: 6px; font-family: Menlo, Monaco, Consolas, monospace; }
|
||||||
|
</style>
|
||||||
|
<table class="table table-striped table-domain-detail">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="140">{:__('Domain')}</td>
|
||||||
|
<td>{$row.domain|htmlentities}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{:__('Full_url')}</td>
|
||||||
|
<td><a href="{$row.full_url|htmlentities}" target="_blank" rel="noopener noreferrer">{$row.full_url|htmlentities}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{:__('Zone_status')}</td>
|
||||||
|
<td>{$row.zone_status_text|htmlentities}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{:__('Ns_status')}</td>
|
||||||
|
<td>{$row.ns_status_text|htmlentities}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{:__('Dns_status')}</td>
|
||||||
|
<td>{$row.dns_status_text|htmlentities}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{:__('Nameservers')}</td>
|
||||||
|
<td>
|
||||||
|
{if !empty($row.nameservers_list)}
|
||||||
|
<ul class="domain-ns-list">
|
||||||
|
{volist name="row.nameservers_list" id="ns"}
|
||||||
|
<li>{$ns|htmlentities}</li>
|
||||||
|
{/volist}
|
||||||
|
</ul>
|
||||||
|
<p class="text-muted">{:__('Domain guide content')}</p>
|
||||||
|
{else/}
|
||||||
|
<span class="text-muted">{:__('None')}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{:__('Check_time')}</td>
|
||||||
|
<td>{if $row.check_time}{$row.check_time|datetime}{else/}{:__('None')}{/if}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{:__('Check_result')}</td>
|
||||||
|
<td>{$row.check_result|default=''|htmlentities}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{:__('Createtime')}</td>
|
||||||
|
<td>{$row.createtime|datetime}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="hide layer-footer">
|
||||||
|
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||||
|
<div class="col-xs-12 col-sm-8">
|
||||||
|
<button type="reset" class="btn btn-primary btn-embossed btn-close" onclick="Layer.closeAll();">{:__('Close')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
<div class="panel panel-default panel-intro">
|
||||||
|
{:build_heading()}
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
<div id="myTabContent" class="tab-content">
|
||||||
|
<div class="tab-pane fade active in" id="one">
|
||||||
|
<div class="widget-body no-padding">
|
||||||
|
<div id="toolbar" class="toolbar">
|
||||||
|
{:build_toolbar('refresh,add')}
|
||||||
|
</div>
|
||||||
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
data-operate-edit="false"
|
||||||
|
data-operate-del="false"
|
||||||
|
width="100%">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
<?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'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合检测三项状态
|
||||||
|
* Zone/NS 检测通过后,自动将根域名 A 记录指向 server_ip 并校验
|
||||||
|
*
|
||||||
|
* @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 {
|
||||||
|
try {
|
||||||
|
if ($this->hasRootCnameConflict($zoneId, $domain)) {
|
||||||
|
$dnsStatus = 'failed';
|
||||||
|
$messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录';
|
||||||
|
} else {
|
||||||
|
$this->upsertRootARecord($zoneId, $domain, $this->serverIp);
|
||||||
|
if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) {
|
||||||
|
$dnsStatus = 'created';
|
||||||
|
$messages[] = 'DNS:已创建(A=' . $this->serverIp . ')';
|
||||||
|
} else {
|
||||||
|
$dnsStatus = 'pending';
|
||||||
|
$messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$dnsStatus = 'failed';
|
||||||
|
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
'proxied' => false,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$recordId = (string)($record['id'] ?? '');
|
||||||
|
$content = (string)($record['content'] ?? '');
|
||||||
|
if ($recordId === '' || $content === $ip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [
|
||||||
|
'content' => $ip,
|
||||||
|
'ttl' => 1,
|
||||||
|
'proxied' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool
|
||||||
|
{
|
||||||
|
$records = $this->listRootARecords($zoneId, $domain);
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if ((string)($record['content'] ?? '') === $ip) {
|
||||||
|
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)), '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use think\Cache;
|
||||||
|
use think\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 域名检测频率限制
|
||||||
|
* 每分钟最多 2 次,超限后冷却 120 秒
|
||||||
|
*/
|
||||||
|
class DomainDetectRateLimitService
|
||||||
|
{
|
||||||
|
/** 统计窗口(秒) */
|
||||||
|
private const WINDOW_SECONDS = 60;
|
||||||
|
|
||||||
|
/** 窗口内最大检测次数 */
|
||||||
|
private const MAX_ATTEMPTS = 2;
|
||||||
|
|
||||||
|
/** 超限后冷却时间(秒) */
|
||||||
|
private const COOLDOWN_SECONDS = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验是否允许检测,不允许时抛出异常
|
||||||
|
*
|
||||||
|
* @param int $adminId 管理员 ID
|
||||||
|
* @param int $domainId 域名记录 ID
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function assertCanDetect(int $adminId, int $domainId): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$baseKey = 'domain_detect:' . $adminId . ':' . $domainId;
|
||||||
|
$cooldownKey = $baseKey . ':cooldown';
|
||||||
|
$attemptsKey = $baseKey . ':attempts';
|
||||||
|
|
||||||
|
$cooldownUntil = (int)Cache::get($cooldownKey, 0);
|
||||||
|
if ($cooldownUntil > $now) {
|
||||||
|
$wait = $cooldownUntil - $now;
|
||||||
|
throw new Exception(sprintf('检测过于频繁,请 %d 秒后再试', $wait));
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamps = Cache::get($attemptsKey);
|
||||||
|
if (!is_array($timestamps)) {
|
||||||
|
$timestamps = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamps = array_values(array_filter($timestamps, static function ($ts) use ($now): bool {
|
||||||
|
return is_int($ts) && ($now - $ts) < self::WINDOW_SECONDS;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (count($timestamps) >= self::MAX_ATTEMPTS) {
|
||||||
|
Cache::set($cooldownKey, $now + self::COOLDOWN_SECONDS, self::COOLDOWN_SECONDS);
|
||||||
|
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
|
||||||
|
throw new Exception(sprintf(
|
||||||
|
'一分钟最多检测 %d 次,请 %d 秒后再试',
|
||||||
|
self::MAX_ATTEMPTS,
|
||||||
|
self::COOLDOWN_SECONDS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamps[] = $now;
|
||||||
|
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use think\Env;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare 配置,优先读取 .env
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'api_token' => Env::get('cloudflare.api_token', ''),
|
||||||
|
'account_id' => Env::get('cloudflare.account_id', ''),
|
||||||
|
'server_ip' => Env::get('cloudflare.server_ip', ''),
|
||||||
|
];
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return array (
|
return array (
|
||||||
'name' => 'LINK S 多功能打粉平台',
|
'name' => 'LINK-S 多功能打粉',
|
||||||
'beian' => '',
|
'beian' => '',
|
||||||
'cdnurl' => '',
|
'cdnurl' => '',
|
||||||
'version' => '1.0.1',
|
'version' => '1.0.1',
|
||||||
@@ -28,12 +28,6 @@ return array (
|
|||||||
'user' => 'User',
|
'user' => 'User',
|
||||||
'example' => 'Example',
|
'example' => 'Example',
|
||||||
),
|
),
|
||||||
'attachmentcategory' =>
|
|
||||||
array (
|
|
||||||
'category1' => 'Category1',
|
|
||||||
'category2' => 'Category2',
|
|
||||||
'custom' => 'Custom',
|
|
||||||
),
|
|
||||||
'mail_type' => '1',
|
'mail_type' => '1',
|
||||||
'mail_smtp_host' => 'smtp.qq.com',
|
'mail_smtp_host' => 'smtp.qq.com',
|
||||||
'mail_smtp_port' => '465',
|
'mail_smtp_port' => '465',
|
||||||
@@ -41,4 +35,10 @@ return array (
|
|||||||
'mail_smtp_pass' => '',
|
'mail_smtp_pass' => '',
|
||||||
'mail_verify_type' => '2',
|
'mail_verify_type' => '2',
|
||||||
'mail_from' => '',
|
'mail_from' => '',
|
||||||
|
'attachmentcategory' =>
|
||||||
|
array (
|
||||||
|
'category1' => 'Category1',
|
||||||
|
'category2' => 'Category2',
|
||||||
|
'custom' => 'Custom',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 部署域名检测频率限制(需 root)
|
||||||
|
set -e
|
||||||
|
BASE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PATCH="$BASE/patches"
|
||||||
|
|
||||||
|
cp -f "$PATCH/application/common/service/DomainDetectRateLimitService.php" "$BASE/application/common/service/"
|
||||||
|
cp -f "$PATCH/application/admin/controller/Domain.php" "$BASE/application/admin/controller/"
|
||||||
|
chown www:www \
|
||||||
|
"$BASE/application/common/service/DomainDetectRateLimitService.php" \
|
||||||
|
"$BASE/application/admin/controller/Domain.php"
|
||||||
|
php -l "$BASE/application/common/service/DomainDetectRateLimitService.php"
|
||||||
|
php -l "$BASE/application/admin/controller/Domain.php"
|
||||||
|
echo "检测频率限制部署完成"
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 部署 server_ip DNS 自动配置逻辑(需 root)
|
||||||
|
set -e
|
||||||
|
BASE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PATCH="$BASE/patches"
|
||||||
|
|
||||||
|
cp -f "$PATCH/application/extra/cloudflare.php" "$BASE/application/extra/cloudflare.php"
|
||||||
|
cp -f "$PATCH/application/common/service/CloudflareService.php" "$BASE/application/common/service/CloudflareService.php"
|
||||||
|
chown www:www "$BASE/application/extra/cloudflare.php" "$BASE/application/common/service/CloudflareService.php"
|
||||||
|
php -l "$BASE/application/common/service/CloudflareService.php"
|
||||||
|
echo "部署完成。请确认 .env 中 cloudflare.server_ip 已填写。"
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller;
|
||||||
|
|
||||||
|
use app\admin\model\Domain as DomainModel;
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use app\common\service\CloudflareService;
|
||||||
|
use app\common\service\DomainDetectRateLimitService;
|
||||||
|
use think\Db;
|
||||||
|
use think\Exception;
|
||||||
|
use think\exception\PDOException;
|
||||||
|
use think\exception\ValidateException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 域名管理
|
||||||
|
*
|
||||||
|
* @icon fa fa-globe
|
||||||
|
* @remark 域名接入Cloudflare,新增后请在详情查看NS并到注册商修改
|
||||||
|
*/
|
||||||
|
class Domain extends Backend
|
||||||
|
{
|
||||||
|
/** @var DomainModel */
|
||||||
|
protected $model = null;
|
||||||
|
|
||||||
|
protected $searchFields = 'domain';
|
||||||
|
protected $dataLimit = 'personal';
|
||||||
|
protected $modelValidate = true;
|
||||||
|
protected $modelSceneValidate = true;
|
||||||
|
|
||||||
|
public function _initialize()
|
||||||
|
{
|
||||||
|
parent::_initialize();
|
||||||
|
$this->model = new DomainModel();
|
||||||
|
$this->view->assign('zoneStatusList', $this->model->getZoneStatusList());
|
||||||
|
$this->view->assign('nsStatusList', $this->model->getNsStatusList());
|
||||||
|
$this->view->assign('dnsStatusList', $this->model->getDnsStatusList());
|
||||||
|
$this->assignconfig('zoneStatusList', $this->model->getZoneStatusList());
|
||||||
|
$this->assignconfig('nsStatusList', $this->model->getNsStatusList());
|
||||||
|
$this->assignconfig('dnsStatusList', $this->model->getDnsStatusList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁止编辑
|
||||||
|
*/
|
||||||
|
public function edit($ids = null)
|
||||||
|
{
|
||||||
|
$this->error(__('Domain cannot be edited after creation'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁止批量操作
|
||||||
|
*/
|
||||||
|
public function multi($ids = '')
|
||||||
|
{
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加域名并创建 Cloudflare Zone
|
||||||
|
*/
|
||||||
|
public function add()
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
return $this->view->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = $this->request->post('row/a', []);
|
||||||
|
if ($params === []) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = $this->preExcludeFields($params);
|
||||||
|
$domain = DomainModel::normalizeDomain((string)($params['domain'] ?? ''));
|
||||||
|
$params['domain'] = $domain;
|
||||||
|
|
||||||
|
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
||||||
|
$params[$this->dataLimitField] = $this->auth->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = false;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($this->modelValidate) {
|
||||||
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||||
|
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||||
|
$this->model->validateFailException()->validate($validate, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DomainModel::where('domain', $domain)->find()) {
|
||||||
|
throw new ValidateException(__('Domain already exists'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$cloudflare = new CloudflareService();
|
||||||
|
$zone = $cloudflare->createZone($domain);
|
||||||
|
|
||||||
|
$params['full_url'] = 'https://' . $domain;
|
||||||
|
$params['zone_id'] = $zone['zone_id'];
|
||||||
|
$params['nameservers'] = $zone['name_servers'];
|
||||||
|
$params['zone_status'] = DomainModel::mapCloudflareZoneStatus($zone['status']);
|
||||||
|
$params['ns_status'] = 'pending';
|
||||||
|
$params['dns_status'] = 'pending';
|
||||||
|
$params['check_result'] = '';
|
||||||
|
|
||||||
|
$result = $this->model->allowField(true)->save($params);
|
||||||
|
Db::commit();
|
||||||
|
} catch (ValidateException $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
$this->error(__('No rows were inserted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(__('Domain submitted, please check NS in detail page and update at registrar'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 域名详情
|
||||||
|
*/
|
||||||
|
public function detail($ids = null)
|
||||||
|
{
|
||||||
|
$row = $this->model->get($ids);
|
||||||
|
if (!$row) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
if (!$this->checkDataLimit($row)) {
|
||||||
|
$this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $row->toArray();
|
||||||
|
$data['nameservers_list'] = $row->getNameserversArray();
|
||||||
|
$this->view->assign('row', $data);
|
||||||
|
return $this->view->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测域名状态
|
||||||
|
*/
|
||||||
|
public function detect($ids = null)
|
||||||
|
{
|
||||||
|
if (!$this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->model->get($ids);
|
||||||
|
if (!$row) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
if (!$this->checkDataLimit($row)) {
|
||||||
|
$this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DomainDetectRateLimitService())->assertCanDetect((int)$this->auth->id, (int)$row['id']);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cloudflare = new CloudflareService();
|
||||||
|
$detect = $cloudflare->detectDomain($row->toArray());
|
||||||
|
$row->save([
|
||||||
|
'zone_status' => $detect['zone_status'],
|
||||||
|
'ns_status' => $detect['ns_status'],
|
||||||
|
'dns_status' => $detect['dns_status'],
|
||||||
|
'check_time' => time(),
|
||||||
|
'check_result' => $detect['check_result'],
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(__('Detection completed'), null, $row->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除(需输入完整域名二次确认)
|
||||||
|
*/
|
||||||
|
public function del($ids = '')
|
||||||
|
{
|
||||||
|
if (!$this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $ids ?: $this->request->post('ids', '');
|
||||||
|
$confirmDomain = trim((string)$this->request->post('confirm_domain', ''));
|
||||||
|
if ($ids === '' || $confirmDomain === '') {
|
||||||
|
$this->error(__('Please enter the full domain to confirm deletion'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$pk = $this->model->getPk();
|
||||||
|
$list = $this->model->where($pk, 'in', $ids)->select();
|
||||||
|
if (!$list || count($list) === 0) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
foreach ($list as $item) {
|
||||||
|
if (!$this->checkDataLimit($item)) {
|
||||||
|
throw new Exception(__('You have no permission'));
|
||||||
|
}
|
||||||
|
if (DomainModel::normalizeDomain($confirmDomain) !== DomainModel::normalizeDomain((string)$item['domain'])) {
|
||||||
|
throw new Exception(__('Domain confirmation does not match'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($item['zone_id'])) {
|
||||||
|
$cloudflare = new CloudflareService();
|
||||||
|
$cloudflare->deleteZone((string)$item['zone_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count += $item->delete();
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
$this->error(__('No rows were deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param DomainModel $row
|
||||||
|
*/
|
||||||
|
protected function checkDataLimit($row): bool
|
||||||
|
{
|
||||||
|
if (!$this->dataLimit) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (!is_array($adminIds)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return in_array((int)$row[$this->dataLimitField], $adminIds, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<?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'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合检测三项状态
|
||||||
|
* Zone/NS 检测通过后,自动将根域名 A 记录指向 server_ip 并校验
|
||||||
|
*
|
||||||
|
* @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 {
|
||||||
|
try {
|
||||||
|
if ($this->hasRootCnameConflict($zoneId, $domain)) {
|
||||||
|
$dnsStatus = 'failed';
|
||||||
|
$messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录';
|
||||||
|
} else {
|
||||||
|
$this->upsertRootARecord($zoneId, $domain, $this->serverIp);
|
||||||
|
if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) {
|
||||||
|
$dnsStatus = 'created';
|
||||||
|
$messages[] = 'DNS:已创建(A=' . $this->serverIp . ')';
|
||||||
|
} else {
|
||||||
|
$dnsStatus = 'pending';
|
||||||
|
$messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$dnsStatus = 'failed';
|
||||||
|
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
'proxied' => false,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$recordId = (string)($record['id'] ?? '');
|
||||||
|
$content = (string)($record['content'] ?? '');
|
||||||
|
if ($recordId === '' || $content === $ip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [
|
||||||
|
'content' => $ip,
|
||||||
|
'ttl' => 1,
|
||||||
|
'proxied' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool
|
||||||
|
{
|
||||||
|
$records = $this->listRootARecords($zoneId, $domain);
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if ((string)($record['content'] ?? '') === $ip) {
|
||||||
|
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)), '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use think\Cache;
|
||||||
|
use think\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 域名检测频率限制
|
||||||
|
* 每分钟最多 2 次,超限后冷却 120 秒
|
||||||
|
*/
|
||||||
|
class DomainDetectRateLimitService
|
||||||
|
{
|
||||||
|
/** 统计窗口(秒) */
|
||||||
|
private const WINDOW_SECONDS = 60;
|
||||||
|
|
||||||
|
/** 窗口内最大检测次数 */
|
||||||
|
private const MAX_ATTEMPTS = 2;
|
||||||
|
|
||||||
|
/** 超限后冷却时间(秒) */
|
||||||
|
private const COOLDOWN_SECONDS = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验是否允许检测,不允许时抛出异常
|
||||||
|
*
|
||||||
|
* @param int $adminId 管理员 ID
|
||||||
|
* @param int $domainId 域名记录 ID
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function assertCanDetect(int $adminId, int $domainId): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$baseKey = 'domain_detect:' . $adminId . ':' . $domainId;
|
||||||
|
$cooldownKey = $baseKey . ':cooldown';
|
||||||
|
$attemptsKey = $baseKey . ':attempts';
|
||||||
|
|
||||||
|
$cooldownUntil = (int)Cache::get($cooldownKey, 0);
|
||||||
|
if ($cooldownUntil > $now) {
|
||||||
|
$wait = $cooldownUntil - $now;
|
||||||
|
throw new Exception(sprintf('检测过于频繁,请 %d 秒后再试', $wait));
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamps = Cache::get($attemptsKey);
|
||||||
|
if (!is_array($timestamps)) {
|
||||||
|
$timestamps = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamps = array_values(array_filter($timestamps, static function ($ts) use ($now): bool {
|
||||||
|
return is_int($ts) && ($now - $ts) < self::WINDOW_SECONDS;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (count($timestamps) >= self::MAX_ATTEMPTS) {
|
||||||
|
Cache::set($cooldownKey, $now + self::COOLDOWN_SECONDS, self::COOLDOWN_SECONDS);
|
||||||
|
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
|
||||||
|
throw new Exception(sprintf(
|
||||||
|
'一分钟最多检测 %d 次,请 %d 秒后再试',
|
||||||
|
self::MAX_ATTEMPTS,
|
||||||
|
self::COOLDOWN_SECONDS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamps[] = $now;
|
||||||
|
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use think\Env;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare 配置,优先读取 .env
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'api_token' => Env::get('cloudflare.api_token', ''),
|
||||||
|
'account_id' => Env::get('cloudflare.account_id', ''),
|
||||||
|
'server_ip' => Env::get('cloudflare.server_ip', ''),
|
||||||
|
];
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||||
|
|
||||||
|
var Controller = {
|
||||||
|
index: function () {
|
||||||
|
Table.api.init({
|
||||||
|
extend: {
|
||||||
|
index_url: 'domain/index' + location.search,
|
||||||
|
add_url: 'domain/add',
|
||||||
|
edit_url: '',
|
||||||
|
del_url: '',
|
||||||
|
multi_url: '',
|
||||||
|
table: 'domain',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var table = $("#table");
|
||||||
|
|
||||||
|
table.bootstrapTable({
|
||||||
|
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||||
|
pk: 'id',
|
||||||
|
sortName: 'id',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
fixedColumns: true,
|
||||||
|
fixedRightNumber: 1,
|
||||||
|
columns: [
|
||||||
|
[
|
||||||
|
{checkbox: true},
|
||||||
|
{field: 'domain', title: __('Domain'), operate: 'LIKE'},
|
||||||
|
{field: 'full_url', title: __('Full_url'), operate: false, formatter: Table.api.formatter.url},
|
||||||
|
{field: 'zone_status', title: __('Zone_status'), searchList: Config.zoneStatusList, formatter: Table.api.formatter.status},
|
||||||
|
{field: 'ns_status', title: __('Ns_status'), searchList: Config.nsStatusList, formatter: Table.api.formatter.status},
|
||||||
|
{field: 'dns_status', title: __('Dns_status'), searchList: Config.dnsStatusList, formatter: Table.api.formatter.status},
|
||||||
|
{field: 'check_time', title: __('Check_time'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true},
|
||||||
|
{field: 'check_result', title: __('Check_result'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content},
|
||||||
|
{field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true},
|
||||||
|
{
|
||||||
|
field: 'operate',
|
||||||
|
title: __('Operate'),
|
||||||
|
table: table,
|
||||||
|
events: Table.api.events.operate,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
name: 'detail',
|
||||||
|
text: __('Detail'),
|
||||||
|
icon: 'fa fa-list',
|
||||||
|
classname: 'btn btn-info btn-xs btn-detail btn-dialog',
|
||||||
|
url: 'domain/detail'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'detect',
|
||||||
|
text: __('Detect'),
|
||||||
|
icon: 'fa fa-refresh',
|
||||||
|
classname: 'btn btn-success btn-xs btn-detect',
|
||||||
|
url: 'domain/detect',
|
||||||
|
refresh: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'delconfirm',
|
||||||
|
text: __('Del'),
|
||||||
|
icon: 'fa fa-trash',
|
||||||
|
classname: 'btn btn-danger btn-xs btn-delconfirm'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
formatter: Table.api.formatter.operate
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('click', '.btn-detect', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var that = this;
|
||||||
|
var rowIndex = $(that).data('row-index');
|
||||||
|
var row = Table.api.getrowbyindex(table, rowIndex);
|
||||||
|
if (!row) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$(that).addClass('disabled');
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'domain/detect/ids/' + row.id,
|
||||||
|
type: 'post'
|
||||||
|
}, function () {
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
return false;
|
||||||
|
}, function () {
|
||||||
|
$(that).removeClass('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('click', '.btn-delconfirm', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
var that = this;
|
||||||
|
var rowIndex = $(that).data('row-index');
|
||||||
|
var row = Table.api.getrowbyindex(table, rowIndex);
|
||||||
|
if (!row) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Layer.prompt({
|
||||||
|
title: __('Delete domain confirm prompt'),
|
||||||
|
formType: 0,
|
||||||
|
value: ''
|
||||||
|
}, function (value, index) {
|
||||||
|
if ($.trim(value) !== row.domain) {
|
||||||
|
Toastr.error(__('Delete domain confirm mismatch'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Layer.close(index);
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'domain/del',
|
||||||
|
data: {ids: row.id, confirm_domain: $.trim(value)}
|
||||||
|
}, function () {
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Table.api.bindevent(table);
|
||||||
|
},
|
||||||
|
add: function () {
|
||||||
|
Controller.api.bindevent();
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
bindevent: function () {
|
||||||
|
Form.api.bindevent($("form[role=form]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Controller;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user