diff --git a/backend/acme_service.py b/backend/acme_service.py index ef8dc38..fc61e34 100644 --- a/backend/acme_service.py +++ b/backend/acme_service.py @@ -310,15 +310,23 @@ class AcmeService: try: cert = x509.load_pem_x509_certificate(cert_path.read_bytes()) + # 兼容新旧版本 cryptography 库 + not_before = getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before + not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after + try: + san = [name.value for name in cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value] + except Exception: + san = [] return { "subject": cert.subject.rfc4514_string(), "issuer": cert.issuer.rfc4514_string(), - "not_before": cert.not_valid_before_utc.isoformat(), - "not_after": cert.not_valid_after_utc.isoformat(), + "not_before": not_before.isoformat(), + "not_after": not_after.isoformat(), "serial_number": str(cert.serial_number), - "san": [name.value for name in cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value], + "san": san, } - except Exception: + except Exception as e: + logger.error(f"解析证书失败 {cert_path}: {e}") return None def auto_renew_all(self, domains: list[Domain]) -> list[dict]: diff --git a/backend/main.py b/backend/main.py index 1d488a1..f424326 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,16 @@ from contextlib import asynccontextmanager from pathlib import Path import logging +import asyncio from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from starlette.requests import Request +from apscheduler.schedulers.asyncio import AsyncIOScheduler -from backend.database import init_db +from backend.database import init_db, async_session from backend.routers import api, admin from backend.config import get_settings from backend.bootstrap import ensure_self_signed_cert @@ -25,12 +27,75 @@ from backend.routers.admin import ( logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger("certcenter") +scheduler = AsyncIOScheduler() + + +async def auto_renew_job(): + """定时任务:自动续签即将过期的证书""" + from backend.routers.admin import _get_domain_acme_config + from backend.models import Domain, AcmeLog + from backend.acme_service import AcmeService + from sqlalchemy import select + from sqlalchemy.orm import selectinload + from datetime import datetime + + logger.info("[定时任务] 开始检查证书续签...") + try: + async with async_session() as db: + settings = get_settings() + result = await db.execute(select(Domain).options(selectinload(Domain.acme_config))) + domains = result.scalars().all() + + renewed = 0 + for d in domains: + try: + config = await _get_domain_acme_config(d, db) + except Exception: + continue + + service = AcmeService(config, settings.cert_dir) + need_renew, days_left = service.check_expiry(d.domain) + if not need_renew: + continue + + logger.info(f"[定时任务] {d.domain} 需要续签(剩余 {days_left} 天)") + log = AcmeLog(domain_id=d.id, action="renew", status="pending", message=f"自动续签 {d.domain}") + db.add(log) + await db.flush() + + try: + success, msg = service.renew_certificate(d.domain) + log.status = "success" if success else "failed" + log.message = msg + if success: + info = service.get_cert_info(d.domain) + if info: + d.cert_not_after = datetime.fromisoformat(info["not_after"]) + d.version = str(int(d.version or "0") + 1) + renewed += 1 + logger.info(f"[定时任务] {d.domain}: {'成功' if success else '失败'} - {msg}") + except Exception as e: + log.status = "failed" + log.message = str(e) + logger.error(f"[定时任务] {d.domain} 续签异常: {e}") + + await db.commit() + logger.info(f"[定时任务] 检查完成,续签 {renewed} 个证书") + except Exception as e: + logger.error(f"[定时任务] 执行异常: {e}") + @asynccontextmanager async def lifespan(app: FastAPI): await init_db() settings = get_settings() ensure_self_signed_cert(settings.base_url, settings.cert_dir) + + # 启动定时续签任务(每天凌晨3点执行) + scheduler.add_job(auto_renew_job, "cron", hour=3, minute=0, id="auto_renew", replace_existing=True) + scheduler.start() + logger.info("=== 定时续签任务已启动(每天 03:00) ===") + logger.info("=== 服务启动完成 ===") # 打印所有已注册的路由 for route in app.routes: @@ -41,6 +106,7 @@ async def lifespan(app: FastAPI): else: logger.info(f" [included router] {type(route).__name__}") yield + scheduler.shutdown() app = FastAPI(title="CertCenter", version="1.0.0", lifespan=lifespan) diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 95c3266..c92498e 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -247,6 +247,16 @@ async def list_domains(db: AsyncSession = Depends(get_db), _: bool = Depends(req select(Domain).options(selectinload(Domain.server), selectinload(Domain.acme_config)).order_by(Domain.id) ) domains = result.scalars().all() + + # 查询每个域名的最新部署日志 + deploy_status = {} + log_result = await db.execute( + select(DeployLog).order_by(DeployLog.id.desc()) + ) + for log in log_result.scalars().all(): + if log.domain_id not in deploy_status: + deploy_status[log.domain_id] = log.status == "success" + return [ { "id": d.id, @@ -260,6 +270,7 @@ async def list_domains(db: AsyncSession = Depends(get_db), _: bool = Depends(req "reload_cmd": d.reload_cmd, "version": d.version, "cert_not_after": d.cert_not_after.isoformat() if d.cert_not_after else None, + "deploy_success": deploy_status.get(d.id), } for d in domains ] @@ -486,6 +497,10 @@ async def issue_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool if info: domain.cert_not_after = datetime.fromisoformat(info["not_after"]) domain.version = str(int(domain.version or "0") + 1) + logger.info(f"证书信息已更新: {domain.domain}, 过期时间: {info['not_after']}") + else: + logger.warning(f"无法获取证书信息: {domain.domain}") + domain.version = str(int(domain.version or "0") + 1) await db.flush() return {"success": success, "message": msg} diff --git a/requirements.txt b/requirements.txt index c418e33..8475833 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ jinja2>=3.1.0 pydantic>=2.9.0 pydantic-settings>=2.5.0 python-dotenv>=1.0.0 +apscheduler>=3.10.0 # ACME 相关 acme>=2.10.0