Files
nginxserver/nginx-python-api/services/cert_notifier.py
2026-07-16 13:03:11 +08:00

264 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
证书到期邮件通知服务
- 由 cron 每日 10:00 触发
- 检查数据库中的 Let's Encrypt 证书
- 到期前 N 天起,连续每天发送提醒邮件
- 邮件通过 Mokee Gateway 对外开放 API 发送
使用方式:
python cert_notifier.py # 直接运行cron 调用)
python cert_notifier.py --dry-run # 预览模式,不实际发送
python cert_notifier.py --check # 仅显示证书状态
"""
import os
import sys
import json
import logging
import urllib.request
import urllib.error
from datetime import datetime
# 数据库配置
DB_HOST = os.environ.get('MYSQL_HOST', '10.1.1.122')
DB_PORT = int(os.environ.get('MYSQL_PORT', '3306'))
DB_USER = os.environ.get('MYSQL_USER', 'nginxserver')
DB_PASS = os.environ.get('MYSQL_PASS', 'mokee.')
DB_NAME = os.environ.get('MYSQL_DB', 'nginxserver')
# 网关配置(可通过环境变量覆盖)
GATEWAY_URL = os.environ.get('MOKEE_GATEWAY_URL', 'https://gw.server.mokee.com')
SYSTEM_CODE = os.environ.get('MOKEE_SYSTEM_CODE', 'nginxServer')
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s'
)
logger = logging.getLogger(__name__)
def get_db_connection():
"""获取数据库连接(延迟导入,避免非数据库场景报错)"""
import pymysql
return pymysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER,
password=DB_PASS, database=DB_NAME,
charset='utf8mb4', connect_timeout=5
)
def get_token():
"""从 Mokee Gateway 获取一次性 Token"""
url = f"{GATEWAY_URL}/zgapi/v1/open/token"
data = json.dumps({"systemCode": SYSTEM_CODE}).encode('utf-8')
req = urllib.request.Request(url, data=data, method='POST')
req.add_header('Content-Type', 'application/json')
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode('utf-8'))
if result.get('code') == 200:
token = result['data']['token']
logger.info(f"获取 Token 成功: {SYSTEM_CODE}")
return token
else:
logger.error(f"获取 Token 失败: {result}")
return None
except Exception as e:
logger.error(f"获取 Token 异常: {e}")
return None
def send_email(token, to, subject, content):
"""
通过 Mokee Gateway 发送邮件
成功 = Token 被消费,需要重新获取
"""
url = f"{GATEWAY_URL}/zgapi/v1/open/email/send"
data = json.dumps({
"to": to,
"subject": subject,
"content": content,
"contentType": "html"
}).encode('utf-8')
req = urllib.request.Request(url, data=data, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('Authorization', f'Bearer {token}')
try:
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode('utf-8'))
return result.get('code') == 200, str(result)
except Exception as e:
return False, str(e)
def build_email_html(domain, days_left, expire_date):
"""生成 HTML 邮件内容"""
if days_left <= 5:
level = 'danger'
color = '#F56C6C'
elif days_left <= 10:
level = 'warning'
color = '#E6A23C'
else:
level = 'info'
color = '#409EFF'
return f"""<!DOCTYPE html>
<html><body style="font-family: 'Microsoft YaHei', Arial, sans-serif;">
<div style="max-width:600px;margin:0 auto;padding:20px;border:1px solid #EBEEF5;border-radius:8px;">
<div style="background:{color};padding:16px;border-radius:8px 8px 0 0;text-align:center;">
<h2 style="color:#fff;margin:0;">⚠️ SSL 证书即将到期</h2>
</div>
<div style="padding:20px;background:#fff;">
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:8px 0;color:#909399;width:80px;">域名</td>
<td style="padding:8px 0;font-weight:bold;font-size:16px;">{domain}</td></tr>
<tr><td style="padding:8px 0;color:#909399;">到期时间</td>
<td style="padding:8px 0;">{expire_date}</td></tr>
<tr><td style="padding:8px 0;color:#909399;">剩余天数</td>
<td style="padding:8px 0;color:{color};font-weight:bold;font-size:20px;">{days_left} 天</td></tr>
<tr><td style="padding:8px 0;color:#909399;">证书类型</td>
<td style="padding:8px 0;">Let's Encrypt (阿里云 DNS 验证)</td></tr>
</table>
<hr style="border:0;border-top:1px solid #EBEEF5;margin:16px 0;">
<p style="color:#909399;font-size:13px;">
系统已配置 acme.sh 自动续期,正常情况下会在到期前自动更新证书。<br>
如续期失败,请检查:<br>
1. 阿里云 AccessKey 是否有效<br>
2. 服务器能否访问 Let's Encrypt API<br>
3. DNS TXT 记录是否正常清理
</p>
<p style="color:#C0C4CC;font-size:12px;">
此邮件由 Nginx 域名管理平台自动发送,连续提醒至证书续期成功。
</p>
</div>
</div>
</body></html>"""
def check_and_notify(dry_run=False):
"""检查证书到期情况并发送通知"""
conn = get_db_connection()
cursor = conn.cursor()
# 查询需要通知的 Let's Encrypt 证书
cursor.execute('''
SELECT id, domain, notify_email, notify_enabled, notify_days_before, expire_date
FROM certificates
WHERE cert_type = 'letsencrypt'
AND status = 'active'
AND notify_enabled = 1
AND notify_email IS NOT NULL
AND notify_email != ''
''')
certs = cursor.fetchall()
if not certs:
logger.info("无需通知的 Let's Encrypt 证书")
conn.close()
return
today = datetime.now()
token = None # 延迟获取
notified_count = 0
for cert_id, domain, notify_email, enabled, days_before, expire_date_db in certs:
# 从数据库获取过期日期
if expire_date_db:
expire_date = expire_date_db if isinstance(expire_date_db, datetime) else datetime.strptime(str(expire_date_db), '%Y-%m-%d %H:%M:%S')
days_left = (expire_date - today).days
else:
logger.warning(f"{domain}: 无过期日期信息,跳过")
continue
logger.info(f"{domain}: 剩余 {days_left} 天到期 (通知阈值: {days_before}天前开始)")
# 判断是否需要通知: days_before 天前开始连续通知到到期前1天
if days_left > days_before or days_left <= 0:
logger.info(f"{domain}: 不在通知窗口 (days_left={days_left}, threshold={days_before})")
continue
# 检查今天是否已发送过通知
cursor.execute('''
SELECT id FROM cert_notify_log
WHERE cert_id = %s AND DATE(sent_at) = CURDATE()
''', (cert_id,))
if cursor.fetchone():
logger.info(f"{domain}: 今日已通知,跳过")
continue
# 生成邮件内容
expire_str = expire_date.strftime('%Y-%m-%d %H:%M')
subject = f"⚠️ SSL证书到期提醒 - {domain} (剩余{days_left}天)"
content = build_email_html(domain, days_left, expire_str)
if dry_run:
logger.info(f"[DRY-RUN] 将发送邮件: to={notify_email}, subject={subject}")
continue
# 获取 Token每次发送前确保有效
if token is None:
token = get_token()
if token is None:
logger.error(f"无法获取 Token跳过 {domain}")
continue
# 发送邮件
success, resp = send_email(token, notify_email, subject, content)
if success:
# Token 已消费,下次需要重新获取
token = None
logger.info(f"{domain}: 邮件发送成功 → {notify_email}")
else:
logger.warning(f"{domain}: 邮件发送失败 → {resp}")
# 500 错误不消费 Token可重试
# 记录日志
cursor.execute('''
INSERT INTO cert_notify_log (cert_id, domain, days_left, email_to, success, response_msg)
VALUES (%s, %s, %s, %s, %s, %s)
''', (cert_id, domain, days_left, notify_email, 1 if success else 0, resp[:1000]))
conn.commit()
if success:
notified_count += 1
conn.close()
logger.info(f"通知完成: 发送 {notified_count} 封邮件")
def check_status():
"""仅显示证书状态(--check 模式)"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT domain, cert_type, status, expire_date,
DATEDIFF(COALESCE(expire_date, NOW()), NOW()) AS days_left,
notify_enabled, notify_email, notify_days_before
FROM certificates
WHERE cert_type = 'letsencrypt'
ORDER BY days_left
''')
print(f"\n{'域名':25s} {'类型':12s} {'状态':8s} {'到期日期':20s} {'剩余天数':>8s} {'通知':>4s} {'收件人'}")
print('-' * 120)
for row in cursor.fetchall():
domain, cert_type, status, expire_date, days_left, enabled, email, days_before = row
dl = str(days_left) if days_left is not None else 'N/A'
print(f"{domain:25s} {cert_type:12s} {status:8s} {str(expire_date):20s} {dl:>8s} {'' if enabled else '':>4s} {email or '-'}")
conn.close()
if __name__ == '__main__':
if '--dry-run' in sys.argv:
print("=== 预览模式 (--dry-run) ===\n")
check_and_notify(dry_run=True)
elif '--check' in sys.argv:
check_status()
else:
check_and_notify()