688 lines
20 KiB
Python
688 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
@File : issue_certificate_service.py
|
|
@Date : 2023-07-23
|
|
"""
|
|
import json
|
|
import time
|
|
import traceback
|
|
from datetime import datetime, timedelta
|
|
|
|
import OpenSSL
|
|
import requests
|
|
|
|
from domain_admin.enums.dns_type_enum import DnsTypeEnum
|
|
from domain_admin.enums.host_auth_type_enum import HostAuthTypeEnum
|
|
from domain_admin.log import logger
|
|
from domain_admin.model.dns_model import DnsModel
|
|
from domain_admin.model.host_model import HostModel
|
|
from domain_admin.model.issue_certificate_model import IssueCertificateModel, ValidStatus, ChallengeDeployTypeEnum, \
|
|
SSLDeployTypeEnum
|
|
from domain_admin.utils import datetime_util, fabric_util, domain_util
|
|
from domain_admin.utils.acme_util import acme_v2_api
|
|
from domain_admin.utils.acme_util.challenge_type import ChallengeType
|
|
from domain_admin.utils.acme_util.directory_type_enum import DirectoryTypeEnum
|
|
from domain_admin.utils.acme_util.key_type_enum import KeyTypeEnum
|
|
from domain_admin.utils.cert_util import cert_common
|
|
from domain_admin.utils.flask_ext.app_exception import AppException
|
|
from domain_admin.utils.open_api import aliyun_domain_api, tencentcloud_domain_api, aliyun_oss_api, aliyun_cdn_api, \
|
|
aliyun_dcdn_api
|
|
from domain_admin.utils.open_api.aliyun_domain_api import RecordTypeEnum
|
|
from domain_admin import config
|
|
|
|
|
|
def issue_certificate(
|
|
domains, user_id,
|
|
directory_type=DirectoryTypeEnum.LETS_ENCRYPT,
|
|
key_type=KeyTypeEnum.RSA
|
|
):
|
|
"""
|
|
申请新证书
|
|
:param key_type:
|
|
:param directory_type:
|
|
:param domains:
|
|
:param user_id:
|
|
:return:
|
|
"""
|
|
# Issue certificate
|
|
|
|
# Create domain private key and CSR
|
|
pkey_pem, csr_pem = acme_v2_api.new_csr_comp(domains=domains)
|
|
|
|
issue_certificate_row = IssueCertificateModel.create(
|
|
user_id=user_id,
|
|
# challenge_type=challenge_type,
|
|
domain_raw=json.dumps(domains),
|
|
ssl_certificate_key=pkey_pem,
|
|
directory_type=directory_type,
|
|
key_type=key_type,
|
|
# status='pending',
|
|
)
|
|
|
|
return issue_certificate_row
|
|
|
|
|
|
def get_certificate_challenges(issue_certificate_id):
|
|
"""
|
|
获取验证方式
|
|
:param issue_certificate_id:
|
|
:return:
|
|
"""
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id)
|
|
# Create domain private key and CSR
|
|
domains = issue_certificate_row.domains
|
|
pkey_pem = issue_certificate_row.ssl_certificate_key
|
|
|
|
pkey_pem, csr_pem = acme_v2_api.new_csr_comp(
|
|
domains=domains,
|
|
pkey_pem=pkey_pem,
|
|
)
|
|
|
|
acme_client = acme_v2_api.get_acme_client(
|
|
directory_type=issue_certificate_row.directory_type,
|
|
key_type=issue_certificate_row.key_type
|
|
)
|
|
orderr = acme_client.new_order(csr_pem)
|
|
|
|
# Select HTTP-01 within offered challenges by the CA server
|
|
lst = []
|
|
|
|
for domain, domain_challenges in acme_v2_api.select_challenge(orderr).items():
|
|
for challenge in domain_challenges:
|
|
response, validation = challenge.response_and_validation(acme_client.net.key)
|
|
|
|
data = {
|
|
'domain': domain,
|
|
'sub_domain': domain_util.get_subdomain(domain),
|
|
'root_domain': domain_util.get_root_domain(domain),
|
|
'validation': validation,
|
|
'challenge': challenge
|
|
}
|
|
|
|
lst.append(data)
|
|
|
|
return lst
|
|
|
|
|
|
def verify_certificate(issue_certificate_id, challenge_type, verify_deploy_path, host_id, dns_id):
|
|
"""
|
|
验证域名
|
|
:param issue_certificate_id:
|
|
:param challenge_type:
|
|
:return:
|
|
"""
|
|
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id)
|
|
|
|
items = get_certificate_challenges(issue_certificate_id)
|
|
acme_client = acme_v2_api.get_acme_client(
|
|
directory_type=issue_certificate_row.directory_type,
|
|
key_type=issue_certificate_row.key_type
|
|
)
|
|
# 如果有challenge_type对应的部署信息,则进行部署
|
|
if challenge_type == ChallengeType.HTTP01 and verify_deploy_path and host_id:
|
|
itemsCopy = []
|
|
for item in items:
|
|
temp = item['challenge'].to_json()
|
|
temp['validation'] = item['validation']
|
|
itemsCopy.append(temp)
|
|
deploy_verify_file(
|
|
host_id=host_id,
|
|
verify_deploy_path=verify_deploy_path,
|
|
challenges=itemsCopy
|
|
)
|
|
|
|
if challenge_type == ChallengeType.DNS01 and dns_id:
|
|
add_dns_domain_record(
|
|
dns_id=dns_id,
|
|
challenges=items
|
|
)
|
|
# 开始验证
|
|
verify_count = 0
|
|
for item in items:
|
|
challenge = item['challenge']
|
|
challenge_json = challenge.to_json()
|
|
|
|
# 指定认证类型
|
|
if challenge_type != challenge_json['type']:
|
|
continue
|
|
|
|
response, validation = challenge.response_and_validation(acme_client.net.key)
|
|
logger.info(validation)
|
|
|
|
# Let the CA server know that we are ready for the challenge.
|
|
acme_client.answer_challenge(challenge, response)
|
|
|
|
count = 0
|
|
max_count = 5
|
|
|
|
while True:
|
|
count += 1
|
|
|
|
status = get_challenge_status(challenge_json['url'])
|
|
|
|
if status == 'valid':
|
|
IssueCertificateModel.update(
|
|
status=status,
|
|
update_time=datetime_util.get_datetime()
|
|
).where(
|
|
IssueCertificateModel.id == issue_certificate_id
|
|
).execute()
|
|
|
|
break
|
|
|
|
if count >= max_count:
|
|
raise AppException("域名验证失败:{}".format(item['domain']))
|
|
|
|
time.sleep(count)
|
|
verify_count += 1
|
|
|
|
if verify_count == 0:
|
|
raise AppException("域名验证失败")
|
|
|
|
# if success update challenge_type
|
|
IssueCertificateModel.update(
|
|
challenge_type=challenge_type,
|
|
).where(
|
|
IssueCertificateModel.id == issue_certificate_id
|
|
).execute()
|
|
|
|
|
|
def renew_certificate(row_id):
|
|
"""
|
|
Renew certificate
|
|
:param row_id:
|
|
:return:
|
|
"""
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(row_id)
|
|
|
|
pkey_pem = issue_certificate_row.ssl_certificate_key
|
|
domains = issue_certificate_row.domains
|
|
|
|
acme_client = acme_v2_api.get_acme_client(
|
|
directory_type=issue_certificate_row.directory_type,
|
|
key_type=issue_certificate_row.key_type
|
|
)
|
|
|
|
# Create domain private key and CSR
|
|
pkey_pem, csr_pem = acme_v2_api.new_csr_comp(
|
|
domains=domains,
|
|
pkey_pem=pkey_pem,
|
|
)
|
|
|
|
orderr = acme_client.new_order(csr_pem)
|
|
|
|
# Select HTTP-01 within offered challenges by the CA server
|
|
# challb = acme_v2_api.select_challenge(orderr, challenge_type)
|
|
# logger.debug(json_util.json_dump(challb.to_json()))
|
|
|
|
# Performing challenge
|
|
|
|
fullchain_pem = acme_v2_api.perform_http01(acme_client, orderr)
|
|
|
|
logger.debug(fullchain_pem)
|
|
|
|
fullchain_com = OpenSSL.crypto.load_certificate(
|
|
OpenSSL.crypto.FILETYPE_PEM, fullchain_pem
|
|
)
|
|
|
|
cert = cert_common.parse_cert(fullchain_com)
|
|
|
|
IssueCertificateModel.update(
|
|
ssl_certificate=fullchain_pem,
|
|
start_time=cert.notBefore,
|
|
expire_time=cert.notAfter,
|
|
update_time=datetime_util.get_datetime()
|
|
).where(
|
|
IssueCertificateModel.id == issue_certificate_row.id
|
|
).execute()
|
|
|
|
|
|
def get_challenge_status(url):
|
|
"""
|
|
:param url:
|
|
:return:
|
|
{
|
|
"type": "http-01",
|
|
"status": "valid",
|
|
"url": "https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/8189968164/2x43yw",
|
|
"token": "uEy2gzPlQN1IIE541sbRK23AQWFh87MvmvzMDOUvIKk",
|
|
"validationRecord": [],
|
|
"validated": "2023-07-23T08:59:59Z"
|
|
}
|
|
"""
|
|
logger.debug(url)
|
|
|
|
res = requests.get(url)
|
|
|
|
data = res.json()
|
|
logger.debug(data)
|
|
|
|
return data['status']
|
|
|
|
|
|
def renew_all_certificate():
|
|
"""
|
|
更新所有证书
|
|
:return:
|
|
"""
|
|
|
|
now = datetime.now()
|
|
notify_expire_time = now + timedelta(days=config.DEFAULT_RENEW_DAYS)
|
|
|
|
rows = IssueCertificateModel.select().where(
|
|
(IssueCertificateModel.is_auto_renew == True)
|
|
& (
|
|
(IssueCertificateModel.expire_time <= notify_expire_time)
|
|
| (IssueCertificateModel.expire_time == None)
|
|
)
|
|
).order_by(IssueCertificateModel.expire_time.asc())
|
|
|
|
for row in rows:
|
|
try:
|
|
renew_certificate_row(row)
|
|
|
|
IssueCertificateModel.update(
|
|
renew_status=0,
|
|
renew_message='成功',
|
|
update_time=datetime.now()
|
|
).where(
|
|
IssueCertificateModel.id == row.id
|
|
).execute()
|
|
|
|
except Exception as e:
|
|
logger.error(traceback.format_exc())
|
|
|
|
IssueCertificateModel.update(
|
|
renew_status=1,
|
|
renew_message=str(e),
|
|
update_time=datetime.now()
|
|
).where(
|
|
IssueCertificateModel.id == row.id
|
|
).execute()
|
|
|
|
|
|
def renew_certificate_row(row):
|
|
"""
|
|
证书自动续期
|
|
:param row:
|
|
:return:
|
|
"""
|
|
# 重新申请
|
|
pkey_pem, csr_pem = acme_v2_api.new_csr_comp(
|
|
domains=row.domains,
|
|
)
|
|
|
|
IssueCertificateModel.update(
|
|
ssl_certificate_key=pkey_pem,
|
|
ssl_certificate='',
|
|
start_time=None,
|
|
expire_time=None,
|
|
status=ValidStatus.PENDING,
|
|
).where(
|
|
IssueCertificateModel.id == row.id
|
|
)
|
|
|
|
# 验证文件部署 & 验证域名
|
|
verify_certificate(
|
|
issue_certificate_id=row.id,
|
|
challenge_type=row.challenge_type,
|
|
verify_deploy_path=row.deploy_verify_path,
|
|
host_id=row.challenge_deploy_id,
|
|
dns_id=row.challenge_deploy_id
|
|
)
|
|
|
|
# 下载证书
|
|
renew_certificate(row.id)
|
|
|
|
# 自动部署,重启服务
|
|
if row.deploy_type_id == SSLDeployTypeEnum.SSH:
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(row.id)
|
|
|
|
deploy_certificate_file(
|
|
host_id=row.deploy_host_id,
|
|
key_content=issue_certificate_row.ssl_certificate_key,
|
|
pem_content=issue_certificate_row.ssl_certificate,
|
|
key_deploy_path=row.deploy_key_file,
|
|
pem_deploy_path=row.deploy_fullchain_file,
|
|
reload_cmd=row.deploy_reloadcmd
|
|
)
|
|
elif row.deploy_type_id == SSLDeployTypeEnum.WEB_HOOK:
|
|
deploy_ssl_by_web_hook(
|
|
issue_certificate_id=row.id,
|
|
url=row.deploy_url,
|
|
headers=row.deploy_header,
|
|
)
|
|
|
|
elif row.deploy_type_id == SSLDeployTypeEnum.OSS:
|
|
deploy_cert_to_oss(
|
|
issue_certificate_id=row.id,
|
|
dns_id=row.deploy_host_id
|
|
)
|
|
|
|
elif row.deploy_type_id == SSLDeployTypeEnum.CDN:
|
|
deploy_cert_to_cdn(
|
|
issue_certificate_id=row.id,
|
|
dns_id=row.deploy_host_id
|
|
)
|
|
elif row.deploy_type_id == SSLDeployTypeEnum.DCDN:
|
|
deploy_cert_to_dcdn(
|
|
issue_certificate_id=row.id,
|
|
dns_id=row.deploy_host_id
|
|
)
|
|
|
|
|
|
def deploy_verify_file(host_id, verify_deploy_path, challenges):
|
|
"""
|
|
部署验证文件
|
|
:return:
|
|
"""
|
|
logger.info(challenges)
|
|
|
|
host_row = HostModel.get_by_id(host_id)
|
|
|
|
host = host_row.host
|
|
port = host_row.port
|
|
user = host_row.user
|
|
password = host_row.password
|
|
private_key = host_row.private_key
|
|
auth_type = host_row.auth_type
|
|
|
|
for row in challenges:
|
|
if not row['token']:
|
|
raise AppException('token is empty')
|
|
|
|
verify_deploy_filename = verify_deploy_path + row['token']
|
|
|
|
if not verify_deploy_filename:
|
|
raise AppException('verify_deploy_filename is empty')
|
|
|
|
logger.debug("verify_deploy_filename: %s", verify_deploy_filename)
|
|
|
|
if not row['validation']:
|
|
raise AppException('validation is empty')
|
|
|
|
if auth_type == HostAuthTypeEnum.PRIVATE_KEY:
|
|
fabric_util.deploy_file_by_key(
|
|
host=host,
|
|
port=port,
|
|
user=user,
|
|
private_key=private_key,
|
|
content=row['validation'],
|
|
remote=verify_deploy_filename
|
|
)
|
|
else:
|
|
fabric_util.deploy_file(
|
|
host=host,
|
|
port=port,
|
|
user=user,
|
|
password=password,
|
|
content=row['validation'],
|
|
remote=verify_deploy_filename
|
|
)
|
|
|
|
|
|
def deploy_certificate_file(
|
|
host_id,
|
|
key_content,
|
|
key_deploy_path,
|
|
pem_content,
|
|
pem_deploy_path,
|
|
reload_cmd
|
|
):
|
|
"""
|
|
自动部署SSL证书到服务器
|
|
:param host_id: 主机id
|
|
|
|
:param key_deploy_path: 私钥部署路径
|
|
:param key_content: 私钥内容
|
|
|
|
:param pem_deploy_path: 公钥部署路径
|
|
:param pem_content: 公钥内容
|
|
|
|
:param reload_cmd: 重启命令
|
|
:return:
|
|
"""
|
|
host_row = HostModel.get_by_id(host_id)
|
|
|
|
host = host_row.host
|
|
port = host_row.port
|
|
user = host_row.user
|
|
password = host_row.password
|
|
auth_type = host_row.auth_type
|
|
private_key = host_row.private_key
|
|
|
|
# deploy key
|
|
if key_deploy_path:
|
|
if auth_type == HostAuthTypeEnum.PRIVATE_KEY:
|
|
fabric_util.deploy_file_by_key(
|
|
host=host,
|
|
port=port,
|
|
user=user,
|
|
private_key=private_key,
|
|
content=key_content,
|
|
remote=key_deploy_path
|
|
)
|
|
else:
|
|
fabric_util.deploy_file(
|
|
host=host,
|
|
port=port,
|
|
user=user,
|
|
password=password,
|
|
content=key_content,
|
|
remote=key_deploy_path
|
|
)
|
|
|
|
# deploy ssl_certificate
|
|
if pem_deploy_path:
|
|
if auth_type == HostAuthTypeEnum.PRIVATE_KEY:
|
|
fabric_util.deploy_file_by_key(
|
|
host=host,
|
|
port=port,
|
|
user=user,
|
|
private_key=private_key,
|
|
content=pem_content,
|
|
remote=pem_deploy_path
|
|
)
|
|
else:
|
|
fabric_util.deploy_file(
|
|
host=host,
|
|
port=port,
|
|
user=user,
|
|
password=password,
|
|
content=pem_content,
|
|
remote=pem_deploy_path
|
|
)
|
|
|
|
# reload
|
|
if reload_cmd:
|
|
if auth_type == HostAuthTypeEnum.PRIVATE_KEY:
|
|
fabric_util.run_command_by_key(
|
|
host=host,
|
|
port=port,
|
|
user=user,
|
|
private_key=private_key,
|
|
command=reload_cmd
|
|
)
|
|
else:
|
|
fabric_util.run_command(
|
|
host=host,
|
|
port=port,
|
|
user=user,
|
|
password=password,
|
|
command=reload_cmd
|
|
)
|
|
|
|
|
|
def add_dns_domain_record(dns_id, challenges):
|
|
"""
|
|
添加dns记录
|
|
:param dns_id:
|
|
:param challenges:
|
|
:return:
|
|
"""
|
|
dns_row = DnsModel.get_by_id(dns_id)
|
|
|
|
for challenge_row in challenges:
|
|
challenge_json = challenge_row['challenge'].to_json()
|
|
if challenge_json['type'] == ChallengeType.DNS01:
|
|
|
|
if challenge_row['sub_domain']:
|
|
record_key = '_acme-challenge.' + challenge_row['sub_domain']
|
|
else:
|
|
record_key = '_acme-challenge'
|
|
if dns_row.dns_type_id == DnsTypeEnum.ALIYUN:
|
|
aliyun_domain_api.add_domain_record(
|
|
access_key_id=dns_row.access_key,
|
|
access_key_secret=dns_row.secret_key,
|
|
domain_name=challenge_row['root_domain'],
|
|
record_type=RecordTypeEnum.TXT,
|
|
record_key=record_key,
|
|
record_value=challenge_row['validation']
|
|
)
|
|
elif dns_row.dns_type_id == DnsTypeEnum.TENCENT_CLOUD:
|
|
tencentcloud_domain_api.add_domain_record(
|
|
access_key_id=dns_row.access_key,
|
|
access_key_secret=dns_row.secret_key,
|
|
domain_name=challenge_row['root_domain'],
|
|
record_type=RecordTypeEnum.TXT,
|
|
record_key=record_key,
|
|
record_value=challenge_row['validation']
|
|
)
|
|
|
|
|
|
def deploy_ssl_by_web_hook(issue_certificate_id, url, headers):
|
|
"""
|
|
通过webhook部署ssl证书
|
|
:param issue_certificate_id:
|
|
:param url:
|
|
:param headers:
|
|
:return:
|
|
"""
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id)
|
|
|
|
if not issue_certificate_row:
|
|
raise AppException('数据不存在')
|
|
|
|
data = {
|
|
'domains': issue_certificate_row.domains,
|
|
'ssl_certificate': issue_certificate_row.ssl_certificate,
|
|
'ssl_certificate_key': issue_certificate_row.ssl_certificate_key,
|
|
'start_time': datetime_util.format_datetime(issue_certificate_row.start_time),
|
|
'expire_time': datetime_util.format_datetime(issue_certificate_row.expire_time),
|
|
}
|
|
|
|
res = requests.request(
|
|
method='POST',
|
|
url=url,
|
|
headers=headers,
|
|
json=data
|
|
)
|
|
|
|
if not res.ok:
|
|
raise res.raise_for_status()
|
|
|
|
return res.text
|
|
|
|
|
|
def deploy_cert_to_oss(issue_certificate_id, dns_id):
|
|
"""
|
|
部署ssl证书到oss
|
|
"""
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id)
|
|
|
|
if not issue_certificate_row:
|
|
raise AppException('证书数据不存在')
|
|
|
|
dns_row = DnsModel.get_by_id(dns_id)
|
|
if not dns_row:
|
|
raise AppException('DNS数据不存在')
|
|
|
|
domain = issue_certificate_row.domains[0]
|
|
|
|
oss_info = aliyun_oss_api.cname_to_oss_info(domain)
|
|
if not oss_info:
|
|
raise AppException('dns 未设置')
|
|
|
|
logger.info("oss_info: %s", oss_info)
|
|
|
|
aliyun_oss_api.put_bucket_cname(
|
|
access_key_id=dns_row.access_key,
|
|
access_key_secret=dns_row.secret_key,
|
|
bucket_name=oss_info['bucket_name'],
|
|
domain=domain,
|
|
certificate=issue_certificate_row.ssl_certificate,
|
|
private_key=issue_certificate_row.ssl_certificate_key,
|
|
endpoint=oss_info['endpoint'],
|
|
)
|
|
|
|
|
|
def deploy_cert_to_cdn(issue_certificate_id, dns_id):
|
|
"""
|
|
部署ssl证书到cdn
|
|
"""
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id)
|
|
|
|
if not issue_certificate_row:
|
|
raise AppException('证书数据不存在')
|
|
|
|
dns_row = DnsModel.get_by_id(dns_id)
|
|
if not dns_row:
|
|
raise AppException('DNS数据不存在')
|
|
|
|
domain = issue_certificate_row.domains[0]
|
|
|
|
# oss_info = aliyun_oss_api.cname_to_oss_info(domain)
|
|
# if not oss_info:
|
|
# raise AppException('dns 未设置')
|
|
#
|
|
# logger.info("oss_info: %s", oss_info)
|
|
|
|
aliyun_cdn_api.set_cdn_domain_ssl_certificate_v2(
|
|
access_key_id=dns_row.access_key,
|
|
access_key_secret=dns_row.secret_key,
|
|
domain_name=domain,
|
|
certificate=issue_certificate_row.ssl_certificate,
|
|
private_key=issue_certificate_row.ssl_certificate_key,
|
|
)
|
|
|
|
|
|
def deploy_cert_to_dcdn(issue_certificate_id, dns_id):
|
|
"""
|
|
部署ssl证书到dcdn
|
|
"""
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id)
|
|
|
|
if not issue_certificate_row:
|
|
raise AppException('证书数据不存在')
|
|
|
|
dns_row = DnsModel.get_by_id(dns_id)
|
|
if not dns_row:
|
|
raise AppException('DNS数据不存在')
|
|
|
|
domain = issue_certificate_row.domains[0]
|
|
|
|
aliyun_dcdn_api.set_dcdn_domain_ssl_certificate(
|
|
access_key_id=dns_row.access_key,
|
|
access_key_secret=dns_row.secret_key,
|
|
domain_name=domain,
|
|
certificate=issue_certificate_row.ssl_certificate,
|
|
private_key=issue_certificate_row.ssl_certificate_key,
|
|
)
|
|
|
|
|
|
def check_auto_renew(issue_certificate_id):
|
|
"""
|
|
首次申请,自动判断是否可以自动续期
|
|
:param issue_certificate_id:
|
|
:return:
|
|
"""
|
|
issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id)
|
|
|
|
if issue_certificate_row.can_auto_renew:
|
|
IssueCertificateModel.update(
|
|
is_auto_renew=True
|
|
).where(
|
|
IssueCertificateModel.id == issue_certificate_id
|
|
).execute()
|