Files

147 lines
5.2 KiB
Python
Raw Permalink Normal View History

2026-07-18 20:09:26 +08:00
from fastapi import APIRouter, Depends, HTTPException, Header
from fastapi.responses import PlainTextResponse, FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
import os
2026-07-21 11:02:26 +08:00
from backend.database import get_db, db_add
2026-07-18 20:09:26 +08:00
from backend.models import Server, Domain, DeployLog
from backend.config import get_settings
router = APIRouter(tags=["client-api"])
settings = get_settings()
# Jinja2 环境,用于生成脚本
template_dir = Path(__file__).parent.parent / "templates_cert"
jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
async def verify_token(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db),
):
"""验证 Bearer Token,返回对应的 Server 对象"""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization header")
token = authorization[7:]
result = await db.execute(select(Server).where(Server.token == token))
2026-07-20 11:29:46 +08:00
server = result.scalars().first()
2026-07-18 20:09:26 +08:00
if not server:
raise HTTPException(status_code=401, detail="Invalid token")
return server
2026-07-20 11:29:46 +08:00
async def _find_domain(db: AsyncSession, domain: str, server_id: int | None = None, acme_config_id: int | None = None) -> Domain:
"""根据条件查找域名记录,acme_config_id 精确匹配"""
conditions = [Domain.domain == domain]
if server_id is not None:
conditions.append(Domain.server_id == server_id)
if acme_config_id is not None:
conditions.append(Domain.acme_config_id == acme_config_id)
result = await db.execute(select(Domain).where(*conditions))
d = result.scalars().first()
2026-07-18 20:09:26 +08:00
if not d:
raise HTTPException(status_code=404, detail="Domain not found")
2026-07-20 11:29:46 +08:00
return d
@router.get("/version")
async def get_version(domain: str, acme_config_id: int | None = None, db: AsyncSession = Depends(get_db)):
"""返回当前证书版本号(无需认证,方便客户端轻量检查)"""
d = await _find_domain(db, domain, acme_config_id=acme_config_id)
2026-07-18 20:09:26 +08:00
return PlainTextResponse(d.version)
2026-07-18 20:54:48 +08:00
def _cert_store_dir(domain: str) -> str:
"""泛域名用裸域名作为存储目录"""
return domain[2:] if domain.startswith("*.") else domain
@router.get("/cert/fullchain")
2026-07-18 20:09:26 +08:00
async def get_fullchain(
domain: str,
2026-07-20 11:29:46 +08:00
acme_config_id: int | None = None,
2026-07-18 20:09:26 +08:00
server: Server = Depends(verify_token),
db: AsyncSession = Depends(get_db),
):
"""下载 fullchain.pem"""
2026-07-20 11:29:46 +08:00
await _find_domain(db, domain, server_id=server.id, acme_config_id=acme_config_id)
2026-07-18 20:54:48 +08:00
cert_path = Path(settings.cert_dir) / _cert_store_dir(domain) / "fullchain.pem"
2026-07-18 20:09:26 +08:00
if not cert_path.exists():
raise HTTPException(status_code=404, detail="Certificate file not found")
return FileResponse(str(cert_path), media_type="application/x-pem-file", filename="fullchain.pem")
2026-07-18 20:54:48 +08:00
@router.get("/cert/private")
2026-07-18 20:09:26 +08:00
async def get_private_key(
domain: str,
2026-07-20 11:29:46 +08:00
acme_config_id: int | None = None,
2026-07-18 20:09:26 +08:00
server: Server = Depends(verify_token),
db: AsyncSession = Depends(get_db),
):
"""下载 private.key"""
2026-07-20 11:29:46 +08:00
await _find_domain(db, domain, server_id=server.id, acme_config_id=acme_config_id)
2026-07-18 20:54:48 +08:00
key_path = Path(settings.cert_dir) / _cert_store_dir(domain) / "private.key"
2026-07-18 20:09:26 +08:00
if not key_path.exists():
raise HTTPException(status_code=404, detail="Private key file not found")
return FileResponse(str(key_path), media_type="application/x-pem-file", filename="private.key")
2026-07-18 20:54:48 +08:00
@router.get("/script")
2026-07-18 20:09:26 +08:00
async def generate_script(
domain: str,
server_name: str,
2026-07-20 11:29:46 +08:00
acme_config_id: int | None = None,
2026-07-18 20:09:26 +08:00
db: AsyncSession = Depends(get_db),
):
"""根据 server_name 生成对应的部署脚本"""
# 查找服务器
result = await db.execute(select(Server).where(Server.name == server_name))
2026-07-20 11:29:46 +08:00
server = result.scalars().first()
2026-07-18 20:09:26 +08:00
if not server:
raise HTTPException(status_code=404, detail="Server not found")
# 查找域名配置
2026-07-20 11:29:46 +08:00
d = await _find_domain(db, domain, server_id=server.id, acme_config_id=acme_config_id)
2026-07-18 20:09:26 +08:00
# 选择模板
if server.platform == "windows":
template_name = "deploy-cert.ps1.j2"
else:
template_name = "deploy-cert.sh.j2"
tpl = jinja_env.get_template(template_name)
script = tpl.render(
domain=domain,
base_url=settings.base_url,
token=server.token,
cert_dir=d.cert_dir,
check_cmd=d.check_cmd,
reload_cmd=d.reload_cmd,
2026-07-20 11:29:46 +08:00
acme_config_id=d.acme_config_id or "",
2026-07-18 20:09:26 +08:00
)
media_type = "text/plain" if server.platform == "windows" else "application/x-sh"
return PlainTextResponse(script, media_type=media_type)
2026-07-18 20:54:48 +08:00
@router.post("/report")
2026-07-18 20:09:26 +08:00
async def report_deploy(
domain: str,
status: str,
message: str = "",
2026-07-20 10:26:58 +08:00
hostname: str = "",
2026-07-20 11:29:46 +08:00
acme_config_id: int | None = None,
2026-07-18 20:09:26 +08:00
server: Server = Depends(verify_token),
db: AsyncSession = Depends(get_db),
):
"""客户端上报部署结果"""
2026-07-20 11:29:46 +08:00
d = await _find_domain(db, domain, server_id=server.id, acme_config_id=acme_config_id)
2026-07-18 20:09:26 +08:00
2026-07-20 10:26:58 +08:00
log = DeployLog(domain_id=d.id, server_id=server.id, hostname=hostname, status=status, message=message)
2026-07-21 11:02:26 +08:00
await db_add(db, log)
2026-07-18 20:09:26 +08:00
return {"ok": True}