├── 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 |
--------------------------------------------------------------------------------