0x01 前言

许多厂商的 DNS 解析服务免费版的 TTL 在300至600之间,如果需要更低的 TTL 就需要购买升级套餐,但是这个套餐并不便宜。另一方面,对于家用甚至公司使用的用户,DDNS 域名的解析请求次数比用于业务的环境的次数要低很多,比较过 AWS 与国内各厂商的价格后,我决定将一部分域名交由 AWS 用 Route53 解析。

如果你是新用户,可以享受一年的优惠,但对于我来说,是需要付费的。默认情况下,托管域名和查询都需要付费,具体可以从以下页面找到资费信息:

自费大致如下:

  • 域名托管:
    • 前25个域名:0.5 USD/月
    • 从第26个开始:0.1 USD/月
  • 标准查询:
    • 前10亿次:0.4 USD/1百万次
    • 超过10亿次后:0.2 USD/1百万次

根据我的使用环境,完全处于保底费用区间之内,也就是每月 0.9 USD,月 6.5 RMB,年付 78 RMB。

最重要的是 TTL 可以低至 1 秒!

0x02 脚本

虽然这篇文章介绍的是 Docker 版本,但这个脚本也可以通过 Linux 或 Windows 系统的定时任务调用,只需要部署所需的 Python3 环境即可。

首先是参数:

  • -d DOMAIN, –domain DOMAIN:必填,域名。可以包含域名后的点或者去掉,比如:
    • ngx.hk
    • ngx.hk.
  • -s SUBDOMAIN, –subdomain SUBDOMAIN:必填,子域名。只允许英文字母、数字与 “-”。
  • -t SECOND, –ttl SECOND:必填,TTL 的值,最低 1。但低的 TTL 意味着查询次数的增加,有可能会增加费用。
  • -v 4 OR 6, –ip-ver 4 OR 6:可选,IP 版本,默认为 4,即 IPv4。
  • -id STRING, –key-id STRING:AWS Secret key ID。
  • -sk STRING, –secret-key STRING:AWS Secret key。
  • -it SECOND, –interval-time SECOND:脚本运行的间隔

参数的部分如下:

parser = argparse.ArgumentParser(description="AWS Route53 DDNS Python Script by NGX.HK")

parser.add_argument('-d', '--domain', dest='domain', type=str, default=None, required=True,
                    help='Domain name. \n'
                         'With a dot in the end of domain name or not are both ok. Like: \n'
                         'With a dot: ngx.hk. \n'
                         'Without a dot: ngx.hk')

parser.add_argument('-s', '--subdomain', dest='subdomain', type=str, default=None, required=True,
                    help='Subdomain. Only a-z, A-Z, 0-9 and \'-\' accepted. \n'
                         ' Like: ddns, home-ddns')

parser.add_argument('-t', '--ttl', dest='second', type=int, default=600,
                    help='Value for Time To Live. Default is 600s. \n'
                         'You can set lower or higher TTL in some DNS service provider, like AWS Route53. \n'
                         'Be careful with your wallet! \n'
                         'Lower TTL may be good for your services but cost more money, read DNS service provider doc., first.')

parser.add_argument('-v', '--ip-ver', dest='ver', type=int, default=4,
                    help='IP version. Default is 4, means IPv4.')

parser.add_argument('-id', '--key-id', dest='id', type=str, default=None, required=True,
                    help='Access key id.')

parser.add_argument('-sk', '--secret-key', dest='sk', type=str, default=None, required=True,
                    help='Secret key.')

parser.add_argument('-it', '--interval-time', dest='time', type=int, default=60,
                    help='Time for check interval. Default is 60s.')

args = parser.parse_args()

因为 AWS 的 API 对域名的格式有要求,必须要以 “.” 结尾,这代表一个域名的结束,所以下面的代码对传入的域名变量做出调整:

# 处理域名后面的 "."
if args.domain[:-1] == ".":
    domain_name = args.domain
else:
    domain_name = args.domain + "."

这个脚本目前只支持 A 与 AAAA 解析类型,分别对应 IPv4 与 IPv6,后续计划支持其他类型。需要注意的是:AWS 不支持同一子域同时存在 A/AAAA 记录和 CNAME 记录:

# 判断解析类型
if args.ver == 4:
    rc_type = "A"
else:
    rc_type = "AAAA"

默认情况下,脚本会选择 IPv4 进行解析,同时访问我的私有 IP 检测服务地址获取当前的公网 IP,后续会增加其他服务商的检测服务并建立一个参数让大家指定检测服务:

# 获取本机 IP
def my_ip(ip_ver):
    if ip_ver == 4:
        get_ip_value = requests.get('https://ipv4.ngx.hk', timeout=30).content.decode().strip('\n')
    else:
        get_ip_value = requests.get('https://ipv6.ngx.hk', timeout=30).content.decode().strip('\n')
    return get_ip_value

其实 AWS Route53 的功能非常强大,而且 API 简洁明了,所以这个脚本的逻辑也极其简单:

  1. 获取本地公网IP
  2. 尝试打开一个临时文件
    1. 文件存在:与新获取的公网 IP 相比较:
      1. 新旧 IP 一样:脚本逻辑终止
      2. 新旧 IP 不一样:调用 AWS API 更新 DNS 记录并写入临时文件
    2. 文件不存在:调用 AWS API 更新 DNS 记录并写入临时文件

调用 AWS API 更新 DNS 记录之前还会判断账户下是否有指定的域名和子域名,如果没有或者存在 CNAME就会返回错误信息并以 Code 1 状态退出脚本。

但需要注意的是,上面所说的 “没有” 还包含权限不足的情况,具体请留意下一章节。

调用 AWS API 需要指定域名的 ID,这部分的代码如下:

# 获取域名的 zone id
def get_zone_id():
    response_content = client.list_hosted_zones()
    for i in response_content["HostedZones"]:
        if i["Name"] == domain_name:
            zone_id = i["Id"].split("/")[2]
            return zone_id
    else:
        return None

通过上面获取到的域名 ID 及其他参数组合成字典,然后再次调用 AWS API:

# 创建/更新 子域名
def upsert_sub_domain(zone_id, ip_addr):
    response_content = client.change_resource_record_sets(
        HostedZoneId=zone_id,
        ChangeBatch={
            'Comment': 'NGX DDNS Record',
            'Changes': [
                {
                    'Action': 'UPSERT',
                    'ResourceRecordSet': {
                        'Name': args.subdomain + '.' + domain_name,
                        'Type': rc_type,
                        'TTL': args.ttl,
                        'ResourceRecords': [
                            {
                                'Value': ip_addr
                            },
                        ],
                    }
                },
            ]
        }
    )

    request_id = response_content['ChangeInfo']['Id'].split('/')[2]
    return request_id

如果成功,这里会返还一个 Change ID,在 AWS Route53 的体系中,更改 DNS 记录之后的状态是 Pending,稍等几秒后再次调用 AWS API 查询这次变更的最终状态,如果处于 INSYNC 状态则说明变更完成:

# 检查解析状态
def chk_status(request_id):
    response_content = client.get_change(
        Id=request_id
    )

    if response_content['ChangeInfo']['Status'] == "INSYNC":
        return True
    else:
        return response_content

将上面的函数根据设计好的逻辑进行组合:

def run():
    my_ip_addr = my_ip(args.ver)
    time_now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')

    # 读取临时文件
    r_content = ''
    try:
        r = open('./ddns_temporary.log', 'r')
    except FileNotFoundError:
        pass
    else:
        r_content = r.read()
        r.close()

    # 判断 IP 是否有变化
    if r_content == my_ip_addr:
        return time_now + " The parameter IP same as old one."
    else:
        zone_id = get_zone_id()

    # 更新
    if zone_id is not None:
        response_content = upsert_sub_domain(zone_id, my_ip_addr)

        # 等待同步
        time.sleep(6)

        # 检查同步状态
        if chk_status(response_content):
            r = open('./ddns_temporary.log', 'w')
            r.write(my_ip_addr)
            r.close()
            return time_now + " DDNS UPDATED, IP: " + my_ip_addr
        else:
            return time_now + " Error, " + str(response_content)
    else:
        print(time_now + " Error, Domain name not exist!")
        exit(1)

最后循环调用:

if __name__ == '__main__':
    while True:
        print(run())
        time.sleep(args.time)

0x03 权限

无论是自用还是商用,安全都是首先要考虑的,这里强烈建议使用 AWS IAM 进行权限控制。首先在 服务搜索框 中搜索 IAM 并打开:

然后新建一个策略:

任何选择目标服务为 Route53 并选择以下三项访问级别:

  • 列表(只读):
    • GetChange:获取 DNS 解析变更状态
    • ListHostedZones:列出授权的所有域名
  • 写入(写入):
    • ChangeResourceRecordSets:修改指定域名的 DNS 解析

默认情况下,每一种访问级别都是全局的,权限极其大,建议只给这个策略赋予某个或指定多个域名的权限:

这里可以指定 ARN(资源名称)的 ID,在这里的这个 ID 指域名 ID,而域名 ID 可以在这里找到:

请留意 resource-record-sets 的值,该值就是这个域名的 zone id,也是 ARN ID,将它填写到 ChangeResourceRecordSets 中的 ARN ID 输入框中即可,而 GetChange 则选择 “任意”:

当使用这个策略查询域名和子域都只会显示 ngx.hk,而查询其他 ID 都会响应无权限。因为 Change ID 是每次调用 AWS API 后随机生成的,所以勾选任意即可。最后单击 “查看策略” 跳转到下一个页面并完成创建步骤即可:

然后新建一个用户:

只需要赋予密钥访问权限即可:

附加权限并设定权限边界:

然后单击 “下一步:标签”,跟随提示完成用户创建即可,最后即可获取到该用户的密钥信息:

至此,完成所有权限配置工作,接下来即可通过 Docker 或者在 Rancher 中部署。

0x04 部署

通过 help 参数可以浏览所有参数:

[root@b6b102 route53_docker]# python3 main.py --help
usage: main.py [-h] -d DOMAIN -s SUBDOMAIN [-t SECOND] [-v VER] -id ID -sk SK
               [-it TIME]

AWS Route53 DDNS Python Script by NGX.HK

optional arguments:
  -h, --help            show this help message and exit
  -d DOMAIN, --domain DOMAIN
                        Domain name. With a dot in the end of domain name or
                        not are both ok. Like: With a dot: ngx.hk. Without a
                        dot: ngx.hk
  -s SUBDOMAIN, --subdomain SUBDOMAIN
                        Subdomain. Only a-z, A-Z, 0-9 and '-' accepted. Like:
                        ddns, home-ddns
  -t SECOND, --ttl SECOND
                        Value for Time To Live. Default is 600s. You can set
                        lower or higher TTL in some DNS service provider, like
                        AWS Route53. Be careful with your wallet! Lower TTL
                        may be good for your services but cost more money,
                        read DNS service provider doc., first.
  -v VER, --ip-ver VER  IP version. Default is 4, means IPv4.
  -id ID, --key-id ID   Access key id.
  -sk SK, --secret-key SK
                        Secret key.
  -it TIME, --interval-time TIME
                        Time for check interval. Default is 60s.

直接运行:

[root@b6b102 route53_docker]# python3 main.py -d ngx.hk -s ddns -id AKIAYH26WAZEWUJ25KNC -sk xxx 
2019-10-26 21:19:03 DDNS UPDATED, IP: 113.110.201.000
2019-10-26 21:20:18 The parameter IP same as old one.

如果域名不存在或无权限,则会显示:

[root@b6b102 route53_docker]# python3 main.py -d ngx-test.hk -s ddns -id AKIAYH26WAZEWUJ25KNC -sk xxx 
2019-10-26 21:22:02 Error, Domain name not exist!

如果子域名不存在,则会自动创建,如果子域名存在 CNAME 记录,则会报错:

[root@b6b102 route53_docker]# python3 main.py -d ngx.hk -s ddns -id AKIAYH26WAZEWUJ25KNC -sk xxx 
Traceback (most recent call last):
  File "main.py", line 158, in <module>
    print(run())
  File "main.py", line 138, in run
    response_content = upsert_sub_domain(zone_id, my_ip_addr)
  File "main.py", line 82, in upsert_sub_domain
    'Value': ip_addr
  File "/usr/local/lib/python3.6/site-packages/botocore/client.py", line 357, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/usr/local/lib/python3.6/site-packages/botocore/client.py", line 661, in _make_api_call
    raise error_class(parsed_response, operation_name)
botocore.errorfactory.InvalidChangeBatch: An error occurred (InvalidChangeBatch) when calling the ChangeResourceRecordSets operation: [RRSet of type A with DNS name ddns.ngx.hk. is not permitted because a conflicting RRSet of type  CNAME with the same DNS name already exists in zone ngx.hk.]

使用 Docker 只需要一行命令:

docker run -itd \
 --restart always
 --name [container name] \
 ngxproj/route53_ddns:[tag name] \
 --d=[domain name] \
 --s=[subdomain \
 --id=xxxx \
 --sk=xxxx  

如果需要在 Rancher 中运行,只需要将参数部分填写到 CMD 对话框中即可,具体请留意以下文章中的配图与说明:

0x05 结语

阿里云在不久前改了套餐,性价比极速降低,如果只是家用的 DDNS,完全可以选择更优的 AWS Route53。

0x06 其他: