This commit is contained in:
2026-07-20 10:04:57 +08:00
parent 22cbe5bb4a
commit 9658015972
4 changed files with 95 additions and 5 deletions
+12 -4
View File
@@ -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]:
+67 -1
View File
@@ -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)
+15
View File
@@ -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}