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, cert_info, raw_output = service.renew_certificate(d.domain) log.status = "success" if success else "failed" log.message = msg log.detail = raw_output[-5000:] if raw_output else None if success and cert_info.get("not_after"): d.cert_not_after = datetime.fromisoformat(cert_info["not_after"]) d.version = str(int(d.version or "0") + 1) log.cert_not_after = d.cert_not_after log.cert_serial = cert_info.get("serial_number", "") log.cert_san = ", ".join(cert_info.get("san", [])) renewed += 1 elif success: 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)