0x01 前言

我的博客和腾讯云CDN都是用let’s encrypt的数字证书,每次签发的有效期为90天,也就是说每年我至少需要手动更换4次数字证书。

这里有个大问题,我都懒得出汁了,监控数字证书的有效期都要依靠腾讯云的服务,每次需要更新我都是等到最后几天才进行操作。为此,我决定写个简单的脚本,实现自动更换腾讯云CDN的数字证书。

0x02 准备

在使用该脚本前需要先在腾讯云CDN服务中完成域名的初步配置,也就是完成域名的添加并处于正常服务状态。完成添加的域名是否已启用HTTPS并不重要,在本脚本中的配置文件有相关设置内容,请注意本文的相关内容。

确认腾讯云CDN的服务处于正常状态后,需要准备调用API时用于验证的SecretId与SecretKey,该部分请根据腾讯云的文档进行安全配置,在这里我给出最快捷但不安全的获取方式:

登入腾讯云的管理界面后,将鼠标放置在用户名上,在弹出的菜单中选择“访问管理”打开管理页面,然后单击“云API密钥”中的“API密钥管理”,在打开的页面中新建或查看相关API密钥对。

注意!在此处建立API密钥对将有可能让你的腾讯云账户处于危险的处境,请根据腾讯云的相关手册进行安全配置!

将此密钥对记录到本地,稍后需要填写到脚本中。

第三步需要准备有效的数字证书,数字证书必须处于有效期内,必须是由可信的颁发机构颁发的且格式为pem。

当数字证书未生效、已过期或不受信任将使脚本运行错误并使腾讯云返还错误信息。有误的数字证书并不会对生产环境造成影响,因为它无法通过脚本和腾讯云的验证。

0x03 脚本

0x03.1 逻辑

我这个脚本的逻辑很简单,主要是校验证书的有效期与相关域名是否在该证书的授权范围内。如果一切正常、有效,则调用腾讯云的API。

脚本内并无校验证书有效性的模块,如果证书是由不受信任的颁发机构颁发的,调用腾讯云API后将返还错误信息。

0x03.2 配置文件

以下是配置文件的内容,json格式,可添加多个域名:

{
  "domain1.com": {
    "https_type": 2,
    "https_force_switch": 2,
    "http2": "on",
    "secret_id": "",
    "secret_key": "",
    "cert_filename": "",
    "key_filename": "",
    "validity": "10"
  },
  "www.domain1.com": {
    "https_type": 2,
    "https_force_switch": 2,
    "http2": "on",
    "secret_id": "",
    "secret_key": "",
    "cert_filename": "",
    "key_filename": "",
    "validity": "10"
  }
}

相关字段的意义如下:

腾讯云CDN HTTPS配置API相关文档地址:HTTPS 配置

  • https_type:对应以上文档地址中的输入参数“httpsType”,“2”为上传自有证书,并协议跟随回源;
  • https_force_switch:对应以上文档地址中的输入参数“forceSwitch”,“2”开启 https 强制跳转(302);
  • http2:是否开启HTTP/2的支持;
  • secret_id:腾讯云API密钥对中的“SecretId”;
  • secret_key:腾讯云API密钥对中的“SecretKey”;
  • cert_filename:定义数字证书的文件名;
  • key_filename定义数字证书私钥的文件名;
  • validity:如果证书有效期大于设定值将执行脚本。

以上内容请根据实际情况进行配置,在我的使用环境中使用了通配数字证书,所以可以在各个域名下的配置文件中配置不同的证书与私钥文件名。

0x03.3 证书SAN字段

Subject Alternative Name简称为:SAN,该字段中记录着数字证书授权的所有域名:

在脚本中将读取该字段的内容,与配置文件中的key,也就是域名相比对。如果配置文件中的域名在该数字证书的授权范围内,则通过验证,否则将抛出错误并跳过。

在提取该字段的模块中,我是用cryptography这个函数:

# 查找证书的有效期、序列号
# 查找证书中包含的所有域名
def check_cert_info(cert_file_path):
    crt_open = open(cert_file_path, 'r')
    crt_content = crt_open.read()
    crt_open.close()

    crt = x509.load_pem_x509_certificate(crt_content.encode(), default_backend())

    # 获取证书序列号
    crt_serial_number = crt.serial_number

    # 获取alt name
    crt_altname = crt.extensions.get_extension_for_oid(x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
    crt_altname = crt_altname.value.get_values_for_type(x509.DNSName)

    # 获取生效时间
    crt_not_valid_before = crt.not_valid_before
    # 获取过期时间
    crt_not_valid_after = crt.not_valid_after

    crt_output = dict()
    crt_output['crt_altname'] = crt_altname
    crt_output['crt_not_valid_before'] = crt_not_valid_before
    crt_output['crt_not_valid_after'] = crt_not_valid_after
    crt_output['crt_serial_number'] = crt_serial_number

    return crt_output

该自定义函数输出字典格式的内容,内容如下:

{'crt_serial_number': 311947205282921440934715912729475609824391, 'crt_not_valid_after': datetime.datetime(2018, 12, 31, 15, 59, 26), 'crt_not_valid_before': datetime.datetime(2018, 10, 2, 15, 59, 26), 'crt_altname': ['*.ngx.hk', 'ngx.hk']}

返还的内容中除了SAN字段的域名以外,还包含该证书的序列号与有效期。其中有效期为datetime类型,方便传递到下一个函数进行运算。

0x03.4 证书有效期

首先需要确认证书已生效,然后判断证书的失效期大于配置文件中“validity”的值,确认无误后分别将证书与私钥的内容进行base64编码:

def format_cert_key(domain_name, crt_file_name, key_file_name, crt_not_valid_before, crt_not_valid_after):
    crt_file_path = cert_file_folder + crt_file_name
    key_file_path = cert_file_folder + key_file_name

    datetime_now = datetime.utcnow()

    # 如果现在的时间大于证书生效时间,则为True
    if datetime_now >= crt_not_valid_before:
        # 如果证书失效时间-现在的时间大于配置文件中validity(单位:天)的值,则为True
        if (crt_not_valid_after - datetime_now).days >= int(config_json[domain_name]['validity']):
            crt_content = try_to_open_file(crt_file_path, 1)
            crt_base64 = base64.encodebytes(crt_content[1].encode()).decode()

            key_content = try_to_open_file(key_file_path, 1)
            key_base64 = base64.encodebytes(key_content[1].encode()).decode()

            output_dict = dict()
            output_dict['crt'] = crt_base64
            output_dict['key'] = key_base64
            return output_dict
        else:
            return False
    else:
        return False

如果相关的时间有误,则返还False。

0x03.5 检查CDN域名

在调试中我觉得需要增加一个检查腾讯CDN域名的模块,以此进一步提升可靠性:

def get_cdn_domain(config):
    # 腾讯云基础设定
    action = 'DescribeCdnHosts'
    module = 'cdn'
    params = {
        'detail': 0
    }
    # 调用API
    service = QcloudApi(module, config)
    # 可输出编码后的URL,主要用于日志,也可以生成URL后手动执行
    # 自动化应用一般不需要
    # print(service.generateUrl(action, params))
    # 执行API
    qcloud_output = service.call(action, params).decode()
    qcloud_output_json = json.loads(qcloud_output)

    cdn_host_list = []
    for i in qcloud_output_json['data']['hosts']:
        cdn_host_list.append(i['host'])

    return cdn_host_list

该模块会调用腾讯云CDN的API,获取CDN服务中所有的域名并以字典的形式输出,方便后续的模块检查配置文件中的域名是否已完成配置。

0x03.6 SAN校验

既然获得了证书中SAN字段的内容,接下来就需要进行校验,以下是校验模块的代码:

def crt_chk_alt_name(domain, alt_name):
    for i in alt_name:
        if i[0] == "*":
            i = i[2:]
            domain = domain.replace(i, '')
            if domain.count('.') == 1:
                return True
            else:
                pass
        else:
            if i == domain:
                return True
            else:
                pass
    return False

一开始我是使用正则来进行校验的,但实现起来过于复杂,最终形成了以上的形式。首先分为两种情况,一种是适配通配域名,另一种是普通域名。

首先函数接收两个传入参数,使用遍历功能逐一匹配alt_name变量中的域名。

  1. 如果第1个字符为“*”,则为通配域名,选取第3个到最后一个字符的内容传递给变量“i”;
  2. 将该变量中与变量“domain”相同的部分删除并重新赋值给变量“domain”;
  3. 统计变量“domain”中“.”的数量,如果为1则返还True,否则跳过此次循环。

第二种情况比较简单,如果变量“domain”匹配上列表“alt_name”中的某个值,则返还True否则跳过此次循环。

如果循环结束还没返还True,则返还False。

在这里演示下比较复杂的部分,举个例子,通配域名是这样的:*.ngx.hk,而我的CDN域名是这样的:cdn.ngx.hk,我需要怎样才能判断CDN域名在通配域名的子集里?

首先通配域名有一个特征,第一个字符肯定是星号“*”,后面跟着一个半个字符“.”,这样从第3个字符开始到最后一个字符为域名,这里成为域名i。

此时只需要将CDN域名中与域名i相同的部分删除,就会剩下“cdn.”。如果不是,则说明该CDN域名不在通配域名的子集里!

最后只需要计算删除后剩下的部分中的半个字符“.”的数量,如果为1则通过,否则为False。

0x03.7 临时文件

最后还需要一个临时文件的帮助,减少不必要的API调用。因为API调用次数在某一时段内是有限制的,所需要记录下域名与数字证书的序列号。

当脚本执行时或检索该临时文件,找到相关的序列号,如果临时文件中的序列号与证书文件中的一致,则不调用腾讯云CDN API,因为正是并未发生改动。

def write_temp_file(qcloud_output, domain, crt_serial_number):
    qcloud_output_json = json.loads(qcloud_output)
    if qcloud_output_json['code'] == 0:
        o_file = try_to_open_file(tmp_file_path, 0)
        if o_file[0]:
            content_dict = json.loads(o_file[1])
        else:
            content_dict = dict()

        o_file = open(tmp_file_path, 'w')
        content_dict[domain] = crt_serial_number
        content_dict = json.dumps(content_dict)
        o_file.write(str(content_dict))
        o_file.close()
    else:
        pass

0x04 结语

该脚本尚待完善,但基本功能已经实现,只需要将证书放置在cert目录中并填写配置文件,通过cronjob定时调用即可。

具体、最新的代码可关注我的私有gitlab: