├── cache └── .placeholder ├── index.html ├── .gitignore ├── readme.md ├── composer.json ├── phpcs.xml ├── function.php ├── ddns.php ├── routeros_script ├── ddns.rsc ├── ddnsv6.rsc └── ddnsv6-for-pc.rsc └── service ├── alidns.php ├── dnspod.php └── aliesa.php /cache/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 |

RouterOS DDNS Server

-------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor/ 3 | cache/ 4 | test/ 5 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 用于 RouterOS 的 DDNS 脚本 2 | 适用于 阿里云 / 腾讯云 3 | 4 | 该脚本无任何售后技术支持 5 | Use it wisely -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "alibabacloud/alidns-20150109": "^4.0", 4 | "alibabacloud/esa-20240910": "^2.0", 5 | "tencentcloud/dnspod": "^3.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | The coding standard for PHP_CodeSniffer itself. 4 | 5 | /vendor/* 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /function.php: -------------------------------------------------------------------------------- 1 | new AlidnsService($accessID, $accessSecret), 41 | 'aliesa' => new AliesaService($accessID, $accessSecret), 42 | 'dnspod' => new DnspodService($accessID, $accessSecret), 43 | default => throw new Exception('Unknown service type'), 44 | }; 45 | 46 | echo $ddnsService->ddns($domain, $accessIP); 47 | } catch (Exception $e) { 48 | echo $e->getMessage(); 49 | } 50 | -------------------------------------------------------------------------------- /routeros_script/ddns.rsc: -------------------------------------------------------------------------------- 1 | ########################################## 2 | ## RouterOS DDNS 脚本 for 阿里云 / 腾讯云 3 | ## 4 | ## 该 DDNS 脚本可自动 获取/识别/更新 IP 地址 5 | ## 兼容 阿里云 / 腾讯云 DNS接口 6 | ## 7 | ## 作者: vibbow 8 | ## https://vsean.net/ 9 | ## 10 | ## 修改日期: 2025/12/17 11 | ## 12 | ## 该脚本无任何售后技术支持 13 | ## Use it wisely 14 | ########################################## 15 | 16 | # 域名 17 | :local domainName "sub.example.com"; 18 | 19 | # wan接口名称 20 | :local wanInterface "ether1"; 21 | 22 | # 要使用的服务 (alidns/aliesa/dnspod) 23 | :local service "alidns"; 24 | 25 | # API接口 Access ID 26 | :local accessID ""; 27 | 28 | # API接口 Access Secret 29 | :local accessSecret ""; 30 | 31 | # ==== 以下内容无需修改 ==== 32 | # ========================= 33 | 34 | :local publicIP; 35 | :local dnsIP; 36 | :local epicFail false; 37 | 38 | # 获取当前外网IP 39 | :do { 40 | :local interfaceIP [ /ip address get [ find interface=$wanInterface ] address ]; 41 | :set interfaceIP [ :pick $interfaceIP 0 [ :find $interfaceIP "/" ] ]; 42 | 43 | :if ($interfaceIP ~ "^(10|100|172|192)\\.") \ 44 | do={ 45 | :local fetchResult [/tool fetch url="http://ip.3322.net/" mode=http as-value output=user]; 46 | :set publicIP ($fetchResult->"data"); 47 | :set publicIP [ :pick $publicIP 0 [ :find $publicIP "\n" ] ]; 48 | :set publicIP [ :toip $publicIP ]; 49 | } \ 50 | else={ 51 | :set publicIP [ :toip $interfaceIP ]; 52 | } 53 | } \ 54 | on-error { 55 | :set epicFail true; 56 | :log error ("DDNS: Get public IP failed."); 57 | } 58 | 59 | # 获取当前解析的IP 60 | :do { 61 | :set dnsIP [ :resolve $domainName ]; 62 | } \ 63 | on-error { 64 | :set epicFail true; 65 | :log error ("DDNS: Resolve domain " . $domainName . " failed."); 66 | } 67 | 68 | # 如IP有变动,则更新解析 69 | :if ($epicFail = false && $publicIP != $dnsIP) \ 70 | do={ 71 | :local callUrl ("https://ddns.vsean.net/ddns.php"); 72 | :local postData ("service=" . $service . "&domain=" . $domainName . "&access_id=" . $accessID . "&access_secret=" . $accessSecret); 73 | :local fetchResult [/tool fetch url=$callUrl mode=https http-method=post http-data=$postData as-value output=user]; 74 | :log info ("DDNS: " . $fetchResult->"data"); 75 | } 76 | -------------------------------------------------------------------------------- /service/alidns.php: -------------------------------------------------------------------------------- 1 | 'access_key', 19 | 'accessKeyId' => $accessID, 20 | 'accessKeySecret' => $accessSecret, 21 | ]); 22 | 23 | $config = new OpenApiConfig([ 24 | 'credential' => new Credential($credConfig), 25 | 'endpoint' => 'alidns.aliyuncs.com', 26 | ]); 27 | 28 | $this->client = new Alidns($config); 29 | } 30 | 31 | public function ddns(string $domain, string $accessIP): string 32 | { 33 | $recordType = getIPType($accessIP); 34 | $record = $this->getRecord($domain, $recordType); 35 | $recordIP = $record->value; 36 | 37 | if ($recordIP === $accessIP) { 38 | return 'IP not changed'; 39 | } 40 | 41 | $this->updateRecord($record, $accessIP); 42 | return "IP update from {$recordIP} to {$accessIP}"; 43 | } 44 | 45 | private function getRecord(string $domain, string $recordType): DomainRecord 46 | { 47 | $req = new DescribeSubDomainRecordsRequest(); 48 | $req->subDomain = $domain; 49 | $req->type = $recordType; 50 | 51 | try { 52 | $response = $this->client->DescribeSubDomainRecords($req); 53 | $records = $response->body->domainRecords->record; 54 | 55 | if (empty($records)) { 56 | throw new Exception('Record not found'); 57 | } 58 | 59 | return $records[0]; 60 | } catch (Exception $e) { 61 | throw new Exception('Failed to get record: ' . $e->getMessage()); 62 | } 63 | } 64 | 65 | private function updateRecord(DomainRecord $record, string $ip): void 66 | { 67 | $req = new UpdateDomainRecordRequest(); 68 | $req->recordId = $record->recordId; 69 | $req->RR = $record->RR; 70 | $req->type = $record->type; 71 | $req->value = $ip; 72 | 73 | try { 74 | $this->client->UpdateDomainRecord($req); 75 | } catch (Exception $e) { 76 | throw new Exception('Failed to update record: ' . $e->getMessage()); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /routeros_script/ddnsv6.rsc: -------------------------------------------------------------------------------- 1 | ########################################## 2 | ## RouterOS DDNS 脚本 for 阿里云 / 腾讯云 IPv6版 3 | ## 4 | ## 该 DDNS 脚本可自动 获取/识别/更新 IP 地址 5 | ## 兼容 阿里云 / 腾讯云 DNS接口 6 | ## 7 | ## 作者: vibbow 8 | ## https://vsean.net/ 9 | ## 10 | ## 修改日期: 2025/12/17 11 | ## 12 | ## 该脚本无任何售后技术支持 13 | ## Use it wisely 14 | ########################################## 15 | 16 | # 域名 17 | :local domainName "sub.example.com"; 18 | 19 | # wan接口名称 20 | :local wanInterface "ether1"; 21 | 22 | # 要使用的服务 (alidns/aliesa/dnspod) 23 | :local service "alidns"; 24 | 25 | # API接口 Access ID 26 | :local accessID ""; 27 | 28 | # API接口 Access Secret 29 | :local accessSecret ""; 30 | 31 | # ==== 以下内容无需修改 ==== 32 | # ========================= 33 | 34 | :local publicIP; 35 | :local dnsIP; 36 | :local epicFail false; 37 | 38 | # 获取当前接口IPv6地址 39 | :do { 40 | :local interfaceIP; 41 | :local interfaceIPList [ /ipv6 address find interface=$wanInterface global ]; 42 | :local interfaceIPListSize [ :len $interfaceIPList ]; 43 | 44 | # 找到接口上的公网IP地址 45 | if ($interfaceIPListSize >= 1) \ 46 | do={ 47 | :foreach id in $interfaceIPList \ 48 | do={ 49 | :local eachAddress [ /ipv6 address get $id address ]; 50 | :local isLinkLocal false; 51 | 52 | if ($eachAddress in fc00::/7) \ 53 | do={ 54 | :set isLinkLocal true; 55 | } 56 | 57 | if ($eachAddress in fe80::/10) \ 58 | do={ 59 | :set isLinkLocal true; 60 | } 61 | 62 | if (!$isLinkLocal) \ 63 | do={ 64 | :set interfaceIP $eachAddress; 65 | } 66 | } 67 | } 68 | 69 | :local interfaceIPLength [ :len $interfaceIP ]; 70 | 71 | if ($interfaceIPLength = 0) \ 72 | do={ 73 | :set epicFail true; 74 | :log error ("DDNSv6: No public IP on interface " . $wanInterface); 75 | } \ 76 | else={ 77 | :set $interfaceIP [ :pick $interfaceIP 0 [ :find $interfaceIP "/" ] ]; 78 | :set $publicIP [ :toip6 $interfaceIP ]; 79 | # :log info ("DDNSv6: Current interface IP is " . $publicIP); 80 | } 81 | } \ 82 | on-error { 83 | :set epicFail true; 84 | :log error ("DDNSv6: Get public IP failed."); 85 | } 86 | 87 | # 获取当前解析的IP 88 | :do { 89 | :set $dnsIP [ :resolve $domainName ]; 90 | # :log info ("DDNSv6: Current resolved IP is " . $dnsIP); 91 | } \ 92 | on-error { 93 | :set epicFail true; 94 | :log error ("DDNSv6: Resolve domain " . $domainName . " failed."); 95 | } 96 | 97 | # 如IP有变动,则更新解析 98 | :if ($epicFail = false && $publicIP != $dnsIP) \ 99 | do={ 100 | :local callUrl ("https://ddns6.vsean.net/ddns.php"); 101 | :local postData ("service=" . $service . "&domain=" . $domainName . "&access_id=" . $accessID . "&access_secret=" . $accessSecret); 102 | :local fetchResult [/tool fetch url=$callUrl mode=https http-method=post http-data=$postData as-value output=user]; 103 | :log info ("DDNSv6: " . $fetchResult->"data"); 104 | } 105 | -------------------------------------------------------------------------------- /routeros_script/ddnsv6-for-pc.rsc: -------------------------------------------------------------------------------- 1 | ########################################## 2 | ## RouterOS DDNS 脚本 for 阿里云 / 腾讯云 IPv6版 3 | ## 4 | ## 该 DDNS 脚本可自动对指定 PC 做 IPv6 DDNS 5 | ## 兼容 阿里云 / 腾讯云 DNS接口 6 | ## 7 | ## 作者: vibbow 8 | ## https://vsean.net/ 9 | ## 10 | ## 修改日期: 2025/12/17 11 | ## 12 | ## 该脚本无任何售后技术支持 13 | ## Use it wisely 14 | ########################################## 15 | 16 | # 用来DDNS的域名 17 | :local domainName "sub.example.com"; 18 | 19 | # 要更新的计算机MAC地址 20 | :local macAddress "AA:BB:CC:DD:EE:FF"; 21 | 22 | # 用来查找计算机的端口 (通常是bridge) 23 | :local lanInterface "bridge"; 24 | 25 | # 要使用DDNS的服务 (alidns/aliesa/dnspod) 26 | :local service "alidns"; 27 | 28 | # DDNS API接口 Access ID 29 | :local accessID ""; 30 | 31 | # DDNS API接口 Access Secret 32 | :local accessSecret ""; 33 | 34 | # ==== 以下内容无需修改 ==== 35 | # ========================= 36 | 37 | :local epicFail false; 38 | :local ipv6Address; 39 | :local ipv6AddressList; 40 | :local dnsAddress; 41 | 42 | # 获取指定mac的所有ipv6地址 43 | :do { 44 | :set ipv6AddressList [ /ipv6 neighbor find mac-address=$macAddress interface=$lanInterface ]; 45 | 46 | :local addressListLength [ :len $ipv6AddressList ]; 47 | 48 | if ($addressListLength = 0) \ 49 | do={ 50 | :log error ("No ipv6 address found for " . $macAddress); 51 | :set epicFail true; 52 | } 53 | } \ 54 | on-error { 55 | :set epicFail true; 56 | } 57 | 58 | 59 | # 获取非本地的ipv6地址 60 | if ($epicFail = false) \ 61 | do={ 62 | :foreach id in=$ipv6AddressList \ 63 | do={ 64 | :local eachAddress [ /ipv6 neighbor get $id address ]; 65 | :local eachAddressStr [ :toip6 $eachAddress ]; 66 | :local isLinkLocal false; 67 | 68 | if ($eachAddress in fc00::/7) \ 69 | do={ 70 | :set isLinkLocal true; 71 | } 72 | 73 | if ($eachAddress in fe80::/10) \ 74 | do={ 75 | :set isLinkLocal true; 76 | } 77 | 78 | if (!$isLinkLocal) \ 79 | do={ 80 | :set ipv6Address $eachAddressStr; 81 | } 82 | } 83 | 84 | :local addressLength [ :len $ipv6Address ]; 85 | if ($addressLength = 0) \ 86 | do={ 87 | :log error ("No public ipv6 address for " . $macAddress); 88 | :set epicFail true; 89 | } 90 | } 91 | 92 | 93 | # 获取当前解析的IP 94 | :do { 95 | :set dnsAddress [ :resolve $domainName ]; 96 | } \ 97 | on-error { 98 | :set epicFail true; 99 | :log error ("Resolve domain " . $domainName . " failed."); 100 | } 101 | 102 | 103 | # 更新 IPv6 地址到 DDNS 104 | if ($epicFail = false && $ipv6Address != $dnsAddress) \ 105 | do={ 106 | :local callUrl ("https://ddns6.vsean.net/ddns.php"); 107 | :local postData ("service=" . $service . "&domain=" . $domainName . "&ip=" . $ipv6Address . "&access_id=" . $accessID . "&access_secret=" . $accessSecret); 108 | :local fetchResult [/tool fetch url=$callUrl mode=https http-method=post http-data=$postData as-value output=user]; 109 | :log info ("DDNSv6: " . $fetchResult->"data"); 110 | } 111 | -------------------------------------------------------------------------------- /service/dnspod.php: -------------------------------------------------------------------------------- 1 | client = new DnspodClient($cred, ''); 21 | } 22 | 23 | public function ddns(string $domain, string $accessIP): string 24 | { 25 | $recordType = getIPType($accessIP); 26 | $this->getId($domain, $recordType); 27 | 28 | $record = $this->getRecord(); 29 | $recordIP = $record->Value; 30 | 31 | if ($recordIP === $accessIP) { 32 | return 'IP not changed'; 33 | } 34 | 35 | $this->updateRecord($record, $accessIP); 36 | return "IP update from {$recordIP} to {$accessIP}"; 37 | } 38 | 39 | private function getRecord(): RecordInfo 40 | { 41 | $request = new DescribeRecordRequest(); 42 | $request->Domain = ''; 43 | $request->DomainId = $this->domainID; 44 | $request->RecordId = $this->recordID; 45 | 46 | try { 47 | $response = $this->client->DescribeRecord($request); 48 | return $response->getRecordInfo(); 49 | } catch (Exception $e) { 50 | throw new Exception('Failed to get record: ' . $e->getMessage()); 51 | } 52 | } 53 | 54 | private function updateRecord(RecordInfo $record, string $ip): void 55 | { 56 | $request = new ModifyDynamicDNSRequest(); 57 | $request->Domain = ''; 58 | $request->DomainId = $this->domainID; 59 | $request->RecordId = $this->recordID; 60 | $request->Value = $ip; 61 | $request->SubDomain = $record->SubDomain; 62 | $request->RecordLine = $record->RecordLine; 63 | 64 | try { 65 | $this->client->ModifyDynamicDNS($request); 66 | } catch (Exception $e) { 67 | throw new Exception('Failed to update record: ' . $e->getMessage()); 68 | } 69 | } 70 | 71 | private function getId(string $domain, string $recordType): void 72 | { 73 | $cacheFile = CACHE_DIR . md5('dnspod' . $domain . $recordType); 74 | 75 | if (file_exists($cacheFile)) { 76 | $content = file_get_contents($cacheFile); 77 | $cache = json_decode($content, true); 78 | 79 | $this->domainID = $cache['domain_id']; 80 | $this->recordID = $cache['record_id']; 81 | return; 82 | } 83 | 84 | $request = new DescribeDomainListRequest(); 85 | $response = $this->client->DescribeDomainList($request); 86 | $domainList = $response->getDomainList(); 87 | 88 | $domainID = null; 89 | $domainName = null; 90 | 91 | foreach ($domainList as $eachDomain) { 92 | $thisDomainName = $eachDomain->Name; 93 | $thisDomainID = $eachDomain->DomainId; 94 | 95 | if (str_ends_with($domain, $thisDomainName)) { 96 | $domainID = $thisDomainID; 97 | $domainName = $thisDomainName; 98 | break; 99 | } 100 | } 101 | 102 | if (empty($domainID)) { 103 | throw new Exception('Domain not found'); 104 | } 105 | 106 | $request = new DescribeRecordListRequest(); 107 | $request->Domain = $domainName; 108 | $request->DomainId = $domainID; 109 | 110 | $response = $this->client->DescribeRecordList($request); 111 | $recordList = $response->getRecordList(); 112 | 113 | $recordID = null; 114 | 115 | foreach ($recordList as $eachRecord) { 116 | $thisRecordID = $eachRecord->RecordId; 117 | $thisRecordName = trim($eachRecord->Name, '.'); 118 | $thisRecordType = $eachRecord->Type; 119 | $thisRecordDomain = $thisRecordName . '.' . $domainName; 120 | 121 | if ($thisRecordType === $recordType && $thisRecordDomain === $domain) { 122 | $recordID = $thisRecordID; 123 | break; 124 | } 125 | } 126 | 127 | if (empty($recordID)) { 128 | throw new Exception('Record not found'); 129 | } 130 | 131 | // 确保缓存目录存在 132 | if (!is_dir(CACHE_DIR)) { 133 | mkdir(CACHE_DIR, 0755, true); 134 | } 135 | 136 | $cacheData = [ 137 | 'domain' => $domain, 138 | 'domain_id' => $domainID, 139 | 'record_type' => $recordType, 140 | 'record_id' => $recordID, 141 | ]; 142 | 143 | file_put_contents($cacheFile, json_encode($cacheData)); 144 | 145 | $this->domainID = $domainID; 146 | $this->recordID = $recordID; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /service/aliesa.php: -------------------------------------------------------------------------------- 1 | 'access_key', 23 | 'accessKeyId' => $accessID, 24 | 'accessKeySecret' => $accessSecret, 25 | ]); 26 | 27 | $config = new OpenApiConfig([ 28 | 'credential' => new Credential($credConfig), 29 | 'endpoint' => 'esa.cn-hangzhou.aliyuncs.com', 30 | ]); 31 | 32 | $this->client = new ESA($config); 33 | } 34 | 35 | public function ddns(string $domain, string $accessIP): string 36 | { 37 | $recordType = getIPType($accessIP); 38 | $this->getId($domain, $recordType); 39 | 40 | $record = $this->getRecord(); 41 | $recordIP = $record->data->value; 42 | 43 | if ($recordIP === $accessIP) { 44 | return 'IP not changed'; 45 | } 46 | 47 | $this->updateRecord($record, $accessIP); 48 | return "IP update from {$recordIP} to {$accessIP}"; 49 | } 50 | 51 | private function getRecord(): recordModel 52 | { 53 | $req = new GetRecordRequest(); 54 | $req->recordId = $this->recordID; 55 | 56 | try { 57 | $response = $this->client->GetRecord($req); 58 | return $response->body->recordModel; 59 | } catch (Exception $e) { 60 | throw new Exception('Failed to get record: ' . $e->getMessage()); 61 | } 62 | } 63 | 64 | private function updateRecord(recordModel $record, string $ip): void 65 | { 66 | $req = new UpdateRecordRequest(); 67 | $req->recordId = $record->recordId; 68 | $req->data = new UpdateRecordRequest\data(); 69 | $req->data->value = $ip; 70 | 71 | try { 72 | $this->client->UpdateRecord($req); 73 | } catch (Exception $e) { 74 | throw new Exception('Failed to update record: ' . $e->getMessage()); 75 | } 76 | } 77 | 78 | private function getId(string $domain, string $recordType): void 79 | { 80 | $cacheFile = CACHE_DIR . md5('aliesa' . $domain . $recordType); 81 | 82 | if (file_exists($cacheFile)) { 83 | $content = file_get_contents($cacheFile); 84 | $cache = json_decode($content, true); 85 | 86 | $this->recordID = $cache['record_id']; 87 | return; 88 | } 89 | 90 | $req = new ListSitesRequest(); 91 | $response = $this->client->ListSites($req); 92 | $sites = $response->body->sites; 93 | 94 | $siteID = null; 95 | $siteName = null; 96 | $siteAccessType = null; 97 | 98 | foreach ($sites as $eachSite) { 99 | $thisSiteID = $eachSite->siteId; 100 | $thisSiteName = $eachSite->siteName; 101 | $thisSiteAccessType = $eachSite->accessType; 102 | 103 | if (str_ends_with($domain, $thisSiteName)) { 104 | $siteID = $thisSiteID; 105 | $siteName = $thisSiteName; 106 | $siteAccessType = $thisSiteAccessType; 107 | break; 108 | } 109 | } 110 | 111 | if (empty($siteID)) { 112 | throw new Exception('Site not found'); 113 | } 114 | 115 | if ($siteAccessType !== 'NS') { 116 | throw new Exception('Site access type is not NS'); 117 | } 118 | 119 | $req = new ListRecordsRequest(); 120 | $req->siteId = $siteID; 121 | $req->type = 'A/AAAA'; 122 | $response = $this->client->ListRecords($req); 123 | $records = $response->body->records; 124 | 125 | $recordID = null; 126 | 127 | foreach ($records as $eachRecord) { 128 | $thisRecordID = $eachRecord->recordId; 129 | $thisRecordName = $eachRecord->recordName; 130 | $thisRecordType = getIPType($eachRecord->data->value); 131 | 132 | if ($thisRecordType === $recordType && $thisRecordName === $domain) { 133 | $recordID = $thisRecordID; 134 | break; 135 | } 136 | } 137 | 138 | if (empty($recordID)) { 139 | throw new Exception('Record not found'); 140 | } 141 | 142 | // 确保缓存目录存在 143 | if (!is_dir(CACHE_DIR)) { 144 | mkdir(CACHE_DIR, 0755, true); 145 | } 146 | 147 | $cacheData = [ 148 | 'domain' => $domain, 149 | 'record_type' => $recordType, 150 | 'record_id' => $recordID, 151 | ]; 152 | 153 | file_put_contents($cacheFile, json_encode($cacheData)); 154 | 155 | $this->recordID = $recordID; 156 | } 157 | } 158 | --------------------------------------------------------------------------------