用Delphi调用阿里云的OpenAPI更新动态域名解析记录
第一稿是 2020-03-23
现已于2024-01-10更新
家里一直是电信的宽带,虽然只是200M下行30M上行的平均水平,但是对于平时下载量不大的我来说已经绰绰有余了,很多时候需要从外网访问家里的NAS的资料,但是因为不是固定IP,每次一重启路由器或者每隔几天,家里的IP地址都是变动一下,前阵子一直使用docker安装了一个阿里云的DDNS软件非常好用,突然有一天不更新IP到阿里云的服务器上了,又加上自己的管理软件的服务端是安装到Windows下(用unRaidNAS虚拟了一个WindowsServer),所以干脆想办法把这个功能加到自己的管理服务端软件上,这时候开始祭出我们的工具Delphi。(前几篇文章已经说了如何安装社区版的Delphi和如何在unRaid下虚拟Windows)。
在正式操作之前,首先说明下使用aliyun的DDNS的基本条件。
1、这个也是最最关键的必不可少的一步,就是你家必须要有公网IP,现在很多地方宽带供应商只给内网IP(特别是移动和联通),如果是电信的话,可以打10000号免费开通公网IP功能,至于给不给你开,看各地的政策。反正我家一直是公网IP。
2、你得有个阿里云的域名,如果没有,赶紧去阿里云上去买个,不差钱的可以买个.com、.cn的,如果只是玩玩的可以买个.xyz等超级便宜的域名。至于腾讯云的域名,有两种方法:你可以把域名改为阿里云托管,也可以将下面的代码稍微改动改成腾讯云的。因为我只有阿里云的域名,所以没法测试腾讯云的,这里就不改动了
3、获得阿里云的访问key和访问key的密钥。这个在你登录阿里云后,在头像上点击鼠标左键会弹出来一个菜单,就可以直接看到。这里也给个建议,最好不要用主key和密钥,因为一旦泄露,也就是将你的阿里云的所有权限暴露给别人了,所以可以建立一个子用户key并且给他设置权限只能修改域名解析。
4、提前在阿里云的域名管理上建立一个新的二级域名,虽然可以通过代码添加,但是我觉得自己手工添加会更安全,至少不会让自己的域名列表不经意的多出很多无用的二级域名。
有了前面的几步,现在正是进入主题。代码写起来比较简单,只要根据阿里云的OpenAPI的文档编写就可以,当然你得学会看懂文档。很快我就将所有的代码完成,但是运行的时候一直提示错误,最主要错误就是一直提示签名值错误,这个签名值在我的理解也就是校验值,用来校验你是否有权限访问和修改域名解析。然后慢慢调试,在查看文档和阿里云公开的代码,发现有几个大坑,而在文档上却没有说明,当然也可能和每个编程工具的处理有点小关系,现在将每个大坑一一的说明下:
坑1:做签名值的时候,发现调试出来的签名值很长,几乎是官方计算出来的两到三倍,代码仔细看看没什么打问题,已经按照文档要求使用了URL编码后再进行HMAC_SHA1然后最后再进行BASE64编码,整个过程都没任何问题,而且编码函数经过测试也没问题。文档上也没有任何说明要做其他的编码或者转变。后来发现惊醒HMAC_SHA1编码的时候,一定要生成他的原始二进制数据,而我们正常情况下一般都是生成字符串数据,我了了个去!好了,现在生成的签名值已经和官方的签名值的长度一模一样。
var sSignByte: TBytes; begin // 构造用于签名的字符串 Result := Format('GET&%s&%s', [TURI.URLEncode('/'), TURI.URLEncode(sCanonicalQueryString)]); // 使用HMAC-SHA1计算返回原始二进制数据 sSignByte := THashSHA1.GetHMACAsBytes(Result, sKeyScret + '&'); // 得到签名值 Result := TURI.URLEncode(TNetEncoding.Base64.EncodeBytesToString(sSignByte)); end;
坑2:这下自以为没问题了,但是生成的签名值一直没官方的不一样啊,编码前的原始字符串貌似也一模一样,就是生成的签名值确实另外一个值,无奈,只能又是一步一步调试并且每一步和官方的值做对比,突然发现,UTC时间经过URL编码后会把里面的时间分隔符的":"会编码上"%3A",在生成签名值前,再进行一次URL编码,官方的会把签名的“%3A”里面的"%"再进行一次编码,变成了"%253A",而在Delphi中却没有编码,在我的理解中,确实不需要再编码了啊,因为这里已经按URL编码过一次了,这也算是一个坑吧,没办法,只能按照阿里云的要求,对其中的%再进行一次单独的替换。所以就有了下面代码的最后2行代码。
const BodyFormatStr = 'AccessKeyId=%s&Action=DescribeSubDomainRecords&Format=json&SignatureMethod=HMAC-SHA1&SignatureNonce=%s&SignatureVersion=1.0&SubDomain=%s&Timestamp=%s&Version=2015-01-09'; sTimestamp := GetUTCTime; sSignatureNonce := getGUID; sBody := Format(BodyFormatStr, [sKey.Trim, sSignatureNonce, sDomain, sTimestamp]); sBody1 := Format(BodyFormatStr, [sKey.Trim, sSignatureNonce, sDomain, sTimestamp.Replace('%', '%25')]);
坑3:获取UTC时间,UTC时间也就是我们说的世界标准时间,正常我们获取UTC时间都是带毫秒的,这个一点问题都没有,而且也都是ISO8601的标准对时间进行格式化,但是我发现,在生成签名值的时候,和官方算出来的虽然一模一样,但是每次还是提示签名值不对,这个坑整整耗了我一天时间,百思不得其解。后来仔细对比,官方获取的UTC时间只精确到秒,而我获取的UTC时间是精确到毫秒,理论上这个没太大的关系,但是我尝试和官方同步使用精确到秒之后,这个问题竟然给解决了。
function GetUTCTime: string; // 获取ISO8601格式的UTC世界标准时间 begin // 自带的函数带有毫秒,在Update的时候签名值出错,所以使用不带毫秒的时间 // 以下两种方法都可以 Result := MyURLEncode(FormatDateTime('YYYY-MM-DD''T''hh:nn:ss''Z''', IncSecond(UnixDateDelta, DateTimeToUnix(Now, False)))); // Result := MyURLEncode(FormatDateTime('YYYY-MM-DD''T''hh:nn:ss''Z''', TTimeZone.Local.ToUniversalTime(Now, False))); end;
经过上面的填坑,很快整个代码都修改完成,其他的代码都是最最常规的,经过测试,已经很轻松的能够更新阿里云的域名解析了。加到自己的软件服务端,再也不需要安装官方或者其他的AliDDNS软件了。下面将所有的代码贴上,拿走不谢,如果有看官发现里面的错误或者有新的改动,希望能给个反馈哦。
// ******************************************************* // // AliDDNS - 阿里云DDNS自动更新代码 // // 版权所有 (C) 2019-2024 // 作者: HenryXu // 更新日期: 2024.01.10 // 联系方式:QQ( 55524082 ) // 说明: // 01、本函数只适用于AliYun的动态域名更新、添加、删除解析使用 // 02、域名必须在AliYun上注册 // 03、AliYun的AccessKeyID和AccessKeySecret可控制AliYun的所有权限,请妥善保管 // 04、建议开通子用户AccessKey,并设置只能修改云解析的权限 // 05、本函数待有优化之处,我会及时更新 // 06、有什么建议或修改其中的代码,希望能发一份给我以共同修改 // // ******************************************************* unit SQ_AliDDNS; interface uses System.Classes, System.SysUtils, System.JSON, System.DateUtils, System.Hash, System.NetEncoding, System.Net.URLClient, System.Net.HttpClient, System.Net.HttpClientComponent, System.Net.Socket; type TSubDomainInfo = record RR: string; DomainName: string; TTL: string; IP: string; RecordId: string; Output: string; end; { ------------------------------------------------------------------------------- 过程名: GetDomainRecords 作用: 获取阿里的云解析记录列表 作者: HenryXu 日期: 2024.01.08 参数: sKey, sKeyScret, sDomain: string 参数说明: AccessKeyID,AccessKeySecret,主域名 返回值: string 返回说明: 返回域名列表的Json字符串 ------------------------------------------------------------------------------- } function GetDomainRecords(sKey, sKeyScret, sDomain: string): string; { ------------------------------------------------------------------------------- 过程名: GetDomainRecordID 作用: 获取阿里的云解析的子域名记录信息 作者: HenryXu 日期: 2020.03.29 参数: sKey, sKeyScret, sDomain: string 参数说明: AccessKeyID,AccessKeySecret,域名 返回值: TSubDomainInfo 返回说明: 返回子域名记录的详细信息 ------------------------------------------------------------------------------- } function GetDomainRecordInfo(sKey, sKeyScret, sDomain: string): TSubDomainInfo; { ------------------------------------------------------------------------------- 过程名: UpdateDomainRecord 作用: 更新本机所在网络的公网IP到阿里的云解析里 作者: HenryXu 日期: 2020.03.21 参数: sKey, sKeyScret, sDomain: string 参数说明: AccessKeyID,AccessKeySecret,域名 返回值: string 返回说明: 如果更新成功,返回更新后的IP,否则返回空 ------------------------------------------------------------------------------- } function UpdateDomainRecord(sKey, sKeyScret, sDomain: string): string; { ------------------------------------------------------------------------------- 过程名: AddDomainRecord 作用: 添加新的解析记录到阿里的云解析里 作者: HenryXu 日期: 2024.01.08 参数: sKey, sKeyScret, sRR,sDomain: string 参数说明: AccessKeyID,AccessKeySecret,记录,域名 返回值: string 返回说明: 如果更新成功, ------------------------------------------------------------------------------- } function AddDomainRecord(sKey, sKeyScret, sRR, sDomain: string): string; { ------------------------------------------------------------------------------- 过程名: DeleteDomainRecord 作用: 删除解析记录 作者: HenryXu 日期: 2024.01.08 参数: sKey, sKeyScret, sRR,sDomain: string 参数说明: AccessKeyID,AccessKeySecret,记录,域名 返回值: string 返回说明: 如果更新成功, ------------------------------------------------------------------------------- } function DeleteDomainRecord(sKey, sKeyScret, sRR, sDomain: string): string; { ------------------------------------------------------------------------------- 过程名: GetPublicIP 作用: 获取本机所在网络的公网IP 作者: HenryXu 日期: 2020.03.21 参数: 无 返回值: string ------------------------------------------------------------------------------- } function GetPublicIP: string; implementation const ApiAliAddr = 'http://alidns.aliyuncs.com/'; PubIPAddr = 'http://ip.3322.net/'; function MyURLEncode(const AValue: string; SpacesAsPlus: Boolean = False): string; const FormUnsafeChars: TURLEncoding.TUnsafeChars = [Ord('!'), Ord('"'), Ord('#'), Ord('$'), Ord('%'), Ord('&'), Ord(''''), Ord('('), Ord(')'), Ord('*'), Ord('+'), Ord(','), Ord('"'), Ord('/'), Ord(':'), Ord(';'), Ord('<'), Ord('>'), Ord('='), Ord('?'), Ord('@'), Ord('['), Ord(']'), Ord('\'), Ord('^'), Ord('`'), Ord('{'), Ord('}'), Ord('|')]; var LEncoding: TURLEncoding; begin LEncoding := TURLEncoding.Create; try if SpacesAsPlus then Result := LEncoding.Encode(AValue, FormUnsafeChars, [TURLEncoding.TEncodeOption.SpacesAsPlus]) else Result := LEncoding.Encode(AValue, FormUnsafeChars, []); finally LEncoding.Free; end; end; function GetUTCTime: string; // 获取ISO8601格式的UTC世界标准时间 begin // 自带的函数带有毫秒,在Update的时候签名值出错,所以使用不带毫秒的时间 // 以下两种方法都可以 Result := MyURLEncode(FormatDateTime('YYYY-MM-DD''T''hh:nn:ss''Z''', IncSecond(UnixDateDelta, DateTimeToUnix(Now, False)))); // Result := MyURLEncode(FormatDateTime('YYYY-MM-DD''T''hh:nn:ss''Z''', TTimeZone.Local.ToUniversalTime(Now, False))); end; function getGUID(): string; var gGUID: TGUID; sGUID: string; begin CreateGUID(gGUID); sGUID := GUIDToString(gGUID); Delete(sGUID, 1, 1); Delete(sGUID, Length(sGUID), 1); sGUID := StringReplace(sGUID, '-', '', [rfReplaceAll]); Result := sGUID; end; function httpSendRequest(sBody: string): string; // 发送Http请求 var FHttpClient: TNetHttpClient; begin FHttpClient := TNetHttpClient.Create(nil); try try Result := FHttpClient.Get(sBody).ContentAsString(TEncoding.UTF8); except Result := 'Wrong'; end; finally FHttpClient.Free; end; end; function GetPublicIP: string; // 获取本机的公网IP begin Result := httpSendRequest(PubIPAddr).Trim; if Result = 'Wrong' then Result := ''; end; function GetSignature(sCanonicalQueryString, sKeyScret: string): string; var sSignByte: TBytes; begin // 构造用于签名的字符串 Result := Format('GET&%s&%s', [MyURLEncode('/'), MyURLEncode(sCanonicalQueryString)]); // 使用HMAC-SHA1计算返回原始二进制数据 sSignByte := THashSHA1.GetHMACAsBytes(Result, sKeyScret + '&'); // 得到签名值 Result := MyURLEncode(TNetEncoding.Base64.EncodeBytesToString(sSignByte)); end; function GetDomainRecords(sKey, sKeyScret, sDomain: string): string; // 获取阿里的云解析记录列表 var sBody, sBody1: string; // 必填参数 sTimestamp: string; sSignatureNonce: string; const BodyFormatStr = 'AccessKeyId=%s&Action=DescribeDomainRecords&DomainName=%s&Format=json&SignatureMethod=HMAC-SHA1&SignatureNonce=%s&SignatureVersion=1.0&Timestamp=%s&Version=2015-01-09'; // ====Key====DomainName====SignNonce====TimeStamp==== begin if sKey.IsEmpty or sKeyScret.IsEmpty or sDomain.IsEmpty then Exit; sTimestamp := GetUTCTime; sSignatureNonce := getGUID; sBody := Format(BodyFormatStr, [sKey.Trim, sDomain, sSignatureNonce, sTimestamp]); sBody1 := Format(BodyFormatStr, [sKey.Trim, sDomain, sSignatureNonce, sTimestamp.Replace('%', '%25')]); Result := httpSendRequest(Format('%s?%s&Signature=%s', [ApiAliAddr, sBody, GetSignature(sBody1, sKeyScret.Trim)])); end; function GetDomainRecordInfo(sKey, sKeyScret, sDomain: string): TSubDomainInfo; // 获取AliYun上对应域名的解析信息 var sBody, sBody1: string; oJson: TJSONObject; index: integer; // 必填参数 sTimestamp: string; sSignatureNonce: string; const BodyFormatStr = 'AccessKeyId=%s&Action=DescribeSubDomainRecords&Format=json&SignatureMethod=HMAC-SHA1&SignatureNonce=%s&SignatureVersion=1.0&SubDomain=%s&Timestamp=%s&Version=2015-01-09'; // ====Key====SignNonce====SubDomain====TimeStamp==== begin Result.RR := ''; Result.TTL := ''; Result.IP := ''; Result.DomainName := ''; Result.RecordId := ''; if sKey.IsEmpty or sKeyScret.IsEmpty or sDomain.IsEmpty then Exit; sTimestamp := GetUTCTime; sSignatureNonce := getGUID; sBody := Format(BodyFormatStr, [sKey.Trim, sSignatureNonce, sDomain, sTimestamp]); sBody1 := Format(BodyFormatStr, [sKey.Trim, sSignatureNonce, sDomain, sTimestamp.Replace('%', '%25')]); Result.Output := httpSendRequest(Format('%s?%s&Signature=%s', [ApiAliAddr, sBody, GetSignature(sBody1, sKeyScret.Trim)])); if Result.Output = 'Wrong' then Exit; // 这里从结果中需要取RecordId,RR和TTL等值 oJson := TJSONObject.ParseJSONValue(Trim(Result.Output)) as TJSONObject; if oJson <> nil then begin try if oJson.TryGetValue('TotalCount', index) and (index > 0) then begin Result.RecordId := oJson.GetValue<String>('DomainRecords.Record[0].RecordId').Trim; Result.RR := oJson.GetValue<String>('DomainRecords.Record[0].RR').Trim; Result.IP := oJson.GetValue<String>('DomainRecords.Record[0].Value').Trim; Result.TTL := oJson.GetValue<String>('DomainRecords.Record[0].TTL').Trim; Result.DomainName := oJson.GetValue<String>('DomainRecords.Record[0].DomainName').Trim; end; finally oJson.Free; end; end; end; function UpdateDomainRecord(sKey, sKeyScret, sDomain: string): string; // 更新AliYun上对应域名的IP记录 var SubdomainInfo: TSubDomainInfo; sPublicIP: string; sBody, sBody1: string; oJson: TJSONObject; sResult: string; // 必填参数 sTimestamp: string; sSignatureNonce: string; const BodyFormatStr = 'AccessKeyId=%s&Action=UpdateDomainRecord&Format=json&RR=%s&RecordId=%s&SignatureMethod=HMAC-SHA1&SignatureNonce=%s&SignatureVersion=1.0&TTL=%s&Timestamp=%s&Type=A&Value=%s&Version=2015-01-09'; // ====Key====RR====RecordID====SignNonce====TTL====TimeStamp====Value==== begin if sKey.IsEmpty or sKeyScret.IsEmpty or sDomain.IsEmpty then Exit; sPublicIP := GetPublicIP; if sPublicIP = TIPAddress.LookupName(sDomain).Address then begin Result := 'Same IP. Don''t need Update!'; Exit; end; SubdomainInfo := GetDomainRecordInfo(sKey, sKeyScret, sDomain); if SubdomainInfo.RecordId.IsEmpty or (sPublicIP = SubdomainInfo.IP) then begin Result := 'Same IP or Invalid Record!'; Exit; end; if SubdomainInfo.RR.IsEmpty then Exit; sTimestamp := GetUTCTime; sSignatureNonce := getGUID; sBody := Format(BodyFormatStr, [sKey, SubdomainInfo.RR, SubdomainInfo.RecordId, sSignatureNonce, SubdomainInfo.TTL, sTimestamp, sPublicIP]); sBody1 := Format(BodyFormatStr, [sKey, SubdomainInfo.RR, SubdomainInfo.RecordId, sSignatureNonce, SubdomainInfo.TTL, sTimestamp.Replace('%', '%25'), sPublicIP]); Result := httpSendRequest(Format('%s?%s&Signature=%s', [ApiAliAddr, sBody, GetSignature(sBody1, sKeyScret.Trim)])); oJson := TJSONObject.ParseJSONValue(Result) as TJSONObject; if oJson <> nil then begin try oJson.TryGetValue('RecordId', sResult); if not sResult.IsEmpty then Result := sPublicIP; finally oJson.Free; end; end; end; function AddDomainRecord(sKey, sKeyScret, sRR, sDomain: string): string; // 添加新的解析记录到阿里的云解析里 var sBody, sBody1: string; // 必填参数 sTimestamp: string; sSignatureNonce: string; sValue: string; oJson: TJSONObject; sResult: string; const BodyFormatStr = 'AccessKeyId=%s&Action=AddDomainRecord&DomainName=%s&Format=json&RR=%s&SignatureMethod=HMAC-SHA1&SignatureNonce=%s&SignatureVersion=1.0&Timestamp=%s&Type=A&Value=%s&Version=2015-01-09'; // ====AccessKeyId====DomainName====RR====SignNonce====Timestamp====Value==== begin if sKey.IsEmpty or sKeyScret.IsEmpty or sRR.IsEmpty or sDomain.IsEmpty then Exit; sTimestamp := GetUTCTime; sSignatureNonce := getGUID; sValue := GetPublicIP; sBody := Format(BodyFormatStr, [sKey, sDomain, sRR, sSignatureNonce, sTimestamp, sValue]); sBody1 := Format(BodyFormatStr, [sKey, sDomain, sRR, sSignatureNonce, sTimestamp.Replace('%', '%25'), sValue]); Result := httpSendRequest(Format('%s?%s&Signature=%s', [ApiAliAddr, sBody, GetSignature(sBody1, sKeyScret.Trim)])); oJson := TJSONObject.ParseJSONValue(sResult) as TJSONObject; if oJson <> nil then begin try oJson.TryGetValue('RecordId', sResult); if not sResult.IsEmpty then Result := sResult; finally oJson.Free; end; end; end; function DeleteDomainRecord(sKey, sKeyScret, sRR, sDomain: string): string; // 删除解析记录 var sBody, sBody1: string; oJson: TJSONObject; sResult: string; // 必填参数 sTimestamp: string; sSignatureNonce: string; const BodyFormatStr = 'AccessKeyId=%s&Action=DeleteSubDomainRecords&DomainName=%s&Format=json&RR=%s&SignatureMethod=HMAC-SHA1&SignatureNonce=%s&SignatureVersion=1.0&Timestamp=%s&Type=A&Version=2015-01-09'; // ====Key====DomainName====RR====SignNonce====TimeStamp==== begin if sKey.IsEmpty or sKeyScret.IsEmpty or sRR.IsEmpty or sDomain.IsEmpty then Exit; sTimestamp := GetUTCTime; sSignatureNonce := getGUID; sBody := Format(BodyFormatStr, [sKey.Trim, sDomain, sRR, sSignatureNonce, sTimestamp]); sBody1 := Format(BodyFormatStr, [sKey.Trim, sDomain, sRR, sSignatureNonce, sTimestamp.Replace('%', '%25')]); Result := httpSendRequest(Format('%s?%s&Signature=%s', [ApiAliAddr, sBody, GetSignature(sBody1, sKeyScret.Trim)])); oJson := TJSONObject.ParseJSONValue(Result) as TJSONObject; if oJson <> nil then begin try oJson.TryGetValue('TotalCount', sResult); if StrToIntDef(sResult, 0) > 0 then Result := sResult; finally oJson.Free; end; end; end; end.