2026-07-18 20:09:26 +08:00
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
import logging
|
2026-07-20 10:04:57 +08:00
|
|
|
|
import asyncio
|
2026-07-18 20:09:26 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-20 10:04:57 +08:00
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
2026-07-18 20:09:26 +08:00
|
|
|
|
|
2026-07-20 10:04:57 +08:00
|
|
|
|
from backend.database import init_db, async_session
|
2026-07-18 20:09:26 +08:00
|
|
|
|
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,
|
2026-07-18 23:17:53 +08:00
|
|
|
|
list_logs,
|
|
|
|
|
|
list_acme_configs, get_acme_config, create_acme_config, update_acme_config, delete_acme_config,
|
2026-07-18 20:09:26 +08:00
|
|
|
|
issue_cert, renew_cert, auto_renew_all, list_acme_logs, get_cert_info,
|
2026-07-19 00:21:23 +08:00
|
|
|
|
download_cert_file, admin_generate_script, admin_download_script, admin_setup_script,
|
2026-07-18 20:09:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
|
|
|
|
logger = logging.getLogger("certcenter")
|
|
|
|
|
|
|
2026-07-20 10:04:57 +08:00
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-07-18 20:09:26 +08:00
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def lifespan(app: FastAPI):
|
|
|
|
|
|
await init_db()
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
ensure_self_signed_cert(settings.base_url, settings.cert_dir)
|
2026-07-20 10:04:57 +08:00
|
|
|
|
|
|
|
|
|
|
# 启动定时续签任务(每天凌晨3点执行)
|
|
|
|
|
|
scheduler.add_job(auto_renew_job, "cron", hour=3, minute=0, id="auto_renew", replace_existing=True)
|
|
|
|
|
|
scheduler.start()
|
|
|
|
|
|
logger.info("=== 定时续签任务已启动(每天 03:00) ===")
|
|
|
|
|
|
|
2026-07-18 20:09:26 +08: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
|
2026-07-20 10:04:57 +08:00
|
|
|
|
scheduler.shutdown()
|
2026-07-18 20:09:26 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-18 23:17:53 +08:00
|
|
|
|
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)
|
2026-07-18 20:09:26 +08:00
|
|
|
|
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)
|
2026-07-18 20:54:48 +08:00
|
|
|
|
app.get("/admin/api/cert-download/{domain_id}/{file_type}")(download_cert_file)
|
2026-07-18 23:17:53 +08:00
|
|
|
|
app.get("/admin/api/script")(admin_generate_script)
|
2026-07-18 23:51:14 +08:00
|
|
|
|
app.get("/admin/api/script/download")(admin_download_script)
|
2026-07-19 00:21:23 +08:00
|
|
|
|
app.get("/admin/api/script/setup")(admin_setup_script)
|
2026-07-18 20:09:26 +08: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__}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 前端静态文件 ──
|
|
|
|
|
|
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
|
2026-07-18 20:46:07 +08:00
|
|
|
|
uvicorn.run("backend.main:app", host="0.0.0.0", port=8021, reload=True)
|