From ca957385f2f6fe0a1c547385f3fad35f410478d6 Mon Sep 17 00:00:00 2001 From: sans Date: Sat, 18 Jul 2026 20:54:48 +0800 Subject: [PATCH] bugfix --- backend/main.py | 2 ++ backend/routers/admin.py | 40 +++++++++++++++++++++++ backend/routers/api.py | 19 +++++++---- backend/templates_cert/deploy-cert.ps1.j2 | 9 ++--- backend/templates_cert/deploy-cert.sh.j2 | 8 ++--- certcenter.service | 15 +++++++++ frontend/src/api/index.js | 2 +- frontend/src/views/Domains.vue | 25 ++++++++++++-- start.sh | 13 ++++++++ 9 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 certcenter.service create mode 100644 start.sh diff --git a/backend/main.py b/backend/main.py index 66362a2..8ad7904 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ from backend.routers.admin import ( list_domains, get_domain, create_domain, update_domain, delete_domain, list_logs, get_acme_config, update_acme_config, issue_cert, renew_cert, auto_renew_all, list_acme_logs, get_cert_info, + download_cert_file, ) logging.basicConfig(level=logging.DEBUG) @@ -87,6 +88,7 @@ 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) logger.info("=== 路由注册完成 ===") for route in app.routes: diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 144ccc9..c3b0ec4 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -533,3 +533,43 @@ async def get_cert_info(domain_id: int, db: AsyncSession = Depends(get_db), _: b "need_renew": need_renew, "days_left": days_left, } + + +# ──────────────── 证书下载 ──────────────── + +def _cert_store_dir(domain: str) -> str: + """泛域名用裸域名作为存储目录""" + return domain[2:] if domain.startswith("*.") else domain + + +@router.get("/cert-download/{domain_id}/{file_type}") +async def download_cert_file(domain_id: int, file_type: str, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)): + """下载证书文件,file_type: fullchain 或 private""" + from fastapi.responses import FileResponse + + if file_type not in ("fullchain", "private"): + raise HTTPException(400, "file_type must be fullchain or private") + + 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") + + settings = get_settings() + store_dir = _cert_store_dir(domain.domain) + + if file_type == "fullchain": + file_path = Path(settings.cert_dir) / store_dir / "fullchain.pem" + filename = f"{store_dir}-fullchain.pem" + else: + file_path = Path(settings.cert_dir) / store_dir / "private.key" + filename = f"{store_dir}-private.key" + + if not file_path.exists(): + raise HTTPException(404, "文件不存在,请先申请证书") + + return FileResponse( + str(file_path), + media_type="application/x-pem-file", + filename=filename, + ) diff --git a/backend/routers/api.py b/backend/routers/api.py index 5e70ced..c2fe28b 100644 --- a/backend/routers/api.py +++ b/backend/routers/api.py @@ -34,7 +34,7 @@ async def verify_token( return server -@router.get("/version/{domain}") +@router.get("/version") async def get_version(domain: str, db: AsyncSession = Depends(get_db)): """返回当前证书版本号(无需认证,方便客户端轻量检查)""" result = await db.execute(select(Domain).where(Domain.domain == domain)) @@ -44,7 +44,12 @@ async def get_version(domain: str, db: AsyncSession = Depends(get_db)): return PlainTextResponse(d.version) -@router.get("/cert/{domain}/fullchain") +def _cert_store_dir(domain: str) -> str: + """泛域名用裸域名作为存储目录""" + return domain[2:] if domain.startswith("*.") else domain + + +@router.get("/cert/fullchain") async def get_fullchain( domain: str, server: Server = Depends(verify_token), @@ -57,13 +62,13 @@ async def get_fullchain( d = result.scalar_one_or_none() if not d: raise HTTPException(status_code=404, detail="Domain not found for this server") - cert_path = Path(settings.cert_dir) / domain / "fullchain.pem" + cert_path = Path(settings.cert_dir) / _cert_store_dir(domain) / "fullchain.pem" 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") -@router.get("/cert/{domain}/private") +@router.get("/cert/private") async def get_private_key( domain: str, server: Server = Depends(verify_token), @@ -76,13 +81,13 @@ async def get_private_key( d = result.scalar_one_or_none() if not d: raise HTTPException(status_code=404, detail="Domain not found for this server") - key_path = Path(settings.cert_dir) / domain / "private.key" + key_path = Path(settings.cert_dir) / _cert_store_dir(domain) / "private.key" 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") -@router.get("/script/{domain}") +@router.get("/script") async def generate_script( domain: str, server_name: str, @@ -123,7 +128,7 @@ async def generate_script( return PlainTextResponse(script, media_type=media_type) -@router.post("/report/{domain}") +@router.post("/report") async def report_deploy( domain: str, status: str, diff --git a/backend/templates_cert/deploy-cert.ps1.j2 b/backend/templates_cert/deploy-cert.ps1.j2 index c06d53c..b0b7b3a 100644 --- a/backend/templates_cert/deploy-cert.ps1.j2 +++ b/backend/templates_cert/deploy-cert.ps1.j2 @@ -9,7 +9,8 @@ $CheckCmd = "{{ check_cmd }}" $ReloadCmd = "{{ reload_cmd }}" # =========================== -$TmpDir = "$env:TEMP\cert-sync-$Domain" +$EncodedDomain = [System.Uri]::EscapeDataString($Domain) +$TmpDir = "$env:TEMP\cert-sync-$($Domain -replace '[*\.]','_')" $VersionFile = "$CertDir\.version" New-Item -ItemType Directory -Force -Path $CertDir, $TmpDir | Out-Null @@ -19,13 +20,13 @@ $headers = @{ Authorization = "Bearer $Token" } [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } # 1. 检查版本 -$remote = (Invoke-WebRequest -Uri "$BaseUrl/api/version/$Domain" -Headers $headers -UseBasicParsing -SkipCertificateCheck).Content.Trim() +$remote = (Invoke-WebRequest -Uri "$BaseUrl/api/version?domain=$EncodedDomain" -Headers $headers -UseBasicParsing -SkipCertificateCheck).Content.Trim() $local = if (Test-Path $VersionFile) { (Get-Content $VersionFile).Trim() } else { "0" } if ($remote -eq $local) { exit 0 } # 2. 下载证书 -Invoke-WebRequest -Uri "$BaseUrl/api/cert/$Domain/fullchain" -Headers $headers -OutFile "$TmpDir\fullchain.pem" -UseBasicParsing -SkipCertificateCheck -Invoke-WebRequest -Uri "$BaseUrl/api/cert/$Domain/private" -Headers $headers -OutFile "$TmpDir\private.key" -UseBasicParsing -SkipCertificateCheck +Invoke-WebRequest -Uri "$BaseUrl/api/cert/fullchain?domain=$EncodedDomain" -Headers $headers -OutFile "$TmpDir\fullchain.pem" -UseBasicParsing -SkipCertificateCheck +Invoke-WebRequest -Uri "$BaseUrl/api/cert/private?domain=$EncodedDomain" -Headers $headers -OutFile "$TmpDir\private.key" -UseBasicParsing -SkipCertificateCheck # 3. 备份旧证书 Copy-Item "$CertDir\fullchain.pem" "$CertDir\fullchain.pem.bak" -ErrorAction SilentlyContinue diff --git a/backend/templates_cert/deploy-cert.sh.j2 b/backend/templates_cert/deploy-cert.sh.j2 index 19cb522..ea6b13e 100644 --- a/backend/templates_cert/deploy-cert.sh.j2 +++ b/backend/templates_cert/deploy-cert.sh.j2 @@ -10,10 +10,10 @@ CHECK_CMD="{{ check_cmd }}" RELOAD_CMD="{{ reload_cmd }}" # =========================== -VERSION_URL="${BASE_URL}/api/version/${DOMAIN}" -FULLCHAIN_URL="${BASE_URL}/api/cert/${DOMAIN}/fullchain" -PRIVATE_URL="${BASE_URL}/api/cert/${DOMAIN}/private" -TMP_DIR="/tmp/cert-sync-${DOMAIN}" +VERSION_URL="${BASE_URL}/api/version?domain=${DOMAIN}" +FULLCHAIN_URL="${BASE_URL}/api/cert/fullchain?domain=${DOMAIN}" +PRIVATE_URL="${BASE_URL}/api/cert/private?domain=${DOMAIN}" +TMP_DIR="/tmp/cert-sync-${DOMAIN//\*/_}" VERSION_FILE="${CERT_DIR}/.version" mkdir -p "${CERT_DIR}" "${TMP_DIR}" diff --git a/certcenter.service b/certcenter.service new file mode 100644 index 0000000..d11cd27 --- /dev/null +++ b/certcenter.service @@ -0,0 +1,15 @@ +[Unit] +Description=CertCenter SSL Certificate Management +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/acme-auto +ExecStart=/opt/acme-auto/.venv/bin/python -m backend.main +Restart=on-failure +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6afc3c4..a73c063 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -49,6 +49,6 @@ export const getLogs = (status) => api.get('/logs', { params: { status } }) // Script (通过管理接口获取脚本内容) export const getScript = (domain, serverName) => - axios.get(`/api/script/${domain}`, { params: { server_name: serverName } }) + axios.get('/api/script', { params: { domain, server_name: serverName } }) export default api diff --git a/frontend/src/views/Domains.vue b/frontend/src/views/Domains.vue index 130ec9d..923f227 100644 --- a/frontend/src/views/Domains.vue +++ b/frontend/src/views/Domains.vue @@ -39,6 +39,8 @@ + + 编辑 查看脚本 @@ -115,14 +117,33 @@ const copyDeployCmd = async (d) => { const encodedDomain = encodeURIComponent(d.domain) let cmd if (d.platform === 'windows') { - cmd = `Invoke-WebRequest "${baseUrl}/api/script/${encodedDomain}?server=${d.server_name}" -OutFile C:\\scripts\\deploy-cert-${safeName}.ps1` + cmd = `Invoke-WebRequest "${baseUrl}/api/script?domain=${encodedDomain}&server=${encodeURIComponent(d.server_name)}" -OutFile C:\\scripts\\deploy-cert-${safeName}.ps1` } else { - cmd = `curl -fsSL "${baseUrl}/api/script/${encodedDomain}?server=${d.server_name}" -o /usr/local/bin/deploy-cert-${safeName}.sh && chmod +x /usr/local/bin/deploy-cert-${safeName}.sh` + cmd = `curl -fsSL "${baseUrl}/api/script?domain=${encodedDomain}&server=${encodeURIComponent(d.server_name)}" -o /usr/local/bin/deploy-cert-${safeName}.sh && chmod +x /usr/local/bin/deploy-cert-${safeName}.sh` } await navigator.clipboard.writeText(cmd) showCopied.value = true setTimeout(() => { showCopied.value = false }, 2000) } +const downloadCert = (d, fileType) => { + const token = localStorage.getItem('admin_token') + const url = `/admin/api/cert-download/${d.id}/${fileType}` + // 用 fetch 下载,带上 token + fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + .then(res => { + if (!res.ok) return res.json().then(e => { throw new Error(e.detail || '下载失败') }) + return res.blob() + }) + .then(blob => { + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = fileType === 'fullchain' ? 'fullchain.pem' : 'private.key' + a.click() + URL.revokeObjectURL(a.href) + }) + .catch(e => toast.error(e.message)) +} + onMounted(load) diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..74e5b14 --- /dev/null +++ b/start.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# CertCenter 启动脚本 +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# 激活虚拟环境 +if [ -f ".venv/bin/activate" ]; then + source .venv/bin/activate +fi + +exec python -m backend.main