改用element
增加acme多配置
This commit is contained in:
+168
-49
@@ -5,11 +5,15 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import Server, Domain, DeployLog, AcmeConfig, AcmeLog
|
||||
from backend.config import get_settings
|
||||
@@ -115,13 +119,15 @@ class ServerUpdate(BaseModel):
|
||||
class DomainCreate(BaseModel):
|
||||
server_id: int
|
||||
domain: str
|
||||
cert_dir: str
|
||||
acme_config_id: int | None = None
|
||||
cert_dir: str | None = None
|
||||
check_cmd: str = "nginx -t"
|
||||
reload_cmd: str = "systemctl reload nginx"
|
||||
|
||||
|
||||
class DomainUpdate(BaseModel):
|
||||
server_id: int | None = None
|
||||
acme_config_id: int | None = None
|
||||
domain: str | None = None
|
||||
cert_dir: str | None = None
|
||||
check_cmd: str | None = None
|
||||
@@ -238,7 +244,7 @@ async def delete_server(server_id: int, db: AsyncSession = Depends(get_db), _: b
|
||||
@router.get("/domains")
|
||||
async def list_domains(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
result = await db.execute(
|
||||
select(Domain).options(selectinload(Domain.server)).order_by(Domain.id)
|
||||
select(Domain).options(selectinload(Domain.server), selectinload(Domain.acme_config)).order_by(Domain.id)
|
||||
)
|
||||
domains = result.scalars().all()
|
||||
return [
|
||||
@@ -246,6 +252,8 @@ async def list_domains(db: AsyncSession = Depends(get_db), _: bool = Depends(req
|
||||
"id": d.id,
|
||||
"server_id": d.server_id,
|
||||
"server_name": d.server.name if d.server else None,
|
||||
"acme_config_id": d.acme_config_id,
|
||||
"acme_config_name": d.acme_config.name if d.acme_config else None,
|
||||
"domain": d.domain,
|
||||
"cert_dir": d.cert_dir,
|
||||
"check_cmd": d.check_cmd,
|
||||
@@ -266,6 +274,7 @@ async def get_domain(domain_id: int, db: AsyncSession = Depends(get_db), _: bool
|
||||
return {
|
||||
"id": d.id,
|
||||
"server_id": d.server_id,
|
||||
"acme_config_id": d.acme_config_id,
|
||||
"domain": d.domain,
|
||||
"cert_dir": d.cert_dir,
|
||||
"check_cmd": d.check_cmd,
|
||||
@@ -276,7 +285,10 @@ async def get_domain(domain_id: int, db: AsyncSession = Depends(get_db), _: bool
|
||||
|
||||
@router.post("/domains", status_code=201)
|
||||
async def create_domain(data: DomainCreate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
domain = Domain(**data.model_dump())
|
||||
dump = data.model_dump()
|
||||
if not dump.get("cert_dir"):
|
||||
dump["cert_dir"] = dump["domain"].lstrip("*.")
|
||||
domain = Domain(**dump)
|
||||
db.add(domain)
|
||||
await db.flush()
|
||||
await db.refresh(domain)
|
||||
@@ -331,9 +343,19 @@ async def list_logs(status: str | None = None, db: AsyncSession = Depends(get_db
|
||||
]
|
||||
|
||||
|
||||
# ──────────────── ACME 配置 ────────────────
|
||||
# ──────────────── ACME 配置(多配置)────────────────
|
||||
|
||||
class AcmeConfigCreate(BaseModel):
|
||||
name: str = "默认配置"
|
||||
acme_server: str = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
email: str = ""
|
||||
dns_provider: str = "aliyun"
|
||||
dns_credentials: str = "{}"
|
||||
renew_days: int = 30
|
||||
|
||||
|
||||
class AcmeConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
acme_server: str | None = None
|
||||
email: str | None = None
|
||||
dns_provider: str | None = None
|
||||
@@ -341,62 +363,111 @@ class AcmeConfigUpdate(BaseModel):
|
||||
renew_days: int | None = None
|
||||
|
||||
|
||||
@router.get("/acme/config")
|
||||
async def get_acme_config(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
# 返回默认配置
|
||||
return {
|
||||
"acme_server": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"email": "",
|
||||
"dns_provider": "aliyun",
|
||||
"dns_credentials": "{}",
|
||||
"renew_days": 30,
|
||||
"has_account_key": False,
|
||||
}
|
||||
def _serialize_acme_config(c: AcmeConfig) -> dict:
|
||||
return {
|
||||
"acme_server": config.acme_server,
|
||||
"email": config.email,
|
||||
"dns_provider": config.dns_provider,
|
||||
"dns_credentials": config.dns_credentials,
|
||||
"renew_days": config.renew_days,
|
||||
"has_account_key": config.account_key is not None,
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"acme_server": c.acme_server,
|
||||
"email": c.email,
|
||||
"dns_provider": c.dns_provider,
|
||||
"dns_credentials": c.dns_credentials,
|
||||
"renew_days": c.renew_days,
|
||||
"has_account_key": c.account_key is not None,
|
||||
"domain_count": len(c.domains) if c.domains else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/acme/config")
|
||||
async def update_acme_config(data: AcmeConfigUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
|
||||
@router.get("/acme/configs")
|
||||
async def list_acme_configs(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
result = await db.execute(select(AcmeConfig).options(selectinload(AcmeConfig.domains)).order_by(AcmeConfig.id))
|
||||
configs = result.scalars().all()
|
||||
return [_serialize_acme_config(c) for c in configs]
|
||||
|
||||
|
||||
@router.get("/acme/configs/{config_id}")
|
||||
async def get_acme_config(config_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == config_id))
|
||||
c = result.scalar_one_or_none()
|
||||
if not c:
|
||||
raise HTTPException(404, "ACME config not found")
|
||||
return {
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"acme_server": c.acme_server,
|
||||
"email": c.email,
|
||||
"dns_provider": c.dns_provider,
|
||||
"dns_credentials": c.dns_credentials,
|
||||
"renew_days": c.renew_days,
|
||||
"has_account_key": c.account_key is not None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/acme/configs", status_code=201)
|
||||
async def create_acme_config(data: AcmeConfigCreate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
config = AcmeConfig(**data.model_dump())
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await db.refresh(config)
|
||||
return {"id": config.id, "name": config.name}
|
||||
|
||||
|
||||
@router.put("/acme/configs/{config_id}")
|
||||
async def update_acme_config(config_id: int, data: AcmeConfigUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == config_id))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
config = AcmeConfig(id=1)
|
||||
db.add(config)
|
||||
raise HTTPException(404, "ACME config not found")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(config, key, value)
|
||||
return {"id": config.id}
|
||||
|
||||
|
||||
@router.delete("/acme/configs/{config_id}")
|
||||
async def delete_acme_config(config_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == config_id))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(404, "ACME config not found")
|
||||
# 解除关联的域名
|
||||
result = await db.execute(select(Domain).where(Domain.acme_config_id == config_id))
|
||||
for d in result.scalars().all():
|
||||
d.acme_config_id = None
|
||||
await db.delete(config)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ──────────────── ACME 操作 ────────────────
|
||||
|
||||
async def _get_domain_acme_config(domain: Domain, db: AsyncSession) -> AcmeConfig:
|
||||
"""获取域名关联的 ACME 配置,未关联则使用第一个可用配置"""
|
||||
if domain.acme_config_id:
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == domain.acme_config_id))
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
return config
|
||||
# 回退:使用第一个有邮箱的配置
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.email != "").order_by(AcmeConfig.id))
|
||||
config = result.scalars().first()
|
||||
if not config:
|
||||
raise HTTPException(400, "无可用的 ACME 配置,请先配置邮箱")
|
||||
return config
|
||||
|
||||
|
||||
@router.post("/acme/issue/{domain_id}")
|
||||
async def issue_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||
"""为指定域名申请证书"""
|
||||
from backend.acme_service import AcmeService
|
||||
from backend.config import get_settings
|
||||
|
||||
# 获取 ACME 配置
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config or not config.email:
|
||||
raise HTTPException(400, "ACME not configured, please set email first")
|
||||
|
||||
# 获取域名
|
||||
result = await db.execute(select(Domain).where(Domain.id == domain_id))
|
||||
domain = result.scalar_one_or_none()
|
||||
if not domain:
|
||||
raise HTTPException(404, "Domain not found")
|
||||
|
||||
# 获取该域名关联的 ACME 配置
|
||||
config = await _get_domain_acme_config(domain, db)
|
||||
|
||||
settings = get_settings()
|
||||
service = AcmeService(config, settings.cert_dir)
|
||||
|
||||
@@ -411,7 +482,6 @@ async def issue_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool
|
||||
log.message = msg
|
||||
|
||||
if success:
|
||||
# 更新域名的证书到期时间
|
||||
info = service.get_cert_info(domain.domain)
|
||||
if info:
|
||||
domain.cert_not_after = datetime.fromisoformat(info["not_after"])
|
||||
@@ -438,23 +508,30 @@ async def auto_renew_all(db: AsyncSession = Depends(get_db), _: bool = Depends(r
|
||||
from backend.acme_service import AcmeService
|
||||
from backend.config import get_settings
|
||||
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config or not config.email:
|
||||
raise HTTPException(400, "ACME not configured")
|
||||
|
||||
settings = get_settings()
|
||||
service = AcmeService(config, settings.cert_dir)
|
||||
|
||||
# 获取所有域名
|
||||
result = await db.execute(select(Domain))
|
||||
result = await db.execute(select(Domain).options(selectinload(Domain.acme_config)))
|
||||
domains = result.scalars().all()
|
||||
|
||||
# 按 ACME 配置分组,为每个配置创建一个 service 实例
|
||||
services: dict[int, AcmeService] = {}
|
||||
|
||||
results = []
|
||||
for d in domains:
|
||||
try:
|
||||
config = await _get_domain_acme_config(d, db)
|
||||
except HTTPException:
|
||||
results.append({"domain": d.domain, "success": False, "message": "无可用 ACME 配置", "days_left": 0})
|
||||
continue
|
||||
|
||||
if config.id not in services:
|
||||
services[config.id] = AcmeService(config, settings.cert_dir)
|
||||
service = services[config.id]
|
||||
|
||||
need_renew, days_left = service.check_expiry(d.domain)
|
||||
if need_renew:
|
||||
log = AcmeLog(domain_id=d.id, action="renew", status="pending", message=f"Auto-renewing {d.domain}")
|
||||
log = AcmeLog(domain_id=d.id, action="renew", status="pending", message=f"Auto-renewing {d.domain} (config: {config.name})")
|
||||
db.add(log)
|
||||
await db.flush()
|
||||
|
||||
@@ -503,6 +580,49 @@ async def list_acme_logs(status: str | None = None, db: AsyncSession = Depends(g
|
||||
]
|
||||
|
||||
|
||||
# ──────────────── 部署脚本 ────────────────
|
||||
|
||||
@router.get("/script")
|
||||
async def admin_generate_script(
|
||||
domain: str,
|
||||
server_name: str,
|
||||
os: str = "linux",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(require_auth),
|
||||
):
|
||||
"""管理端生成部署脚本"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
template_dir = Path(__file__).parent.parent / "templates_cert"
|
||||
jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
|
||||
|
||||
result = await db.execute(select(Server).where(Server.name == server_name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Domain).where(Domain.domain == domain, Domain.server_id == server.id)
|
||||
)
|
||||
d = result.scalar_one_or_none()
|
||||
if not d:
|
||||
raise HTTPException(404, "Domain not found for this server")
|
||||
|
||||
settings = get_settings()
|
||||
template_name = "deploy-cert.ps1.j2" if os == "windows" else "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,
|
||||
)
|
||||
return PlainTextResponse(script)
|
||||
|
||||
|
||||
# ──────────────── 证书信息 ────────────────
|
||||
|
||||
@router.get("/cert-info/{domain_id}")
|
||||
@@ -516,10 +636,7 @@ async def get_cert_info(domain_id: int, db: AsyncSession = Depends(get_db), _: b
|
||||
if not domain:
|
||||
raise HTTPException(404, "Domain not found")
|
||||
|
||||
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
config = AcmeConfig(id=1)
|
||||
config = await _get_domain_acme_config(domain, db)
|
||||
|
||||
settings = get_settings()
|
||||
service = AcmeService(config, settings.cert_dir)
|
||||
@@ -565,8 +682,10 @@ async def download_cert_file(domain_id: int, file_type: str, db: AsyncSession =
|
||||
file_path = Path(settings.cert_dir) / store_dir / "private.key"
|
||||
filename = f"{store_dir}-private.key"
|
||||
|
||||
logger.debug(f"下载证书: domain={domain.domain}, store_dir={store_dir}, file_path={file_path}, exists={file_path.exists()}")
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "文件不存在,请先申请证书")
|
||||
raise HTTPException(404, f"文件不存在 ({file_path}),请先申请证书")
|
||||
|
||||
return FileResponse(
|
||||
str(file_path),
|
||||
|
||||
Reference in New Issue
Block a user