From 4c5aa147eb5138531620346149e87538435c4542 Mon Sep 17 00:00:00 2001 From: pengshiyu <1940607002@qq.com> Date: Sun, 30 Jul 2023 11:37:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0SSL=E8=AF=81=E4=B9=A6DNS?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=EF=BC=8C=E8=BF=9C=E7=A8=8B=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=EF=BC=8C=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- domain_admin/api/host_api.py | 78 +++++ domain_admin/api/issue_certificate_api.py | 152 +++++++++- domain_admin/enums/version_enum.py | 4 + domain_admin/migrate/migrate_158_to_159.py | 70 +++++ domain_admin/model/database.py | 4 +- domain_admin/model/host_model.py | 49 +++ domain_admin/model/issue_certificate_model.py | 49 ++- domain_admin/router/api_map.py | 16 +- .../service/issue_certificate_service.py | 281 ++++++++++++++---- domain_admin/service/scheduler_service.py | 6 +- domain_admin/service/version_service.py | 16 +- domain_admin/utils/acme_util/acme_v2_api.py | 77 ++++- .../utils/acme_util/challenge_type.py | 17 ++ domain_admin/utils/cert_util/cert_common.py | 9 - domain_admin/utils/fabric_util.py | 32 ++ domain_admin/utils/ip_util.py | 12 + domain_admin/utils/json_util.py | 6 +- http/issue_certificate.http | 124 ++++++++ requirements/production.txt | 3 +- 19 files changed, 910 insertions(+), 95 deletions(-) create mode 100644 domain_admin/api/host_api.py create mode 100644 domain_admin/migrate/migrate_158_to_159.py create mode 100644 domain_admin/model/host_model.py create mode 100644 domain_admin/utils/acme_util/challenge_type.py create mode 100644 domain_admin/utils/fabric_util.py create mode 100644 http/issue_certificate.http diff --git a/domain_admin/api/host_api.py b/domain_admin/api/host_api.py new file mode 100644 index 0000000000..1b51cd4b53 --- /dev/null +++ b/domain_admin/api/host_api.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" +@File : host_api.py +@Date : 2023-07-29 +""" +from flask import request, g + +from domain_admin.model.host_model import HostModel + + +def add_host(): + current_user_id = g.user_id + + host = request.json['host'] + user = request.json['user'] + password = request.json['password'] + + row = HostModel.create( + user_id=current_user_id, + host=host, + user=user, + password=password, + ) + + return row + + +def update_host_by_id(): + current_user_id = g.user_id + + host_id = request.json['host_id'] + host = request.json['host'] + user = request.json['user'] + password = request.json['password'] + + HostModel.update( + host=host, + user=user, + password=password, + ).where( + HostModel.id == host_id + ).execute() + +def get_host_by_id(): + host_id = request.json['host_id'] + + return HostModel.get_by_id(host_id) + +def get_host_list(): + """ + 主机列表 + :return: + """ + + current_user_id = g.user_id + + page = request.json.get('page', 1) + size = request.json.get('size', 10) + keyword = request.json.get('keyword') + + query = HostModel.select().where( + HostModel.user_id == current_user_id + ) + + if keyword: + query.where(HostModel.host.contains(keyword)) + + total = query.count() + + rows = query.order_by( + HostModel.create_time.desc(), + HostModel.id.desc() + ).paginate(page, size) + + return { + 'list': rows, + 'total': total, + } diff --git a/domain_admin/api/issue_certificate_api.py b/domain_admin/api/issue_certificate_api.py index 3e8683b1f2..19f9c33388 100644 --- a/domain_admin/api/issue_certificate_api.py +++ b/domain_admin/api/issue_certificate_api.py @@ -6,8 +6,12 @@ from flask import g, request from playhouse.shortcuts import model_to_dict +from domain_admin.model.host_model import HostModel from domain_admin.model.issue_certificate_model import IssueCertificateModel from domain_admin.service import issue_certificate_service +from domain_admin.utils import ip_util +from domain_admin.utils.acme_util.challenge_type import ChallengeType +from domain_admin.utils.flask_ext.app_exception import AppException def issue_certificate(): @@ -25,7 +29,7 @@ def issue_certificate(): return model_to_dict( issue_certificate_row, - extra_attrs=['domains', 'create_time_label', 'domain_validation_urls'] + extra_attrs=['domains', 'create_time_label'] ) @@ -37,8 +41,111 @@ def verify_certificate(): current_user_id = g.user_id issue_certificate_id = request.json['issue_certificate_id'] + challenge_type = request.json['challenge_type'] + + issue_certificate_service.verify_certificate(issue_certificate_id, challenge_type) + + issue_certificate_service.renew_certificate(issue_certificate_id) + + + +def get_certificate_challenges(): + issue_certificate_id = request.json['issue_certificate_id'] + + lst = issue_certificate_service.get_certificate_challenges(issue_certificate_id) + + return { + 'total': len(lst), + 'list': lst + } + + +def get_domain_host(): + domain = request.json['domain'] + host = ip_util.get_domain_ip(domain) + + return { + 'domain': domain, + 'host': host + } + + +def deploy_verify_file(): + """ + 部署验证文件 + :return: + """ + current_user_id = g.user_id + + issue_certificate_id = request.json['issue_certificate_id'] + verify_deploy_path = request.json['verify_deploy_path'] + challenges = request.json['challenges'] + host_id = request.json['host_id'] + + if not verify_deploy_path.endswith("/"): + raise AppException("verify_deploy_path must endswith '/'") + + # deploy + issue_certificate_service.deploy_verify_file( + host_id=host_id, + verify_deploy_path=verify_deploy_path, + challenges=challenges + ) + + IssueCertificateModel.update( + deploy_host_id=host_id, + deploy_verify_path=verify_deploy_path, + ).where( + IssueCertificateModel.id == issue_certificate_id + ).execute() + + +def deploy_certificate_file(): + current_user_id = g.user_id + + issue_certificate_id = request.json['issue_certificate_id'] + host_id = request.json['host_id'] + + key_deploy_path = request.json['key_deploy_path'] + pem_deploy_path = request.json['pem_deploy_path'] + reload_cmd = request.json['reloadcmd'] + + host_row = HostModel.get_by_id(host_id) + + host = host_row.host + user = host_row.user + password = host_row.password + + issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id) + + if not issue_certificate_row.ssl_certificate: + issue_certificate_service.renew_certificate(issue_certificate_id) + issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id) + + # deploy key + issue_certificate_service.deploy_certificate_file( + host_id=host_id, + issue_certificate_id=issue_certificate_id, + key_deploy_path=key_deploy_path, + pem_deploy_path=pem_deploy_path, + reload_cmd=reload_cmd + ) - issue_certificate_service.verify_certificate(issue_certificate_id) + # update only support file verify + if issue_certificate_row.challenge_type == ChallengeType.HTTP01: + is_auto_renew = True + else: + is_auto_renew = False + + IssueCertificateModel.update( + deploy_host_id=host_id, + deploy_key_file=key_deploy_path, + deploy_fullchain_file=pem_deploy_path, + deploy_reloadcmd=reload_cmd, + is_auto_renew=is_auto_renew + ).where( + IssueCertificateModel.id == issue_certificate_id + ).execute() def renew_certificate(): @@ -68,11 +175,15 @@ def get_certificate_list(): current_user_id = g.user_id page = request.json.get('page', 1) size = request.json.get('size', 10) + keyword = request.json.get('keyword') query = IssueCertificateModel.select().where( IssueCertificateModel.user_id == current_user_id ) + if keyword: + query.where(IssueCertificateModel.domain_raw.contains(keyword)) + total = query.count() rows = query.order_by( @@ -106,8 +217,43 @@ def get_issue_certificate_by_id(): issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id) - return model_to_dict( + data = model_to_dict( issue_certificate_row, extra_attrs=[ 'domains', 'create_time_label', 'domain_validation_urls'] ) + + if data['deploy_host_id']: + data['deploy_host'] = HostModel.get_by_id(data['deploy_host_id']) + else: + data['deploy_host'] = None + + return data + + +def delete_certificate_by_id(): + """ + 获取 + :return: + """ + current_user_id = g.user_id + + issue_certificate_id = request.json['issue_certificate_id'] + + IssueCertificateModel.delete_by_id(issue_certificate_id) + + +def delete_certificate_by_batch(): + """ + 批量删除 + @since v1.2.16 + :return: + """ + current_user_id = g.user_id + + ids = request.json['ids'] + + IssueCertificateModel.delete().where( + IssueCertificateModel.id.in_(ids), + IssueCertificateModel.user_id == current_user_id + ).execute() diff --git a/domain_admin/enums/version_enum.py b/domain_admin/enums/version_enum.py index 6daae2156b..c5b23b5b82 100644 --- a/domain_admin/enums/version_enum.py +++ b/domain_admin/enums/version_enum.py @@ -121,3 +121,7 @@ class VersionEnum(object): Version_153 = '1.5.3' Version_154 = '1.5.4' Version_155 = '1.5.5' + Version_156 = '1.5.6' + Version_157 = '1.5.7' + Version_158 = '1.5.8' + Version_159 = '1.5.9' diff --git a/domain_admin/migrate/migrate_158_to_159.py b/domain_admin/migrate/migrate_158_to_159.py new file mode 100644 index 0000000000..728fe6dd93 --- /dev/null +++ b/domain_admin/migrate/migrate_158_to_159.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +""" +@File : migrate_158_to_159.py +@Date : 2023-06-30 + +cmd: +$ python domain_admin/migrate/migrate_158_to_159.py +""" +from __future__ import print_function, unicode_literals, absolute_import, division + +from domain_admin.migrate import migrate_common +from domain_admin.model.base_model import db +from domain_admin.model.domain_info_model import DomainInfoModel +from domain_admin.model.issue_certificate_model import IssueCertificateModel +from domain_admin.model.user_model import UserModel + + +def execute_migrate(): + """ + 版本升级 1.5.8 => 1.5.9 + :return: + """ + migrator = migrate_common.get_migrator(db) + + migrate_rows = [ + # add column + migrator.add_column( + IssueCertificateModel._meta.table_name, + IssueCertificateModel.challenge_type.name, + IssueCertificateModel.challenge_type), + + # add column + migrator.add_column( + IssueCertificateModel._meta.table_name, + IssueCertificateModel.deploy_host_id.name, + IssueCertificateModel.deploy_host_id + ), + + migrator.add_column( + IssueCertificateModel._meta.table_name, + IssueCertificateModel.deploy_verify_path.name, + IssueCertificateModel.deploy_verify_path + ), + + migrator.add_column( + IssueCertificateModel._meta.table_name, + IssueCertificateModel.deploy_key_file.name, + IssueCertificateModel.deploy_key_file + ), + + migrator.add_column( + IssueCertificateModel._meta.table_name, + IssueCertificateModel.deploy_fullchain_file.name, + IssueCertificateModel.deploy_fullchain_file + ), + + migrator.add_column( + IssueCertificateModel._meta.table_name, + IssueCertificateModel.deploy_reloadcmd.name, + IssueCertificateModel.deploy_reloadcmd + ), + + migrator.add_column( + IssueCertificateModel._meta.table_name, + IssueCertificateModel.is_auto_renew.name, + IssueCertificateModel.is_auto_renew + ), + ] + + migrate_common.try_execute_migrate(migrate_rows) diff --git a/domain_admin/model/database.py b/domain_admin/model/database.py index 16fc81dc2e..6b0ce93afe 100644 --- a/domain_admin/model/database.py +++ b/domain_admin/model/database.py @@ -3,9 +3,10 @@ database.py """ from __future__ import print_function, unicode_literals, absolute_import, division + from domain_admin.log import logger from domain_admin.model import address_model, log_operation_model, group_user_model, log_async_task_model, \ - issue_certificate_model + issue_certificate_model, host_model from domain_admin.model import domain_info_model from domain_admin.model import domain_model from domain_admin.model import group_model @@ -31,6 +32,7 @@ (group_user_model.GroupUserModel, None), (log_async_task_model.AsyncTaskModel, None), (issue_certificate_model.IssueCertificateModel, None), + (host_model.HostModel, None), ] diff --git a/domain_admin/model/host_model.py b/domain_admin/model/host_model.py new file mode 100644 index 0000000000..01c56da7c2 --- /dev/null +++ b/domain_admin/model/host_model.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +""" +host_model.py +""" +from __future__ import print_function, unicode_literals, absolute_import, division + +from datetime import datetime + +from peewee import CharField, IntegerField, DateTimeField, AutoField + +from domain_admin.model.base_model import BaseModel +from domain_admin.utils import datetime_util + + +class HostModel(BaseModel): + """ + 证书主机 + @since v1.5.9 + """ + id = AutoField(primary_key=True) + + # 用户id + user_id = IntegerField(default=0) + + # 远程主机验证信息 + host = CharField(default=None, null=True) + + port = CharField(default=22, null=True) + + user = CharField(default=None, null=True) + + password = CharField(default=None, null=True) + + # 创建时间 + create_time = DateTimeField(default=datetime.now) + + # 更新时间 + update_time = DateTimeField(default=datetime.now) + + class Meta: + table_name = 'tb_host' + + @property + def create_time_label(self): + return datetime_util.time_for_human(self.create_time) + + @property + def update_time_label(self): + return datetime_util.time_for_human(self.update_time) diff --git a/domain_admin/model/issue_certificate_model.py b/domain_admin/model/issue_certificate_model.py index 37a6f42ffc..43f4e82dbe 100644 --- a/domain_admin/model/issue_certificate_model.py +++ b/domain_admin/model/issue_certificate_model.py @@ -7,10 +7,11 @@ import json from datetime import datetime -from peewee import CharField, IntegerField, DateTimeField, BooleanField, AutoField, TextField +from peewee import CharField, IntegerField, DateTimeField, AutoField, TextField, BooleanField from domain_admin.model.base_model import BaseModel -from domain_admin.utils import datetime_util, time_util +from domain_admin.utils import datetime_util +from domain_admin.utils.acme_util.challenge_type import ChallengeType URI_ROOT_PATH = ".well-known/acme-challenge" @@ -25,26 +26,32 @@ class IssueCertificateModel(BaseModel): # 用户id user_id = IntegerField(default=0) - # 域名列表 - domain_raw = TextField() - # SSL证书 ssl_certificate = TextField(default=None, null=True) # SSL证书私钥 ssl_certificate_key = TextField(default=None, null=True) + # 域名列表 + domain_raw = TextField(default='') + + # 验证类型 http dns + challenge_type = CharField(default=ChallengeType.HTTP01, null=True) + # 域名验证token + # @Deprecation @since v1.5.9 token = CharField(default=None, null=True) # 域名验证数据 + # @Deprecation @since v1.5.9 validation = CharField(default=None, null=True) # 验证状态url + # @Deprecation @since v1.5.9 status_url = CharField(default=None, null=True) # 验证状态 valid pending - status = CharField(default=None, null=True) + status = CharField(default='pending', null=True) # SSL签发时间 start_time = DateTimeField(default=None, null=True) @@ -52,6 +59,24 @@ class IssueCertificateModel(BaseModel): # SSL过期时间 expire_time = DateTimeField(default=None, null=True) + # 部署机器 + deploy_host_id = IntegerField(default=0) + + # 验证文件部署目录 + deploy_verify_path = CharField(default=None, null=True) + + # key部署路径 + deploy_key_file = CharField(default=None, null=True) + + # pem部署路径 + deploy_fullchain_file = CharField(default=None, null=True) + + # 部署重启命令 + deploy_reloadcmd = CharField(default=None, null=True) + + # 自动续期 + is_auto_renew = BooleanField(default=False) + # 创建时间 create_time = DateTimeField(default=datetime.now) @@ -86,10 +111,14 @@ def challenge_url(self): @property def domains(self): - return json.loads(self.domain_raw) + if self.domain_raw: + return json.loads(self.domain_raw) + else: + return [] @property def domain_validation_urls(self): - return [ - "http://" + domain + '/' + URI_ROOT_PATH + '/' + self.token - for domain in self.domains] + return [] + + # ["http://" + domain + '/' + URI_ROOT_PATH + '/' + self.token + # for domain in self.domains] diff --git a/domain_admin/router/api_map.py b/domain_admin/router/api_map.py index 9901e1a590..6766d8efa6 100644 --- a/domain_admin/router/api_map.py +++ b/domain_admin/router/api_map.py @@ -8,7 +8,7 @@ whois_api, address_api, domain_info_api, prometheus_api, log_operation_api, group_user_api, - log_async_task_api, issue_certificate_api) + log_async_task_api, issue_certificate_api, host_api) from domain_admin.api import domain_api from domain_admin.api import group_api from domain_admin.api import auth_api @@ -159,4 +159,18 @@ '/api/renewCertificate': issue_certificate_api.renew_certificate, '/api/getIssueCertificateById': issue_certificate_api.get_issue_certificate_by_id, '/api/verifyCertificateById': issue_certificate_api.verify_certificate, + + + '/api/getDomainHost': issue_certificate_api.get_domain_host, + '/api/deployVerifyFile': issue_certificate_api.deploy_verify_file, + '/api/deployCertificateFile': issue_certificate_api.deploy_certificate_file, + '/api/getCertificateChallenges': issue_certificate_api.get_certificate_challenges, + '/api/deleteCertificateById': issue_certificate_api.delete_certificate_by_id, + '/api/deleteCertificateByBatch': issue_certificate_api.delete_certificate_by_batch, + + # 主机管理 + '/api/addHost': host_api.add_host, + '/api/getHostById': host_api.get_host_by_id, + '/api/updateHostById': host_api.update_host_by_id, + '/api/getHostList': host_api.get_host_list, } diff --git a/domain_admin/service/issue_certificate_service.py b/domain_admin/service/issue_certificate_service.py index 3cf6754945..89a73d6870 100644 --- a/domain_admin/service/issue_certificate_service.py +++ b/domain_admin/service/issue_certificate_service.py @@ -5,95 +5,137 @@ """ import json import time +import traceback +from datetime import datetime, timedelta import OpenSSL import requests from domain_admin.log import logger +from domain_admin.model.host_model import HostModel from domain_admin.model.issue_certificate_model import IssueCertificateModel -from domain_admin.utils import datetime_util +from domain_admin.utils import datetime_util, fabric_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.cert_util import cert_common from domain_admin.utils.flask_ext.app_exception import AppException def issue_certificate(domains, user_id): + """ + 申请新证书 + :param domains: + :param user_id: + :return: + """ # Issue certificate - acme_client = acme_v2_api.get_acme_client() # Create domain private key and CSR pkey_pem, csr_pem = acme_v2_api.new_csr_comp(domains) issue_certificate_row = IssueCertificateModel.create( user_id=user_id, + # challenge_type=challenge_type, domain_raw=json.dumps(domains), ssl_certificate_key=pkey_pem, - status='pending', + # 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, pkey_pem) + + acme_client = acme_v2_api.get_acme_client() orderr = acme_client.new_order(csr_pem) # Select HTTP-01 within offered challenges by the CA server - challb = acme_v2_api.select_http01_chall(orderr) - logger.debug(challb.to_json()) + lst = [] - response, validation = challb.response_and_validation(acme_client.net.key) + 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) - IssueCertificateModel.update( - status_url=challb.to_json()['url'], - token=challb.to_json()['token'], - validation=validation, - update_time=datetime_util.get_datetime() - ).where( - IssueCertificateModel.id == issue_certificate_row.id - ).execute() - - return issue_certificate_row.id + data = { + 'domain': domain, + 'validation': validation, + 'challenge': challenge + } + lst.append(data) -def verify_certificate(row_id): - issue_certificate_row = IssueCertificateModel.get_by_id(row_id) + return lst - pkey_pem = issue_certificate_row.ssl_certificate_key - domains = issue_certificate_row.domains +def verify_certificate(issue_certificate_id, challenge_type): + """ + 验证域名 + :param issue_certificate_id: + :param challenge_type: + :return: + """ + items = get_certificate_challenges(issue_certificate_id) acme_client = acme_v2_api.get_acme_client() - # Create domain private key and CSR - pkey_pem, csr_pem = acme_v2_api.new_csr_comp(domains, pkey_pem) + verify_count = 0 + for item in items: + challenge = item['challenge'] + challenge_json = challenge.to_json() - orderr = acme_client.new_order(csr_pem) + # 指定认证类型 + if challenge_type != challenge_json['type']: + continue - # Select HTTP-01 within offered challenges by the CA server - challb = acme_v2_api.select_http01_chall(orderr) + response, validation = challenge.response_and_validation(acme_client.net.key) + logger.info(validation) - response, validation = challb.response_and_validation(acme_client.net.key) + # Let the CA server know that we are ready for the challenge. + acme_client.answer_challenge(challenge, response) - # Let the CA server know that we are ready for the challenge. - acme_client.answer_challenge(challb, response) + count = 0 + max_count = 5 - count = 0 - max_count = 5 + while True: + count += 1 - while True: - count += 1 + status = get_challenge_status(challenge_json['url']) - status = get_challenge_status(issue_certificate_row.status_url) + if status == 'valid': + IssueCertificateModel.update( + status=status, + update_time=datetime_util.get_datetime() + ).where( + IssueCertificateModel.id == issue_certificate_id + ).execute() - if status == 'valid': - IssueCertificateModel.update( - status=status, - update_time=datetime_util.get_datetime() - ).where( - IssueCertificateModel.id == issue_certificate_row.id - ).execute() + break - break + if count >= max_count: + raise AppException("域名验证失败:{}".format(item['domain'])) - if count >= max_count: - raise AppException("验证失败") + time.sleep(count) + verify_count += 1 - time.sleep(count) + 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): @@ -104,11 +146,6 @@ def renew_certificate(row_id): """ issue_certificate_row = IssueCertificateModel.get_by_id(row_id) - status = get_challenge_status(issue_certificate_row.status_url) - - if status != 'valid': - raise AppException("域名未验证") - pkey_pem = issue_certificate_row.ssl_certificate_key domains = issue_certificate_row.domains @@ -120,12 +157,12 @@ def renew_certificate(row_id): orderr = acme_client.new_order(csr_pem) # Select HTTP-01 within offered challenges by the CA server - challb = acme_v2_api.select_http01_chall(orderr) - logger.debug(challb.to_json()) + # 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, challb, orderr) + fullchain_pem = acme_v2_api.perform_http01(acme_client, orderr) logger.debug(fullchain_pem) @@ -137,7 +174,6 @@ def renew_certificate(row_id): IssueCertificateModel.update( ssl_certificate=fullchain_pem, - status='valid', start_time=cert.notBefore, expire_time=cert.notAfter, update_time=datetime_util.get_datetime() @@ -159,9 +195,148 @@ def get_challenge_status(url): "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=30) + + 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) + except Exception as e: + logger.error(traceback.format_exc()) + + +def renew_certificate_row(row: IssueCertificateModel): + """ + + :param row: + :return: + """ + # 重新申请 + if row.expire_time is None: + pkey_pem, csr_pem = acme_v2_api.new_csr_comp(row.domains) + + IssueCertificateModel.update( + ssl_certificate_key=pkey_pem, + ssl_certificate='', + start_time=None, + expire_time=None, + status='pending', + ).where( + IssueCertificateModel.id == row.id + ) + + # 获取验证方式 + challenge_list = get_certificate_challenges(row.id) + + challenge_rows = [] + for challenge_row in challenge_list: + if challenge_row['challenge'].to_json()['type'] == ChallengeType.HTTP01: + challenge_rows.append(challenge_row['validation']) + + # 验证文件部署 + deploy_verify_file( + host_id=row.host_id, + verify_deploy_path=row.verify_deploy_path, + challenges=challenge_rows + ) + + # 验证域名 + verify_certificate(row.id, ChallengeType.HTTP01) + + # 下载证书 + renew_certificate(row.id) + + # 自动部署,重启服务 + deploy_certificate_file( + host_id=row.host_id, + issue_certificate_id=row.id, + key_deploy_path=row.deploy_key_file, + pem_deploy_path=row.deploy_fullchain_file, + reload_cmd=row.deploy_reloadcmd + ) + + +def deploy_verify_file(host_id, verify_deploy_path, challenges): + """ + 部署验证文件 + :return: + """ + + host_row = HostModel.get_by_id(host_id) + + host = host_row.host + user = host_row.user + password = host_row.password + + for row in challenges: + verify_deploy_filename = verify_deploy_path + row['token'] + + logger.debug("verify_deploy_filename: %s", verify_deploy_filename) + + fabric_util.deploy_file( + host=host, + user=user, + password=password, + content=row['validation'], + remote=verify_deploy_filename + ) + + +def deploy_certificate_file(host_id, issue_certificate_id, key_deploy_path, pem_deploy_path, reload_cmd): + host_row = HostModel.get_by_id(host_id) + + host = host_row.host + user = host_row.user + password = host_row.password + + issue_certificate_row = IssueCertificateModel.get_by_id(issue_certificate_id) + + # deploy key + fabric_util.deploy_file( + host=host, + user=user, + password=password, + content=issue_certificate_row.ssl_certificate_key, + remote=key_deploy_path + ) + + # deploy ssl_certificate + fabric_util.deploy_file( + host=host, + user=user, + password=password, + content=issue_certificate_row.ssl_certificate, + remote=pem_deploy_path + ) + + # reload + fabric_util.run_command( + host=host, + user=user, + password=password, + command=reload_cmd + ) diff --git a/domain_admin/service/scheduler_service.py b/domain_admin/service/scheduler_service.py index a59eee025e..0248b2ab0d 100644 --- a/domain_admin/service/scheduler_service.py +++ b/domain_admin/service/scheduler_service.py @@ -8,7 +8,8 @@ from domain_admin.enums.config_key_enum import ConfigKeyEnum from domain_admin.model.log_scheduler_model import LogSchedulerModel -from domain_admin.service import system_service, domain_service, domain_info_service, notify_service +from domain_admin.service import system_service, domain_service, domain_info_service, notify_service, \ + issue_certificate_service from domain_admin.service.file_service import resolve_log_file from domain_admin.utils import datetime_util @@ -88,6 +89,9 @@ def task(): # 更新域名信息 domain_info_service.update_all_domain_info() + # 更新所有SSL证书 + issue_certificate_service.renew_all_certificate() + # 触发通知 success = notify_service.notify_all_event() diff --git a/domain_admin/service/version_service.py b/domain_admin/service/version_service.py index 5e1fc37476..72dff66b87 100644 --- a/domain_admin/service/version_service.py +++ b/domain_admin/service/version_service.py @@ -23,7 +23,7 @@ migrate_1413_to_1414, migrate_1422_to_1423, migrate_151_to_152, - migrate_154_to_155) + migrate_154_to_155, migrate_158_to_159) from domain_admin.model.version_model import VersionModel from domain_admin.version import VERSION @@ -268,6 +268,20 @@ def update_version(): local_version = VersionEnum.Version_155 + # 2023-07-22 + if local_version in [ + VersionEnum.Version_155, + VersionEnum.Version_156, + VersionEnum.Version_157, + VersionEnum.Version_158, + ]: + # 1.5.8 => 1.5.9 + logger.info('update version: %s => %s', local_version, VersionEnum.Version_159) + + migrate_158_to_159.execute_migrate() + + local_version = VersionEnum.Version_159 + # 更新版本号 # fix: 多实例同时启动版本号写入失败问题 try: diff --git a/domain_admin/utils/acme_util/acme_v2_api.py b/domain_admin/utils/acme_util/acme_v2_api.py index 2411bcbcce..b5251127bb 100644 --- a/domain_admin/utils/acme_util/acme_v2_api.py +++ b/domain_admin/utils/acme_util/acme_v2_api.py @@ -19,6 +19,7 @@ https://letsencrypt.org/zh-cn/docs/challenge-types/ https://datatracker.ietf.org/doc/html/rfc8555 +https://github.com/Trim/acme-dns-tiny Example ACME-V2 API for HTTP-01 challenge. @@ -48,16 +49,11 @@ - Deactivate Account """ +import json import os import traceback from datetime import datetime, timedelta -from cryptography.hazmat.primitives.asymmetric import rsa - -from domain_admin.config import ACME_DIR - -import json - import OpenSSL import josepy as jose from acme import challenges, errors @@ -66,13 +62,16 @@ from acme import messages from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa -# Constants: - -# This is the staging point for ACME-V2 within Let's Encrypt. +from domain_admin.config import ACME_DIR from domain_admin.log import logger +# This is the staging point for ACME-V2 within Let's Encrypt. +from domain_admin.utils.acme_util.challenge_type import ChallengeType from domain_admin.utils.flask_ext.app_exception import AppException +# Constants: + DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory' USER_AGENT = 'domain-admin' @@ -122,13 +121,62 @@ def select_http01_chall(orderr): raise Exception('HTTP-01 challenge was not offered by the CA server.') -def perform_http01(client_acme, challb, orderr): - """Set up standalone webserver and perform HTTP-01 challenge.""" +def select_challenge(orderr): + """Extract authorization resource from within order resource.""" + # Authorization Resource: authz. + # This object holds the offered challenges by the server and their status. + + logger.info("authorizations len: %s", len(orderr.authorizations)) + + challenge_map = {} + + for authz in orderr.authorizations: + # Choosing challenge. + # authz.body.challenges is a set of ChallengeBody objects. + + domain_challenge = [] + domain = authz.body.identifier.value + logger.info('challenges len: %s - %s', domain, len(authz.body.challenges)) + + for challenge in authz.body.challenges: + # Find the supported challenge. - response, validation = challb.response_and_validation(client_acme.net.key) + if isinstance(challenge.chall, challenges.DNS01): + # domain_challenge[ChallengeType.DNS01] = challenge + domain_challenge.append(challenge) + elif isinstance(challenge.chall, challenges.HTTP01): + # domain_challenge[ChallengeType.HTTP01] = challenge + domain_challenge.append(challenge) + # elif isinstance(challenge.chall, challenges.TLSALPN01): + # domain_challenge[ChallengeType.TLSALPN01] = challenge + + challenge_map[domain] = domain_challenge + + logger.info(challenge_map) + + return challenge_map + # raise Exception('{} challenge was not offered by the CA server.'.format(challenge_type)) + + +def select_challenge_by(orderr, domain, challenge_type): + domain_challenges = select_challenge(orderr)[domain] + + for challenge in domain_challenges: + if challenge_type == ChallengeType.HTTP01 and isinstance(challenge.chall, challenges.HTTP01): + return challenge + elif challenge_type == ChallengeType.DNS01 and isinstance(challenge.chall, challenges.DNS01): + return challenge + else: + raise AppException('not found challenge') + + +def perform_http01(client_acme, orderr): + """Set up standalone webserver and perform HTTP-01 challenge.""" - # Let the CA server know that we are ready for the challenge. - client_acme.answer_challenge(challb, response) + # response, validation = challb.response_and_validation(client_acme.net.key) + # + # # Let the CA server know that we are ready for the challenge. + # client_acme.answer_challenge(challb, response) # Wait for challenge status and then issue a certificate. # It is possible to set a deadline time. @@ -196,6 +244,7 @@ def ensure_account_exists(client_acme): else: # 账户不存在 register = client_acme.new_account(messages.NewRegistration.from_data( + # email=('mouday@qq.com'), terms_of_service_agreed=True) ) diff --git a/domain_admin/utils/acme_util/challenge_type.py b/domain_admin/utils/acme_util/challenge_type.py new file mode 100644 index 0000000000..4ac64295a1 --- /dev/null +++ b/domain_admin/utils/acme_util/challenge_type.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +""" +@File : challenge_type.py +@Date : 2023-07-27 +""" +from acme.challenges import HTTP01Response + + +class ChallengeType: + # HTTP01Response.typ + HTTP01 = 'http-01' + + # DNS01Response.typ + DNS01 = 'dns-01' + + # TLSALPN01Response.typ + TLSALPN01 = 'tls-alpn-01' diff --git a/domain_admin/utils/cert_util/cert_common.py b/domain_admin/utils/cert_util/cert_common.py index 4d9abd8e45..a52778a29b 100644 --- a/domain_admin/utils/cert_util/cert_common.py +++ b/domain_admin/utils/cert_util/cert_common.py @@ -79,15 +79,6 @@ def short_name_convert(data): return dct -def get_domain_ip(domain): - """ - 获取ip地址 - :param domain: str - :return: str - """ - return socket.gethostbyname(domain) - - class X509Item(object): version = '' subject = dict() diff --git a/domain_admin/utils/fabric_util.py b/domain_admin/utils/fabric_util.py new file mode 100644 index 0000000000..d57b012cee --- /dev/null +++ b/domain_admin/utils/fabric_util.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" +@File : fabric_util.py +@Date : 2023-07-26 + +参考 +https://www.fabfile.org/installing.html +https://docs.fabfile.org/en/stable/api/connection.html + +https://www.cnblogs.com/superhin/p/13887526.html +""" + +import six +from fabric import Connection + + +def deploy_file(host, user, password, content, remote): + with Connection( + host=host, + user=user, + connect_kwargs={"password": password} + ) as conn: + conn.put(six.StringIO(content), remote) + + +def run_command(host, user, password, command): + with Connection( + host=host, + user=user, + connect_kwargs={"password": password} + ) as conn: + conn.run(command, hide=True) diff --git a/domain_admin/utils/ip_util.py b/domain_admin/utils/ip_util.py index 381e4ff388..9861716417 100644 --- a/domain_admin/utils/ip_util.py +++ b/domain_admin/utils/ip_util.py @@ -5,6 +5,9 @@ @Author : Peng Shiyu """ from __future__ import print_function, unicode_literals, absolute_import, division + +import socket + import requests @@ -29,6 +32,15 @@ def get_ip_info(ip): return res.json().get('data') +def get_domain_ip(domain): + """ + 获取ip地址 + :param domain: str + :return: str + """ + return socket.gethostbyname(domain) + + if __name__ == '__main__': print(get_ip_info('221.218.209.125')) diff --git a/domain_admin/utils/json_util.py b/domain_admin/utils/json_util.py index 253a2deb42..660b5e3c39 100644 --- a/domain_admin/utils/json_util.py +++ b/domain_admin/utils/json_util.py @@ -7,6 +7,7 @@ import json from datetime import datetime +from acme.messages import ChallengeBody from peewee import ModelSelect, Model from playhouse.shortcuts import model_to_dict @@ -29,6 +30,9 @@ def default_json_encoder(o): if isinstance(o, datetime): return o.strftime(DATETIME_FORMAT) + if isinstance(o, ChallengeBody): + return o.to_json() + return o @@ -44,4 +48,4 @@ def json_encode(data, default=default_json_encoder, **kwargs): def json_dump(obj): - print(json_encode(obj, ensure_ascii=False, indent=2)) + return json_encode(obj, ensure_ascii=False, indent=2) diff --git a/http/issue_certificate.http b/http/issue_certificate.http new file mode 100644 index 0000000000..a29f8163c9 --- /dev/null +++ b/http/issue_certificate.http @@ -0,0 +1,124 @@ + +# For a quick start check out our HTTP Requests collection (Tools|HTTP Client|Open HTTP Requests Collection). +# +# Following HTTP Request Live Templates are available: +# * 'gtrp' and 'gtr' create a GET request with or without query parameters; +# * 'ptr' and 'ptrp' create a POST request with a simple or parameter-like body; +# * 'mptr' and 'fptr' create a POST request to submit a form with a text or file field (multipart/form-data); + +POST {{baseUrl}}/api/issueCertificate +Content-Type: application/json +X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2OTEwNDQ3ODN9.K4iIEqRKe0FmB8KMUl7P_ueGCHY7khHaxRYI89l_oVs + +{ + "domains": ["zmlm.com.cn", "www.zmlm.com.cn"], + "challenge_type": "HTTP01" +} + +### + +POST {{baseUrl}}/api/verifyCertificateById +Content-Type: application/json +X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2OTEwNDQ3ODN9.K4iIEqRKe0FmB8KMUl7P_ueGCHY7khHaxRYI89l_oVs + +{ + "issue_certificate_id": 21 +} + +### + +POST {{baseUrl}}/api/renewCertificate +Content-Type: application/json +X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2OTEwNDQ3ODN9.K4iIEqRKe0FmB8KMUl7P_ueGCHY7khHaxRYI89l_oVs + +{ + "issue_certificate_id": 20 +} + +### + +POST {{baseUrl}}/api/getDomainById +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjQ5NjUwMzB9.myLjQpUraYm69JWrgeEUqPpQ_GroEWPIWYCCJqbg5gg + +{ + "id": 3 +} + +### +POST {{baseUrl}}/api/deleteDomainById +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjQ5NjUwMzB9.myLjQpUraYm69JWrgeEUqPpQ_GroEWPIWYCCJqbg5gg + +{ + "id": 1 +} + +### +POST {{baseUrl}}/api/updateDomainCertInfoById +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjQ5NjUwMzB9.myLjQpUraYm69JWrgeEUqPpQ_GroEWPIWYCCJqbg5gg + +{ + "id": 3 +} + +### +POST {{baseUrl}}/api/updateAllDomainCertInfo +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjQ5NjUwMzB9.myLjQpUraYm69JWrgeEUqPpQ_GroEWPIWYCCJqbg5gg + +{} + +### +POST {{baseUrl}}/api/updateAllDomainCertInfoOfUser +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjQ5NjUwMzB9.myLjQpUraYm69JWrgeEUqPpQ_GroEWPIWYCCJqbg5gg + +{} + +### +POST {{baseUrl}}/api/sendDomainInfoListEmail +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjQ5NjUwMzB9.myLjQpUraYm69JWrgeEUqPpQ_GroEWPIWYCCJqbg5gg + +{ + "to_addresses": [ + "1940607002@qq.com" + ] +} + +### + +POST {{baseUrl}}/api/checkDomainCert +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjQ5NjUwMzB9.myLjQpUraYm69JWrgeEUqPpQ_GroEWPIWYCCJqbg5gg + +{} + +### + +POST {{baseUrl}}/api/exportDomainToFile +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjUzMjA3ODN9.pLfVwuxWvBUr4GtJdmBzXs_X8fieaPesgLwgdsIGnv4 + +{} + +### + +POST {{baseUrl}}/api/getAllDomainListOfUser +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2NjYwODU0MTh9.k8G3LvPMrKJm5_1Eodzto7X1YAFTz57iKbmolas7oOs + +{} + +### +POST {{baseUrl}}/api/getWhoisRaw +Content-Type: application/json +X-TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE2ODYyMDkzMzJ9.azNxgF_GDa-4aI_XOUm8sQbNhJfkY3rNvEaIgn8DUiI + +{ + "domain": "www.baidu.com" +} + +### diff --git a/requirements/production.txt b/requirements/production.txt index fc694a678b..21957cf61b 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -15,4 +15,5 @@ python-dotenv environs pyOpenSSL prometheus-client -acme \ No newline at end of file +acme +fabric