Files
acme-auto/backend/main.py
T

203 lines
8.0 KiB
Python
Raw Normal View History

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:
2026-07-20 10:26:58 +08:00
success, msg, cert_info, raw_output = service.renew_certificate(d.domain)
2026-07-20 10:04:57 +08:00
log.status = "success" if success else "failed"
log.message = msg
2026-07-20 10:26:58 +08:00
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:
2026-07-20 10:04:57 +08:00
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)