Files
acme-auto/backend/main.py
T
2026-07-20 10:04:57 +08:00

198 lines
7.6 KiB
Python
Raw 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.
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, async_session
from backend.routers import api, admin
from backend.config import get_settings
from backend.bootstrap import ensure_self_signed_cert
from backend.routers.admin import (
login, logout, check_auth,
get_stats, list_servers, get_server, create_server, update_server, delete_server,
list_domains, get_domain, create_domain, update_domain, delete_domain,
list_logs,
list_acme_configs, get_acme_config, create_acme_config, update_acme_config, delete_acme_config,
issue_cert, renew_cert, auto_renew_all, list_acme_logs, get_cert_info,
download_cert_file, admin_generate_script, admin_download_script, admin_setup_script,
)
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:
methods = getattr(route, "methods", None)
path = getattr(route, "path", None)
if path:
logger.info(f" {methods} {path}")
else:
logger.info(f" [included router] {type(route).__name__}")
yield
scheduler.shutdown()
app = FastAPI(title="CertCenter", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── 请求日志中间件 ──
@app.middleware("http")
async def log_request(request: Request, call_next):
logger.info(f">>> {request.method} {request.url.path}")
response = await call_next(request)
logger.info(f"<<< {request.method} {request.url.path} -> {response.status_code}")
return response
# ── 客户端 API ──
app.include_router(api.router, prefix="/api")
# ── 管理 API(直接注册)──
app.post("/admin/api/login")(login)
app.post("/admin/api/logout")(logout)
app.get("/admin/api/me")(check_auth)
app.get("/admin/api/stats")(get_stats)
app.get("/admin/api/servers")(list_servers)
app.get("/admin/api/servers/{server_id}")(get_server)
app.post("/admin/api/servers", status_code=201)(create_server)
app.put("/admin/api/servers/{server_id}")(update_server)
app.delete("/admin/api/servers/{server_id}")(delete_server)
app.get("/admin/api/domains")(list_domains)
app.get("/admin/api/domains/{domain_id}")(get_domain)
app.post("/admin/api/domains", status_code=201)(create_domain)
app.put("/admin/api/domains/{domain_id}")(update_domain)
app.delete("/admin/api/domains/{domain_id}")(delete_domain)
app.get("/admin/api/logs")(list_logs)
app.get("/admin/api/acme/configs")(list_acme_configs)
app.get("/admin/api/acme/configs/{config_id}")(get_acme_config)
app.post("/admin/api/acme/configs")(create_acme_config)
app.put("/admin/api/acme/configs/{config_id}")(update_acme_config)
app.delete("/admin/api/acme/configs/{config_id}")(delete_acme_config)
app.post("/admin/api/acme/issue/{domain_id}")(issue_cert)
app.post("/admin/api/acme/renew/{domain_id}")(renew_cert)
app.post("/admin/api/acme/auto-renew")(auto_renew_all)
app.get("/admin/api/acme/logs")(list_acme_logs)
app.get("/admin/api/cert-info/{domain_id}")(get_cert_info)
app.get("/admin/api/cert-download/{domain_id}/{file_type}")(download_cert_file)
app.get("/admin/api/script")(admin_generate_script)
app.get("/admin/api/script/download")(admin_download_script)
app.get("/admin/api/script/setup")(admin_setup_script)
logger.info("=== 路由注册完成 ===")
for route in app.routes:
methods = getattr(route, "methods", None)
path = getattr(route, "path", None)
if path:
logger.info(f" {methods} {path}")
else:
logger.info(f" [included router] {type(route).__name__}")
# ── 前端静态文件 ──
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
if frontend_dist.exists():
assets_dir = frontend_dist / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
index_file = frontend_dist / "index.html"
@app.middleware("http")
async def spa_fallback(request: Request, call_next):
response = await call_next(request)
if request.method == "GET" and response.status_code == 404:
path = request.url.path
if not path.startswith("/api/") and not path.startswith("/admin/api/") and not path.startswith("/assets/"):
return FileResponse(str(index_file))
return response
if __name__ == "__main__":
import uvicorn
uvicorn.run("backend.main:app", host="0.0.0.0", port=8021, reload=True)