Files
dromara--domain-admin/domain_admin/service/domain_service.py
T
2026-07-13 12:31:34 +08:00

952 lines
26 KiB
Python

# -*- coding: utf-8 -*-
"""
domain_service.py
"""
from __future__ import print_function, unicode_literals, absolute_import, division
import io
import time
import traceback
from datetime import datetime, timedelta
from peewee import chunked, fn
from playhouse.shortcuts import model_to_dict
from domain_admin.enums.role_enum import RoleEnum
from domain_admin.enums.source_enum import SourceEnum
from domain_admin.log import logger
from domain_admin.model import domain_model
from domain_admin.model.address_model import AddressModel
from domain_admin.model.domain_info_model import DomainInfoModel
from domain_admin.model.domain_model import DomainModel
from domain_admin.model.group_model import GroupModel
from domain_admin.model.group_user_model import GroupUserModel
from domain_admin.model.user_model import UserModel
from domain_admin.service import file_service, async_task_service
from domain_admin.service import render_service, group_service
from domain_admin.utils import datetime_util, cert_util, file_util, json_util, time_util
from domain_admin.utils import domain_util
from domain_admin.config import SHODAN_API_KEY
from domain_admin.utils.cert_util import cert_socket_v2, cert_openssl_v2, cert_ftp
from domain_admin.utils.flask_ext.app_exception import ForbiddenAppException
from domain_admin.utils.open_api import certspotter_api, shodan_api, crtsh_api
def update_domain_host_list(domain_row):
"""
更新ip信息
:param domain_row: DomainModel
:return:
@since v1.2.24
"""
domain_host_list = []
try:
domain_host_list = cert_socket_v2.get_domain_host_list(
domain=domain_row.domain,
port=domain_row.port
)
except Exception as e:
logger.error(traceback.format_exc())
lst = [
{
'domain_id': domain_row.id,
'host': domain_host
} for domain_host in domain_host_list]
logger.info(lst)
AddressModel.insert_many(lst).on_conflict_ignore().execute()
def update_domain_address_list_cert(domain_row):
"""
更新证书信息
:param domain_row: DomainModel
:return:
"""
# logger.info("%s", model_to_dict(domain_row))
lst = AddressModel.select().where(
AddressModel.domain_id == domain_row.id
)
err = ''
for address_row in lst:
err = update_address_row_info_wrap(address_row, domain_row)
sync_address_info_to_domain_info(domain_row)
return err
def update_address_row_info_wrap(address_row, domain_row):
"""
更新单个地址信息 的代理方法 增加重试次数
:param address_row:
:param domain_row:
:return: error
"""
# 最大重试次数
MAX_RETRY_COUNT = 3
retry_count = 0
err = ''
while True:
retry_count += 1
logger.info("retry_count: %s", retry_count)
err = update_address_row_info(address_row, domain_row)
if not err or retry_count >= MAX_RETRY_COUNT:
break
if _is_unreachable_handshake_error(err):
break
# sleep
time.sleep(0.5)
return err
def _is_unreachable_handshake_error(err_msg):
if not err_msg:
return False
err_msg = err_msg.lower()
if 'domain not verified' in err_msg:
return False
unreachable_keywords = [
'timed out',
'timeout',
'name or service not known',
'temporary failure in name resolution',
'nodename nor servname provided',
'connection refused',
'connection reset',
'network is unreachable',
'no route to host',
'host unreachable',
'errno 111',
'errno 113',
'errno -2',
]
return any(keyword in err_msg for keyword in unreachable_keywords)
def _get_latest_certspotter_cert_info(domain):
if domain_util.is_ipv4(domain):
return {}
try:
rows = certspotter_api.search(domain)
except Exception:
logger.error(traceback.format_exc())
return {}
if not rows:
return {}
candidates = []
for row in rows:
names = [item.strip() for item in row.get('dns_names', []) if item and item.strip()]
matched = False
for name in names:
if domain_util.verify_cert_common_name(name, domain):
matched = True
break
if not matched:
continue
not_before = row.get('not_before')
not_after = row.get('not_after')
if not not_before or not not_after:
continue
try:
start_date = time_util.parse_time(not_before)
expire_date = time_util.parse_time(not_after)
entry_timestamp = row.get('entry_timestamp')
if entry_timestamp:
entry_time = time_util.parse_time(entry_timestamp)
else:
entry_time = expire_date
row_id = row.get('id') or 0
candidates.append({
'start_date': start_date,
'expire_date': expire_date,
'entry_time': entry_time,
'row_id': int(row_id),
})
except Exception:
logger.error(traceback.format_exc())
if not candidates:
return {}
latest = max(
candidates,
key=lambda item: (
item['entry_time'],
item['start_date'],
item['expire_date'],
item['row_id'],
)
)
return {
'start_date': latest['start_date'],
'expire_date': latest['expire_date'],
}
def _get_latest_shodan_cert_info(domain):
if domain_util.is_ipv4(domain):
return {}
query_domains = [domain]
root_domain = domain_util.get_root_domain(domain)
if root_domain and root_domain not in query_domains:
query_domains.append(root_domain)
candidates = []
for query_domain in query_domains:
rows = shodan_api.search_domain_certificates(domain=query_domain, api_key=SHODAN_API_KEY)
if not rows:
continue
for row in rows:
subdomain = row.get('subdomain')
if subdomain and subdomain != '@':
record_domain = '{subdomain}.{domain}'.format(subdomain=subdomain, domain=query_domain)
else:
record_domain = query_domain
if not domain_util.verify_cert_common_name(record_domain, domain):
continue
value = row.get('value') or ''
value = value.strip().lower()
if len(value) != 40:
continue
cert_detail = shodan_api.get_certificate_by_sha1(value, SHODAN_API_KEY)
if not cert_detail:
continue
cert_name = cert_detail.get('subject', {}).get('CN')
if cert_name and not domain_util.verify_cert_common_name(cert_name, domain):
continue
not_before = cert_detail.get('issued') or cert_detail.get('validity', {}).get('start')
not_after = cert_detail.get('expires') or cert_detail.get('validity', {}).get('end')
if not not_before or not not_after:
continue
try:
start_date = time_util.parse_time(not_before)
expire_date = time_util.parse_time(not_after)
candidates.append({
'start_date': start_date,
'expire_date': expire_date,
'fingerprint': value,
})
except Exception:
logger.error(traceback.format_exc())
if not candidates:
return {}
latest = max(
candidates,
key=lambda item: (
item['start_date'],
item['expire_date'],
item['fingerprint'],
)
)
return {
'start_date': latest['start_date'],
'expire_date': latest['expire_date'],
}
def _get_latest_fallback_cert_info(domain):
cert_info = _get_latest_certspotter_cert_info(domain)
if cert_info:
return cert_info
return _get_latest_shodan_cert_info(domain)
def update_address_row_info(address_row, domain_row):
"""
更新单个地址信息
:param domain_row:
:param address_row:
:return: error
"""
# 获取证书信息
cert_info = {}
err = ''
try:
if domain_row.port == 21:
cert_info = cert_ftp.get_ftp_cert_by_ftplib(
domain=domain_row.domain,
host=address_row.host,
port=domain_row.port,
)
else:
cert_info = cert_openssl_v2.get_ssl_cert_by_openssl(
domain=domain_row.domain,
host=address_row.host,
port=domain_row.port,
ssl_type=domain_row.ssl_type
)
except Exception as e:
err = e.__str__()
logger.error(traceback.format_exc())
fallback_applied = False
if not cert_info and _is_unreachable_handshake_error(err):
cert_info = _get_latest_fallback_cert_info(domain_row.domain)
fallback_applied = True
logger.info(cert_info)
if fallback_applied and not cert_info:
err = ''
update_time = datetime_util.get_datetime()
update_data = {
'update_time': update_time,
}
if cert_info.get('expire_date'):
address = AddressModel()
address.ssl_start_time = cert_info.get('start_date')
address.ssl_expire_time = cert_info.get('expire_date')
update_data.update({
'ssl_start_time': address.ssl_start_time,
'ssl_expire_time': address.ssl_expire_time,
'ssl_expire_days': address.real_time_ssl_expire_days,
})
AddressModel.update(
**update_data
).where(
AddressModel.id == address_row.id
).execute()
if fallback_applied and cert_info.get('expire_date'):
AddressModel.update(
ssl_start_time=update_data.get('ssl_start_time'),
ssl_expire_time=update_data.get('ssl_expire_time'),
ssl_expire_days=update_data.get('ssl_expire_days'),
update_time=update_time,
).where(
AddressModel.domain_id == address_row.domain_id,
AddressModel.id != address_row.id,
AddressModel.ssl_expire_time.is_null(False),
AddressModel.ssl_expire_time < update_data.get('ssl_expire_time')
).execute()
return err
def update_address_row_info_with_sync_domain_row(address_id):
"""
更新主机信息并同步到与名表
:param address_id: int
:return:
"""
address_row = AddressModel.get_by_id(address_id)
domain_row = DomainModel.get_by_id(address_row.domain_id)
update_address_row_info_wrap(address_row, domain_row)
sync_address_info_to_domain_info(domain_row)
def sync_address_info_to_domain_info(domain_row):
"""
同步主机信息到域名信息表
:param domain_row: DomainModel
:return:
"""
first_address_row = AddressModel.select().where(
AddressModel.domain_id == domain_row.id,
AddressModel.ssl_expire_time.is_null(False)
).order_by(
AddressModel.ssl_expire_days.asc()
).first()
update_data = {
'connect_status': False,
'update_time': datetime_util.get_datetime(),
'version': DomainModel.version + 1
}
if first_address_row is not None:
if first_address_row.real_time_ssl_expire_days > 0:
update_data['connect_status'] = True
update_data.update({
'start_time': first_address_row.ssl_start_time,
'expire_time': first_address_row.ssl_expire_time,
'expire_days': first_address_row.real_time_ssl_expire_days,
})
DomainModel.update(
**update_data
).where(
DomainModel.id == domain_row.id
).execute()
def update_domain_row(domain_row):
"""
更新域名相关数据
:param domain_row: DomainModel
:return:
"""
# fix old data update root domain
if not domain_row.root_domain:
DomainModel.update(
root_domain=domain_util.get_root_domain(domain_row.domain)
).where(
DomainModel.id == domain_row.id
).execute()
# 动态主机ip,需要先删除所有主机地址
if domain_row.is_dynamic_host:
pass
# 移除动态主机行为,都清空自动添加的数据再获取
AddressModel.delete().where(
AddressModel.domain_id == domain_row.id,
AddressModel.source == SourceEnum.AUTO
).execute()
# 主机ip信息
update_domain_host_list(domain_row)
# 证书信息
update_domain_address_list_cert(domain_row)
def get_cert_info(domain):
"""
:param domain: str
:return:
"""
now = datetime.now()
info = {}
expire_days = 0
total_days = 0
connect_status = True
try:
info = cert_util.get_cert_info(domain)
except Exception:
logger.error(traceback.format_exc())
connect_status = False
start_date = info.get('start_date')
expire_date = info.get('expire_date')
if start_date and expire_date:
start_time = datetime_util.parse_datetime(start_date)
expire_time = datetime_util.parse_datetime(expire_date)
expire_days = (expire_time - now).days
total_days = (expire_time - start_time).days
return {
'start_date': start_date,
'expire_date': expire_date,
'expire_days': expire_days,
'total_days': total_days,
'connect_status': connect_status,
# 'ip': info.get('ip', ''),
'info': info,
}
def update_all_domain_cert_info():
"""
更新所有域名信息
:return:
"""
rows = DomainModel.select().where(
DomainModel.auto_update == True
).order_by(DomainModel.expire_days.asc())
for row in rows:
try:
update_domain_row(row)
except Exception as e:
logger.error(traceback.format_exc())
@async_task_service.async_task_decorator("更新证书信息")
def update_all_domain_cert_info_of_user(user_id):
"""
更新用户的所有证书信息
:return:
"""
rows = DomainModel.select().where(
DomainModel.user_id == user_id,
DomainModel.auto_update == True
)
for row in rows:
update_domain_row(row)
# key = f'update_domain_status:{user_id}'
# global_data_service.set_value(key, False)
def get_domain_info_list(user_id=None):
query = DomainModel.select()
user_row = UserModel.get_by_id(user_id)
query = query.where(
DomainModel.user_id == user_id,
DomainModel.is_monitor == True,
DomainModel.expire_days < user_row.before_expire_days
)
query = query.order_by(
DomainModel.expire_days.asc(),
DomainModel.id.desc()
)
lst = list(map(lambda m: model_to_dict(
model=m,
# exclude=[DomainModel.detail_raw],
extra_attrs=[
'start_date',
'expire_date',
# 'real_time_domain_expire_days',
'real_time_expire_days',
# 'expire_days',
]
), query))
# def compare(a, b):
# if a['expire_days'] and b['expire_days']:
# return a['expire_days'] - b['expire_days']
# else:
# if a['expire_days']:
# return a['expire_days']
# else:
# return -b['expire_days']
# lst = sorted(lst, key=cmp_to_key(compare))
return lst
def check_permission_and_get_row(domain_id, user_id):
"""
权限检查
:param domain_id:
:param user_id:
:return:
"""
row = DomainModel.get_by_id(domain_id)
if row.user_id != user_id:
raise ForbiddenAppException()
return row
@async_task_service.async_task_decorator("自动批量导入子域名证书")
def auto_import_from_domain_batch_async(rows, user_id):
# type: (list[DomainInfoModel], int)->None
"""
自动批量导入子域名证书
:param rows:
:param user_id:
:return:
"""
for row in rows:
auto_import_from_domain(root_domain=row.domain, group_id=row.group_id, user_id=user_id)
init_domain_cert_info_of_user(user_id=user_id)
@async_task_service.async_task_decorator("自动导入子域名证书")
def auto_import_from_domain_async(root_domain, group_id=0, user_id=0):
# type: (str, int, int)->None
auto_import_from_domain(root_domain=root_domain, group_id=group_id, user_id=user_id)
init_domain_cert_info_of_user(user_id=user_id)
def auto_import_from_domain(root_domain, group_id=0, user_id=0):
"""
自动导入顶级域名下包含的子域名到证书列表
:param root_domain: str
:param group_id: int
:param user_id: int
:return:
"""
logger.info("domain: %s", root_domain)
lst = crtsh_api.search(root_domain)
domain_set = set()
for domain in lst:
common_name = domain['common_name']
# 过滤空域名
if not common_name:
continue
# 过滤非主域名下子域
if not common_name.endswith(root_domain):
continue
# 过滤邮箱数据
if '@' in common_name:
continue
# 识别通配符 *.music.163.com -> music.163.com
if common_name.startswith('*.'):
common_name = common_name[2:]
domain_set.add(common_name)
data = [
{
'domain': domain,
'root_domain': root_domain,
'port': 443,
'alias': '',
'user_id': user_id,
'group_id': group_id,
} for domain in domain_set
]
for batch in chunked(data, 500):
DomainModel.insert_many(batch).on_conflict_ignore().execute()
def add_domain_from_file(filename, user_id):
logger.info('user_id: %s, filename: %s', user_id, filename)
lst = list(domain_util.parse_domain_from_file(filename, domain_model.FIELD_MAPPING))
# 导入分组
group_name_list = [item.get('group_name') for item in lst if item.get('group_name')]
if group_name_list:
group_map = group_service.get_or_create_group_map(group_name_list, user_id)
else:
group_map = {}
items = [
{
'domain': item['domain'],
'root_domain': domain_util.get_root_domain(item['domain']),
'port': item.get('port'),
'alias': item.get('alias') or '',
'user_id': user_id,
'group_id': group_map.get(item.get('group_name'), 0),
} for item in lst
]
# print(json_util.json_dump(items))
# fix: peewee.OperationalError: too many SQL variables
# https://github.com/mouday/domain-admin/issues/63
for batch in chunked(items, 500):
DomainModel.insert_many(batch).on_conflict_ignore().execute()
def export_domain_to_file(rows, ext):
"""
导出域名到文件
:param rows:
:return:
"""
# content = render_service.render_template('cert-export.csv', {'list': rows})
filename = datetime.now().strftime("cert_%Y%m%d%H%M%S") + '.' + ext
temp_filename = file_service.resolve_temp_file(filename)
if ext == 'txt':
lst = [row['domain'] for row in rows]
else:
lst = file_util.convert_to_export(rows, domain_model.FIELD_MAPPING)
file_util.write_data_to_file(temp_filename, lst)
# temp_filename = file_service.resolve_temp_file(filename)
# # print(temp_filename)
# with io.open(temp_filename, 'w', encoding='utf-8') as f:
# f.write(content)
return filename
def load_domain_expire_days(lst):
"""
加载域名过期时间字段 Number or None
:param lst: List[DomainModel dict]
:return:
"""
root_domains = [row['root_domain'] for row in lst]
domain_info_rows = DomainInfoModel.select().where(
DomainInfoModel.domain.in_(root_domains)
)
domain_info_map = {
row.domain: row.real_domain_expire_days
for row in domain_info_rows
}
for row in lst:
row['domain_expire_days'] = domain_info_map.get(row['root_domain'])
return lst
def load_address_count(lst):
"""
加载主机数量字段
:param lst: List
:return:
"""
row_ids = [row['id'] for row in lst]
# 主机数量
address_groups = AddressModel.select(
AddressModel.domain_id,
fn.COUNT(AddressModel.id).alias('count')
).where(
AddressModel.domain_id.in_(row_ids)
).group_by(AddressModel.domain_id)
address_group_map = {
str(row.domain_id): row.count
for row in address_groups
}
for row in lst:
row['address_count'] = address_group_map.get(str(row['id']), 0)
return lst
def get_domain_list_query(keyword, group_id, group_ids, expire_days, user_id, role):
user_group_ids = None
if role == RoleEnum.ADMIN:
pass
else:
# 所在分组
group_user_rows = GroupUserModel.select().where(
GroupUserModel.user_id == user_id
)
group_user_list = list(group_user_rows)
user_group_ids = [row.group_id for row in group_user_list]
query = DomainModel.select()
if isinstance(group_id, int):
query = query.where(DomainModel.group_id == group_id)
if keyword:
query = query.where(DomainModel.domain.contains(keyword))
if group_ids:
query = query.where(DomainModel.group_id.in_(group_ids))
else:
if role == RoleEnum.ADMIN:
pass
elif user_group_ids:
query = query.where(
(DomainModel.user_id == user_id)
| (DomainModel.group_id.in_(user_group_ids))
)
else:
query = query.where(DomainModel.user_id == user_id)
if expire_days is not None:
if expire_days[0] is None:
max_expire_time = datetime.now() + timedelta(days=expire_days[1])
query = query.where(
DomainModel.expire_time.is_null(False),
DomainModel.expire_time <= max_expire_time
)
elif expire_days[1] is None:
min_expire_time = datetime.now() + timedelta(days=expire_days[0])
query = query.where(DomainModel.expire_time >= min_expire_time)
else:
min_expire_time = datetime.now() + timedelta(days=expire_days[0])
max_expire_time = datetime.now() + timedelta(days=expire_days[1])
query = query.where(DomainModel.expire_time.between(min_expire_time, max_expire_time))
return query
def get_domain_ordering(order_prop='expire_days', order_type='ascending'):
ordering = []
# order by expire_days
if order_prop == 'expire_days':
if order_type == 'descending':
ordering.append(DomainModel.expire_time.desc())
else:
ordering.append(DomainModel.expire_time.asc())
# order by connect_status
elif order_prop == 'connect_status':
if order_type == 'descending':
ordering.append(DomainModel.connect_status.desc())
else:
ordering.append(DomainModel.connect_status.asc())
# order by domain
elif order_prop == 'domain':
if order_type == 'descending':
ordering.append(DomainModel.domain.desc())
else:
ordering.append(DomainModel.domain.asc())
# order by group_id
elif order_prop == 'group_name':
if order_type == 'descending':
ordering.append(DomainModel.group_id.desc())
else:
ordering.append(DomainModel.group_id.asc())
# order by port
elif order_prop == 'port':
if order_type == 'descending':
ordering.append(DomainModel.port.desc())
else:
ordering.append(DomainModel.port.asc())
# order by update_time
elif order_prop == 'update_time':
if order_type == 'descending':
ordering.append(DomainModel.update_time.desc())
else:
ordering.append(DomainModel.update_time.asc())
# order by domain_expire_monitor
elif order_prop == 'domain_expire_monitor':
if order_type == 'descending':
ordering.append(DomainModel.domain_expire_monitor.desc())
else:
ordering.append(DomainModel.domain_expire_monitor.asc())
# order by auto_update
elif order_prop == 'auto_update':
if order_type == 'descending':
ordering.append(DomainModel.auto_update.desc())
else:
ordering.append(DomainModel.auto_update.asc())
# order by is_monitor
elif order_prop == 'is_monitor':
if order_type == 'descending':
ordering.append(DomainModel.is_monitor.desc())
else:
ordering.append(DomainModel.is_monitor.asc())
ordering.append(DomainModel.id.desc())
return ordering
def init_domain_cert_info_of_user(user_id):
"""
初始化证书信息
:param user_id:
:return:
"""
# 更新插入的证书
rows = DomainModel.select().where(
DomainModel.version == 0,
DomainModel.user_id == user_id,
)
for row in rows:
update_domain_row(row)
def has_read_permission(domain_row, user_id):
"""
读取权限
"""
# 1、域名所有人
if domain_row.user_id == user_id:
return True
# 2、分组成员
group_user_row = GroupUserModel.select().where(
GroupUserModel.group_id == domain_row.group_id,
GroupUserModel.user_id == user_id
).first()
if group_user_row:
return True
return False
def has_edit_permission(domain_row, user_id):
"""
编辑权限
"""
# 1、域名所有人
if domain_row.user_id == user_id:
return True
# 2、分组成员 并且拥有编辑权限
group_user_row = GroupUserModel.select().where(
GroupUserModel.group_id == domain_row.group_id,
GroupUserModel.user_id == user_id,
GroupUserModel.has_edit_permission == True
).first()
if group_user_row:
return True
return False