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