198 lines
7.6 KiB
Python
198 lines
7.6 KiB
Python
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)
|