bugfix
This commit is contained in:
@@ -18,6 +18,7 @@ from backend.routers.admin import (
|
|||||||
list_domains, get_domain, create_domain, update_domain, delete_domain,
|
list_domains, get_domain, create_domain, update_domain, delete_domain,
|
||||||
list_logs, get_acme_config, update_acme_config,
|
list_logs, get_acme_config, update_acme_config,
|
||||||
issue_cert, renew_cert, auto_renew_all, list_acme_logs, get_cert_info,
|
issue_cert, renew_cert, auto_renew_all, list_acme_logs, get_cert_info,
|
||||||
|
download_cert_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
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.post("/admin/api/acme/auto-renew")(auto_renew_all)
|
||||||
app.get("/admin/api/acme/logs")(list_acme_logs)
|
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-info/{domain_id}")(get_cert_info)
|
||||||
|
app.get("/admin/api/cert-download/{domain_id}/{file_type}")(download_cert_file)
|
||||||
|
|
||||||
logger.info("=== 路由注册完成 ===")
|
logger.info("=== 路由注册完成 ===")
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
|
|||||||
@@ -533,3 +533,43 @@ async def get_cert_info(domain_id: int, db: AsyncSession = Depends(get_db), _: b
|
|||||||
"need_renew": need_renew,
|
"need_renew": need_renew,
|
||||||
"days_left": days_left,
|
"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,
|
||||||
|
)
|
||||||
|
|||||||
+12
-7
@@ -34,7 +34,7 @@ async def verify_token(
|
|||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
@router.get("/version/{domain}")
|
@router.get("/version")
|
||||||
async def get_version(domain: str, db: AsyncSession = Depends(get_db)):
|
async def get_version(domain: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""返回当前证书版本号(无需认证,方便客户端轻量检查)"""
|
"""返回当前证书版本号(无需认证,方便客户端轻量检查)"""
|
||||||
result = await db.execute(select(Domain).where(Domain.domain == domain))
|
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)
|
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(
|
async def get_fullchain(
|
||||||
domain: str,
|
domain: str,
|
||||||
server: Server = Depends(verify_token),
|
server: Server = Depends(verify_token),
|
||||||
@@ -57,13 +62,13 @@ async def get_fullchain(
|
|||||||
d = result.scalar_one_or_none()
|
d = result.scalar_one_or_none()
|
||||||
if not d:
|
if not d:
|
||||||
raise HTTPException(status_code=404, detail="Domain not found for this server")
|
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():
|
if not cert_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="Certificate file not found")
|
raise HTTPException(status_code=404, detail="Certificate file not found")
|
||||||
return FileResponse(str(cert_path), media_type="application/x-pem-file", filename="fullchain.pem")
|
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(
|
async def get_private_key(
|
||||||
domain: str,
|
domain: str,
|
||||||
server: Server = Depends(verify_token),
|
server: Server = Depends(verify_token),
|
||||||
@@ -76,13 +81,13 @@ async def get_private_key(
|
|||||||
d = result.scalar_one_or_none()
|
d = result.scalar_one_or_none()
|
||||||
if not d:
|
if not d:
|
||||||
raise HTTPException(status_code=404, detail="Domain not found for this server")
|
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():
|
if not key_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="Private key file not found")
|
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")
|
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(
|
async def generate_script(
|
||||||
domain: str,
|
domain: str,
|
||||||
server_name: str,
|
server_name: str,
|
||||||
@@ -123,7 +128,7 @@ async def generate_script(
|
|||||||
return PlainTextResponse(script, media_type=media_type)
|
return PlainTextResponse(script, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/report/{domain}")
|
@router.post("/report")
|
||||||
async def report_deploy(
|
async def report_deploy(
|
||||||
domain: str,
|
domain: str,
|
||||||
status: str,
|
status: str,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ $CheckCmd = "{{ check_cmd }}"
|
|||||||
$ReloadCmd = "{{ reload_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"
|
$VersionFile = "$CertDir\.version"
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path $CertDir, $TmpDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $CertDir, $TmpDir | Out-Null
|
||||||
@@ -19,13 +20,13 @@ $headers = @{ Authorization = "Bearer $Token" }
|
|||||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||||
|
|
||||||
# 1. 检查版本
|
# 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" }
|
$local = if (Test-Path $VersionFile) { (Get-Content $VersionFile).Trim() } else { "0" }
|
||||||
if ($remote -eq $local) { exit 0 }
|
if ($remote -eq $local) { exit 0 }
|
||||||
|
|
||||||
# 2. 下载证书
|
# 2. 下载证书
|
||||||
Invoke-WebRequest -Uri "$BaseUrl/api/cert/$Domain/fullchain" -Headers $headers -OutFile "$TmpDir\fullchain.pem" -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/$Domain/private" -Headers $headers -OutFile "$TmpDir\private.key" -UseBasicParsing -SkipCertificateCheck
|
Invoke-WebRequest -Uri "$BaseUrl/api/cert/private?domain=$EncodedDomain" -Headers $headers -OutFile "$TmpDir\private.key" -UseBasicParsing -SkipCertificateCheck
|
||||||
|
|
||||||
# 3. 备份旧证书
|
# 3. 备份旧证书
|
||||||
Copy-Item "$CertDir\fullchain.pem" "$CertDir\fullchain.pem.bak" -ErrorAction SilentlyContinue
|
Copy-Item "$CertDir\fullchain.pem" "$CertDir\fullchain.pem.bak" -ErrorAction SilentlyContinue
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ CHECK_CMD="{{ check_cmd }}"
|
|||||||
RELOAD_CMD="{{ reload_cmd }}"
|
RELOAD_CMD="{{ reload_cmd }}"
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
||||||
VERSION_URL="${BASE_URL}/api/version/${DOMAIN}"
|
VERSION_URL="${BASE_URL}/api/version?domain=${DOMAIN}"
|
||||||
FULLCHAIN_URL="${BASE_URL}/api/cert/${DOMAIN}/fullchain"
|
FULLCHAIN_URL="${BASE_URL}/api/cert/fullchain?domain=${DOMAIN}"
|
||||||
PRIVATE_URL="${BASE_URL}/api/cert/${DOMAIN}/private"
|
PRIVATE_URL="${BASE_URL}/api/cert/private?domain=${DOMAIN}"
|
||||||
TMP_DIR="/tmp/cert-sync-${DOMAIN}"
|
TMP_DIR="/tmp/cert-sync-${DOMAIN//\*/_}"
|
||||||
VERSION_FILE="${CERT_DIR}/.version"
|
VERSION_FILE="${CERT_DIR}/.version"
|
||||||
|
|
||||||
mkdir -p "${CERT_DIR}" "${TMP_DIR}"
|
mkdir -p "${CERT_DIR}" "${TMP_DIR}"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -49,6 +49,6 @@ export const getLogs = (status) => api.get('/logs', { params: { status } })
|
|||||||
|
|
||||||
// Script (通过管理接口获取脚本内容)
|
// Script (通过管理接口获取脚本内容)
|
||||||
export const getScript = (domain, serverName) =>
|
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
|
export default api
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
<button @click="handleIssue(d)" :disabled="d._issuing" class="text-orange-600 hover:underline text-xs disabled:opacity-50">
|
<button @click="handleIssue(d)" :disabled="d._issuing" class="text-orange-600 hover:underline text-xs disabled:opacity-50">
|
||||||
{{ d._issuing ? '申请中...' : '申请证书' }}
|
{{ d._issuing ? '申请中...' : '申请证书' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="downloadCert(d, 'fullchain')" class="text-teal-600 hover:underline text-xs">下载证书</button>
|
||||||
|
<button @click="downloadCert(d, 'private')" class="text-teal-600 hover:underline text-xs">下载密钥</button>
|
||||||
<router-link :to="`/domains/${d.id}/edit`" class="text-blue-600 hover:underline text-xs">编辑</router-link>
|
<router-link :to="`/domains/${d.id}/edit`" class="text-blue-600 hover:underline text-xs">编辑</router-link>
|
||||||
<button @click="copyDeployCmd(d)" class="text-green-600 hover:underline text-xs">复制部署命令</button>
|
<button @click="copyDeployCmd(d)" class="text-green-600 hover:underline text-xs">复制部署命令</button>
|
||||||
<router-link :to="`/script/${d.domain}?server=${d.server_name}`" class="text-purple-600 hover:underline text-xs">查看脚本</router-link>
|
<router-link :to="`/script/${d.domain}?server=${d.server_name}`" class="text-purple-600 hover:underline text-xs">查看脚本</router-link>
|
||||||
@@ -115,14 +117,33 @@ const copyDeployCmd = async (d) => {
|
|||||||
const encodedDomain = encodeURIComponent(d.domain)
|
const encodedDomain = encodeURIComponent(d.domain)
|
||||||
let cmd
|
let cmd
|
||||||
if (d.platform === 'windows') {
|
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 {
|
} 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)
|
await navigator.clipboard.writeText(cmd)
|
||||||
showCopied.value = true
|
showCopied.value = true
|
||||||
setTimeout(() => { showCopied.value = false }, 2000)
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user