Pārlūkot izejas kodu

Add More Options into DDNS & improve existing one

- Improve Amazon DDNS to handle more complex cases;
- Add Azure as new DDNS option;
- Add ClouDNS as new DDNS option;
- Add Google Cloud DNS as new DDNS option;

All DDNS setting information is here, https://proxypanel.gitbook.io/wiki/ddns
BrettonYe 1 gadu atpakaļ
vecāks
revīzija
5131417ebc

+ 0 - 2
app/Exceptions/Handler.php

@@ -46,8 +46,6 @@ class Handler extends ExceptionHandler
     {
         if (config('app.debug')) { // 调试模式下记录错误详情
             Log::debug('来源:'.url()->full().PHP_EOL.'访问者IP:'.IP::getClientIP().PHP_EOL.$exception);
-        } else {
-            Log::error('来源:'.url()->full().PHP_EOL.'访问者IP:'.IP::getClientIP().get_class($exception)); // 记录异常来源
         }
 
         parent::report($exception);

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

@@ -11,7 +11,7 @@ use RuntimeException;
 
 class AliYun implements DNS
 {
-    //  开发依据: https://api.aliyun.com/document/Alidns/2015-01-09/overview
+    // 开发依据: https://api.aliyun.com/document/Alidns/2015-01-09/overview
     private const API_ENDPOINT = 'https://alidns.aliyuncs.com/';
 
     public const KEY = 'aliyun';

+ 69 - 54
app/Utils/DDNS/Amazon.php

@@ -77,96 +77,92 @@ class Amazon implements DNS
     }
 
     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);
+
+        $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);
+        $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);
+        $records = $this->getRecords($type);
 
-        if ($record) {
-            array_walk_recursive($record, static function (&$value) use ($latest_ip, $original_ip) {
-                if ($value === $original_ip) {
-                    $value = $latest_ip;
-                }
-            });
+        if ($records) {
+            $recordCount = count($records[$type]);
+            $records[$type] = array_filter($records[$type], static fn ($ip) => $ip !== $original_ip);
+            $requiredAction = count($records[$type]) !== $recordCount;
+        }
+
+        if (! in_array($latest_ip, $records[$type] ?? [], true)) {
+            $records[$type][] = $latest_ip;
+            $requiredAction = true;
+        }
 
-            $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateChangeResourceRecordSetsXml('UPSERT', $record));
+        if ($requiredAction ?? false) {
+            $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateXml('UPSERT', $records));
 
             return isset($response['ChangeInfo']['Status']) && $response['ChangeInfo']['Status'] === 'PENDING';
         }
 
-        return $this->store($latest_ip, $type);
+        return true;
     }
 
-    private function getRecords(string $type, string $ip): array
+    private function getRecords(string $type): array
     {
         $response = $this->sendRequest('ListResourceRecordSets');
 
-        if (isset($response['ResourceRecordSets'])) {
-            $records = $response['ResourceRecordSets'];
+        if (! isset($response['ResourceRecordSets'])) {
+            return [];
+        }
 
-            $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;
-                }
+        $records = $response['ResourceRecordSets'];
 
+        if ($type) {
+            $records = array_filter($records, function ($record) use ($type) {
+                return $record['Type'] === $type && $record['Name'] === "$this->subdomain.";
+            });
+        } else {
+            $records = array_filter($records, function ($record) {
                 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 [];
+        return array_reduce($records, static function ($carry, $record) {
+            $carry[$record['Type']] = Arr::pluck($record['ResourceRecords'], 'Value');
+
+            return $carry;
+        }, []);
     }
 
-    private function generateChangeResourceRecordSetsXml(string $action, array $records): string
+    private function generateXml(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) {
+        foreach ($records as $type => $ips) {
             $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']);
+            $resourceRecordSet->addChild('Name', "$this->subdomain.");
+            $resourceRecordSet->addChild('Type', $type);
+            $resourceRecordSet->addChild('TTL', 300);
 
             $resourceRecords = $resourceRecordSet->addChild('ResourceRecords');
-            foreach ($record['ResourceRecords'] as $value) {
+            foreach ($ips as $ip) {
                 $resourceRecord = $resourceRecords->addChild('ResourceRecord');
-                $resourceRecord->addChild('Value', $value);
+                $resourceRecord->addChild('Value', $ip);
             }
         }
 
@@ -178,21 +174,40 @@ class Amazon implements DNS
 
     public function store(string $ip, string $type): bool
     {
-        $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateChangeResourceRecordSetsXml('UPSERT', [['Name' => $this->subdomain, 'Type' => $type, 'TTL' => 300, 'ResourceRecords' => [$ip]]]));
+        $records = $this->getRecords($type);
 
-        return isset($response['ChangeInfo']['Status']) && $response['ChangeInfo']['Status'] === 'PENDING';
+        if (! in_array($ip, $records[$type] ?? [], true)) {
+            $records[$type][] = $ip;
+            $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateXml('UPSERT', $records));
+
+            return isset($response['ChangeInfo']['Status']) && $response['ChangeInfo']['Status'] === 'PENDING';
+        }
+
+        return true;
     }
 
     public function destroy(string $type, string $ip): bool
     {
-        $records = $this->getRecords($type, $ip);
+        $records = $this->getRecords($type);
 
-        if ($records) {
-            $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateChangeResourceRecordSetsXml('DELETE', $records));
+        if (! $records) {
+            return true; // 无记录可操作,直接返回
+        }
 
-            return isset($response['ChangeInfo']['Status']) && $response['ChangeInfo']['Status'] === 'PENDING';
+        if ($type && $ip) {
+            $filteredRecords = array_filter($records[$type], static fn ($hasIp) => $hasIp !== $ip);
+            if (count($records[$type]) !== count($filteredRecords)) {
+                if (count($filteredRecords) !== 0) {
+                    $action = 'UPSERT';
+                    $records[$type] = $filteredRecords;
+                }
+            } else {
+                return true;
+            }
         }
 
-        return true;
+        $response = $this->sendRequest('ChangeResourceRecordSets', $this->generateXml($action ?? 'DELETE', $records));
+
+        return isset($response['ChangeInfo']['Status']) && $response['ChangeInfo']['Status'] === 'PENDING';
     }
 }

+ 168 - 0
app/Utils/DDNS/Azure.php

@@ -0,0 +1,168 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Arr;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+
+class Azure implements DNS
+{
+    // 开发依据: https://learn.microsoft.com/en-us/rest/api/eventhub/get-azure-active-directory-token?view=rest-dns-2018-05-01 https://learn.microsoft.com/en-us/rest/api/dns/?view=rest-dns-2018-05-01
+    private const API_ENDPOINT = 'https://management.azure.com/';
+
+    public const KEY = 'azure';
+
+    public const LABEL = 'Microsoft Azure';
+
+    private string $tenantId;
+
+    private string $clientId;
+
+    private string $clientSecret;
+
+    private string $token;
+
+    private string $subscriptionId;
+
+    private string $zoneID;
+
+    private array $domainInfo;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $ids = explode(',', sysConfig('ddns_key'));
+        $this->tenantId = $ids[1];
+        $this->clientId = $ids[2];
+        $this->clientSecret = sysConfig('ddns_secret');
+        $this->subscriptionId = $ids[0];
+        $this->token = $this->getBearerToken();
+        $this->zoneID = $this->getZoneIdentifier();
+    }
+
+    private function getBearerToken(): string
+    {
+        return Cache::remember('azure_token', 3599, function () {
+            $response = Http::timeout(15)->retry(3, 1000)->asForm()->post("https://login.microsoftonline.com/$this->tenantId/oauth2/token",
+                ['grant_type' => 'client_credentials', 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, 'resource' => self::API_ENDPOINT]);
+
+            if ($response->successful() && $data = $response->json()) {
+                return $data['access_token'];
+            }
+
+            exit(400);
+        });
+    }
+
+    private function getZoneIdentifier(): string
+    {
+        $zones = Cache::remember('ddns_get_domains', now()->addHour(), function () {
+            return array_column($this->sendRequest('ListZones')['value'] ?? [], 'name', 'id');
+        });
+
+        foreach ($zones as $zoneID => $zoneName) {
+            if (str_contains($this->subdomain, $zoneName)) {
+                $this->domainInfo = [
+                    'sub' => rtrim(substr($this->subdomain, 0, -strlen($zoneName)), '.'),
+                    'domain' => $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 $payload = [], string $type = 'A'): array|bool
+    {
+        $client = Http::timeout(15)->retry(3, 1000)->withToken($this->token)->baseUrl(self::API_ENDPOINT)->withQueryParameters(['api-version' => '2018-05-01'])->asJson();
+
+        $response = match ($action) {
+            'ListZones' => $client->get("/subscriptions/$this->subscriptionId/providers/Microsoft.Network/dnszones"),
+            'ListRecordSets' => $client->get("$this->zoneID/".($type ?: 'all')),
+            'CreateRecordSet' => $client->put("$this->zoneID/$type/{$this->domainInfo['sub']}", $payload),
+            'DeleteRecordSet' => $client->delete("$this->zoneID/$type/{$this->domainInfo['sub']}"),
+        };
+
+        $data = $response->json();
+        if ($response->successful()) {
+            return $data ?: true;
+        }
+
+        if ($data) {
+            Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['message'] ?? 'Unknown error');
+        } else {
+            Log::error('['.self::LABEL." — $action] 请求失败");
+        }
+
+        exit(400);
+    }
+
+    public function store(string $ip, string $type): bool
+    {
+        $ips = $this->getRecordIps($type);
+        if (! in_array($ip, $ips, true)) {
+            $ips[] = $ip;
+
+            return $this->updateRecord($type, $ips);
+        }
+
+        return true;
+    }
+
+    private function getRecordIps(string $type): array
+    { // 域名信息
+        $records = $this->sendRequest('ListRecordSets', [], $type)['value'] ?? [];
+
+        $records = array_filter($records, function ($record) {
+            return $record['name'] === $this->domainInfo['sub'];
+        });
+
+        return Arr::flatten(Arr::first($records)['properties']["{$type}Records"] ?? []);
+    }
+
+    private function updateRecord(string $type, array $ips): bool
+    {
+        $ipKey = $type === 'A' ? 'ipv4Address' : 'ipv6Address';
+
+        $ipInfo = array_map(static function ($ip) use ($ipKey) {
+            return [$ipKey => $ip];
+        }, $ips);
+
+        return (bool) $this->sendRequest('CreateRecordSet', ['properties' => ['TTL' => 300, "{$type}Records" => $ipInfo]]);
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $ips = $this->getRecordIps($type);
+
+        if ($ips) {
+            $ips = array_filter($ips, static fn ($ip) => $ip !== $original_ip);
+        }
+
+        $ips[] = $latest_ip;
+
+        return $this->updateRecord($type, $ips);
+    }
+
+    public function destroy(string $type, string $ip): bool
+    {
+        if (! $type) {
+            return $this->sendRequest('DeleteRecordSet') && $this->sendRequest('DeleteRecordSet', [], 'AAAA');
+        }
+
+        if ($ip) {
+            $ips = array_filter($this->getRecordIps($type), static fn ($hasIp) => $hasIp !== $ip);
+
+            if ($ips) {
+                return $this->updateRecord($type, $ips);
+            }
+        }
+
+        return (bool) $this->sendRequest('DeleteRecordSet', [], $type);
+    }
+}

+ 119 - 0
app/Utils/DDNS/ClouDNS.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Arr;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+
+class ClouDNS implements DNS
+{
+    // 开发依据: https://www.cloudns.net/wiki/article/41/
+    private const API_ENDPOINT = 'https://api.cloudns.net/dns/';
+
+    public const KEY = 'cloudns';
+
+    public const LABEL = 'ClouDNS';
+
+    private string $authID;
+
+    private string $authPassword;
+
+    private array $domainInfo;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $this->authID = sysConfig('ddns_key');
+        $this->authPassword = 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('list-zones', ['page' => 1, 'rows-per-page' => 100]) ?? [], '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 = []): array
+    {
+        $response = Http::timeout(15)->get(self::API_ENDPOINT."$action.json", array_merge(['auth-id' => $this->authID, 'auth-password' => $this->authPassword], $parameters));
+
+        if ($response->successful()) {
+            $data = $response->json();
+            if (isset($data['status']) && $data['status'] === 'Failed') {
+                Log::error('['.self::LABEL." — $action] 返回错误信息: ".$data['statusDescription'] ?? 'Unknown error');
+            } else {
+                return $data;
+            }
+        } else {
+            Log::error('['.self::LABEL." — $action] 请求失败");
+        }
+
+        exit(400);
+    }
+
+    public function store(string $ip, string $type): bool
+    {
+        $result = $this->sendRequest('add-record', ['domain-name' => $this->domainInfo['domain'], 'record-type' => $type, 'host' => $this->domainInfo['sub'], 'record' => $ip, 'ttl' => 300]);
+
+        return $result['status'] === 'Success';
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $recordIds = $this->getRecordIds($type, $original_ip);
+        if ($recordIds) {
+            $ret = $this->sendRequest('mod-record', ['domain-name' => $this->domainInfo['domain'], 'record-id' => $recordIds[0], 'host' => $this->domainInfo['sub'], 'record' => $latest_ip, 'ttl' => 300]);
+            if (count($recordIds) > 1) {
+                $this->destroy($type, $original_ip);
+            }
+        }
+
+        return ($ret['status'] ?? false) === 'Success';
+    }
+
+    private function getRecordIds(string $type, string $ip): array
+    { // 域名信息
+        $records = $this->sendRequest('records', ['domain-name' => $this->domainInfo['domain'], 'host' => $this->domainInfo['sub'], 'type' => $type]) ?? [];
+
+        if ($ip) {
+            $records = array_filter($records, static function ($record) use ($ip) {
+                return $record['record'] === $ip;
+            });
+        }
+
+        return array_column($records, 'id');
+    }
+
+    public function destroy(string $type, string $ip): int
+    {
+        $recordIds = $this->getRecordIds($type, $ip);
+        $deletedCount = 0;
+
+        foreach ($recordIds as $recordId) {
+            $result = $this->sendRequest('delete-record', ['domain-name' => $this->domainInfo['domain'], 'record-id' => $recordId]);
+            if (isset($result['status']) && $result['status'] === 'Success') {
+                $deletedCount++;
+            }
+        }
+
+        return $deletedCount;
+    }
+}

+ 2 - 2
app/Utils/DDNS/DNSimple.php

@@ -51,9 +51,9 @@ class DNSimple implements DNS
         ];
     }
 
-    private function sendRequest(string $action, array $parameters = [], string $recordId = ''): bool|array
+    private function sendRequest(string $action, array $parameters = [], string $recordId = ''): array|bool
     {
-        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "Bearer $this->accessToken")->baseUrl(self::API_ENDPOINT.$this->accountID)->asJson();
+        $client = Http::timeout(15)->retry(3, 1000)->withToken($this->accessToken)->baseUrl(self::API_ENDPOINT.$this->accountID)->asJson();
 
         $response = match ($action) {
             'ListDomains' => $client->get('/domains'),

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

@@ -50,7 +50,7 @@ class DigitalOcean implements DNS
 
     private function sendRequest(string $action, array $parameters = [], string $recordId = ''): array|bool
     {
-        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "Bearer $this->accessToken")->baseUrl(self::API_ENDPOINT)->asJson();
+        $client = Http::timeout(15)->retry(3, 1000)->withToken($this->accessToken)->baseUrl(self::API_ENDPOINT)->asJson();
 
         $response = match ($action) {
             'DescribeDomains' => $client->get(''),

+ 178 - 0
app/Utils/DDNS/Google.php

@@ -0,0 +1,178 @@
+<?php
+
+namespace App\Utils\DDNS;
+
+use App\Utils\Library\Templates\DNS;
+use Arr;
+use Cache;
+use Http;
+use Log;
+use RuntimeException;
+
+class Google implements DNS
+{
+    // 开发依据: https://developers.google.com/identity/protocols/oauth2/service-account?hl=zh-cn#httprest https://cloud.google.com/dns/docs/apis?hl=zh-cn
+    public const KEY = 'google';
+
+    public const LABEL = 'Google Cloud DNS';
+
+    private string $apiEndpoint = 'https://dns.googleapis.com/dns/v1/projects/';
+
+    private array $credentials;
+
+    private string $token;
+
+    private string $zoneID;
+
+    public function __construct(private readonly string $subdomain)
+    {
+        $this->credentials = json_decode(sysConfig('ddns_secret'), true);
+        if (! $this->credentials) {
+            exit(400);
+        }
+        $this->apiEndpoint .= "{$this->credentials['project_id']}/";
+        $this->token = $this->getBearerToken();
+        $this->zoneID = $this->getZoneIdentifier();
+    }
+
+    private function getBearerToken(): string
+    {
+        return Cache::remember('google_token', 3599, function () {
+            $response = Http::timeout(15)->asForm()->post('https://oauth2.googleapis.com/token', ['grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $this->generateJWT()]);
+
+            if ($response->successful() && $data = $response->json()) {
+                return $data['access_token'];
+            }
+
+            exit(400);
+        });
+    }
+
+    private function generateJWT(): string
+    {
+        $headerEncoded = base64url_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT', 'kid' => $this->credentials['private_key_id']]));
+
+        $now = time();
+        $payloadEncoded = base64url_encode(json_encode([
+            'iss' => $this->credentials['client_email'],
+            'scope' => 'https://www.googleapis.com/auth/ndev.clouddns.readwrite',
+            'aud' => 'https://oauth2.googleapis.com/token',
+            'iat' => $now,
+            'exp' => $now + 3600,
+        ]));
+
+        $dataToSign = "$headerEncoded.$payloadEncoded";
+        openssl_sign($dataToSign, $signature, $this->credentials['private_key'], OPENSSL_ALGO_SHA256);
+        $signatureEncoded = base64url_encode($signature);
+
+        return "$dataToSign.$signatureEncoded";
+    }
+
+    private function getZoneIdentifier(): string
+    {
+        $zones = Cache::remember('ddns_get_domains', now()->addHour(), function () {
+            return array_column($this->sendRequest('ListZones')['managedZones'] ?? [], 'dnsName', '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 = [], string $type = 'A'): array|bool
+    {
+        $client = Http::timeout(15)->retry(3, 1000)->withToken($this->token)->baseUrl($this->apiEndpoint)->withQueryParameters(['api-version' => '2018-05-01'])->asJson();
+
+        $response = match ($action) {
+            'ListZones' => $client->get('managedZones'),
+            'ListRecordSets' => $client->get("managedZones/$this->zoneID/rrsets", $parameters),
+            'CreateRecordSet' => $client->post("managedZones/$this->zoneID/rrsets", $parameters),
+            'PatchRecordSet' => $client->patch("managedZones/$this->zoneID/rrsets/$this->subdomain./$type", $parameters),
+            'DeleteRecordSet' => $client->delete("managedZones/$this->zoneID/rrsets/$this->subdomain./$type"),
+        };
+
+        $data = $response->json();
+        if ($response->successful()) {
+            return $data ?: true;
+        }
+
+        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
+    {
+        $ips = $this->getRecordIps($type);
+
+        if (! $ips) {
+            return (bool) $this->sendRequest('CreateRecordSet', ['kind' => 'dns#resourceRecordSet', 'name' => "$this->subdomain.", 'type' => $type, 'ttl' => 300, 'rrdatas' => [$ip]]);
+        }
+
+        if (! in_array($ip, $ips, true)) {
+            $ips[] = $ip;
+
+            return $this->updateRecord($type, $ips);
+        }
+
+        return true;
+    }
+
+    private function getRecordIps(string $type): array
+    {
+        $parameters = ['name' => "$this->subdomain."];
+        if ($type) {
+            $parameters['type'] = $type;
+        }
+        $records = $this->sendRequest('ListRecordSets', $parameters)['rrsets'] ?? [];
+
+        if ($records) {
+            return Arr::first($records)['rrdatas'] ?? [];
+        }
+
+        return [];
+    }
+
+    private function updateRecord(string $type, array $ips): bool
+    {
+        return (bool) $this->sendRequest('PatchRecordSet', ['kind' => 'dns#resourceRecordSet', 'name' => "$this->subdomain.", 'type' => $type, 'ttl' => 300, 'rrdatas' => array_values($ips)]);
+    }
+
+    public function update(string $latest_ip, string $original_ip, string $type): bool
+    {
+        $ips = $this->getRecordIps($type);
+
+        if ($ips) {
+            $ips = array_filter($ips, static fn ($ip) => $ip !== $original_ip);
+        }
+
+        $ips[] = $latest_ip;
+
+        return $this->updateRecord($type, $ips);
+    }
+
+    public function destroy(string $type, string $ip): bool
+    {
+        if (! $type) {
+            return $this->sendRequest('DeleteRecordSet') && $this->sendRequest('DeleteRecordSet', [], 'AAAA');
+        }
+
+        if ($ip) {
+            $ips = array_filter($this->getRecordIps($type), static fn ($hasIp) => $hasIp !== $ip);
+
+            if ($ips) {
+                return $this->updateRecord($type, $ips);
+            }
+        }
+
+        return (bool) $this->sendRequest('DeleteRecordSet', [], $type);
+    }
+}

+ 1 - 1
app/Utils/DDNS/Namecheap.php

@@ -12,7 +12,7 @@ use RuntimeException;
 
 class Namecheap implements DNS
 {
-    //  开发依据: https://www.namecheap.com/support/api/methods/
+    // 开发依据: https://www.namecheap.com/support/api/methods/
     private const API_ENDPOINT = 'https://api.namecheap.com/xml.response';
 
     public const KEY = 'namecheap';

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

@@ -5,6 +5,7 @@ namespace App\Utils\DDNS;
 use App\Utils\Library\Templates\DNS;
 use Arr;
 use Cache;
+use Http;
 use Log;
 use RuntimeException;
 
@@ -30,7 +31,7 @@ class Namesilo implements DNS
     private function parseDomainInfo(): array
     {
         $domains = Cache::remember('ddns_get_domains', now()->addHour(), function () {
-            return $this->sendRequest('listDomains')['domains']['domain'];
+            return array_column($this->sendRequest('listDomains')['domains'] ?? [], 'domain');
         });
 
         if ($domains) {
@@ -49,11 +50,11 @@ class Namesilo implements DNS
 
     private function sendRequest(string $action, array $parameters = []): array
     {
-        $request = simplexml_load_string(file_get_contents(self::API_ENDPOINT.$action.'?'.Arr::query(array_merge(['version' => 1, 'type' => 'xml', 'key' => $this->apiKey], $parameters))));
+        $response = Http::timeout(15)->retry(3, 1000)->get(self::API_ENDPOINT.$action, array_merge(['version' => 1, 'type' => 'xml', 'key' => $this->apiKey], $parameters));
 
-        if ($request) {
-            $data = json_decode(json_encode($request), true);
-            if ($data && $data['reply']['code'] === '300') {
+        if ($response->ok()) {
+            $data = $response->json();
+            if ($data && isset($data['reply']['code']) && $data['reply']['code'] === '300') {
                 return $data['reply'];
             }
 

+ 1 - 1
app/Utils/DDNS/Porkbun.php

@@ -51,7 +51,7 @@ class Porkbun implements DNS
         ];
     }
 
-    private function sendRequest(string $uri, array $parameters = []): bool|array
+    private function sendRequest(string $uri, array $parameters = []): array|bool
     {
         $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);

+ 1 - 1
app/Utils/DDNS/Vercel.php

@@ -53,7 +53,7 @@ class Vercel implements DNS
 
     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();
+        $client = Http::timeout(15)->retry(3, 1000)->withToken($this->token)->baseUrl(self::API_ENDPOINT)->withQueryParameters(['teamId' => $this->teamID])->asJson();
 
         $response = match ($action) {
             'DescribeDomains' => $client->get('v5/domains'),

+ 2 - 2
app/Utils/DDNS/Vultr.php

@@ -48,9 +48,9 @@ class Vultr implements DNS
         ];
     }
 
-    private function sendRequest(string $action, array $parameters = [], string $recordId = ''): bool|array
+    private function sendRequest(string $action, array $parameters = [], string $recordId = ''): array|bool
     {
-        $client = Http::timeout(15)->retry(3, 1000)->withHeader('Authorization', "Bearer $this->apiKey")->baseUrl(self::API_ENDPOINT)->asJson();
+        $client = Http::timeout(15)->retry(3, 1000)->withToken($this->apiKey)->baseUrl(self::API_ENDPOINT)->asJson();
 
         $response = match ($action) {
             'ListDNSDomains' => $client->get('/domains'),