改用element

增加acme多配置
This commit is contained in:
2026-07-18 23:17:53 +08:00
parent ca957385f2
commit 226f6a76dd
26 changed files with 1572 additions and 1922 deletions
+15
View File
@@ -4,6 +4,7 @@ ACME 核心服务 - 集成 Let's Encrypt 证书申请与续签
"""
import json
import os
import shutil
import time
import hashlib
import base64
@@ -223,6 +224,20 @@ class AcmeService:
)
output = result.stdout + "\n" + result.stderr
success = result.returncode == 0
# certbot 实际存储路径(可能忽略了 --cert-path
certbot_live = self.cert_dir / ".certbot-config" / "live" / store_dir
if success and certbot_live.exists():
import shutil
src_fullchain = certbot_live / "fullchain.pem"
src_privkey = certbot_live / "privkey.pem"
dst_fullchain = domain_dir / "fullchain.pem"
dst_privkey = domain_dir / "private.key"
if src_fullchain.exists():
shutil.copy2(str(src_fullchain), str(dst_fullchain))
if src_privkey.exists():
shutil.copy2(str(src_privkey), str(dst_privkey))
return success, output
except subprocess.TimeoutExpired:
return False, "Certbot timeout after 180s"
+31
View File
@@ -24,4 +24,35 @@ async def get_db():
async def init_db():
async with engine.begin() as conn:
# 迁移旧表:acme_config → acme_configs
try:
result = await conn.execute(
__import__("sqlalchemy").text("SELECT name FROM sqlite_master WHERE type='table' AND name='acme_config'")
)
if result.fetchone():
await conn.execute(__import__("sqlalchemy").text("ALTER TABLE acme_config RENAME TO acme_configs"))
except Exception:
pass
await conn.run_sync(Base.metadata.create_all)
# 迁移:给 domains 表加 acme_config_id 列
try:
await conn.execute(
__import__("sqlalchemy").text("ALTER TABLE domains ADD COLUMN acme_config_id INTEGER REFERENCES acme_configs(id)")
)
except Exception:
pass # 列已存在
# 确保至少有一条默认配置
result = await conn.execute(
__import__("sqlalchemy").text("SELECT COUNT(*) FROM acme_configs")
)
count = result.scalar()
if count == 0:
await conn.execute(
__import__("sqlalchemy").text(
"INSERT INTO acme_configs (name, acme_server, email, dns_provider, dns_credentials, renew_days) "
"VALUES ('默认配置', 'https://acme-v02.api.letsencrypt.org/directory', '', 'aliyun', '{}', 30)"
)
)
+9 -4
View File
@@ -16,9 +16,10 @@ 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, get_acme_config, update_acme_config,
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,
download_cert_file, admin_generate_script,
)
logging.basicConfig(level=logging.DEBUG)
@@ -81,14 +82,18 @@ 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/config")(get_acme_config)
app.put("/admin/api/acme/config")(update_acme_config)
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)
logger.info("=== 路由注册完成 ===")
for route in app.routes:
+9 -3
View File
@@ -23,6 +23,7 @@ class Domain(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(Integer, ForeignKey("servers.id"), nullable=False)
acme_config_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("acme_configs.id"), nullable=True)
domain: Mapped[str] = mapped_column(String(200), nullable=False)
cert_dir: Mapped[str] = mapped_column(String(500), nullable=False)
check_cmd: Mapped[str] = mapped_column(String(200), nullable=False)
@@ -33,6 +34,7 @@ class Domain(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
server: Mapped["Server"] = relationship(back_populates="domains")
acme_config: Mapped["AcmeConfig | None"] = relationship(back_populates="domains")
logs: Mapped[list["DeployLog"]] = relationship(back_populates="domain")
@@ -50,10 +52,11 @@ class DeployLog(Base):
class AcmeConfig(Base):
"""ACME 全局配置(单例"""
__tablename__ = "acme_config"
"""ACME 配置(支持多个"""
__tablename__ = "acme_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, default="默认配置")
# ACME 服务器地址
acme_server: Mapped[str] = mapped_column(String(500), default="https://acme-v02.api.letsencrypt.org/directory")
# 邮箱
@@ -66,8 +69,11 @@ class AcmeConfig(Base):
account_key: Mapped[str | None] = mapped_column(Text, nullable=True)
# 续签提前天数
renew_days: Mapped[int] = mapped_column(Integer, default=30)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
domains: Mapped[list["Domain"]] = relationship(back_populates="acme_config")
class AcmeLog(Base):
"""ACME 操作日志"""
+168 -49
View File
@@ -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),