소스 검색

Add Amazon, DNSimple, HuaweiCloud, Porkbun, Vercel, Vultr into DDNS

BrettonYe 1 년 전
부모
커밋
2f2e308979

+ 3 - 3
app/Utils/DDNS/AliYun.php

@@ -55,7 +55,7 @@ class AliYun implements DNS
         }
 
         if (empty($matched)) {
-            throw new RuntimeException("[AliYun – DescribeDomains] The subdomain {$this->subdomain} does not match any domain in your account.");
+            throw new RuntimeException('['.self::LABEL." — DescribeDomains] The subdomain $this->subdomain does not match any domain in your account.");
         }
 
         return [
@@ -86,9 +86,9 @@ class AliYun implements DNS
                 return $data;
             }
 
-            Log::error('[AliYun - '.$action.'] 返回错误信息:'.$data['Message'] ?? 'Unknown error');
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['Message'] ?? 'Unknown error');
         } else {
-            Log::error('[AliYun - '.$action.'] 请求失败');
+            Log::error('['.self::LABEL." — $action] 请求失败");
         }
 
         exit(400);

+ 198 - 0
app/Utils/DDNS/Amazon.php

@@ -0,0 +1,198 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Arr;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+use SimpleXMLElement;
+
+class Amazon implements DNS
+{
+    // 开发依据: https://docs.aws.amazon.com/zh_cn/Route53/latest/APIReference/Welcome.html
+    private const API_ENDPOINT = 'https://route53.amazonaws.com';
+
+    public const KEY = 'amazon';
+
+    public const LABEL = 'Amazon Route 53';
+
+    private string $accessKeyID;
+
+    private string $secretAccessKey;
+
+    private string $zoneID;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $this->accessKeyID = sysConfig('ddns_key');
+        $this->secretAccessKey = sysConfig('ddns_secret');
+        $this->zoneID = $this->getZoneIdentifier();
+    }
+
+    private function getZoneIdentifier(): string
+    {
+        $zones = Cache::remember('ddns_get_domains', now()->addHour(), function () {
+            return array_column($this->sendRequest('ListHostedZones')['HostedZones'] ?? [], 'Name', 'Id');
+        });
+
+        foreach ($zones as $zoneID => $zoneName) {
+            if (str_contains("$this->subdomain.", $zoneName)) {
+                return $zoneID;
+            }
+        }
+
+        throw new RuntimeException('['.self::LABEL." — ListHostedZones] The subdomain $this->subdomain does not match any domain in your account.");
+    }
+
+    private function sendRequest(string $action, string $payload = ''): array
+    {
+        $timestamp = time();
+        $client = Http::timeout(15)->retry(3, 1000)->withHeaders(['Host' => 'route53.amazonaws.com', 'X-Amz-Date' => gmdate("Ymd\THis\Z", $timestamp), 'Accept' => 'application/json'])->baseUrl(self::API_ENDPOINT);
+
+        $uri = match ($action) {
+            'ListHostedZones' => '/2013-04-01/hostedzone',
+            'ListResourceRecordSets', 'ChangeResourceRecordSets' => "/2013-04-01$this->zoneID/rrset",
+        };
+
+        $response = match ($action) {
+            'ListHostedZones', 'ListResourceRecordSets' => $client->withHeader('Authorization', $this->generateSignature('GET', $uri, $payload, $timestamp))->get($uri),
+            'ChangeResourceRecordSets' => $client->withHeader('Authorization', $this->generateSignature('POST', $uri, $payload, $timestamp))->withBody($payload, 'application/xml')->post($uri),
+        };
+
+        $data = $response->json();
+        if ($response->successful()) {
+            return $data;
+        }
+
+        if ($data) {
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['message'] ?? 'Unknown error');
+        } else {
+            Log::error('['.self::LABEL." — $action] 请求失败");
+        }
+
+        exit(400);
+    }
+
+    private function generateSignature(string $method, string $uri, string $payload, int $timestamp): string
+    { // 签名
+        $dateTime = gmdate("Ymd\THis\Z", $timestamp);
+        $date = gmdate('Ymd', $timestamp);
+        $canonicalRequest = "$method\n$uri\n\naccept:application/json\nhost:route53.amazonaws.com\nx-amz-date:$dateTime\n\naccept;host;x-amz-date\n".hash('sha256', $payload);
+        $credentialScope = "$date/us-east-1/route53/aws4_request";
+        $stringToSign = "AWS4-HMAC-SHA256\n$dateTime\n$credentialScope\n".hash('sha256', $canonicalRequest);
+        $dateKey = hash_hmac('SHA256', $date, 'AWS4'.$this->secretAccessKey, true);
+        $regionKey = hash_hmac('SHA256', 'us-east-1', $dateKey, true);
+        $serviceKey = hash_hmac('SHA256', 'route53', $regionKey, true);
+        $SigningKey = hash_hmac('SHA256', 'aws4_request', $serviceKey, true);
+        $signature = hash_hmac('SHA256', $stringToSign, $SigningKey);
+
+        return "AWS4-HMAC-SHA256 Credential=$this->accessKeyID/$credentialScope, SignedHeaders=accept;host;x-amz-date, Signature=$signature";
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $record = $this->getRecords($type, $original_ip);
+
+        if ($record) {
+            array_walk_recursive($record, static function (&$value) use ($latest_ip, $original_ip) {
+                if ($value === $original_ip) {
+                    $value = $latest_ip;
+                }
+            });
+
+            $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateChangeResourceRecordSetsXml('UPSERT', $record));
+
+            return isset($response['ChangeInfo']['Status']) && $response['ChangeInfo']['Status'] === 'PENDING';
+        }
+
+        return $this->store($latest_ip, $type);
+    }
+
+    private function getRecords(string $type, string $ip): array
+    {
+        $response = $this->sendRequest('ListResourceRecordSets');
+
+        if (isset($response['ResourceRecordSets'])) {
+            $records = $response['ResourceRecordSets'];
+
+            $filterCondition = function ($record) use ($type, $ip) {
+                if ($type && $record['Type'] !== $type) {
+                    return false;
+                }
+
+                if ($ip && ! in_array($ip, Arr::pluck($record['ResourceRecords'], 'Value'), true)) {
+                    return false;
+                }
+
+                return $record['Name'] === "$this->subdomain.";
+            };
+
+            $filteredRecords = array_filter($records, $filterCondition);
+
+            return array_map(static function ($record) {
+                $resourceValues = Arr::pluck($record['ResourceRecords'], 'Value');
+
+                return [
+                    'Name' => $record['Name'],
+                    'Type' => $record['Type'],
+                    'TTL' => $record['TTL'],
+                    'ResourceRecords' => $resourceValues,
+                ];
+            }, $filteredRecords);
+        }
+
+        return [];
+    }
+
+    private function generateChangeResourceRecordSetsXml(string $action, array $records): string
+    {
+        $xml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8" ?><ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"></ChangeResourceRecordSetsRequest>');
+
+        $changeBatch = $xml->addChild('ChangeBatch');
+        $changes = $changeBatch->addChild('Changes');
+
+        foreach ($records as $record) {
+            $change = $changes->addChild('Change');
+            $change->addChild('Action', $action);
+
+            $resourceRecordSet = $change->addChild('ResourceRecordSet');
+            $resourceRecordSet->addChild('Name', htmlspecialchars($record['Name'])); // Escape special characters in Name
+            $resourceRecordSet->addChild('Type', $record['Type']);
+            $resourceRecordSet->addChild('TTL', $record['TTL']);
+
+            $resourceRecords = $resourceRecordSet->addChild('ResourceRecords');
+            foreach ($record['ResourceRecords'] as $value) {
+                $resourceRecord = $resourceRecords->addChild('ResourceRecord');
+                $resourceRecord->addChild('Value', $value);
+            }
+        }
+
+        $dom = dom_import_simplexml($xml)->ownerDocument;
+        $dom->formatOutput = true;
+
+        return $dom->saveXML();
+    }
+
+    public function store(string $ip, string $type): bool
+    {
+        $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateChangeResourceRecordSetsXml('UPSERT', [['Name' => $this->subdomain, 'Type' => $type, 'TTL' => 300, 'ResourceRecords' => [$ip]]]));
+
+        return isset($response['ChangeInfo']['Status']) && $response['ChangeInfo']['Status'] === 'PENDING';
+    }
+
+    public function destroy(string $type, string $ip): bool
+    {
+        $records = $this->getRecords($type, $ip);
+
+        if ($records) {
+            $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateChangeResourceRecordSetsXml('DELETE', $records));
+
+            return isset($response['ChangeInfo']['Status']) && $response['ChangeInfo']['Status'] === 'PENDING';
+        }
+
+        return true;
+    }
+}

+ 6 - 6
app/Utils/DDNS/Baidu.php

@@ -18,7 +18,7 @@ class Baidu implements DNS
 
     public const LABEL = 'Baidu AI Cloud | 百度智能云';
 
-    private string $secretId;
+    private string $accessKey;
 
     private string $secretKey;
 
@@ -26,7 +26,7 @@ class Baidu implements DNS
 
     public function __construct(private readonly string $subdomain)
     {
-        $this->secretId = sysConfig('ddns_key');
+        $this->accessKey = sysConfig('ddns_key');
         $this->secretKey = sysConfig('ddns_secret');
         $this->domainInfo = $this->parseDomainInfo();
     }
@@ -42,7 +42,7 @@ class Baidu implements DNS
         }
 
         if (empty($matched)) {
-            throw new RuntimeException("[Baidu – DescribeDomains] The subdomain {$this->subdomain} does not match any domain in your account.");
+            throw new RuntimeException('['.self::LABEL." — DescribeDomains] The subdomain $this->subdomain does not match any domain in your account.");
         }
 
         return [
@@ -84,9 +84,9 @@ class Baidu implements DNS
         }
 
         if ($data) {
-            Log::error('[Baidu – '.$action.'] 返回错误信息:'.$data['message'] ?? 'Unknown error');
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['message'] ?? 'Unknown error');
         } else {
-            Log::error('[Baidu – '.$action.'] 请求失败');
+            Log::error('['.self::LABEL." — $action] 请求失败");
         }
 
         exit(400);
@@ -94,7 +94,7 @@ class Baidu implements DNS
 
     private function generateSignature(array $parameters, string $httpMethod, string $path, string $date): string
     { // 签名
-        $authStringPrefix = "bce-auth-v1/$this->secretId/$date/1800";
+        $authStringPrefix = "bce-auth-v1/$this->accessKey/$date/1800";
         $signingKey = hash_hmac('sha256', $authStringPrefix, $this->secretKey);
         $canonicalRequest = "$httpMethod\n$path\n".http_build_query($parameters)."\nhost:dns.baidubce.com\nx-bce-date:".rawurlencode($date);
         $signature = hash_hmac('sha256', $canonicalRequest, $signingKey);

+ 21 - 23
app/Utils/DDNS/CloudFlare.php

@@ -3,7 +3,6 @@
 namespace App\Utils\DDNS;
 
 use App\Utils\Library\Templates\DNS;
-use Arr;
 use Cache;
 use Http;
 use Log;
@@ -12,12 +11,12 @@ use RuntimeException;
 class CloudFlare implements DNS
 {
     // 开发依据: https://developers.cloudflare.com/api/
-    private string $apiEndpoint;
-
     public const KEY = 'cloudflare';
 
     public const LABEL = 'CloudFlare';
 
+    private string $apiEndpoint;
+
     private array $auth;
 
     public function __construct(private readonly string $subdomain)
@@ -26,25 +25,23 @@ class CloudFlare implements DNS
         $this->auth = ['X-Auth-Key' => sysConfig('ddns_secret'), 'X-Auth-Email' => sysConfig('ddns_key')];
         $zoneIdentifier = $this->getZoneIdentifier();
         if ($zoneIdentifier) {
-            $this->apiEndpoint .= $zoneIdentifier.'/dns_records/';
+            $this->apiEndpoint .= "$zoneIdentifier/dns_records/";
         }
     }
 
     private function getZoneIdentifier(): string
     {
         $zones = Cache::remember('ddns_get_domains', now()->addHour(), function () {
-            return $this->sendRequest('list');
+            return array_column($this->sendRequest('ListZones') ?? [], 'name', 'id');
         });
 
-        if ($zones) {
-            $matched = Arr::first($zones, fn ($zone) => str_contains($this->subdomain, $zone['name']));
-        }
-
-        if (empty($matched)) {
-            throw new RuntimeException("[CloudFlare – list] The subdomain {$this->subdomain} does not match any domain in your account.");
+        foreach ($zones as $zoneID => $zoneName) {
+            if (str_contains($this->subdomain, $zoneName)) {
+                return $zoneID;
+            }
         }
 
-        return $matched['id'];
+        throw new RuntimeException('['.self::LABEL." — ListZones] The subdomain $this->subdomain does not match any domain in your account.");
     }
 
     private function sendRequest(string $action, array $parameters = [], ?string $identifier = null): array
@@ -52,11 +49,11 @@ class CloudFlare implements DNS
         $client = Http::timeout(10)->retry(3, 1000)->withHeaders($this->auth)->baseUrl($this->apiEndpoint)->asJson();
 
         $response = match ($action) {
-            'list' => $client->get(''),
-            'get' => $client->get('', $parameters),
-            'create' => $client->post('', $parameters),
-            'update' => $client->put($identifier, $parameters),
-            'delete' => $client->delete($identifier),
+            'ListZones' => $client->get(''),
+            'ListDNSRecords' => $client->get('', $parameters),
+            'CreateDNSRecord' => $client->post('', $parameters),
+            'UpdateDNSRecord' => $client->put($identifier, $parameters),
+            'DeleteDNSRecord' => $client->delete($identifier),
         };
 
         $data = $response->json();
@@ -64,9 +61,10 @@ class CloudFlare implements DNS
             if ($data['success'] && $response->ok()) {
                 return $data['result'] ?? [];
             }
-            Log::error('[CloudFlare - '.$action.'] 返回错误信息:'.$data['errors'][0]['message'] ?? 'Unknown error');
+
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['errors'][0]['message'] ?? 'Unknown error');
         } else {
-            Log::error('[CloudFlare - '.$action.'] 请求失败');
+            Log::error('['.self::LABEL." — $action] 请求失败");
         }
 
         exit(400);
@@ -74,7 +72,7 @@ class CloudFlare implements DNS
 
     public function store(string $ip, string $type): bool
     {
-        $result = $this->sendRequest('create', ['content' => $ip, 'name' => $this->subdomain, 'type' => $type]);
+        $result = $this->sendRequest('CreateDNSRecord', ['content' => $ip, 'name' => $this->subdomain, 'type' => $type]);
 
         return ! empty($result);
     }
@@ -84,7 +82,7 @@ class CloudFlare implements DNS
         $recordIds = $this->getRecordIds($type, $original_ip);
 
         if ($recordIds) {
-            $ret = $this->sendRequest('update', ['content' => $latest_ip, 'name' => $this->subdomain, 'type' => $type], $recordIds[0]);
+            $ret = $this->sendRequest('UpdateDNSRecord', ['content' => $latest_ip, 'name' => $this->subdomain, 'type' => $type], $recordIds[0]);
         }
 
         return (bool) ($ret ?? false);
@@ -92,7 +90,7 @@ class CloudFlare implements DNS
 
     private function getRecordIds(string $type, string $ip): array|false
     {
-        $records = $this->sendRequest('get', ['content' => $ip, 'name' => $this->subdomain, 'type' => $type]);
+        $records = $this->sendRequest('ListDNSRecords', ['content' => $ip, 'name' => $this->subdomain, 'type' => $type]);
 
         if ($records) {
             return array_column($records, 'id');
@@ -107,7 +105,7 @@ class CloudFlare implements DNS
         $deletedCount = 0;
 
         foreach ($recordIds as $recordId) {
-            if ($this->sendRequest('delete', [], $recordId)) {
+            if ($this->sendRequest('DeleteDNSRecord', [], $recordId)) {
                 $deletedCount++;
             }
         }

+ 7 - 7
app/Utils/DDNS/DNSPod.php

@@ -11,14 +11,14 @@ use RuntimeException;
 
 class DNSPod implements DNS
 {
-    // 开发依据: https://docs.dnspod.cn/api/
+    // 开发依据: https://cloud.tencent.com/document/api/1427/56193
     private const API_ENDPOINT = 'https://dnspod.tencentcloudapi.com';
 
     public const KEY = 'dnspod';
 
     public const LABEL = 'Tencent Cloud | DNSPod | 腾讯云';
 
-    private string $secretId;
+    private string $secretID;
 
     private string $secretKey;
 
@@ -26,7 +26,7 @@ class DNSPod implements DNS
 
     public function __construct(private readonly string $subdomain)
     {
-        $this->secretId = sysConfig('ddns_key');
+        $this->secretID = sysConfig('ddns_key');
         $this->secretKey = sysConfig('ddns_secret');
         $this->domainInfo = $this->parseDomainInfo();
     }
@@ -42,7 +42,7 @@ class DNSPod implements DNS
         }
 
         if (empty($matched)) {
-            throw new RuntimeException("[DNSPod – DescribeDomainList] The subdomain {$this->subdomain} does not match any domain in your account.");
+            throw new RuntimeException('['.self::LABEL." — DescribeDomainList] The subdomain $this->subdomain does not match any domain in your account.");
         }
 
         return [
@@ -69,9 +69,9 @@ class DNSPod implements DNS
                 return $data;
             }
 
-            Log::error('[DNSPod – '.$action.'] 返回错误信息:'.$data['Error']['Message'] ?? 'Unknown error');
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['Error']['Message'] ?? 'Unknown error');
         } else {
-            Log::error('[DNSPod – '.$action.'] 请求失败');
+            Log::error('['.self::LABEL." — $action] 请求失败");
         }
 
         exit(400);
@@ -90,7 +90,7 @@ class DNSPod implements DNS
         $secretSigning = hash_hmac('SHA256', 'tc3_request', $secretService, true);
         $signature = hash_hmac('SHA256', $stringToSign, $secretSigning);
 
-        return 'TC3-HMAC-SHA256 Credential='.$this->secretId.'/'.$credentialScope.', SignedHeaders=content-type;host, Signature='.$signature;
+        return 'TC3-HMAC-SHA256 Credential='.$this->secretID.'/'.$credentialScope.', SignedHeaders=content-type;host, Signature='.$signature;
     }
 
     public function store(string $ip, string $type): bool

+ 131 - 0
app/Utils/DDNS/DNSimple.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Arr;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+
+class DNSimple implements DNS
+{
+    // 开发依据: https://developer.dnsimple.com/v2/
+    private const API_ENDPOINT = 'https://api.dnsimple.com/v2/';
+
+    public const KEY = 'dnsimple';
+
+    public const LABEL = 'DNSimple';
+
+    private string $accountID;
+
+    private string $accessToken;
+
+    private array $domainInfo;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $this->accountID = sysConfig('ddns_key');
+        $this->accessToken = sysConfig('ddns_secret');
+        $this->domainInfo = $this->parseDomainInfo();
+    }
+
+    private function parseDomainInfo(): array
+    {
+        $domains = Cache::remember('ddns_get_domains', now()->addHour(), function () {
+            return array_column($this->sendRequest('ListDomains') ?? [], 'name');
+        });
+
+        if ($domains) {
+            $matched = Arr::first($domains, fn ($domain) => str_contains($this->subdomain, $domain));
+        }
+
+        if (empty($matched)) {
+            throw new RuntimeException('['.self::LABEL." — ListDomains] The subdomain $this->subdomain does not match any domain in your account.");
+        }
+
+        return [
+            'sub' => rtrim(substr($this->subdomain, 0, -strlen($matched)), '.'),
+            'domain' => $matched,
+        ];
+    }
+
+    private function sendRequest(string $action, array $parameters = [], string $recordId = ''): bool|array
+    {
+        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "Bearer $this->accessToken")->baseUrl(self::API_ENDPOINT.$this->accountID)->asJson();
+
+        $response = match ($action) {
+            'ListDomains' => $client->get('/domains'),
+            'ListZoneRecords' => $client->get("/zones/{$this->domainInfo['domain']}/records", $parameters),
+            'CreateZoneRecord' => $client->post("/zones/{$this->domainInfo['domain']}/records", $parameters),
+            'UpdateZoneRecord' => $client->patch("/zones/{$this->domainInfo['domain']}/records/$recordId", $parameters),
+            'DeleteZoneRecord' => $client->delete("/zones/{$this->domainInfo['domain']}/records/$recordId"),
+        };
+
+        $data = $response->json();
+        if ($response->successful()) {
+            return $data['data'] ?? true;
+        }
+
+        if ($data) {
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['errors']['base'] ?? $data['message'] ?? 'Unknown error');
+        } else {
+            Log::error('['.self::LABEL." — $action] 请求失败");
+        }
+
+        exit(400);
+    }
+
+    public function store(string $ip, string $type): bool
+    {
+        return (bool) $this->sendRequest('CreateZoneRecord', ['name' => $this->domainInfo['sub'], 'type' => $type, 'content' => $ip]);
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $recordIds = $this->getRecordIds($type, $original_ip);
+        if ($recordIds) {
+            $this->sendRequest('UpdateZoneRecord', ['content' => $latest_ip], $recordIds[0]);
+
+            return true;
+        }
+
+        return false;
+    }
+
+    private function getRecordIds(string $type, string $ip): array
+    {
+        $parameter = ['name' => $this->domainInfo['sub']];
+        if ($type) {
+            $parameter['type'] = $type;
+        }
+        $records = $this->sendRequest('ListZoneRecords', $parameter);
+
+        if ($records) {
+            if ($ip) {
+                $records = array_filter($records, static function ($record) use ($ip) {
+                    return $record['content'] === $ip;
+                });
+            }
+
+            return array_column($records, 'id');
+        }
+
+        return [];
+    }
+
+    public function destroy(string $type, string $ip): int|bool
+    {
+        $recordIds = $this->getRecordIds($type, $ip);
+        $deletedCount = 0;
+
+        foreach ($recordIds as $recordId) {
+            if ($this->sendRequest('DeleteZoneRecord', $recordId)) {
+                $deletedCount++;
+            }
+        }
+
+        return $deletedCount;
+    }
+}

+ 14 - 14
app/Utils/DDNS/DigitalOcean.php

@@ -11,20 +11,20 @@ use RuntimeException;
 
 class DigitalOcean implements DNS
 {
-    //  开发依据:https://docs.digitalocean.com/products/networking/dns/how-to/manage-records/
+    // 开发依据: https://docs.digitalocean.com/products/networking/dns/how-to/manage-records/
     private const API_ENDPOINT = 'https://api.digitalocean.com/v2/domains';
 
     public const KEY = 'digitalocean';
 
     public const LABEL = 'DigitalOcean';
 
-    private string $accessKeySecret;
+    private string $accessToken;
 
     private array $domainInfo;
 
     public function __construct(private readonly string $subdomain)
     {
-        $this->accessKeySecret = sysConfig('ddns_secret');
+        $this->accessToken = sysConfig('ddns_secret');
         $this->domainInfo = $this->parseDomainInfo();
     }
 
@@ -39,7 +39,7 @@ class DigitalOcean implements DNS
         }
 
         if (empty($matched)) {
-            throw new RuntimeException("[DigitalOcean – DescribeDomains] The subdomain {$this->subdomain} does not match any domain in your account.");
+            throw new RuntimeException('['.self::LABEL." — DescribeDomains] The subdomain $this->subdomain does not match any domain in your account.");
         }
 
         return [
@@ -48,16 +48,16 @@ class DigitalOcean implements DNS
         ];
     }
 
-    private function sendRequest(string $action, array $parameters = []): array|bool
+    private function sendRequest(string $action, array $parameters = [], string $recordId = ''): array|bool
     {
-        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "Bearer $this->accessKeySecret")->baseUrl(self::API_ENDPOINT)->asJson();
+        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "Bearer $this->accessToken")->baseUrl(self::API_ENDPOINT)->asJson();
 
         $response = match ($action) {
             'DescribeDomains' => $client->get(''),
             'DescribeSubDomainRecords' => $client->get("/{$this->domainInfo['domain']}/records"),
-            'AddDomainRecord' => $client->post("/{$this->domainInfo['domain']}/records", $parameters),
-            'UpdateDomainRecord' => $client->patch("/{$this->domainInfo['domain']}/records/{$parameters['domainRecordId']}", $parameters['data']),
-            'DeleteDomainRecord' => $client->delete("/{$this->domainInfo['domain']}/records/{$parameters['domainRecordId']}"),
+            'CreateDomainRecord' => $client->post("/{$this->domainInfo['domain']}/records", $parameters),
+            'UpdateDomainRecord' => $client->patch("/{$this->domainInfo['domain']}/records/$recordId", $parameters),
+            'DeleteDomainRecord' => $client->delete("/{$this->domainInfo['domain']}/records/$recordId"),
         };
 
         $data = $response->json();
@@ -66,9 +66,9 @@ class DigitalOcean implements DNS
         }
 
         if ($data) {
-            Log::error('[DigitalOcean - '.$action.'] 返回错误信息:'.$data['message'] ?? 'Unknown error');
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['message'] ?? 'Unknown error');
         } else {
-            Log::error('[DigitalOcean - '.$action.'] 请求失败');
+            Log::error('['.self::LABEL." — $action] 请求失败");
         }
 
         exit(400);
@@ -76,7 +76,7 @@ class DigitalOcean implements DNS
 
     public function store(string $ip, string $type): bool
     {
-        return (bool) $this->sendRequest('AddDomainRecord', ['name' => $this->domainInfo['sub'], 'type' => $type, 'data' => $ip]);
+        return (bool) $this->sendRequest('CreateDomainRecord', ['name' => $this->domainInfo['sub'], 'type' => $type, 'data' => $ip]);
     }
 
     public function update(string $latest_ip, string $original_ip, string $type): bool
@@ -84,7 +84,7 @@ class DigitalOcean implements DNS
         $recordIds = $this->getRecordIds($type, $original_ip);
         if ($recordIds) {
             foreach ($recordIds as $recordId) {
-                $this->sendRequest('UpdateDomainRecord', ['domainRecordId' => $recordId, 'data' => ['type' => $type, 'data' => $latest_ip]]);
+                $this->sendRequest('UpdateDomainRecord', ['type' => $type, 'data' => $latest_ip], $recordId);
             }
 
             return true;
@@ -126,7 +126,7 @@ class DigitalOcean implements DNS
         $deletedCount = 0;
 
         foreach ($recordIds as $recordId) {
-            if ($this->sendRequest('DeleteDomainRecord', ['domainRecordId' => $recordId])) {
+            if ($this->sendRequest('DeleteDomainRecord', [], $recordId)) {
                 $deletedCount++;
             }
         }

+ 9 - 9
app/Utils/DDNS/GoDaddy.php

@@ -11,23 +11,23 @@ use RuntimeException;
 
 class GoDaddy implements DNS
 {
-    //  开发依据: https://developer.godaddy.com/doc/endpoint/domains
+    // 开发依据: https://developer.godaddy.com/doc/endpoint/domains
     private const API_ENDPOINT = 'https://api.godaddy.com/v1/domains/';
 
     public const KEY = 'godaddy';
 
     public const LABEL = 'GoDaddy';
 
-    private string $accessKeyID;
+    private string $key;
 
-    private string $accessKeySecret;
+    private string $secret;
 
     private array $domainInfo;
 
     public function __construct(private readonly string $subdomain)
     {
-        $this->accessKeyID = sysConfig('ddns_key');
-        $this->accessKeySecret = sysConfig('ddns_secret');
+        $this->key = sysConfig('ddns_key');
+        $this->secret = sysConfig('ddns_secret');
         $this->domainInfo = $this->parseDomainInfo();
     }
 
@@ -42,7 +42,7 @@ class GoDaddy implements DNS
         }
 
         if (empty($matched)) {
-            throw new RuntimeException("[GoDaddy – DescribeDomains] The subdomain {$this->subdomain} does not match any domain in your account.");
+            throw new RuntimeException('['.self::LABEL." — DescribeDomains] The subdomain $this->subdomain does not match any domain in your account.");
         }
 
         return [
@@ -53,7 +53,7 @@ class GoDaddy implements DNS
 
     private function sendRequest(string $action, array $parameters = []): array|bool
     {
-        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "sso-key $this->accessKeyID:$this->accessKeySecret")->baseUrl(self::API_ENDPOINT)->asJson();
+        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "sso-key $this->key:$this->secret")->baseUrl(self::API_ENDPOINT)->asJson();
 
         $response = match ($action) {
             'DescribeDomains' => $client->get('', ['statuses' => 'ACTIVE']),
@@ -69,9 +69,9 @@ class GoDaddy implements DNS
         }
 
         if ($data) {
-            Log::error('[GoDaddy - '.$action.'] 返回错误信息:'.$data['message'] ?? 'Unknown error');
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['message'] ?? 'Unknown error');
         } else {
-            Log::error('[GoDaddy - '.$action.'] 请求失败');
+            Log::error('['.self::LABEL." — $action] 请求失败");
         }
 
         return false;

+ 136 - 0
app/Utils/DDNS/HuaweiCloud.php

@@ -0,0 +1,136 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+
+class HuaweiCloud implements DNS
+{
+    // 开发依据: https://support.huaweicloud.com/api-dns/dns_api_64001.html
+    private const API_ENDPOINT = 'https://dns.myhuaweicloud.com';
+
+    public const KEY = 'huaweicloud';
+
+    public const LABEL = 'HuaweiCloud 华为云';
+
+    private string $accessKeyID;
+
+    private string $secretAccessKey;
+
+    private string $zoneID;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $this->accessKeyID = sysConfig('ddns_key');
+        $this->secretAccessKey = sysConfig('ddns_secret');
+        $this->zoneID = $this->getZoneIdentifier();
+    }
+
+    private function getZoneIdentifier(): string
+    {
+        $zones = Cache::remember('ddns_get_domains', now()->addHour(), function () {
+            return array_column($this->sendRequest('ListPublicZones')['zones'] ?? [], 'name', 'id');
+        });
+
+        foreach ($zones as $zoneID => $zoneName) {
+            if (str_contains("$this->subdomain.", $zoneName)) {
+                return $zoneID;
+            }
+        }
+
+        throw new RuntimeException('['.self::LABEL." — ListPublicZones] The subdomain $this->subdomain does not match any domain in your account.");
+    }
+
+    private function sendRequest(string $action, array $parameters = [], array $payload = [], string $recordsetId = ''): array
+    {
+        $date = gmdate("Ymd\THis\Z");
+        $client = Http::timeout(15)->retry(3, 1000)->withHeaders(['Host' => 'dns.myhuaweicloud.com', 'X-Sdk-Date' => $date])->baseUrl(self::API_ENDPOINT)->asJson();
+
+        $uri = match ($action) {
+            'ListPublicZones' => '/v2/zones',
+            'ListRecordSets' => '/v2/recordsets',
+            'CreateRecordSet' => "/v2/zones/$this->zoneID/recordsets",
+            'UpdateRecordSet' => "/v2/zones/$this->zoneID/recordsets/$recordsetId",
+            'DeleteRecordSets' => "/v2.1/zones/$this->zoneID/recordsets",
+        };
+
+        $response = match ($action) {
+            'ListPublicZones', 'ListRecordSets' => $client->withHeader('Authorization', $this->generateSignature('GET', $uri, $parameters, $payload, $date))->get($uri, $parameters),
+            'CreateRecordSet' => $client->withHeader('Authorization', $this->generateSignature('POST', $uri, $parameters, $payload, $date))->post($uri, $payload),
+            'UpdateRecordSet' => $client->withHeader('Authorization', $this->generateSignature('PUT', $uri, $parameters, $payload, $date))->put($uri, $payload),
+            'DeleteRecordSets' => $client->withHeader('Authorization', $this->generateSignature('DELETE', $uri, $parameters, $payload, $date))->delete($uri, $payload),
+        };
+
+        $data = $response->json();
+        if ($response->successful()) {
+            return $data;
+        }
+
+        if ($data) {
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['error_msg'] ?? 'Unknown error');
+        } else {
+            Log::error('['.self::LABEL." — $action] 请求失败");
+        }
+
+        exit(400);
+    }
+
+    private function generateSignature(string $method, string $uri, array $parameters, array $payload, string $date): string
+    { // 签名
+        $canonicalRequest = "$method\n$uri/\n".http_build_query($parameters)."\nhost:dns.myhuaweicloud.com\nx-sdk-date:$date\n\nhost;x-sdk-date\n".hash('sha256', $payload ? json_encode($payload) : '');
+        $stringToSign = "SDK-HMAC-SHA256\n$date\n".hash('sha256', $canonicalRequest);
+        $signature = hash_hmac('SHA256', $stringToSign, $this->secretAccessKey);
+
+        return "SDK-HMAC-SHA256 Access=$this->accessKeyID, SignedHeaders=host;x-sdk-date, Signature=$signature";
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $recordIds = $this->getRecordIds($type, $original_ip);
+
+        if ($recordIds) {
+            $response = $this->sendRequest('UpdateRecordSet', [], ['name' => "$this->subdomain.", 'type' => $type, 'records' => [$latest_ip]], $recordIds[0]);
+
+            return isset($response['status']) && $response['status'] === 'PENDING_UPDATE';
+        }
+
+        return $this->store($latest_ip, $type);
+    }
+
+    private function getRecordIds(string $type, string $ip): array
+    {
+        $response = $this->sendRequest('ListRecordSets', ['name' => "$this->subdomain.", 'records' => $ip, 'type' => $type]);
+
+        if (isset($response['recordsets'])) {
+            $records = $response['recordsets'];
+
+            return array_column($records, 'id');
+        }
+
+        return [];
+    }
+
+    public function store(string $ip, string $type): bool
+    {
+        $response = $this->sendRequest('CreateRecordSet', [], ['name' => "$this->subdomain.", 'type' => $type, 'records' => [$ip]]);
+
+        return isset($response['status']) && $response['status'] === 'PENDING_CREATE';
+    }
+
+    public function destroy(string $type, string $ip): int
+    {
+        $recordIds = $this->getRecordIds($type, $ip);
+
+        if ($recordIds) {
+            $response = $this->sendRequest('DeleteRecordSets', [], ['recordset_ids' => $recordIds]);
+
+            return $response['metadata']['total_count'];
+        }
+
+        return true;
+    }
+}

+ 13 - 12
app/Utils/DDNS/Namecheap.php

@@ -19,9 +19,9 @@ class Namecheap implements DNS
 
     public const LABEL = 'Namecheap';
 
-    private string $accessKeyID;
+    private string $username;
 
-    private string $accessKeySecret;
+    private string $apiKey;
 
     private array $domainInfo;
 
@@ -29,8 +29,8 @@ class Namecheap implements DNS
 
     public function __construct(private readonly string $subdomain)
     {
-        $this->accessKeyID = sysConfig('ddns_key');
-        $this->accessKeySecret = sysConfig('ddns_secret');
+        $this->username = sysConfig('ddns_key');
+        $this->apiKey = sysConfig('ddns_secret');
         $this->domainInfo = $this->parseDomainInfo();
         $this->domainRecords = $this->fetchDomainRecords();
     }
@@ -48,7 +48,7 @@ class Namecheap implements DNS
         }
 
         if (empty($matched)) {
-            throw new RuntimeException("[Namecheap – domains.getList] The subdomain {$this->subdomain} does not match any domain in your account.");
+            throw new RuntimeException('['.self::LABEL." — domains.getList] The subdomain $this->subdomain does not match any domain in your account.");
         }
 
         $domainParts = explode('.', $matched);
@@ -60,14 +60,14 @@ class Namecheap implements DNS
         ];
     }
 
-    private function sendRequest(string $command, array $parameters = []): array
+    private function sendRequest(string $action, array $parameters = []): array
     {
         $parameters = array_merge([
-            'ApiUser' => $this->accessKeyID,
-            'ApiKey' => $this->accessKeySecret,
-            'UserName' => $this->accessKeyID,
+            'ApiUser' => $this->username,
+            'ApiKey' => $this->apiKey,
+            'UserName' => $this->username,
             'ClientIp' => IP::getClientIP(),
-            'Command' => $command,
+            'Command' => $action,
         ], $parameters);
 
         $response = Http::timeout(15)->retry(3, 1000)->get(self::API_ENDPOINT, $parameters);
@@ -77,9 +77,10 @@ class Namecheap implements DNS
             if ($response->successful() && $data['@attributes']['Status'] === 'OK') {
                 return $data['CommandResponse'];
             }
-            Log::error('[Namecheap - '.$command.'] 返回错误信息:'.$data['Errors']['Error'] ?? 'Unknown error');
+
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['Errors']['Error'] ?? 'Unknown error');
         } else {
-            Log::error('[Namecheap - '.$command.'] 请求失败');
+            Log::error('['.self::LABEL." — $action] 请求失败");
         }
 
         exit(400);

+ 6 - 6
app/Utils/DDNS/Namesilo.php

@@ -23,7 +23,7 @@ class Namesilo implements DNS
 
     public function __construct(private readonly string $subdomain)
     {
-        $this->apiKey = sysConfig('ddns_key');
+        $this->apiKey = sysConfig('ddns_secret');
         $this->domainInfo = $this->parseDomainInfo();
     }
 
@@ -38,7 +38,7 @@ class Namesilo implements DNS
         }
 
         if (empty($matched)) {
-            throw new RuntimeException("[Namesilo – listDomains] The subdomain {$this->subdomain} does not match any domain in your account.");
+            throw new RuntimeException('['.self::LABEL." — listDomains] The subdomain $this->subdomain does not match any domain in your account.");
         }
 
         return [
@@ -47,9 +47,9 @@ class Namesilo implements DNS
         ];
     }
 
-    private function sendRequest(string $operation, array $parameters = []): array
+    private function sendRequest(string $action, array $parameters = []): array
     {
-        $request = simplexml_load_string(file_get_contents(self::API_ENDPOINT.$operation.'?'.Arr::query(array_merge(['version' => 1, 'type' => 'xml', 'key' => $this->apiKey], $parameters))));
+        $request = simplexml_load_string(file_get_contents(self::API_ENDPOINT.$action.'?'.Arr::query(array_merge(['version' => 1, 'type' => 'xml', 'key' => $this->apiKey], $parameters))));
 
         if ($request) {
             $data = json_decode(json_encode($request), true);
@@ -57,9 +57,9 @@ class Namesilo implements DNS
                 return $data['reply'];
             }
 
-            Log::error('[Namesilo – '.$operation.'] 返回错误信息:'.$data['reply']['detail'] ?? 'Unknown error');
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['reply']['detail'] ?? 'Unknown error');
         } else {
-            Log::error('[Namesilo – '.$operation.'] 请求失败');
+            Log::error('['.self::LABEL." — $action] 请求失败");
         }
 
         exit(400);

+ 136 - 0
app/Utils/DDNS/Porkbun.php

@@ -0,0 +1,136 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Arr;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+
+class Porkbun implements DNS
+{
+    // 开发依据: https://porkbun.com/api/json/v3/documentation
+    private const API_ENDPOINT = 'https://porkbun.com/api/json/v3/';
+
+    public const KEY = 'porkbun';
+
+    public const LABEL = 'Porkbun';
+
+    private string $apiKey;
+
+    private string $secretKey;
+
+    private array $domainInfo;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $this->apiKey = sysConfig('ddns_key');
+        $this->secretKey = sysConfig('ddns_secret');
+        $this->domainInfo = $this->parseDomainInfo();
+    }
+
+    private function parseDomainInfo(): array
+    {
+        $domains = Cache::remember('ddns_get_domains', now()->addHour(), function () {
+            return array_column($this->sendRequest('domain/listAll')['domains'] ?? [], 'domain');
+        });
+
+        if ($domains) {
+            $matched = Arr::first($domains, fn ($domain) => str_contains($this->subdomain, $domain));
+        }
+
+        if (empty($matched)) {
+            throw new RuntimeException('['.self::LABEL." — domain/listAll] The subdomain $this->subdomain does not match any domain in your account.");
+        }
+
+        return [
+            'sub' => rtrim(substr($this->subdomain, 0, -strlen($matched)), '.'),
+            'domain' => $matched,
+        ];
+    }
+
+    private function sendRequest(string $uri, array $parameters = []): bool|array
+    {
+        $parameters = array_merge($parameters, ['apikey' => $this->apiKey, 'secretapikey' => $this->secretKey]);
+        $response = Http::timeout(15)->retry(3, 1000)->baseUrl(self::API_ENDPOINT)->asJson()->post($uri, $parameters);
+
+        $data = $response->json();
+        if ($response->successful()) {
+            return $data ?? true;
+        }
+
+        if ($data) {
+            Log::error('['.self::LABEL." — $uri] 返回错误信息: ".$data['message'] ?? 'Unknown error');
+        } else {
+            Log::error('['.self::LABEL." — $uri] 请求失败");
+        }
+
+        exit(400);
+    }
+
+    public function store(string $ip, string $type): bool
+    {
+        return (bool) $this->sendRequest("dns/create/{$this->domainInfo['domain']}", ['name' => $this->domainInfo['sub'], 'type' => $type, 'content' => $ip]);
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $recordIds = $this->getRecordIds($type, $original_ip);
+        if ($recordIds) {
+            $this->sendRequest("dns/edit/{$this->domainInfo['domain']}/$recordIds[0]", ['type' => $type, 'content' => $latest_ip]);
+
+            return true;
+        }
+
+        return false;
+    }
+
+    private function getRecordIds(string $type, string $ip): array
+    {
+        if ($type) {
+            $response = $this->sendRequest("dns/retrieveByNameType/{$this->domainInfo['domain']}/$type/{$this->domainInfo['sub']}");
+        } else {
+            $response = $this->sendRequest("dns/retrieve/{$this->domainInfo['domain']}");
+        }
+
+        if (isset($response['records'])) {
+            $records = $response['records'];
+
+            if (! $type) {
+                $records = array_filter($records, function ($record) {
+                    return $record['name'] === $this->subdomain;
+                });
+            }
+
+            if ($ip) {
+                $records = array_filter($records, static function ($record) use ($ip) {
+                    return $record['content'] === $ip;
+                });
+            }
+
+            return array_column($records, 'id');
+        }
+
+        return [];
+    }
+
+    public function destroy(string $type, string $ip): int|bool
+    {
+        if (! $ip) {
+            return $this->sendRequest("dns/deleteByNameType/{$this->domainInfo['domain']}/$type/{$this->domainInfo['sub']}");
+        }
+
+        $recordIds = $this->getRecordIds($type, $ip);
+        $deletedCount = 0;
+
+        foreach ($recordIds as $recordId) {
+            if ($this->sendRequest("dns/delete/{$this->domainInfo['domain']}/$recordId")) {
+                $deletedCount++;
+            }
+        }
+
+        return $deletedCount;
+    }
+}

+ 137 - 0
app/Utils/DDNS/Vercel.php

@@ -0,0 +1,137 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Arr;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+
+class Vercel implements DNS
+{
+    // 开发依据: https://vercel.com/docs/rest-api
+    private const API_ENDPOINT = 'https://api.vercel.com/';
+
+    public const KEY = 'vercel';
+
+    public const LABEL = 'Vercel';
+
+    private string $teamID;
+
+    private string $token;
+
+    private array $domainInfo;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $this->teamID = sysConfig('ddns_key');
+        $this->token = sysConfig('ddns_secret');
+        $this->domainInfo = $this->parseDomainInfo();
+    }
+
+    private function parseDomainInfo(): array
+    {
+        $domains = Cache::remember('ddns_get_domains', now()->addHour(), function () {
+            return array_column($this->sendRequest('DescribeDomains')['domains'] ?? [], 'name');
+        });
+
+        if ($domains) {
+            $matched = Arr::first($domains, fn ($domain) => str_contains($this->subdomain, $domain));
+        }
+
+        if (empty($matched)) {
+            throw new RuntimeException('['.self::LABEL." — DescribeDomains] The subdomain $this->subdomain does not match any domain in your account.");
+        }
+
+        return [
+            'sub' => rtrim(substr($this->subdomain, 0, -strlen($matched)), '.'),
+            'domain' => $matched,
+        ];
+    }
+
+    private function sendRequest(string $action, array $parameters = [], string $recordId = ''): array
+    {
+        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "Bearer $this->token")->baseUrl(self::API_ENDPOINT)->withQueryParameters(['teamId' => $this->teamID])->asJson();
+
+        $response = match ($action) {
+            'DescribeDomains' => $client->get('v5/domains'),
+            'DescribeSubDomainRecords' => $client->get("v4/domains/{$this->domainInfo['domain']}/records"),
+            'AddDomainRecord' => $client->post("v2/domains/{$this->domainInfo['domain']}/records", $parameters),
+            'UpdateDomainRecord' => $client->patch("v1/domains/records/$recordId", $parameters),
+            'DeleteDomainRecord' => $client->delete("v2/domains/{$this->domainInfo['domain']}/records/$recordId"),
+        };
+
+        $data = $response->json();
+        if ($response->successful()) {
+            return $data;
+        }
+
+        if ($data) {
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['error']['message'] ?? 'Unknown error');
+        } else {
+            Log::error('['.self::LABEL." — $action] 请求失败");
+        }
+
+        exit(400);
+    }
+
+    public function store(string $ip, string $type): bool
+    {
+        return (bool) $this->sendRequest('AddDomainRecord', ['name' => $this->domainInfo['sub'], 'type' => $type, 'value' => $ip]);
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $recordIds = $this->getRecordIds($type, $original_ip);
+        if ($recordIds) {
+            $this->sendRequest('UpdateDomainRecord', ['value' => $latest_ip], $recordIds[0]);
+
+            return true;
+        }
+
+        return false;
+    }
+
+    private function getRecordIds(string $type, string $ip): array
+    {
+        $response = $this->sendRequest('DescribeSubDomainRecords');
+
+        if (isset($response['records'])) {
+            $records = $response['records'];
+
+            if ($ip) {
+                $records = array_filter($records, function ($record) use ($ip) {
+                    return $record['value'] === $ip && $record['name'] === $this->domainInfo['sub'];
+                });
+            } elseif ($type) {
+                $records = array_filter($records, function ($record) use ($type) {
+                    return $record['type'] === $type && $record['name'] === $this->domainInfo['sub'];
+                });
+            } else {
+                $records = array_filter($records, function ($record) {
+                    return $record['name'] === $this->domainInfo['sub'];
+                });
+            }
+
+            return array_column($records, 'id');
+        }
+
+        return [];
+    }
+
+    public function destroy(string $type, string $ip): bool|int
+    {
+        $recordIds = $this->getRecordIds($type, $ip);
+        $deletedCount = 0;
+
+        foreach ($recordIds as $recordId) {
+            if ($this->sendRequest('DeleteDomainRecord', $recordId) === []) {
+                $deletedCount++;
+            }
+        }
+
+        return $deletedCount;
+    }
+}

+ 134 - 0
app/Utils/DDNS/Vultr.php

@@ -0,0 +1,134 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Arr;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+
+class Vultr implements DNS
+{
+    // 开发依据: https://www.vultr.com/api/#tag/dns
+    private const API_ENDPOINT = 'https://api.vultr.com/v2/';
+
+    public const KEY = 'vultr';
+
+    public const LABEL = 'Vultr';
+
+    private string $apiKey;
+
+    private array $domainInfo;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $this->apiKey = sysConfig('ddns_secret');
+        $this->domainInfo = $this->parseDomainInfo();
+    }
+
+    private function parseDomainInfo(): array
+    {
+        $domains = Cache::remember('ddns_get_domains', now()->addHour(), function () {
+            return array_column($this->sendRequest('ListDNSDomains')['domains'] ?? [], 'domain');
+        });
+
+        if ($domains) {
+            $matched = Arr::first($domains, fn ($domain) => str_contains($this->subdomain, $domain));
+        }
+
+        if (empty($matched)) {
+            throw new RuntimeException('['.self::LABEL." — ListDNSDomains] The subdomain $this->subdomain does not match any domain in your account.");
+        }
+
+        return [
+            'sub' => rtrim(substr($this->subdomain, 0, -strlen($matched)), '.'),
+            'domain' => $matched,
+        ];
+    }
+
+    private function sendRequest(string $action, array $parameters = [], string $recordId = ''): bool|array
+    {
+        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "Bearer $this->apiKey")->baseUrl(self::API_ENDPOINT)->asJson();
+
+        $response = match ($action) {
+            'ListDNSDomains' => $client->get('/domains'),
+            'ListRecords' => $client->get("/domains/{$this->domainInfo['domain']}/records", $parameters),
+            'CreateRecord' => $client->post("/domains/{$this->domainInfo['domain']}/records", $parameters),
+            'UpdateRecord' => $client->patch("/domains/{$this->domainInfo['domain']}/records/$recordId", $parameters),
+            'DeleteRecord' => $client->delete("/domains/{$this->domainInfo['domain']}/records/$recordId"),
+        };
+
+        $data = $response->json();
+        if ($response->successful()) {
+            return $data ?? true;
+        }
+
+        if ($data) {
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['error'] ?? 'Unknown error');
+        } else {
+            Log::error('['.self::LABEL." — $action] 请求失败");
+        }
+
+        exit(400);
+    }
+
+    public function store(string $ip, string $type): bool
+    {
+        return (bool) $this->sendRequest('CreateRecord', ['name' => $this->domainInfo['sub'], 'type' => $type, 'data' => $ip]);
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $recordIds = $this->getRecordIds($type, $original_ip);
+        if ($recordIds) {
+            $this->sendRequest('UpdateRecord', ['data' => $latest_ip], $recordIds[0]);
+
+            return true;
+        }
+
+        return false;
+    }
+
+    private function getRecordIds(string $type, string $ip): array
+    {
+        $response = $this->sendRequest('ListRecords');
+
+        if (isset($response['records'])) {
+            $records = $response['records'];
+
+            if ($ip) {
+                $records = array_filter($records, function ($record) use ($ip) {
+                    return $record['data'] === $ip && $record['name'] === $this->domainInfo['sub'];
+                });
+            } elseif ($type) {
+                $records = array_filter($records, function ($record) use ($type) {
+                    return $record['type'] === $type && $record['name'] === $this->domainInfo['sub'];
+                });
+            } else {
+                $records = array_filter($records, function ($record) {
+                    return $record['name'] === $this->domainInfo['sub'];
+                });
+            }
+
+            return array_column($records, 'id');
+        }
+
+        return [];
+    }
+
+    public function destroy(string $type, string $ip): int|bool
+    {
+        $recordIds = $this->getRecordIds($type, $ip);
+        $deletedCount = 0;
+
+        foreach ($recordIds as $recordId) {
+            if ($this->sendRequest('DeleteRecord', $recordId)) {
+                $deletedCount++;
+            }
+        }
+
+        return $deletedCount;
+    }
+}