diff --git a/backend/acme_service.py b/backend/acme_service.py index fc61e34..196c5dc 100644 --- a/backend/acme_service.py +++ b/backend/acme_service.py @@ -250,10 +250,10 @@ class AcmeService: except OSError: pass - def issue_certificate(self, domain: str) -> tuple[bool, str]: + def issue_certificate(self, domain: str) -> tuple[bool, str, dict, str]: """ 申请证书(使用 certbot + DNS-01 验证) - 返回 (成功?, 日志信息) + 返回 (成功?, 摘要信息, 证书详情dict, certbot原始输出) 泛域名 *.example.com 会自动同时申请裸域名 example.com """ @@ -264,23 +264,36 @@ class AcmeService: success, output = self._run_certbot(domain, "certonly") + cert_info = {} if success: cert_path = domain_dir / "fullchain.pem" if cert_path.exists(): - cert_data = cert_path.read_bytes() - cert = x509.load_pem_x509_certificate(cert_data) - not_after = cert.not_valid_after_utc - san = [] try: - san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) - san = san_ext.value.get_values_for_type(x509.DNSName) - except x509.ExtensionNotFound: - pass - return True, f"Certificate issued, expires: {not_after.strftime('%Y-%m-%d %H:%M:%S')}, SAN: {san}" + cert_data = cert_path.read_bytes() + cert = x509.load_pem_x509_certificate(cert_data) + not_before = getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before + not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after + san = [] + try: + san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) + san = san_ext.value.get_values_for_type(x509.DNSName) + except x509.ExtensionNotFound: + pass + cert_info = { + "not_before": not_before.isoformat(), + "not_after": not_after.isoformat(), + "serial_number": str(cert.serial_number), + "san": [str(s) for s in san], + "issuer": cert.issuer.rfc4514_string(), + } + msg = f"签发成功,过期时间: {not_after.strftime('%Y-%m-%d %H:%M:%S')}, SAN: {san}" + return True, msg, cert_info, output + except Exception as e: + return True, f"签发成功但解析证书失败: {e}", cert_info, output - return success, output + return success, output, cert_info, output - def renew_certificate(self, domain: str) -> tuple[bool, str]: + def renew_certificate(self, domain: str) -> tuple[bool, str, dict, str]: """续签证书""" return self.issue_certificate(domain) diff --git a/backend/database.py b/backend/database.py index 83ce6a0..d6b4ed0 100644 --- a/backend/database.py +++ b/backend/database.py @@ -55,6 +55,23 @@ async def init_db(): except Exception: pass # 列已存在 + # 迁移:给 deploy_logs 表加 hostname 列 + try: + await conn.execute(__import__("sqlalchemy").text("ALTER TABLE deploy_logs ADD COLUMN hostname VARCHAR(200)")) + except Exception: + pass + + # 迁移:给 acme_logs 表加证书信息列 + for col_def in [ + "ALTER TABLE acme_logs ADD COLUMN cert_not_after DATETIME", + "ALTER TABLE acme_logs ADD COLUMN cert_serial VARCHAR(100)", + "ALTER TABLE acme_logs ADD COLUMN cert_san VARCHAR(500)", + ]: + try: + await conn.execute(__import__("sqlalchemy").text(col_def)) + except Exception: + pass # 列已存在 + # 确保至少有一条默认配置 result = await conn.execute( __import__("sqlalchemy").text("SELECT COUNT(*) FROM acme_configs") diff --git a/backend/main.py b/backend/main.py index f424326..a778eef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -64,13 +64,18 @@ async def auto_renew_job(): await db.flush() try: - success, msg = service.renew_certificate(d.domain) + success, msg, cert_info, raw_output = service.renew_certificate(d.domain) log.status = "success" if success else "failed" log.message = msg - if success: - info = service.get_cert_info(d.domain) - if info: - d.cert_not_after = datetime.fromisoformat(info["not_after"]) + log.detail = raw_output[-5000:] if raw_output else None + if success and cert_info.get("not_after"): + d.cert_not_after = datetime.fromisoformat(cert_info["not_after"]) + d.version = str(int(d.version or "0") + 1) + log.cert_not_after = d.cert_not_after + log.cert_serial = cert_info.get("serial_number", "") + log.cert_san = ", ".join(cert_info.get("san", [])) + renewed += 1 + elif success: d.version = str(int(d.version or "0") + 1) renewed += 1 logger.info(f"[定时任务] {d.domain}: {'成功' if success else '失败'} - {msg}") diff --git a/backend/models.py b/backend/models.py index 9f82a0c..f4216ca 100644 --- a/backend/models.py +++ b/backend/models.py @@ -44,6 +44,7 @@ class DeployLog(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) domain_id: Mapped[int] = mapped_column(Integer, ForeignKey("domains.id"), nullable=False) server_id: Mapped[int] = mapped_column(Integer, ForeignKey("servers.id"), nullable=False) + hostname: Mapped[str | None] = mapped_column(String(200), nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False) # success / failed / skipped message: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -84,7 +85,10 @@ class AcmeLog(Base): action: Mapped[str] = mapped_column(String(50), nullable=False) # issue / renew / revoke / register status: Mapped[str] = mapped_column(String(20), nullable=False) # success / failed / pending message: Mapped[str | None] = mapped_column(Text, nullable=True) - detail: Mapped[str | None] = mapped_column(Text, nullable=True) # 详细日志 + detail: Mapped[str | None] = mapped_column(Text, nullable=True) # certbot 原始输出 + cert_not_after: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + cert_serial: Mapped[str | None] = mapped_column(String(100), nullable=True) + cert_san: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) domain: Mapped["Domain | None"] = relationship() diff --git a/backend/routers/admin.py b/backend/routers/admin.py index c92498e..87808a9 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -346,6 +346,7 @@ async def list_logs(status: str | None = None, db: AsyncSession = Depends(get_db "id": log.id, "domain": log.domain.domain if log.domain else None, "server": log.domain.server.name if log.domain and log.domain.server else None, + "hostname": log.hostname, "status": log.status, "message": log.message, "created_at": log.created_at.isoformat(), @@ -488,19 +489,22 @@ async def issue_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool await db.flush() try: - success, msg = service.issue_certificate(domain.domain) + success, msg, cert_info, raw_output = service.issue_certificate(domain.domain) log.status = "success" if success else "failed" log.message = msg + log.detail = raw_output[-5000:] if raw_output else None # 截断防止过大 if success: - info = service.get_cert_info(domain.domain) - if info: - domain.cert_not_after = datetime.fromisoformat(info["not_after"]) + if cert_info.get("not_after"): + domain.cert_not_after = datetime.fromisoformat(cert_info["not_after"]) domain.version = str(int(domain.version or "0") + 1) - logger.info(f"证书信息已更新: {domain.domain}, 过期时间: {info['not_after']}") + log.cert_not_after = domain.cert_not_after + log.cert_serial = cert_info.get("serial_number", "") + log.cert_san = ", ".join(cert_info.get("san", [])) + logger.info(f"证书信息已更新: {domain.domain}, 过期时间: {cert_info['not_after']}") else: - logger.warning(f"无法获取证书信息: {domain.domain}") domain.version = str(int(domain.version or "0") + 1) + logger.warning(f"无法获取证书信息: {domain.domain}") await db.flush() return {"success": success, "message": msg} @@ -550,15 +554,17 @@ async def auto_renew_all(db: AsyncSession = Depends(get_db), _: bool = Depends(r db.add(log) await db.flush() - success, msg = service.renew_certificate(d.domain) + success, msg, cert_info, raw_output = service.renew_certificate(d.domain) log.status = "success" if success else "failed" log.message = msg + log.detail = raw_output[-5000:] if raw_output else None - if success: - info = service.get_cert_info(d.domain) - if info: - d.cert_not_after = datetime.fromisoformat(info["not_after"]) - d.version = str(int(d.version or "0") + 1) + if success and cert_info.get("not_after"): + d.cert_not_after = datetime.fromisoformat(cert_info["not_after"]) + d.version = str(int(d.version or "0") + 1) + log.cert_not_after = d.cert_not_after + log.cert_serial = cert_info.get("serial_number", "") + log.cert_san = ", ".join(cert_info.get("san", [])) results.append({"domain": d.domain, "success": success, "message": msg, "days_left": days_left}) else: @@ -589,6 +595,9 @@ async def list_acme_logs(status: str | None = None, db: AsyncSession = Depends(g "status": log.status, "message": log.message, "detail": log.detail, + "cert_not_after": log.cert_not_after.isoformat() if log.cert_not_after else None, + "cert_serial": log.cert_serial, + "cert_san": log.cert_san, "created_at": log.created_at.isoformat(), } for log in logs diff --git a/backend/routers/api.py b/backend/routers/api.py index c2fe28b..99a8a65 100644 --- a/backend/routers/api.py +++ b/backend/routers/api.py @@ -133,6 +133,7 @@ async def report_deploy( domain: str, status: str, message: str = "", + hostname: str = "", server: Server = Depends(verify_token), db: AsyncSession = Depends(get_db), ): @@ -144,6 +145,6 @@ async def report_deploy( if not d: raise HTTPException(status_code=404, detail="Domain not found for this server") - log = DeployLog(domain_id=d.id, server_id=server.id, status=status, message=message) + log = DeployLog(domain_id=d.id, server_id=server.id, hostname=hostname, status=status, message=message) db.add(log) return {"ok": True} diff --git a/backend/templates_cert/deploy-cert.ps1.j2 b/backend/templates_cert/deploy-cert.ps1.j2 index f0899c7..a84f28c 100644 --- a/backend/templates_cert/deploy-cert.ps1.j2 +++ b/backend/templates_cert/deploy-cert.ps1.j2 @@ -37,16 +37,17 @@ Move-Item "$TmpDir\fullchain.pem" "$CertDir\fullchain.pem" -Force Move-Item "$TmpDir\private.key" "$CertDir\private.key" -Force # 5. 校验 & 重载 & 上报 +$HostName = $env:COMPUTERNAME try { Invoke-Expression $CheckCmd $remote | Out-File -NoNewline -Encoding ascii $VersionFile Invoke-Expression $ReloadCmd Write-Host "[$(Get-Date -Format o)] updated: $Domain -> $remote" - try { Invoke-WebRequest -Uri "$BaseUrl/api/report?domain=$EncodedDomain&status=success&message=deployed+v$remote" -Headers $headers -Method POST -UseBasicParsing -SkipCertificateCheck | Out-Null } catch {} + try { Invoke-WebRequest -Uri "$BaseUrl/api/report?domain=$EncodedDomain&status=success&hostname=$HostName&message=deployed+v$remote" -Headers $headers -Method POST -UseBasicParsing -SkipCertificateCheck | Out-Null } catch {} } catch { Move-Item "$CertDir\fullchain.pem.bak" "$CertDir\fullchain.pem" -Force -ErrorAction SilentlyContinue Move-Item "$CertDir\private.key.bak" "$CertDir\private.key" -Force -ErrorAction SilentlyContinue Write-Error "[$(Get-Date -Format o)] FAILED: $Domain, rolled back" - try { Invoke-WebRequest -Uri "$BaseUrl/api/report?domain=$EncodedDomain&status=failed&message=check+failed+rolled+back" -Headers $headers -Method POST -UseBasicParsing -SkipCertificateCheck | Out-Null } catch {} + try { Invoke-WebRequest -Uri "$BaseUrl/api/report?domain=$EncodedDomain&status=failed&hostname=$HostName&message=check+failed+rolled+back" -Headers $headers -Method POST -UseBasicParsing -SkipCertificateCheck | Out-Null } catch {} exit 1 } diff --git a/backend/templates_cert/deploy-cert.sh.j2 b/backend/templates_cert/deploy-cert.sh.j2 index 8de3676..fe4c672 100644 --- a/backend/templates_cert/deploy-cert.sh.j2 +++ b/backend/templates_cert/deploy-cert.sh.j2 @@ -42,15 +42,16 @@ mv -f "${TMP_DIR}/fullchain.pem" "${CERT_DIR}/fullchain.pem" mv -f "${TMP_DIR}/private.key" "${CERT_DIR}/private.key" # 5. 校验 & 重载 & 上报 +HOSTNAME=$(hostname) if ${CHECK_CMD}; then echo "${REMOTE}" > "${VERSION_FILE}" ${RELOAD_CMD} echo "[$(date -Is)] updated: ${DOMAIN} -> ${REMOTE}" - curl "${curl_opts[@]}" "${auth[@]}" -X POST "${BASE_URL}/api/report?domain=${DOMAIN}&status=success&message=deployed+v${REMOTE}" 2>/dev/null || true + curl "${curl_opts[@]}" "${auth[@]}" -X POST "${BASE_URL}/api/report?domain=${DOMAIN}&status=success&hostname=${HOSTNAME}&message=deployed+v${REMOTE}" 2>/dev/null || true else mv -f "${CERT_DIR}/fullchain.pem.bak" "${CERT_DIR}/fullchain.pem" 2>/dev/null || true mv -f "${CERT_DIR}/private.key.bak" "${CERT_DIR}/private.key" 2>/dev/null || true echo "[$(date -Is)] FAILED: ${DOMAIN}, rolled back" >&2 - curl "${curl_opts[@]}" "${auth[@]}" -X POST "${BASE_URL}/api/report?domain=${DOMAIN}&status=failed&message=check+failed+rolled+back" 2>/dev/null || true + curl "${curl_opts[@]}" "${auth[@]}" -X POST "${BASE_URL}/api/report?domain=${DOMAIN}&status=failed&hostname=${HOSTNAME}&message=check+failed+rolled+back" 2>/dev/null || true exit 1 fi diff --git a/frontend/src/views/AcmeLogs.vue b/frontend/src/views/AcmeLogs.vue index 8ba3174..044cd3a 100644 --- a/frontend/src/views/AcmeLogs.vue +++ b/frontend/src/views/AcmeLogs.vue @@ -44,15 +44,20 @@ diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue index a6426ca..afbebcf 100644 --- a/frontend/src/views/Logs.vue +++ b/frontend/src/views/Logs.vue @@ -15,7 +15,10 @@ - + + + +