初始化1
This commit is contained in:
263
nginx-python-api/services/cert_notifier.py
Normal file
263
nginx-python-api/services/cert_notifier.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
证书到期邮件通知服务
|
||||
- 由 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()
|
||||
Reference in New Issue
Block a user