首次提交
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
# CertCenter 配置
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./certcenter.db
|
||||||
|
SECRET_KEY=change-me-to-a-random-string
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
CERT_DIR=/srv/certs
|
||||||
|
BASE_URL=https://cert.sansenhoshi.top
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CertCenter 配置
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./certcenter.db
|
||||||
|
SECRET_KEY=change-me-to-a-random-string
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
CERT_DIR=/srv/certs
|
||||||
|
BASE_URL=https://cert.example.com
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
CertCenter is a lightweight SSL certificate management platform. It integrates ACME certificate issuance (via certbot), automatic renewal, and multi-server certificate distribution through a Vue 3 WebUI.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
pip install -r requirements.txt # Install Python deps
|
||||||
|
python -m backend.main # Start server at :8000
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm install && npm run build && cd .. # Build for production
|
||||||
|
cd frontend && npm run dev # Dev server at :5173 (proxies API to :8000)
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
python -m tests.test_setup # Validate environment
|
||||||
|
python -m tests.test_e2e --domain X --ak Y --sk Z # End-to-end cert issuance test
|
||||||
|
|
||||||
|
# Start with hash seed fix (Windows)
|
||||||
|
set PYTHONHASHSEED=0 && python -m backend.main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Backend (FastAPI + SQLAlchemy + SQLite):**
|
||||||
|
- `backend/main.py` — App entry point, router registration, SPA middleware
|
||||||
|
- `backend/routers/api.py` — Client-facing API at `/api/*` (Bearer token per-server)
|
||||||
|
- `backend/routers/admin.py` — Admin API at `/admin/api/*` (HMAC token auth)
|
||||||
|
- `backend/acme_service.py` — ACME operations via certbot subprocess + AliDNS API
|
||||||
|
- `backend/models.py` — ORM models: Server, Domain, DeployLog, AcmeConfig, AcmeLog
|
||||||
|
- `backend/templates_cert/` — Jinja2 templates for deploy scripts (bash/PowerShell)
|
||||||
|
|
||||||
|
**Frontend (Vue 3 + Vite + Tailwind CSS):**
|
||||||
|
- Hash-based routing (`/#/path`) — no server-side routing needed
|
||||||
|
- `src/App.vue` — Login gate + sidebar layout
|
||||||
|
- `src/api/index.js` — Axios with auto token injection and 401 handling
|
||||||
|
|
||||||
|
**Dual Router Design:**
|
||||||
|
- `/api/*` — Used by deployment scripts on business servers (token per server)
|
||||||
|
- `/admin/api/*` — Used by WebUI (single admin user from .env)
|
||||||
|
|
||||||
|
**ACME Flow:** certbot is invoked as a subprocess, not used as a library. DNS-01 challenges are handled by calling AliDNS REST API directly. Wildcard certs (`*.example.com`) automatically include the bare domain.
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- Python 3.11-3.12 required (3.14 lacks pydantic-core wheels)
|
||||||
|
- SQLite for zero-dependency deployment
|
||||||
|
- Version-based sync: clients compare integer version numbers, download only on change
|
||||||
|
- Self-signed cert bootstrap on first HTTPS boot (client scripts use `curl -k`)
|
||||||
|
- Admin auth: HMAC-based tokens (`username:expiry:signature`), 7-day expiry
|
||||||
|
- Single admin user configured in `.env` (ADMIN_USERNAME / ADMIN_PASSWORD)
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
# CertCenter - 轻量化证书管理中心
|
||||||
|
|
||||||
|
一站式 SSL 证书管理平台,集成 ACME 证书申请、自动续签、多服务器分发,通过 Web UI 完成所有操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [架构概览](#架构概览)
|
||||||
|
- [环境要求](#环境要求)
|
||||||
|
- [安装部署](#安装部署)
|
||||||
|
- [WebUI 操作指南](#webui-操作指南)
|
||||||
|
- [仪表盘](#1-仪表盘)
|
||||||
|
- [服务器管理](#2-服务器管理)
|
||||||
|
- [域名管理](#3-域名管理)
|
||||||
|
- [ACME 配置](#4-acme-配置)
|
||||||
|
- [部署日志](#5-部署日志)
|
||||||
|
- [ACME 日志](#6-acme-日志)
|
||||||
|
- [测试验证](#测试验证)
|
||||||
|
- [环境检查](#1-环境检查)
|
||||||
|
- [端到端测试](#2-端到端测试)
|
||||||
|
- [客户端部署指南](#客户端部署指南)
|
||||||
|
- [Linux 服务器](#linux-服务器)
|
||||||
|
- [Windows 服务器](#windows-服务器)
|
||||||
|
- [API 参考](#api-参考)
|
||||||
|
- [客户端 API](#客户端-api)
|
||||||
|
- [管理 API](#管理-api)
|
||||||
|
- [配置说明](#配置说明)
|
||||||
|
- [常见问题](#常见问题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ CertCenter 服务 │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ WebUI │ │ ACME 引擎 │ │ 数据库 │ │
|
||||||
|
│ │ (Vue) │ │ (certbot) │ │ (SQLite) │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────┴──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ FastAPI 后端服务 │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────┼────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
|
||||||
|
│ VPS-A │ │ VPS-B │ │ VPS-C │
|
||||||
|
│ Linux │ │ Linux │ │ Windows │
|
||||||
|
│ cron │ │ cron │ │ schtasks│
|
||||||
|
└─────────┘ └─────────┘ └─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心流程:**
|
||||||
|
|
||||||
|
1. 在 WebUI 配置 ACME 凭据(阿里云 DNS / Cloudflare)
|
||||||
|
2. 添加服务器和域名信息
|
||||||
|
3. 点击"申请证书",CertCenter 自动完成 DNS 验证并签发证书
|
||||||
|
4. 业务服务器通过 cron 定时拉取最新证书
|
||||||
|
5. 证书到期前自动续签
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
### 服务端(CertCenter)
|
||||||
|
|
||||||
|
| 依赖 | 版本 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Python | 3.11 ~ 3.12 | ⚠️ 不支持 3.14(pydantic-core 无预编译 wheel) |
|
||||||
|
| certbot | 2.10+ | ACME 证书申请 |
|
||||||
|
| certbot-dns-alicloud | 最新 | 阿里云 DNS 插件(如使用阿里云) |
|
||||||
|
|
||||||
|
### 客户端(业务服务器)
|
||||||
|
|
||||||
|
| 依赖 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| bash / PowerShell | 执行部署脚本 |
|
||||||
|
| curl / Invoke-WebRequest | 下载证书 |
|
||||||
|
| cron / schtasks | 定时任务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装部署
|
||||||
|
|
||||||
|
### 0. 确认 Python 版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python --version
|
||||||
|
# 需要 3.11.x 或 3.12.x,不支持 3.14
|
||||||
|
```
|
||||||
|
|
||||||
|
如果版本不对,先装 Python 3.12:
|
||||||
|
- 下载地址:https://www.python.org/downloads/release/python-3128/
|
||||||
|
- 安装时勾选 "Add python.exe to PATH"
|
||||||
|
|
||||||
|
### 1. 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> certcenter
|
||||||
|
cd certcenter
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建虚拟环境并安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建虚拟环境(指定 Python 3.12)
|
||||||
|
py -3.12 -m venv .venv
|
||||||
|
|
||||||
|
# 激活虚拟环境
|
||||||
|
.venv\Scripts\activate # Windows
|
||||||
|
# source .venv/bin/activate # Linux/Mac
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
如果使用阿里云 DNS,还需要安装 certbot 插件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install certbot-dns-alicloud
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 安装前端依赖并构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
构建完成后,`frontend/dist/` 目录包含前端静态文件,FastAPI 会自动加载。
|
||||||
|
|
||||||
|
### 4. 配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env` 文件:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# 数据库路径(SQLite)
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./certcenter.db
|
||||||
|
|
||||||
|
# 证书存储目录
|
||||||
|
CERT_DIR=/srv/certs
|
||||||
|
|
||||||
|
# CertCenter 的访问地址(客户端通过此地址拉取证书)
|
||||||
|
BASE_URL=https://cert.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m backend.main
|
||||||
|
```
|
||||||
|
|
||||||
|
服务默认监听 `http://0.0.0.0:8000`。
|
||||||
|
|
||||||
|
**首次启动时,系统会自动为 CertCenter 的域名生成一份自签证书**(存放在 `CERT_DIR` 下),确保 HTTPS 可用。后续正式签发证书后会自动替换。
|
||||||
|
|
||||||
|
### 6. 引导流程(首次使用)
|
||||||
|
|
||||||
|
首次使用时的推荐操作顺序:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 启动 CertCenter(自签证书自动生成)
|
||||||
|
python -m backend.main
|
||||||
|
|
||||||
|
2. 打开 WebUI,配置 ACME 凭据(阿里云 AK/SK)
|
||||||
|
|
||||||
|
3. 添加域名:cert.zhzp.top(CertCenter 自身的域名)
|
||||||
|
|
||||||
|
4. 点击"申请证书",为 CertCenter 签发正式证书
|
||||||
|
|
||||||
|
5. 重启 CertCenter,正式证书自动加载
|
||||||
|
# 此时所有客户端连接都是受信任的 HTTPS
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么需要这个流程?**
|
||||||
|
|
||||||
|
```
|
||||||
|
CertCenter 使用 HTTPS → 需要证书 → 证书由 CertCenter 自己签发
|
||||||
|
↑
|
||||||
|
这就是"引导"问题
|
||||||
|
```
|
||||||
|
|
||||||
|
解决方式:先用自签证书启动 → 签发正式证书 → 替换。客户端脚本已内置 `curl -k` 跳过 TLS 验证,不受影响。
|
||||||
|
|
||||||
|
### 7. 生产环境部署(可选)
|
||||||
|
|
||||||
|
使用 systemd 托管服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /etc/systemd/system/certcenter.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=CertCenter Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/certcenter
|
||||||
|
ExecStart=/opt/certcenter/venv/bin/python -m backend.main
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now certcenter
|
||||||
|
```
|
||||||
|
|
||||||
|
如果需要 HTTPS 访问 CertCenter 本身,可以使用 nginx 反向代理:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name cert.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/cert.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/cert.example.com/private.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试验证
|
||||||
|
|
||||||
|
部署完成后,建议按以下步骤验证系统是否正常工作。
|
||||||
|
|
||||||
|
### 1. 环境检查
|
||||||
|
|
||||||
|
运行部署验证脚本,检查所有依赖和配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m tests.test_setup
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本会检查:
|
||||||
|
|
||||||
|
| 检查项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| Python 依赖 | fastapi、sqlalchemy、acme、cryptography 等 |
|
||||||
|
| Certbot | certbot 命令是否可用 |
|
||||||
|
| DNS 插件 | certbot-dns-alicloud 或 certbot-dns-cloudflare |
|
||||||
|
| .env 配置 | 环境变量文件是否存在 |
|
||||||
|
| 数据库 | 数据库连接是否正常 |
|
||||||
|
| 前端构建 | frontend/dist/ 是否存在 |
|
||||||
|
| 证书目录 | 存储目录是否可写 |
|
||||||
|
| BASE_URL | 是否已修改默认值 |
|
||||||
|
| Let's Encrypt 连通性 | Staging 和 Production 环境是否可达 |
|
||||||
|
| FastAPI 服务 | 服务能否正常启动 |
|
||||||
|
|
||||||
|
所有检查通过后,进入下一步。
|
||||||
|
|
||||||
|
### 2. 端到端测试
|
||||||
|
|
||||||
|
使用 Let's Encrypt **Staging 环境**(测试环境)验证完整的证书签发流程。
|
||||||
|
|
||||||
|
**前提条件:**
|
||||||
|
|
||||||
|
- 一个使用阿里云 DNS 解析的域名
|
||||||
|
- 阿里云 RAM 子账号的 AccessKey(需有 `AliyunDNSFullAccess` 权限)
|
||||||
|
- CertCenter 服务已启动
|
||||||
|
|
||||||
|
**执行测试:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m tests.test_e2e \
|
||||||
|
--domain test.example.com \
|
||||||
|
--email admin@example.com \
|
||||||
|
--ak YOUR_ACCESS_KEY \
|
||||||
|
--sk YOUR_ACCESS_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[0] 检查 API 服务是否正常
|
||||||
|
[1] 配置 ACME(Staging 环境 + 阿里云 DNS 凭据)
|
||||||
|
[2] 创建测试服务器
|
||||||
|
[3] 创建测试域名配置
|
||||||
|
[4] 申请证书(自动完成 DNS-01 验证)
|
||||||
|
[5] 检查证书信息(签发者、有效期、SAN)
|
||||||
|
[6] 测试版本接口
|
||||||
|
[7] 测试脚本生成
|
||||||
|
[8] 测试证书下载
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期输出:**
|
||||||
|
|
||||||
|
```
|
||||||
|
==================================================
|
||||||
|
端到端测试: test.example.com
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
✅ [0] API 服务正常
|
||||||
|
✅ [1] ACME 配置完成
|
||||||
|
✅ [2] 服务器 ID: 1
|
||||||
|
✅ [3] 域名 ID: 1
|
||||||
|
✅ [4] 证书申请成功: Certificate issued, expires: 2026-10-16 00:00:00
|
||||||
|
✅ [5] 签发者: CN=(STAGING) Artificial Apricot R3, O=Let's Encrypt
|
||||||
|
✅ [5] 有效期: 2026-07-18 ~ 2026-10-16
|
||||||
|
✅ [5] 域名: ['test.example.com']
|
||||||
|
✅ [5] 剩余天数: 90
|
||||||
|
✅ [6] 版本号: 1
|
||||||
|
✅ [7] 脚本生成成功,长度: 1234 字符
|
||||||
|
✅ [8] 证书下载成功
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
✅ 端到端测试完成!
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
证书位置: /tmp/cert-e2e-test/test.example.com/
|
||||||
|
Staging 证书不受浏览器信任,仅用于验证流程。
|
||||||
|
确认流程正常后,在 WebUI 中切换到生产环境重新签发。
|
||||||
|
```
|
||||||
|
|
||||||
|
**Staging vs Production:**
|
||||||
|
|
||||||
|
| 环境 | 地址 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Staging | `https://acme-staging-v02.api.letsencrypt.org/directory` | 测试环境,证书不受信任,无速率限制 |
|
||||||
|
| Production | `https://acme-v02.api.letsencrypt.org/directory` | 生产环境,正式证书,有速率限制 |
|
||||||
|
|
||||||
|
**建议:** 先用 Staging 环境完成全流程测试,确认无误后在 WebUI 中切换到 Production 环境,删除测试域名,重新添加正式域名并签发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebUI 操作指南
|
||||||
|
|
||||||
|
启动服务后,浏览器访问 `http://服务器IP:8000`(或配置的域名)。
|
||||||
|
|
||||||
|
### 1. 仪表盘
|
||||||
|
|
||||||
|
首页展示:
|
||||||
|
|
||||||
|
- **统计卡片**:服务器总数、域名总数、即将过期证书数
|
||||||
|
- **最近部署日志**:最近 10 条客户端部署记录
|
||||||
|
- **快捷操作**:新增服务器、新增域名
|
||||||
|
|
||||||
|
### 2. 服务器管理
|
||||||
|
|
||||||
|
**新增服务器:**
|
||||||
|
|
||||||
|
1. 点击侧边栏"服务器管理"
|
||||||
|
2. 点击"+ 新增服务器"
|
||||||
|
3. 填写表单:
|
||||||
|
- **名称**:服务器标识,如 `vps-blog`、`api-server`
|
||||||
|
- **平台**:选择 `Linux` 或 `Windows`
|
||||||
|
- **Token**:点击"生成"按钮自动生成,或手动填写
|
||||||
|
- **IP**:可选,仅用于备注
|
||||||
|
4. 点击"创建"
|
||||||
|
|
||||||
|
**编辑/删除服务器:**
|
||||||
|
|
||||||
|
- 在列表中点击"编辑"修改信息
|
||||||
|
- 点击"删除"会同时删除该服务器下的所有域名配置
|
||||||
|
|
||||||
|
### 3. 域名管理
|
||||||
|
|
||||||
|
**新增域名:**
|
||||||
|
|
||||||
|
1. 点击侧边栏"域名管理"
|
||||||
|
2. 点击"+ 新增域名"
|
||||||
|
3. 填写表单:
|
||||||
|
- **所属服务器**:选择域名所在的业务服务器
|
||||||
|
- **域名**:如 `example.com`、`api.example.com`
|
||||||
|
- **证书存放路径**:证书在业务服务器上的存放目录
|
||||||
|
- Linux 示例:`/etc/nginx/ssl/example.com`
|
||||||
|
- Windows 示例:`C:\certs\example.com`
|
||||||
|
- **校验命令**:替换证书后验证服务是否正常的命令
|
||||||
|
- nginx 示例:`nginx -t`
|
||||||
|
- 其他服务根据实际情况填写
|
||||||
|
- **重载命令**:证书更新后重载服务的命令
|
||||||
|
- Linux 示例:`systemctl reload nginx`
|
||||||
|
- Windows 示例:`nginx -s reload`
|
||||||
|
4. 点击"创建"
|
||||||
|
|
||||||
|
**申请证书:**
|
||||||
|
|
||||||
|
1. 在域名列表中找到目标域名
|
||||||
|
2. 点击"申请证书"按钮
|
||||||
|
3. 系统自动完成:
|
||||||
|
- 通过 DNS-01 验证(自动添加 `_acme-challenge` TXT 记录)
|
||||||
|
- 向 Let's Encrypt 申请证书
|
||||||
|
- 保存证书到配置的目录
|
||||||
|
- 更新证书到期时间
|
||||||
|
4. 申请成功后,"证书到期"列会显示到期日期
|
||||||
|
|
||||||
|
**复制部署命令:**
|
||||||
|
|
||||||
|
点击"复制部署命令",获取在业务服务器上执行的一键部署命令。
|
||||||
|
|
||||||
|
**查看脚本:**
|
||||||
|
|
||||||
|
点击"查看脚本",预览将下发到业务服务器的部署脚本内容。
|
||||||
|
|
||||||
|
### 4. ACME 配置
|
||||||
|
|
||||||
|
**基本配置:**
|
||||||
|
|
||||||
|
1. 点击侧边栏"ACME 配置"
|
||||||
|
2. 配置项:
|
||||||
|
- **ACME 服务器**:
|
||||||
|
- Let's Encrypt (生产):正式环境使用
|
||||||
|
- Let's Encrypt (测试):测试环境,不受速率限制
|
||||||
|
- **邮箱**:Let's Encrypt 注册邮箱,用于接收证书到期提醒
|
||||||
|
- **DNS 提商**:选择使用的 DNS 服务商
|
||||||
|
- **续签提前天数**:证书到期前几天自动续签(默认 30 天)
|
||||||
|
|
||||||
|
**DNS 凭据配置:**
|
||||||
|
|
||||||
|
根据选择的 DNS 提商填写对应的凭据:
|
||||||
|
|
||||||
|
| DNS 提商 | 需要的凭据 |
|
||||||
|
|----------|-----------|
|
||||||
|
| 阿里云 DNS | Access Key + Access Secret |
|
||||||
|
| Cloudflare | API Token |
|
||||||
|
|
||||||
|
**获取阿里云 DNS 凭据:**
|
||||||
|
|
||||||
|
1. 登录 [阿里云控制台](https://ram.console.aliyun.com/)
|
||||||
|
2. 创建子账号,授予 `AliyunDNSFullAccess` 权限
|
||||||
|
3. 创建 AccessKey,记录 Access Key ID 和 Access Key Secret
|
||||||
|
|
||||||
|
**自动续签:**
|
||||||
|
|
||||||
|
点击"🔄 自动续签所有"按钮,系统会:
|
||||||
|
|
||||||
|
1. 检查所有域名的证书到期时间
|
||||||
|
2. 对即将过期的证书自动执行续签
|
||||||
|
3. 显示每个域名的续签结果
|
||||||
|
|
||||||
|
### 5. 部署日志
|
||||||
|
|
||||||
|
查看所有客户端(业务服务器)的证书拉取部署记录:
|
||||||
|
|
||||||
|
- **时间**:部署执行时间
|
||||||
|
- **服务器**:执行部署的服务器
|
||||||
|
- **域名**:部署的域名
|
||||||
|
- **状态**:成功 / 失败 / 跳过
|
||||||
|
- **消息**:详细信息
|
||||||
|
|
||||||
|
支持按状态筛选。
|
||||||
|
|
||||||
|
### 6. ACME 日志
|
||||||
|
|
||||||
|
查看所有证书申请/续签操作记录:
|
||||||
|
|
||||||
|
- **时间**:操作时间
|
||||||
|
- **域名**:操作的域名
|
||||||
|
- **操作**:申请 / 续签 / 吊销 / 注册
|
||||||
|
- **状态**:成功 / 失败 / 进行中
|
||||||
|
- **消息**:结果信息
|
||||||
|
- **详情**:点击"查看"显示完整日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 客户端部署指南
|
||||||
|
|
||||||
|
### Linux 服务器
|
||||||
|
|
||||||
|
**首次部署(只需执行一次):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 下载部署脚本(从 WebUI "复制部署命令" 获取)
|
||||||
|
curl -fsSL "https://cert.example.com/api/script/example.com?server_name=vps-blog" \
|
||||||
|
-o /usr/local/bin/deploy-cert.sh
|
||||||
|
|
||||||
|
# 2. 添加执行权限
|
||||||
|
chmod +x /usr/local/bin/deploy-cert.sh
|
||||||
|
|
||||||
|
# 3. 添加 cron 定时任务(每 30 分钟检查一次)
|
||||||
|
echo '*/30 * * * * /usr/local/bin/deploy-cert.sh >> /var/log/deploy-cert.log 2>&1' | crontab -
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动执行测试:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/local/bin/deploy-cert.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**查看部署日志:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f /var/log/deploy-cert.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**脚本执行流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 请求 CertCenter 获取远端版本号
|
||||||
|
2. 对比本地版本号
|
||||||
|
├── 一致 → 退出(无需更新)
|
||||||
|
└── 不一致 ↓
|
||||||
|
3. 下载新证书(fullchain.pem + private.key)
|
||||||
|
4. 备份旧证书
|
||||||
|
5. 替换新证书
|
||||||
|
6. 执行校验命令(nginx -t)
|
||||||
|
├── 失败 → 回滚旧证书 → 退出
|
||||||
|
└── 成功 ↓
|
||||||
|
7. 更新本地版本号
|
||||||
|
8. 重载服务(systemctl reload nginx)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 服务器
|
||||||
|
|
||||||
|
**首次部署:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 创建脚本目录
|
||||||
|
New-Item -ItemType Directory -Force -Path C:\scripts
|
||||||
|
|
||||||
|
# 2. 下载部署脚本
|
||||||
|
Invoke-WebRequest `
|
||||||
|
-Uri "https://cert.example.com/api/script/www.example.com?server_name=win-web" `
|
||||||
|
-OutFile C:\scripts\deploy-cert.ps1
|
||||||
|
|
||||||
|
# 3. 添加计划任务(每 30 分钟)
|
||||||
|
schtasks /create /sc minute /mo 30 /tn "CertDeploy-www.example.com" /tr "powershell -ExecutionPolicy Bypass -File C:\scripts\deploy-cert.ps1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动执行测试:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File C:\scripts\deploy-cert.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### 客户端 API
|
||||||
|
|
||||||
|
供业务服务器上的部署脚本调用,使用 Bearer Token 认证。
|
||||||
|
|
||||||
|
#### 获取证书版本号
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/version/{domain}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 认证:无需认证
|
||||||
|
- 返回:版本号字符串(纯文本)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://cert.example.com/api/version/example.com
|
||||||
|
# 返回: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 下载证书
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cert/{domain}/fullchain
|
||||||
|
GET /api/cert/{domain}/private
|
||||||
|
```
|
||||||
|
|
||||||
|
- 认证:`Authorization: Bearer <token>`
|
||||||
|
- 返回:PEM 格式证书文件
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer tok_xxx" \
|
||||||
|
https://cert.example.com/api/cert/example.com/fullchain \
|
||||||
|
-o fullchain.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 获取部署脚本
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/script/{domain}?server_name={server_name}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 认证:无需认证
|
||||||
|
- 返回:部署脚本内容(bash 或 PowerShell)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://cert.example.com/api/script/example.com?server_name=vps-blog \
|
||||||
|
-o /usr/local/bin/deploy-cert.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 上报部署结果
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/report/{domain}?status={status}&message={message}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 认证:`Authorization: Bearer <token>`
|
||||||
|
- 参数:
|
||||||
|
- `status`:`success` / `failed` / `skipped`
|
||||||
|
- `message`:可选,详细信息
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer tok_xxx" \
|
||||||
|
"https://cert.example.com/api/report/example.com?status=success&message=updated+to+v3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 管理 API
|
||||||
|
|
||||||
|
供 WebUI 前端调用,所有接口以 `/admin/api` 为前缀。
|
||||||
|
|
||||||
|
#### 统计数据
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_count": 3,
|
||||||
|
"domain_count": 5,
|
||||||
|
"expiring_count": 1,
|
||||||
|
"recent_logs": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 服务器管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/admin/api/servers` | 服务器列表 |
|
||||||
|
| GET | `/admin/api/servers/{id}` | 服务器详情 |
|
||||||
|
| POST | `/admin/api/servers` | 新增服务器 |
|
||||||
|
| PUT | `/admin/api/servers/{id}` | 编辑服务器 |
|
||||||
|
| DELETE | `/admin/api/servers/{id}` | 删除服务器 |
|
||||||
|
|
||||||
|
**新增/编辑请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "vps-blog",
|
||||||
|
"platform": "linux",
|
||||||
|
"token": "tok_xxxxxxxxxxxx",
|
||||||
|
"ip": "1.2.3.4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 域名管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/admin/api/domains` | 域名列表 |
|
||||||
|
| GET | `/admin/api/domains/{id}` | 域名详情 |
|
||||||
|
| POST | `/admin/api/domains` | 新增域名 |
|
||||||
|
| PUT | `/admin/api/domains/{id}` | 编辑域名 |
|
||||||
|
| DELETE | `/admin/api/domains/{id}` | 删除域名 |
|
||||||
|
|
||||||
|
**新增/编辑请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_id": 1,
|
||||||
|
"domain": "example.com",
|
||||||
|
"cert_dir": "/etc/nginx/ssl/example.com",
|
||||||
|
"check_cmd": "nginx -t",
|
||||||
|
"reload_cmd": "systemctl reload nginx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ACME 配置
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/admin/api/acme/config` | 获取 ACME 配置 |
|
||||||
|
| PUT | `/admin/api/acme/config` | 更新 ACME 配置 |
|
||||||
|
|
||||||
|
**更新请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"acme_server": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"dns_provider": "aliyun",
|
||||||
|
"dns_credentials": "{\"access_key\":\"xxx\",\"access_secret\":\"xxx\"}",
|
||||||
|
"renew_days": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ACME 操作
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/admin/api/acme/issue/{domain_id}` | 申请证书 |
|
||||||
|
| POST | `/admin/api/acme/renew/{domain_id}` | 续签证书 |
|
||||||
|
| POST | `/admin/api/acme/auto-renew` | 自动续签所有 |
|
||||||
|
|
||||||
|
#### 日志查询
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/admin/api/logs?status={status}` | 部署日志 |
|
||||||
|
| GET | `/admin/api/acme/logs?status={status}` | ACME 日志 |
|
||||||
|
|
||||||
|
#### 证书信息
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/api/cert-info/{domain_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domain": "example.com",
|
||||||
|
"cert_info": {
|
||||||
|
"subject": "CN=example.com",
|
||||||
|
"issuer": "CN=R3, O=Let's Encrypt",
|
||||||
|
"not_before": "2026-01-01T00:00:00",
|
||||||
|
"not_after": "2026-04-01T00:00:00",
|
||||||
|
"serial_number": "1234567890",
|
||||||
|
"san": ["example.com"]
|
||||||
|
},
|
||||||
|
"need_renew": false,
|
||||||
|
"days_left": 75
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配说说明
|
||||||
|
|
||||||
|
### 环境变量(.env)
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `DATABASE_URL` | `sqlite+aiosqlite:///./certcenter.db` | 数据库连接字符串 |
|
||||||
|
| `CERT_DIR` | `/srv/certs` | 证书存储根目录 |
|
||||||
|
| `BASE_URL` | `https://cert.example.com` | CertCenter 访问地址 |
|
||||||
|
|
||||||
|
### 证书存储目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/srv/certs/
|
||||||
|
├── example.com/
|
||||||
|
│ ├── fullchain.pem # 完整证书链
|
||||||
|
│ ├── private.key # 私钥
|
||||||
|
│ └── .version # 当前版本号
|
||||||
|
├── api.example.com/
|
||||||
|
│ ├── fullchain.pem
|
||||||
|
│ ├── private.key
|
||||||
|
│ └── .version
|
||||||
|
└── .certbot-work/ # certbot 工作目录
|
||||||
|
├── .certbot-config/
|
||||||
|
└── .certbot-logs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 版本号说明
|
||||||
|
|
||||||
|
版本号为递增整数,初始值为 `0`。每次证书申请/续签成功后自动 +1。
|
||||||
|
|
||||||
|
客户端通过对比版本号判断是否需要下载新证书,避免重复下载。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 申请证书时报错 "DNS problem: NXDOMAIN looking up TXT for _acme-challenge.xxx"
|
||||||
|
|
||||||
|
**原因:** DNS TXT 记录未正确添加或未及时传播。
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
|
||||||
|
1. 确认阿里云 DNS 凭据正确(Access Key / Secret)
|
||||||
|
2. 确认域名使用的是阿里云 DNS 服务
|
||||||
|
3. 等待 DNS 记录传播(通常 1-2 分钟)
|
||||||
|
4. 使用测试环境(Let's Encrypt Staging)调试
|
||||||
|
|
||||||
|
### Q: 客户端脚本执行后没有更新证书
|
||||||
|
|
||||||
|
**可能原因:**
|
||||||
|
|
||||||
|
1. 版本号一致,无需更新(正常行为)
|
||||||
|
2. Token 不正确,API 返回 401
|
||||||
|
3. 网络不通,无法访问 CertCenter
|
||||||
|
|
||||||
|
**排查步骤:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 手动测试版本接口
|
||||||
|
curl https://cert.example.com/api/version/example.com
|
||||||
|
|
||||||
|
# 2. 手动测试证书下载
|
||||||
|
curl -H "Authorization: Bearer tok_xxx" \
|
||||||
|
https://cert.example.com/api/cert/example.com/fullchain
|
||||||
|
|
||||||
|
# 3. 查看脚本详细执行过程
|
||||||
|
bash -x /usr/local/bin/deploy-cert.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: nginx -t 校验失败,证书已回滚
|
||||||
|
|
||||||
|
**原因:** 新证书与 nginx 配置不匹配。
|
||||||
|
|
||||||
|
**排查步骤:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 检查 nginx 配置
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
# 2. 检查证书内容
|
||||||
|
openssl x509 -in /etc/nginx/ssl/example.com/fullchain.pem -text -noout
|
||||||
|
|
||||||
|
# 3. 检查私钥是否匹配
|
||||||
|
openssl x509 -noout -modulus -in /etc/nginx/ssl/example.com/fullchain.pem | md5sum
|
||||||
|
openssl rsa -noout -modulus -in /etc/nginx/ssl/example.com/private.key | md5sum
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何更换 DNS 提商
|
||||||
|
|
||||||
|
1. 在 WebUI 的"ACME 配置"页面修改"DNS 提商"下拉框
|
||||||
|
2. 填写新提商的凭据
|
||||||
|
3. 点击"保存凭据"
|
||||||
|
4. 重新申请证书
|
||||||
|
|
||||||
|
### Q: 如何备份和迁移
|
||||||
|
|
||||||
|
**备份:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 备份数据库
|
||||||
|
cp certcenter.db certcenter.db.bak
|
||||||
|
|
||||||
|
# 备份证书目录
|
||||||
|
tar czf certs-backup.tar.gz /srv/certs/
|
||||||
|
|
||||||
|
# 备份配置
|
||||||
|
cp .env .env.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
**迁移:**
|
||||||
|
|
||||||
|
1. 在新服务器部署项目
|
||||||
|
2. 恢复数据库和证书目录
|
||||||
|
3. 修改 `.env` 中的 `BASE_URL` 为新地址
|
||||||
|
4. 更新业务服务器脚本中的地址(或重新执行部署命令)
|
||||||
|
|
||||||
|
### Q: 如何添加通配符证书
|
||||||
|
|
||||||
|
通配符证书需要 DNS 验证,CertCenter 已支持。在域名管理中添加:
|
||||||
|
|
||||||
|
```
|
||||||
|
域名: *.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
申请时系统会自动通过 DNS-01 验证完成签发。
|
||||||
|
|
||||||
|
### Q: Let's Encrypt 速率限制
|
||||||
|
|
||||||
|
Let's Encrypt 有以下限制:
|
||||||
|
|
||||||
|
| 限制类型 | 限制值 |
|
||||||
|
|----------|--------|
|
||||||
|
| 每个域名每周证书数 | 50 张 |
|
||||||
|
| 每个域名每小时重复证书数 | 5 张 |
|
||||||
|
| 注册账户数 | 每个 IP 每小时 10 个 |
|
||||||
|
|
||||||
|
**建议:** 先使用测试环境(Staging)调试,确认无误后再切换到生产环境。
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""
|
||||||
|
ACME 核心服务 - 集成 Let's Encrypt 证书申请与续签
|
||||||
|
支持 DNS-01 验证,通过 AliDNS API 自动添加 TXT 记录
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.models import AcmeConfig, AcmeLog, Domain
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AliDNSClient:
|
||||||
|
"""AliDNS API 客户端,用于添加/删除 TXT 记录"""
|
||||||
|
|
||||||
|
def __init__(self, access_key: str, access_secret: str):
|
||||||
|
self.access_key = access_key
|
||||||
|
self.access_secret = access_secret
|
||||||
|
self.api_url = "https://alidns.aliyuncs.com"
|
||||||
|
|
||||||
|
def _sign_params(self, params: dict) -> dict:
|
||||||
|
"""生成阿里云 API 签名"""
|
||||||
|
params.update({
|
||||||
|
"Format": "JSON",
|
||||||
|
"Version": "2015-01-09",
|
||||||
|
"AccessKeyId": self.access_key,
|
||||||
|
"SignatureMethod": "HMAC-SHA1",
|
||||||
|
"Timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"SignatureVersion": "1.0",
|
||||||
|
"SignatureNonce": str(int(time.time() * 1000)),
|
||||||
|
})
|
||||||
|
sorted_params = sorted(params.items())
|
||||||
|
query_string = "&".join(
|
||||||
|
f"{self._percent_encode(k)}={self._percent_encode(v)}" for k, v in sorted_params
|
||||||
|
)
|
||||||
|
string_to_sign = f"GET&{self._percent_encode('/')}&{self._percent_encode(query_string)}"
|
||||||
|
import hmac
|
||||||
|
sign = hmac.new(
|
||||||
|
(self.access_secret + "&").encode(),
|
||||||
|
string_to_sign.encode(),
|
||||||
|
hashlib.sha1,
|
||||||
|
).digest()
|
||||||
|
params["Signature"] = base64.b64encode(sign).decode()
|
||||||
|
return params
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _percent_encode(s: str) -> str:
|
||||||
|
s = str(s)
|
||||||
|
return requests.utils.quote(s, safe="")
|
||||||
|
|
||||||
|
def _request(self, params: dict) -> dict:
|
||||||
|
signed = self._sign_params(params)
|
||||||
|
resp = requests.get(self.api_url, params=signed, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def add_txt_record(self, domain: str, value: str) -> str:
|
||||||
|
"""添加 _acme-challenge TXT 记录,返回 RecordId"""
|
||||||
|
# 提取主域名
|
||||||
|
parts = domain.split(".")
|
||||||
|
rr = f"_acme-challenge.{'.'.join(parts[:-2])}" if len(parts) > 2 else "_acme-challenge"
|
||||||
|
main_domain = ".".join(parts[-2:])
|
||||||
|
|
||||||
|
result = self._request({
|
||||||
|
"Action": "AddDomainRecord",
|
||||||
|
"DomainName": main_domain,
|
||||||
|
"RR": rr,
|
||||||
|
"Type": "TXT",
|
||||||
|
"Value": value,
|
||||||
|
})
|
||||||
|
record_id = result.get("RecordId", "")
|
||||||
|
logger.info(f"AliDNS: added TXT record {rr}.{main_domain} = {value}, RecordId={record_id}")
|
||||||
|
return str(record_id)
|
||||||
|
|
||||||
|
def delete_txt_record(self, record_id: str):
|
||||||
|
"""删除指定的 DNS 记录"""
|
||||||
|
self._request({
|
||||||
|
"Action": "DeleteDomainRecord",
|
||||||
|
"RecordId": record_id,
|
||||||
|
})
|
||||||
|
logger.info(f"AliDNS: deleted record {record_id}")
|
||||||
|
|
||||||
|
|
||||||
|
class AcmeService:
|
||||||
|
"""ACME 证书管理服务"""
|
||||||
|
|
||||||
|
def __init__(self, config: AcmeConfig, cert_dir: str):
|
||||||
|
self.config = config
|
||||||
|
self.cert_dir = Path(cert_dir)
|
||||||
|
self.cert_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _get_dns_client(self) -> AliDNSClient:
|
||||||
|
"""根据配置创建 DNS 客户端"""
|
||||||
|
creds = json.loads(self.config.dns_credentials)
|
||||||
|
if self.config.dns_provider == "aliyun":
|
||||||
|
return AliDNSClient(
|
||||||
|
access_key=creds.get("access_key", ""),
|
||||||
|
access_secret=creds.get("access_secret", ""),
|
||||||
|
)
|
||||||
|
raise ValueError(f"Unsupported DNS provider: {self.config.dns_provider}")
|
||||||
|
|
||||||
|
def _generate_account_key(self) -> rsa.RSAPrivateKey:
|
||||||
|
"""生成 ACME 账户私钥"""
|
||||||
|
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
|
||||||
|
def _load_or_create_account_key(self) -> rsa.RSAPrivateKey:
|
||||||
|
"""加载或创建账户私钥"""
|
||||||
|
if self.config.account_key:
|
||||||
|
return serialization.load_pem_private_key(
|
||||||
|
self.config.account_key.encode(), password=None
|
||||||
|
)
|
||||||
|
key = self._generate_account_key()
|
||||||
|
pem = key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
self.config.account_key = pem.decode()
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _generate_csr(self, domain: str, key_path: Path):
|
||||||
|
"""生成域名私钥和 CSR"""
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
|
||||||
|
# 保存私钥
|
||||||
|
key_pem = key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
key_path.write_bytes(key_pem)
|
||||||
|
|
||||||
|
# 生成 CSR
|
||||||
|
csr = (
|
||||||
|
x509.CertificateSigningRequestBuilder()
|
||||||
|
.subject_name(x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, domain)]))
|
||||||
|
.add_extension(
|
||||||
|
x509.SubjectAlternativeName([x509.DNSName(domain)]),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
csr_pem = csr.public_bytes(serialization.Encoding.PEM)
|
||||||
|
return csr_pem
|
||||||
|
|
||||||
|
def _run_certbot(self, domain: str, action: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
使用 certbot 执行 ACME 操作
|
||||||
|
action: "certonly" (申请) 或 "renew" (续签)
|
||||||
|
|
||||||
|
泛域名处理:
|
||||||
|
- 输入 "*.zhzp.top" 自动同时申请 "*.zhzp.top" + "zhzp.top"
|
||||||
|
- 证书存储目录使用裸域名 "zhzp.top"
|
||||||
|
"""
|
||||||
|
creds = json.loads(self.config.dns_credentials)
|
||||||
|
|
||||||
|
# 泛域名:同时申请 *.example.com 和 example.com
|
||||||
|
# 存储目录使用裸域名
|
||||||
|
if domain.startswith("*."):
|
||||||
|
bare_domain = domain[2:]
|
||||||
|
certbot_domains = ["-d", domain, "-d", bare_domain]
|
||||||
|
store_dir = bare_domain
|
||||||
|
else:
|
||||||
|
certbot_domains = ["-d", domain]
|
||||||
|
store_dir = domain
|
||||||
|
|
||||||
|
domain_dir = self.cert_dir / store_dir
|
||||||
|
domain_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 写入临时凭据文件(certbot-dns-alicloud 需要 INI 格式)
|
||||||
|
creds_content = (
|
||||||
|
f"dns_alicloud_access_key = {creds.get('access_key', '')}\n"
|
||||||
|
f"dns_alicloud_access_key_secret = {creds.get('access_secret', '')}\n"
|
||||||
|
)
|
||||||
|
creds_file = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".ini", prefix="certbot-dns-", delete=False
|
||||||
|
)
|
||||||
|
creds_file.write(creds_content)
|
||||||
|
creds_file.close()
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"certbot", "certonly",
|
||||||
|
"--non-interactive",
|
||||||
|
"--agree-tos",
|
||||||
|
"--email", self.config.email,
|
||||||
|
"--authenticator", "dns-alicloud",
|
||||||
|
"--dns-alicloud-credentials", creds_file.name,
|
||||||
|
*certbot_domains,
|
||||||
|
"--cert-path", str(domain_dir / "fullchain.pem"),
|
||||||
|
"--key-path", str(domain_dir / "private.key"),
|
||||||
|
"--work-dir", str(self.cert_dir / ".certbot-work"),
|
||||||
|
"--config-dir", str(self.cert_dir / ".certbot-config"),
|
||||||
|
"--logs-dir", str(self.cert_dir / ".certbot-logs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.config.acme_server != "https://acme-v02.api.letsencrypt.org/directory":
|
||||||
|
cmd.extend(["--server", self.config.acme_server])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=180,
|
||||||
|
)
|
||||||
|
output = result.stdout + "\n" + result.stderr
|
||||||
|
success = result.returncode == 0
|
||||||
|
return success, output
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Certbot timeout after 180s"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "certbot not found, please install: pip install certbot certbot-dns-alicloud"
|
||||||
|
finally:
|
||||||
|
# 清理临时凭据文件
|
||||||
|
try:
|
||||||
|
os.unlink(creds_file.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def issue_certificate(self, domain: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
申请证书(使用 certbot + DNS-01 验证)
|
||||||
|
返回 (成功?, 日志信息)
|
||||||
|
|
||||||
|
泛域名 *.example.com 会自动同时申请裸域名 example.com
|
||||||
|
"""
|
||||||
|
# 泛域名使用裸域名路径
|
||||||
|
store_dir = domain[2:] if domain.startswith("*.") else domain
|
||||||
|
domain_dir = self.cert_dir / store_dir
|
||||||
|
domain_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
success, output = self._run_certbot(domain, "certonly")
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
return success, output
|
||||||
|
|
||||||
|
def renew_certificate(self, domain: str) -> tuple[bool, str]:
|
||||||
|
"""续签证书"""
|
||||||
|
return self.issue_certificate(domain)
|
||||||
|
|
||||||
|
def check_expiry(self, domain: str) -> tuple[bool, int]:
|
||||||
|
"""检查证书到期天数,返回 (需要续签?, 剩余天数)"""
|
||||||
|
# 泛域名使用裸域名路径
|
||||||
|
store_dir = domain[2:] if domain.startswith("*.") else domain
|
||||||
|
cert_path = self.cert_dir / store_dir / "fullchain.pem"
|
||||||
|
if not cert_path.exists():
|
||||||
|
return True, 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
|
||||||
|
not_after = cert.not_valid_after_utc
|
||||||
|
days_left = (not_after - datetime.utcnow()).days
|
||||||
|
return days_left <= self.config.renew_days, days_left
|
||||||
|
except Exception:
|
||||||
|
return True, 0
|
||||||
|
|
||||||
|
def get_cert_info(self, domain: str) -> dict | None:
|
||||||
|
"""获取证书详细信息"""
|
||||||
|
# 泛域名使用裸域名路径
|
||||||
|
store_dir = domain[2:] if domain.startswith("*.") else domain
|
||||||
|
cert_path = self.cert_dir / store_dir / "fullchain.pem"
|
||||||
|
if not cert_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
|
||||||
|
return {
|
||||||
|
"subject": cert.subject.rfc4514_string(),
|
||||||
|
"issuer": cert.issuer.rfc4514_string(),
|
||||||
|
"not_before": cert.not_valid_before_utc.isoformat(),
|
||||||
|
"not_after": cert.not_valid_after_utc.isoformat(),
|
||||||
|
"serial_number": str(cert.serial_number),
|
||||||
|
"san": [name.value for name in cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value],
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def auto_renew_all(self, domains: list[Domain]) -> list[dict]:
|
||||||
|
"""自动续签所有即将过期的证书"""
|
||||||
|
results = []
|
||||||
|
for d in domains:
|
||||||
|
need_renew, days_left = self.check_expiry(d.domain)
|
||||||
|
if need_renew:
|
||||||
|
success, msg = self.renew_certificate(d.domain)
|
||||||
|
results.append({
|
||||||
|
"domain": d.domain,
|
||||||
|
"action": "renew",
|
||||||
|
"success": success,
|
||||||
|
"message": msg,
|
||||||
|
"days_left": days_left,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
"domain": d.domain,
|
||||||
|
"action": "skip",
|
||||||
|
"success": True,
|
||||||
|
"message": f"Expires in {days_left} days, no renewal needed",
|
||||||
|
"days_left": days_left,
|
||||||
|
})
|
||||||
|
return results
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
首次启动引导:为 CertCenter 自身生成自签证书
|
||||||
|
当 BASE_URL 使用 HTTPS 但证书不存在时自动执行
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_self_signed_cert(base_url: str, cert_dir: str):
|
||||||
|
"""
|
||||||
|
如果 CertCenter 自身的域名没有证书,生成一份自签证书
|
||||||
|
证书存放在 cert_dir/{domain}/ 目录下
|
||||||
|
"""
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
return # HTTP 模式不需要证书
|
||||||
|
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
return
|
||||||
|
|
||||||
|
cert_path = Path(cert_dir) / hostname / "fullchain.pem"
|
||||||
|
key_path = Path(cert_dir) / hostname / "private.key"
|
||||||
|
|
||||||
|
if cert_path.exists() and key_path.exists():
|
||||||
|
return # 证书已存在
|
||||||
|
|
||||||
|
# 创建目录
|
||||||
|
cert_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"[bootstrap] 为 {hostname} 生成自签证书...")
|
||||||
|
|
||||||
|
# 使用 openssl 生成自签证书
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||||
|
"-keyout", str(key_path),
|
||||||
|
"-out", str(cert_path),
|
||||||
|
"-days", "365",
|
||||||
|
"-nodes",
|
||||||
|
"-subj", f"/CN={hostname}",
|
||||||
|
"-addext", f"subjectAltName=DNS:{hostname}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
print(f"[bootstrap] 自签证书已生成: {cert_path}")
|
||||||
|
print(f"[bootstrap] 客户端部署脚本将使用 curl -k 跳过 TLS 验证")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("[bootstrap] 未找到 openssl,跳过自签证书生成")
|
||||||
|
print("[bootstrap] 请手动配置 HTTPS 证书,或使用 HTTP 模式")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"[bootstrap] 生成自签证书失败: {e.stderr.decode()}")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 项目根目录(backend/ 的上级目录)
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
ENV_FILE = PROJECT_ROOT / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./certcenter.db"
|
||||||
|
secret_key: str = "change-me-to-a-random-string"
|
||||||
|
admin_username: str = "admin"
|
||||||
|
admin_password: str = "admin123"
|
||||||
|
cert_dir: str = "/srv/certs"
|
||||||
|
base_url: str = "https://cert.example.com"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = str(ENV_FILE)
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from backend.database import init_db
|
||||||
|
from backend.routers import api, admin
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.bootstrap import ensure_self_signed_cert
|
||||||
|
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,
|
||||||
|
issue_cert, renew_cert, auto_renew_all, list_acme_logs, get_cert_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
logger = logging.getLogger("certcenter")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
settings = get_settings()
|
||||||
|
ensure_self_signed_cert(settings.base_url, settings.cert_dir)
|
||||||
|
logger.info("=== 服务启动完成 ===")
|
||||||
|
# 打印所有已注册的路由
|
||||||
|
for route in app.routes:
|
||||||
|
methods = getattr(route, "methods", None)
|
||||||
|
path = getattr(route, "path", None)
|
||||||
|
if path:
|
||||||
|
logger.info(f" {methods} {path}")
|
||||||
|
else:
|
||||||
|
logger.info(f" [included router] {type(route).__name__}")
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="CertCenter", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 请求日志中间件 ──
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_request(request: Request, call_next):
|
||||||
|
logger.info(f">>> {request.method} {request.url.path}")
|
||||||
|
response = await call_next(request)
|
||||||
|
logger.info(f"<<< {request.method} {request.url.path} -> {response.status_code}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ── 客户端 API ──
|
||||||
|
app.include_router(api.router, prefix="/api")
|
||||||
|
|
||||||
|
# ── 管理 API(直接注册)──
|
||||||
|
app.post("/admin/api/login")(login)
|
||||||
|
app.post("/admin/api/logout")(logout)
|
||||||
|
app.get("/admin/api/me")(check_auth)
|
||||||
|
app.get("/admin/api/stats")(get_stats)
|
||||||
|
app.get("/admin/api/servers")(list_servers)
|
||||||
|
app.get("/admin/api/servers/{server_id}")(get_server)
|
||||||
|
app.post("/admin/api/servers", status_code=201)(create_server)
|
||||||
|
app.put("/admin/api/servers/{server_id}")(update_server)
|
||||||
|
app.delete("/admin/api/servers/{server_id}")(delete_server)
|
||||||
|
app.get("/admin/api/domains")(list_domains)
|
||||||
|
app.get("/admin/api/domains/{domain_id}")(get_domain)
|
||||||
|
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.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)
|
||||||
|
|
||||||
|
logger.info("=== 路由注册完成 ===")
|
||||||
|
for route in app.routes:
|
||||||
|
methods = getattr(route, "methods", None)
|
||||||
|
path = getattr(route, "path", None)
|
||||||
|
if path:
|
||||||
|
logger.info(f" {methods} {path}")
|
||||||
|
else:
|
||||||
|
logger.info(f" [included router] {type(route).__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 前端静态文件 ──
|
||||||
|
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
|
||||||
|
if frontend_dist.exists():
|
||||||
|
assets_dir = frontend_dist / "assets"
|
||||||
|
if assets_dir.exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
|
||||||
|
|
||||||
|
index_file = frontend_dist / "index.html"
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def spa_fallback(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if request.method == "GET" and response.status_code == 404:
|
||||||
|
path = request.url.path
|
||||||
|
if not path.startswith("/api/") and not path.startswith("/admin/api/") and not path.startswith("/assets/"):
|
||||||
|
return FileResponse(str(index_file))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("backend.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Integer, String, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Server(Base):
|
||||||
|
__tablename__ = "servers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||||
|
platform: Mapped[str] = mapped_column(String(20), nullable=False) # linux / windows
|
||||||
|
token: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
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="server", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Domain(Base):
|
||||||
|
__tablename__ = "domains"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
server_id: Mapped[int] = mapped_column(Integer, ForeignKey("servers.id"), nullable=False)
|
||||||
|
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)
|
||||||
|
reload_cmd: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
version: Mapped[str] = mapped_column(String(50), default="0")
|
||||||
|
cert_not_after: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
server: Mapped["Server"] = relationship(back_populates="domains")
|
||||||
|
logs: Mapped[list["DeployLog"]] = relationship(back_populates="domain")
|
||||||
|
|
||||||
|
|
||||||
|
class DeployLog(Base):
|
||||||
|
__tablename__ = "deploy_logs"
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
domain: Mapped["Domain"] = relationship(back_populates="logs")
|
||||||
|
|
||||||
|
|
||||||
|
class AcmeConfig(Base):
|
||||||
|
"""ACME 全局配置(单例)"""
|
||||||
|
__tablename__ = "acme_config"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||||
|
# ACME 服务器地址
|
||||||
|
acme_server: Mapped[str] = mapped_column(String(500), default="https://acme-v02.api.letsencrypt.org/directory")
|
||||||
|
# 邮箱
|
||||||
|
email: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
# DNS 提商类型
|
||||||
|
dns_provider: Mapped[str] = mapped_column(String(50), default="aliyun") # aliyun / cloudflare / manual
|
||||||
|
# DNS 提商凭据(JSON)
|
||||||
|
dns_credentials: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
# 账户私钥(PEM)
|
||||||
|
account_key: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# 续签提前天数
|
||||||
|
renew_days: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class AcmeLog(Base):
|
||||||
|
"""ACME 操作日志"""
|
||||||
|
__tablename__ = "acme_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
domain_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("domains.id"), nullable=True)
|
||||||
|
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) # 详细日志
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
domain: Mapped["Domain | None"] = relationship()
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, Cookie
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import Server, Domain, DeployLog, AcmeConfig, AcmeLog
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(tags=["admin-api"])
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Auth ────────────────
|
||||||
|
|
||||||
|
def generate_token(username: str, secret_key: str) -> str:
|
||||||
|
"""生成简单的认证 token"""
|
||||||
|
expire = int(time.time()) + 86400 * 7 # 7 天过期
|
||||||
|
payload = f"{username}:{expire}"
|
||||||
|
signature = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest()[:16]
|
||||||
|
return f"{payload}:{signature}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str, secret_key: str) -> bool:
|
||||||
|
"""验证 token"""
|
||||||
|
try:
|
||||||
|
parts = token.split(":")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return False
|
||||||
|
username, expire_str, signature = parts
|
||||||
|
expire = int(expire_str)
|
||||||
|
if time.time() > expire:
|
||||||
|
return False
|
||||||
|
payload = f"{username}:{expire_str}"
|
||||||
|
expected = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest()[:16]
|
||||||
|
return hmac.compare_digest(signature, expected)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(authorization: str = Header(None), admin_token: str = Cookie(None)):
|
||||||
|
"""认证依赖,检查 Header 或 Cookie 中的 token"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# 从 Header 获取
|
||||||
|
token = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
token = authorization[7:]
|
||||||
|
# 从 Cookie 获取
|
||||||
|
elif admin_token:
|
||||||
|
token = admin_token
|
||||||
|
|
||||||
|
if not token or not verify_token(token, settings.secret_key):
|
||||||
|
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(data: LoginRequest):
|
||||||
|
settings = get_settings()
|
||||||
|
if data.username != settings.admin_username or data.password != settings.admin_password:
|
||||||
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
token = generate_token(data.username, settings.secret_key)
|
||||||
|
response = JSONResponse(content={"ok": True, "token": token})
|
||||||
|
response.set_cookie(
|
||||||
|
key="admin_token",
|
||||||
|
value=token,
|
||||||
|
max_age=86400 * 7,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout():
|
||||||
|
response = JSONResponse(content={"ok": True})
|
||||||
|
response.delete_cookie("admin_token")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def check_auth(_: bool = Depends(require_auth)):
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Schemas ────────────────
|
||||||
|
|
||||||
|
class ServerCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
platform: str = "linux"
|
||||||
|
token: str
|
||||||
|
ip: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServerUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
platform: str | None = None
|
||||||
|
token: str | None = None
|
||||||
|
ip: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DomainCreate(BaseModel):
|
||||||
|
server_id: int
|
||||||
|
domain: str
|
||||||
|
cert_dir: str
|
||||||
|
check_cmd: str = "nginx -t"
|
||||||
|
reload_cmd: str = "systemctl reload nginx"
|
||||||
|
|
||||||
|
|
||||||
|
class DomainUpdate(BaseModel):
|
||||||
|
server_id: int | None = None
|
||||||
|
domain: str | None = None
|
||||||
|
cert_dir: str | None = None
|
||||||
|
check_cmd: str | None = None
|
||||||
|
reload_cmd: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Stats ────────────────
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
"""仪表盘统计数据"""
|
||||||
|
server_count = (await db.execute(select(func.count(Server.id)))).scalar() or 0
|
||||||
|
domain_count = (await db.execute(select(func.count(Domain.id)))) .scalar() or 0
|
||||||
|
|
||||||
|
# 即将过期(7 天内)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
soon = now.replace(day=now.day + 7) if now.day <= 24 else now.replace(month=now.month + 1, day=now.day + 7 - 30)
|
||||||
|
expiring = (await db.execute(
|
||||||
|
select(func.count(Domain.id)).where(
|
||||||
|
Domain.cert_not_after.isnot(None),
|
||||||
|
Domain.cert_not_after <= soon,
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# 最近日志
|
||||||
|
result = await db.execute(
|
||||||
|
select(DeployLog)
|
||||||
|
.options(selectinload(DeployLog.domain), selectinload(DeployLog.domain).selectinload(Domain.server))
|
||||||
|
.order_by(DeployLog.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"server_count": server_count,
|
||||||
|
"domain_count": domain_count,
|
||||||
|
"expiring_count": expiring,
|
||||||
|
"recent_logs": [
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"status": log.status,
|
||||||
|
"message": log.message,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Servers ────────────────
|
||||||
|
|
||||||
|
@router.get("/servers")
|
||||||
|
async def list_servers(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
result = await db.execute(select(Server).options(selectinload(Server.domains)).order_by(Server.id))
|
||||||
|
servers = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": s.id,
|
||||||
|
"name": s.name,
|
||||||
|
"platform": s.platform,
|
||||||
|
"token": s.token,
|
||||||
|
"ip": s.ip,
|
||||||
|
"domain_count": len(s.domains),
|
||||||
|
"created_at": s.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for s in servers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/servers/{server_id}")
|
||||||
|
async def get_server(server_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
result = await db.execute(select(Server).where(Server.id == server_id))
|
||||||
|
s = result.scalar_one_or_none()
|
||||||
|
if not s:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
return {"id": s.id, "name": s.name, "platform": s.platform, "token": s.token, "ip": s.ip}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers", status_code=201)
|
||||||
|
async def create_server(data: ServerCreate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
server = Server(**data.model_dump())
|
||||||
|
db.add(server)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(server)
|
||||||
|
return {"id": server.id, "name": server.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/servers/{server_id}")
|
||||||
|
async def update_server(server_id: int, data: ServerUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
result = await db.execute(select(Server).where(Server.id == server_id))
|
||||||
|
server = result.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
for key, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(server, key, value)
|
||||||
|
return {"id": server.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/servers/{server_id}")
|
||||||
|
async def delete_server(server_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
result = await db.execute(select(Server).where(Server.id == server_id))
|
||||||
|
server = result.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
await db.delete(server)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Domains ────────────────
|
||||||
|
|
||||||
|
@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)
|
||||||
|
)
|
||||||
|
domains = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": d.id,
|
||||||
|
"server_id": d.server_id,
|
||||||
|
"server_name": d.server.name if d.server else None,
|
||||||
|
"domain": d.domain,
|
||||||
|
"cert_dir": d.cert_dir,
|
||||||
|
"check_cmd": d.check_cmd,
|
||||||
|
"reload_cmd": d.reload_cmd,
|
||||||
|
"version": d.version,
|
||||||
|
"cert_not_after": d.cert_not_after.isoformat() if d.cert_not_after else None,
|
||||||
|
}
|
||||||
|
for d in domains
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/domains/{domain_id}")
|
||||||
|
async def get_domain(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
result = await db.execute(select(Domain).where(Domain.id == domain_id))
|
||||||
|
d = result.scalar_one_or_none()
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(404, "Domain not found")
|
||||||
|
return {
|
||||||
|
"id": d.id,
|
||||||
|
"server_id": d.server_id,
|
||||||
|
"domain": d.domain,
|
||||||
|
"cert_dir": d.cert_dir,
|
||||||
|
"check_cmd": d.check_cmd,
|
||||||
|
"reload_cmd": d.reload_cmd,
|
||||||
|
"version": d.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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())
|
||||||
|
db.add(domain)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(domain)
|
||||||
|
return {"id": domain.id, "domain": domain.domain}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/domains/{domain_id}")
|
||||||
|
async def update_domain(domain_id: int, data: DomainUpdate, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
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")
|
||||||
|
for key, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(domain, key, value)
|
||||||
|
return {"id": domain.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/domains/{domain_id}")
|
||||||
|
async def delete_domain(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
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")
|
||||||
|
await db.delete(domain)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Logs ────────────────
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
async def list_logs(status: str | None = None, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
query = (
|
||||||
|
select(DeployLog)
|
||||||
|
.options(selectinload(DeployLog.domain), selectinload(DeployLog.domain).selectinload(Domain.server))
|
||||||
|
.order_by(DeployLog.created_at.desc())
|
||||||
|
.limit(100)
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
query = query.where(DeployLog.status == status)
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"status": log.status,
|
||||||
|
"message": log.message,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── ACME 配置 ────────────────
|
||||||
|
|
||||||
|
class AcmeConfigUpdate(BaseModel):
|
||||||
|
acme_server: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
dns_provider: str | None = None
|
||||||
|
dns_credentials: str | None = None
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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))
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
config = AcmeConfig(id=1)
|
||||||
|
db.add(config)
|
||||||
|
for key, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(config, key, value)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── ACME 操作 ────────────────
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
service = AcmeService(config, settings.cert_dir)
|
||||||
|
|
||||||
|
# 记录开始
|
||||||
|
log = AcmeLog(domain_id=domain_id, action="issue", status="pending", message=f"Issuing certificate for {domain.domain}")
|
||||||
|
db.add(log)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, msg = service.issue_certificate(domain.domain)
|
||||||
|
log.status = "success" if success else "failed"
|
||||||
|
log.message = msg
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# 更新域名的证书到期时间
|
||||||
|
info = service.get_cert_info(domain.domain)
|
||||||
|
if info:
|
||||||
|
domain.cert_not_after = datetime.fromisoformat(info["not_after"])
|
||||||
|
domain.version = str(int(domain.version or "0") + 1)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return {"success": success, "message": msg}
|
||||||
|
except Exception as e:
|
||||||
|
log.status = "failed"
|
||||||
|
log.message = str(e)
|
||||||
|
await db.flush()
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/acme/renew/{domain_id}")
|
||||||
|
async def renew_cert(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
"""续签指定域名的证书"""
|
||||||
|
return await issue_cert(domain_id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/acme/auto-renew")
|
||||||
|
async def auto_renew_all(db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
"""自动续签所有即将过期的证书"""
|
||||||
|
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))
|
||||||
|
domains = result.scalars().all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for d in domains:
|
||||||
|
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}")
|
||||||
|
db.add(log)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
success, msg = 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"])
|
||||||
|
d.version = str(int(d.version or "0") + 1)
|
||||||
|
|
||||||
|
results.append({"domain": d.domain, "success": success, "message": msg, "days_left": days_left})
|
||||||
|
else:
|
||||||
|
results.append({"domain": d.domain, "success": True, "message": f"Skipped, {days_left} days left", "days_left": days_left})
|
||||||
|
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── ACME 日志 ────────────────
|
||||||
|
|
||||||
|
@router.get("/acme/logs")
|
||||||
|
async def list_acme_logs(status: str | None = None, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
query = (
|
||||||
|
select(AcmeLog)
|
||||||
|
.options(selectinload(AcmeLog.domain))
|
||||||
|
.order_by(AcmeLog.created_at.desc())
|
||||||
|
.limit(100)
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
query = query.where(AcmeLog.status == status)
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": log.id,
|
||||||
|
"domain": log.domain.domain if log.domain else None,
|
||||||
|
"action": log.action,
|
||||||
|
"status": log.status,
|
||||||
|
"message": log.message,
|
||||||
|
"detail": log.detail,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── 证书信息 ────────────────
|
||||||
|
|
||||||
|
@router.get("/cert-info/{domain_id}")
|
||||||
|
async def get_cert_info(domain_id: int, db: AsyncSession = Depends(get_db), _: bool = Depends(require_auth)):
|
||||||
|
"""获取证书详细信息"""
|
||||||
|
from backend.acme_service import AcmeService
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
result = await db.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
config = AcmeConfig(id=1)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
service = AcmeService(config, settings.cert_dir)
|
||||||
|
|
||||||
|
info = service.get_cert_info(domain.domain)
|
||||||
|
need_renew, days_left = service.check_expiry(domain.domain)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"domain": domain.domain,
|
||||||
|
"cert_info": info,
|
||||||
|
"need_renew": need_renew,
|
||||||
|
"days_left": days_left,
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||||
|
from fastapi.responses import PlainTextResponse, FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import Server, Domain, DeployLog
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(tags=["client-api"])
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Jinja2 环境,用于生成脚本
|
||||||
|
template_dir = Path(__file__).parent.parent / "templates_cert"
|
||||||
|
jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_token(
|
||||||
|
authorization: str = Header(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""验证 Bearer Token,返回对应的 Server 对象"""
|
||||||
|
if not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid authorization header")
|
||||||
|
token = authorization[7:]
|
||||||
|
result = await db.execute(select(Server).where(Server.token == token))
|
||||||
|
server = result.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/version/{domain}")
|
||||||
|
async def get_version(domain: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""返回当前证书版本号(无需认证,方便客户端轻量检查)"""
|
||||||
|
result = await db.execute(select(Domain).where(Domain.domain == domain))
|
||||||
|
d = result.scalar_one_or_none()
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(status_code=404, detail="Domain not found")
|
||||||
|
return PlainTextResponse(d.version)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cert/{domain}/fullchain")
|
||||||
|
async def get_fullchain(
|
||||||
|
domain: str,
|
||||||
|
server: Server = Depends(verify_token),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""下载 fullchain.pem"""
|
||||||
|
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(status_code=404, detail="Domain not found for this server")
|
||||||
|
cert_path = Path(settings.cert_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")
|
||||||
|
async def get_private_key(
|
||||||
|
domain: str,
|
||||||
|
server: Server = Depends(verify_token),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""下载 private.key"""
|
||||||
|
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(status_code=404, detail="Domain not found for this server")
|
||||||
|
key_path = Path(settings.cert_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}")
|
||||||
|
async def generate_script(
|
||||||
|
domain: str,
|
||||||
|
server_name: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""根据 server_name 生成对应的部署脚本"""
|
||||||
|
# 查找服务器
|
||||||
|
result = await db.execute(select(Server).where(Server.name == server_name))
|
||||||
|
server = result.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=404, detail="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(status_code=404, detail="Domain not found for this server")
|
||||||
|
|
||||||
|
# 选择模板
|
||||||
|
if server.platform == "windows":
|
||||||
|
template_name = "deploy-cert.ps1.j2"
|
||||||
|
else:
|
||||||
|
template_name = "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,
|
||||||
|
)
|
||||||
|
|
||||||
|
media_type = "text/plain" if server.platform == "windows" else "application/x-sh"
|
||||||
|
return PlainTextResponse(script, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/report/{domain}")
|
||||||
|
async def report_deploy(
|
||||||
|
domain: str,
|
||||||
|
status: str,
|
||||||
|
message: str = "",
|
||||||
|
server: Server = Depends(verify_token),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""客户端上报部署结果"""
|
||||||
|
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(status_code=404, detail="Domain not found for this server")
|
||||||
|
|
||||||
|
log = DeployLog(domain_id=d.id, server_id=server.id, status=status, message=message)
|
||||||
|
db.add(log)
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# === 由 CertCenter 生成 ===
|
||||||
|
$Domain = "{{ domain }}"
|
||||||
|
$BaseUrl = "{{ base_url }}"
|
||||||
|
$Token = "{{ token }}"
|
||||||
|
$CertDir = "{{ cert_dir }}"
|
||||||
|
$CheckCmd = "{{ check_cmd }}"
|
||||||
|
$ReloadCmd = "{{ reload_cmd }}"
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
$TmpDir = "$env:TEMP\cert-sync-$Domain"
|
||||||
|
$VersionFile = "$CertDir\.version"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $CertDir, $TmpDir | Out-Null
|
||||||
|
$headers = @{ Authorization = "Bearer $Token" }
|
||||||
|
|
||||||
|
# 跳过 TLS 证书验证(CertCenter 初期可能使用自签证书)
|
||||||
|
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||||
|
|
||||||
|
# 1. 检查版本
|
||||||
|
$remote = (Invoke-WebRequest -Uri "$BaseUrl/api/version/$Domain" -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
|
||||||
|
|
||||||
|
# 3. 备份旧证书
|
||||||
|
Copy-Item "$CertDir\fullchain.pem" "$CertDir\fullchain.pem.bak" -ErrorAction SilentlyContinue
|
||||||
|
Copy-Item "$CertDir\private.key" "$CertDir\private.key.bak" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# 4. 原子替换
|
||||||
|
Move-Item "$TmpDir\fullchain.pem" "$CertDir\fullchain.pem" -Force
|
||||||
|
Move-Item "$TmpDir\private.key" "$CertDir\private.key" -Force
|
||||||
|
|
||||||
|
# 5. 校验 & 重载
|
||||||
|
try {
|
||||||
|
Invoke-Expression $CheckCmd
|
||||||
|
$remote | Out-File -NoNewline -Encoding ascii $VersionFile
|
||||||
|
Invoke-Expression $ReloadCmd
|
||||||
|
Write-Host "[$(Get-Date -Format o)] updated: $Domain -> $remote"
|
||||||
|
} 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"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# === 由 CertCenter 生成 ===
|
||||||
|
DOMAIN="{{ domain }}"
|
||||||
|
BASE_URL="{{ base_url }}"
|
||||||
|
TOKEN="{{ token }}"
|
||||||
|
CERT_DIR="{{ cert_dir }}"
|
||||||
|
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_FILE="${CERT_DIR}/.version"
|
||||||
|
|
||||||
|
mkdir -p "${CERT_DIR}" "${TMP_DIR}"
|
||||||
|
auth=(-H "Authorization: Bearer ${TOKEN}")
|
||||||
|
# -k 跳过 TLS 证书验证(CertCenter 初期可能使用自签证书)
|
||||||
|
curl_opts=(-fsSL -k --connect-timeout 5 --max-time 30)
|
||||||
|
|
||||||
|
# 1. 检查版本
|
||||||
|
REMOTE=$(curl "${curl_opts[@]}" --max-time 15 "${auth[@]}" "${VERSION_URL}")
|
||||||
|
LOCAL=$(cat "${VERSION_FILE}" 2>/dev/null || echo "0")
|
||||||
|
if [[ "${REMOTE}" == "${LOCAL}" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 下载证书
|
||||||
|
curl "${curl_opts[@]}" "${auth[@]}" "${FULLCHAIN_URL}" -o "${TMP_DIR}/fullchain.pem"
|
||||||
|
curl "${curl_opts[@]}" "${auth[@]}" "${PRIVATE_URL}" -o "${TMP_DIR}/private.key"
|
||||||
|
chmod 600 "${TMP_DIR}/private.key"
|
||||||
|
|
||||||
|
# 3. 备份旧证书
|
||||||
|
cp -f "${CERT_DIR}/fullchain.pem" "${CERT_DIR}/fullchain.pem.bak" 2>/dev/null || true
|
||||||
|
cp -f "${CERT_DIR}/private.key" "${CERT_DIR}/private.key.bak" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 4. 原子替换
|
||||||
|
mv -f "${TMP_DIR}/fullchain.pem" "${CERT_DIR}/fullchain.pem"
|
||||||
|
mv -f "${TMP_DIR}/private.key" "${CERT_DIR}/private.key"
|
||||||
|
|
||||||
|
# 5. 校验 & 重载
|
||||||
|
if ${CHECK_CMD}; then
|
||||||
|
echo "${REMOTE}" > "${VERSION_FILE}"
|
||||||
|
${RELOAD_CMD}
|
||||||
|
echo "[$(date -Is)] updated: ${DOMAIN} -> ${REMOTE}"
|
||||||
|
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
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><link rel="icon" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CertCenter</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2632
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "certcenter-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.4.0",
|
||||||
|
"axios": "^1.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.1.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"autoprefixer": "^10.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 未登录:显示登录页 -->
|
||||||
|
<Login v-if="!isLoggedIn" @login-success="checkAuth" />
|
||||||
|
|
||||||
|
<!-- 已登录:显示主界面 -->
|
||||||
|
<div v-else class="min-h-screen flex">
|
||||||
|
<Toast />
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<aside class="w-64 bg-gray-900 text-white flex flex-col">
|
||||||
|
<div class="p-6 border-b border-gray-700">
|
||||||
|
<h1 class="text-xl font-bold">CertCenter</h1>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">证书管理中心</p>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 p-4 space-y-1">
|
||||||
|
<router-link
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="flex items-center px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||||
|
:class="$route.path === item.path ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-800'"
|
||||||
|
>
|
||||||
|
<span class="mr-3">{{ item.icon }}</span>
|
||||||
|
{{ item.name }}
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="p-4 border-t border-gray-700">
|
||||||
|
<button @click="handleLogout" class="w-full px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors">
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
<div class="p-8">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import Login from './views/Login.vue'
|
||||||
|
import Toast from './components/Toast.vue'
|
||||||
|
import api from './api'
|
||||||
|
|
||||||
|
const isLoggedIn = ref(false)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', icon: '📊', name: '仪表盘' },
|
||||||
|
{ path: '/servers', icon: '🖥️', name: '服务器管理' },
|
||||||
|
{ path: '/domains', icon: '🌐', name: '域名管理' },
|
||||||
|
{ path: '/acme', icon: '🔒', name: 'ACME 配置' },
|
||||||
|
{ path: '/logs', icon: '📋', name: '部署日志' },
|
||||||
|
{ path: '/acme/logs', icon: '📜', name: 'ACME 日志' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (!token) {
|
||||||
|
isLoggedIn.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.get('/me')
|
||||||
|
isLoggedIn.value = true
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
isLoggedIn.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/logout')
|
||||||
|
} catch {}
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
isLoggedIn.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(checkAuth)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/admin/api',
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器:自动添加 token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// 响应拦截器:401 时清除 token
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
// 触发重新检查登录状态(通过刷新页面)
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
export const getStats = () => api.get('/stats')
|
||||||
|
|
||||||
|
// Servers
|
||||||
|
export const getServers = () => api.get('/servers')
|
||||||
|
export const getServer = (id) => api.get(`/servers/${id}`)
|
||||||
|
export const createServer = (data) => api.post('/servers', data)
|
||||||
|
export const updateServer = (id, data) => api.put(`/servers/${id}`, data)
|
||||||
|
export const deleteServer = (id) => api.delete(`/servers/${id}`)
|
||||||
|
|
||||||
|
// Domains
|
||||||
|
export const getDomains = () => api.get('/domains')
|
||||||
|
export const getDomain = (id) => api.get(`/domains/${id}`)
|
||||||
|
export const createDomain = (data) => api.post('/domains', data)
|
||||||
|
export const updateDomain = (id, data) => api.put(`/domains/${id}`, data)
|
||||||
|
export const deleteDomain = (id) => api.delete(`/domains/${id}`)
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
export const getLogs = (status) => api.get('/logs', { params: { status } })
|
||||||
|
|
||||||
|
// Script (通过管理接口获取脚本内容)
|
||||||
|
export const getScript = (domain, serverName) =>
|
||||||
|
axios.get(`/api/script/${domain}`, { params: { server_name: serverName } })
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none">
|
||||||
|
<transition-group name="toast">
|
||||||
|
<div
|
||||||
|
v-for="t in toasts"
|
||||||
|
:key="t.id"
|
||||||
|
class="pointer-events-auto max-w-sm w-full bg-white border rounded-lg shadow-lg p-4 flex gap-3 items-start cursor-pointer select-text"
|
||||||
|
:class="borderColor(t.type)"
|
||||||
|
@click="copyAndDismiss(t)"
|
||||||
|
>
|
||||||
|
<span class="text-lg mt-0.5">{{ icon(t.type) }}</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm text-gray-800 whitespace-pre-wrap break-all">{{ t.message }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">点击复制并关闭</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-gray-400 hover:text-gray-600 text-lg leading-none"
|
||||||
|
@click.stop="dismiss(t.id)"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
|
const { toasts, dismiss } = useToast()
|
||||||
|
|
||||||
|
function borderColor(type) {
|
||||||
|
return {
|
||||||
|
success: 'border-green-400',
|
||||||
|
error: 'border-red-400',
|
||||||
|
info: 'border-blue-400',
|
||||||
|
warn: 'border-yellow-400',
|
||||||
|
}[type] || 'border-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
function icon(type) {
|
||||||
|
return { success: '✅', error: '❌', info: 'ℹ️', warn: '⚠️' }[type] || '📢'
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAndDismiss(t) {
|
||||||
|
navigator.clipboard.writeText(t.message).catch(() => {})
|
||||||
|
dismiss(t.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active { transition: all 0.3s ease-out; }
|
||||||
|
.toast-leave-active { transition: all 0.2s ease-in; }
|
||||||
|
.toast-enter-from { opacity: 0; transform: translateX(100px); }
|
||||||
|
.toast-leave-to { opacity: 0; transform: translateX(100px); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const toasts = reactive([])
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
function addToast(message, type = 'info', duration = 5000) {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.push({ id, message, type })
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => dismiss(id), duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id) {
|
||||||
|
const idx = toasts.findIndex(t => t.id === id)
|
||||||
|
if (idx !== -1) toasts.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return {
|
||||||
|
toasts,
|
||||||
|
dismiss,
|
||||||
|
success: (msg, dur) => addToast(msg, 'success', dur),
|
||||||
|
error: (msg, dur) => addToast(msg, 'error', dur),
|
||||||
|
info: (msg, dur) => addToast(msg, 'info', dur),
|
||||||
|
warn: (msg, dur) => addToast(msg, 'warn', dur),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../views/Dashboard.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/servers',
|
||||||
|
name: 'Servers',
|
||||||
|
component: () => import('../views/Servers.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/servers/new',
|
||||||
|
name: 'ServerNew',
|
||||||
|
component: () => import('../views/ServerForm.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/servers/:id/edit',
|
||||||
|
name: 'ServerEdit',
|
||||||
|
component: () => import('../views/ServerForm.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/domains',
|
||||||
|
name: 'Domains',
|
||||||
|
component: () => import('../views/Domains.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/domains/new',
|
||||||
|
name: 'DomainNew',
|
||||||
|
component: () => import('../views/DomainForm.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/domains/:id/edit',
|
||||||
|
name: 'DomainEdit',
|
||||||
|
component: () => import('../views/DomainForm.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/script/:domain',
|
||||||
|
name: 'Script',
|
||||||
|
component: () => import('../views/Script.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/logs',
|
||||||
|
name: 'Logs',
|
||||||
|
component: () => import('../views/Logs.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/acme',
|
||||||
|
name: 'AcmeSettings',
|
||||||
|
component: () => import('../views/AcmeSettings.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/acme/logs',
|
||||||
|
name: 'AcmeLogs',
|
||||||
|
component: () => import('../views/AcmeLogs.vue'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold">ACME 日志</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select v-model="statusFilter" @change="load"
|
||||||
|
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="success">成功</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
<option value="pending">进行中</option>
|
||||||
|
</select>
|
||||||
|
<button @click="load" class="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">时间</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">操作</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">状态</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">消息</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">详情</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
<tr v-for="log in logs" :key="log.id">
|
||||||
|
<td class="px-6 py-3 text-gray-500">{{ formatTime(log.created_at) }}</td>
|
||||||
|
<td class="px-6 py-3 font-medium">{{ log.domain || '-' }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<span :class="actionClass(log.action)" class="px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{{ actionLabel(log.action) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<span :class="statusClass(log.status)" class="px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{{ log.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 text-gray-500 max-w-xs truncate">{{ log.message }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<button v-if="log.detail" @click="showDetail(log)" class="text-blue-600 hover:underline text-xs">
|
||||||
|
查看
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!logs.length">
|
||||||
|
<td colspan="6" class="px-6 py-8 text-center text-gray-400">暂无日志</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情弹窗 -->
|
||||||
|
<div v-if="detailLog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="detailLog = null">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-auto p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-semibold">日志详情</h3>
|
||||||
|
<button @click="detailLog = null" class="text-gray-400 hover:text-gray-600">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">域名:</span>{{ detailLog.domain || '-' }}</div>
|
||||||
|
<div><span class="text-gray-500">操作:</span>{{ actionLabel(detailLog.action) }}</div>
|
||||||
|
<div><span class="text-gray-500">状态:</span>{{ detailLog.status }}</div>
|
||||||
|
<div><span class="text-gray-500">消息:</span>{{ detailLog.message }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detailLog.detail" class="mt-4">
|
||||||
|
<div class="text-sm text-gray-500 mb-1">详细日志:</div>
|
||||||
|
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs overflow-x-auto whitespace-pre-wrap">{{ detailLog.detail }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const logs = ref([])
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const detailLog = ref(null)
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return '-'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabel = (action) => ({
|
||||||
|
issue: '申请',
|
||||||
|
renew: '续签',
|
||||||
|
revoke: '吊销',
|
||||||
|
register: '注册',
|
||||||
|
}[action] || action)
|
||||||
|
|
||||||
|
const actionClass = (action) => ({
|
||||||
|
issue: 'bg-blue-100 text-blue-700',
|
||||||
|
renew: 'bg-orange-100 text-orange-700',
|
||||||
|
revoke: 'bg-red-100 text-red-700',
|
||||||
|
register: 'bg-green-100 text-green-700',
|
||||||
|
}[action] || 'bg-gray-100 text-gray-700')
|
||||||
|
|
||||||
|
const statusClass = (status) => ({
|
||||||
|
success: 'bg-green-100 text-green-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
}[status] || 'bg-gray-100 text-gray-700')
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const { data } = await api.get('/acme/logs', { params: { status: statusFilter.value || undefined } })
|
||||||
|
logs.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDetail = (log) => {
|
||||||
|
detailLog.value = log
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mb-6">ACME 配置</h2>
|
||||||
|
|
||||||
|
<div class="max-w-2xl space-y-6">
|
||||||
|
<!-- ACME 服务器配置 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h3 class="font-semibold mb-4">基本配置</h3>
|
||||||
|
<form @submit.prevent="saveConfig" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">ACME 服务器</label>
|
||||||
|
<select v-model="form.acme_server"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||||
|
<option value="https://acme-v02.api.letsencrypt.org/directory">Let's Encrypt (生产)</option>
|
||||||
|
<option value="https://acme-staging-v02.api.letsencrypt.org/directory">Let's Encrypt (测试)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||||
|
<input v-model="form.email" type="email" required placeholder="admin@example.com"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">DNS 提商</label>
|
||||||
|
<select v-model="form.dns_provider"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||||
|
<option value="aliyun">阿里云 DNS</option>
|
||||||
|
<option value="cloudflare">Cloudflare</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">续签提前天数</label>
|
||||||
|
<input v-model.number="form.renew_days" type="number" min="1" max="60"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||||
|
保存配置
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DNS 凭据配置 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h3 class="font-semibold mb-4">DNS 凭据</h3>
|
||||||
|
<form @submit.prevent="saveCredentials" class="space-y-4">
|
||||||
|
<div v-if="form.dns_provider === 'aliyun'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Access Key</label>
|
||||||
|
<input v-model="creds.access_key" type="text" placeholder="LTAI5t..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
<div v-if="form.dns_provider === 'aliyun'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Access Secret</label>
|
||||||
|
<input v-model="creds.access_secret" type="password" placeholder="****"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
<div v-if="form.dns_provider === 'cloudflare'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">API Token</label>
|
||||||
|
<input v-model="creds.api_token" type="password" placeholder="****"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
|
||||||
|
保存凭据
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h3 class="font-semibold mb-4">操作</h3>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button @click="autoRenew" :disabled="renewing"
|
||||||
|
class="px-6 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm disabled:opacity-50">
|
||||||
|
{{ renewing ? '续签中...' : '🔄 自动续签所有' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="renewResult" class="mt-4 p-4 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<div v-for="r in renewResult" :key="r.domain" class="flex items-center gap-2 py-1">
|
||||||
|
<span :class="r.success ? 'text-green-600' : 'text-red-600'">{{ r.success ? '✓' : '✗' }}</span>
|
||||||
|
<span class="font-medium">{{ r.domain }}</span>
|
||||||
|
<span class="text-gray-500">- {{ r.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态提示 -->
|
||||||
|
<div v-if="showSaved" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm">
|
||||||
|
配置已保存
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
acme_server: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||||
|
email: '',
|
||||||
|
dns_provider: 'aliyun',
|
||||||
|
renew_days: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
const creds = ref({
|
||||||
|
access_key: '',
|
||||||
|
access_secret: '',
|
||||||
|
api_token: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSaved = ref(false)
|
||||||
|
const renewing = ref(false)
|
||||||
|
const renewResult = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { data } = await api.get('/acme/config')
|
||||||
|
form.value.acme_server = data.acme_server
|
||||||
|
form.value.email = data.email
|
||||||
|
form.value.dns_provider = data.dns_provider
|
||||||
|
form.value.renew_days = data.renew_days
|
||||||
|
|
||||||
|
// 解析已保存的凭据
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(data.dns_credentials || '{}')
|
||||||
|
creds.value.access_key = saved.access_key || ''
|
||||||
|
creds.value.access_secret = saved.access_secret ? '********' : ''
|
||||||
|
creds.value.api_token = saved.api_token ? '********' : ''
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
await api.put('/acme/config', form.value)
|
||||||
|
showSaved.value = true
|
||||||
|
setTimeout(() => { showSaved.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCredentials = async () => {
|
||||||
|
let credentials = {}
|
||||||
|
if (form.value.dns_provider === 'aliyun') {
|
||||||
|
credentials = {
|
||||||
|
access_key: creds.value.access_key,
|
||||||
|
access_secret: creds.value.access_secret === '********' ? undefined : creds.value.access_secret,
|
||||||
|
}
|
||||||
|
} else if (form.value.dns_provider === 'cloudflare') {
|
||||||
|
credentials = {
|
||||||
|
api_token: creds.value.api_token === '********' ? undefined : creds.value.api_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 移除 undefined 值
|
||||||
|
Object.keys(credentials).forEach(k => credentials[k] === undefined && delete credentials[k])
|
||||||
|
await api.put('/acme/config', { dns_credentials: JSON.stringify(credentials) })
|
||||||
|
showSaved.value = true
|
||||||
|
setTimeout(() => { showSaved.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoRenew = async () => {
|
||||||
|
renewing.value = true
|
||||||
|
renewResult.value = null
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/acme/auto-renew')
|
||||||
|
renewResult.value = data.results
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('续签失败: ' + (e.response?.data?.detail || e.message))
|
||||||
|
} finally {
|
||||||
|
renewing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mb-6">仪表盘</h2>
|
||||||
|
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="p-3 bg-blue-100 rounded-lg">
|
||||||
|
<span class="text-2xl">🖥️</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm text-gray-500">服务器</p>
|
||||||
|
<p class="text-2xl font-bold">{{ stats.server_count }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="p-3 bg-green-100 rounded-lg">
|
||||||
|
<span class="text-2xl">🌐</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm text-gray-500">域名</p>
|
||||||
|
<p class="text-2xl font-bold">{{ stats.domain_count }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="p-3 bg-red-100 rounded-lg">
|
||||||
|
<span class="text-2xl">⚠️</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm text-gray-500">即将过期</p>
|
||||||
|
<p class="text-2xl font-bold">{{ stats.expiring_count }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷操作 -->
|
||||||
|
<div class="flex gap-4 mb-8">
|
||||||
|
<router-link to="/servers/new" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||||
|
+ 新增服务器
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/domains/new" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
|
||||||
|
+ 新增域名
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近日志 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 class="font-semibold">最近部署日志</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">时间</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">服务器</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">状态</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">消息</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
<tr v-for="log in stats.recent_logs" :key="log.id">
|
||||||
|
<td class="px-6 py-3 text-gray-500">{{ formatTime(log.created_at) }}</td>
|
||||||
|
<td class="px-6 py-3">{{ log.server }}</td>
|
||||||
|
<td class="px-6 py-3">{{ log.domain }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<span :class="statusClass(log.status)" class="px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{{ log.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 text-gray-500">{{ log.message }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!stats.recent_logs?.length">
|
||||||
|
<td colspan="5" class="px-6 py-8 text-center text-gray-400">暂无日志</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getStats } from '../api'
|
||||||
|
|
||||||
|
const stats = ref({
|
||||||
|
server_count: 0,
|
||||||
|
domain_count: 0,
|
||||||
|
expiring_count: 0,
|
||||||
|
recent_logs: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return '-'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = (status) => ({
|
||||||
|
'bg-green-100 text-green-700': status === 'success',
|
||||||
|
'bg-red-100 text-red-700': status === 'failed',
|
||||||
|
'bg-gray-100 text-gray-700': status === 'skipped',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { data } = await getStats()
|
||||||
|
stats.value = data
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mb-6">{{ isEdit ? '编辑域名' : '新增域名' }}</h2>
|
||||||
|
|
||||||
|
<div class="max-w-xl bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">所属服务器</label>
|
||||||
|
<select v-model="form.server_id" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||||
|
<option value="" disabled>请选择服务器</option>
|
||||||
|
<option v-for="s in servers" :key="s.id" :value="s.id">{{ s.name }} ({{ s.platform }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">域名</label>
|
||||||
|
<input v-model="form.domain" type="text" required placeholder="如 example.com 或 *.example.com"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||||
|
<p class="text-xs text-gray-400 mt-1">泛域名填写 *.example.com,会自动同时申请裸域名证书</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">证书存放路径</label>
|
||||||
|
<input v-model="form.cert_dir" type="text" required :placeholder="certDirPlaceholder"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">校验命令</label>
|
||||||
|
<input v-model="form.check_cmd" type="text" required placeholder="nginx -t"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">重载命令</label>
|
||||||
|
<input v-model="form.reload_cmd" type="text" required placeholder="systemctl reload nginx"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button type="submit" class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</button>
|
||||||
|
<router-link to="/domains" class="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||||
|
取消
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { getDomain, createDomain, updateDomain, getServers } from '../api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const isEdit = computed(() => !!route.params.id)
|
||||||
|
|
||||||
|
const servers = ref([])
|
||||||
|
const form = ref({
|
||||||
|
server_id: '',
|
||||||
|
domain: '',
|
||||||
|
cert_dir: '',
|
||||||
|
check_cmd: 'nginx -t',
|
||||||
|
reload_cmd: 'systemctl reload nginx',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedPlatform = computed(() => {
|
||||||
|
const s = servers.value.find(s => s.id === form.value.server_id)
|
||||||
|
return s?.platform || 'linux'
|
||||||
|
})
|
||||||
|
|
||||||
|
const certDirPlaceholder = computed(() => {
|
||||||
|
return selectedPlatform.value === 'windows'
|
||||||
|
? 'C:\\certs\\example.com'
|
||||||
|
: '/etc/nginx/ssl/example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换服务器时自动调整默认值
|
||||||
|
watch(() => form.value.server_id, () => {
|
||||||
|
if (!isEdit.value) {
|
||||||
|
if (selectedPlatform.value === 'windows') {
|
||||||
|
form.value.check_cmd = 'nginx -t'
|
||||||
|
form.value.reload_cmd = 'nginx -s reload'
|
||||||
|
} else {
|
||||||
|
form.value.check_cmd = 'nginx -t'
|
||||||
|
form.value.reload_cmd = 'systemctl reload nginx'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { data } = await getServers()
|
||||||
|
servers.value = data
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
const { data: d } = await getDomain(route.params.id)
|
||||||
|
form.value = d
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateDomain(route.params.id, form.value)
|
||||||
|
} else {
|
||||||
|
await createDomain(form.value)
|
||||||
|
}
|
||||||
|
router.push('/domains')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold">域名管理</h2>
|
||||||
|
<router-link to="/domains/new" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
|
||||||
|
+ 新增域名
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">服务器</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">证书路径</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">版本</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">证书到期</th>
|
||||||
|
<th class="px-6 py-3 text-right text-gray-500 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
<tr v-for="d in domains" :key="d.id">
|
||||||
|
<td class="px-6 py-3 font-medium">{{ d.domain }}</td>
|
||||||
|
<td class="px-6 py-3 text-gray-500">{{ d.server_name }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<code class="text-xs bg-gray-100 px-2 py-0.5 rounded">{{ d.cert_dir }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<code class="text-xs bg-gray-100 px-2 py-0.5 rounded">{{ d.version }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<span v-if="d.cert_not_after" :class="expiryClass(d.cert_not_after)" class="text-xs">
|
||||||
|
{{ formatDate(d.cert_not_after) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-400 text-xs">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 text-right space-x-2">
|
||||||
|
<button @click="handleIssue(d)" :disabled="d._issuing" class="text-orange-600 hover:underline text-xs disabled:opacity-50">
|
||||||
|
{{ d._issuing ? '申请中...' : '申请证书' }}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
<router-link :to="`/script/${d.domain}?server=${d.server_name}`" class="text-purple-600 hover:underline text-xs">查看脚本</router-link>
|
||||||
|
<button @click="handleDelete(d)" class="text-red-600 hover:underline text-xs">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!domains.length">
|
||||||
|
<td colspan="5" class="px-6 py-8 text-center text-gray-400">暂无域名</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 复制成功提示 -->
|
||||||
|
<div v-if="showCopied" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm transition-opacity">
|
||||||
|
已复制到剪贴板
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getDomains, deleteDomain } from '../api'
|
||||||
|
import api from '../api'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const domains = ref([])
|
||||||
|
const showCopied = ref(false)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const { data } = await getDomains()
|
||||||
|
domains.value = data.map(d => ({ ...d, _issuing: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return '-'
|
||||||
|
return new Date(iso).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryClass = (iso) => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const days = Math.ceil((new Date(iso) - new Date()) / 86400000)
|
||||||
|
if (days <= 7) return 'text-red-600 font-medium'
|
||||||
|
if (days <= 30) return 'text-orange-600'
|
||||||
|
return 'text-green-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIssue = async (d) => {
|
||||||
|
if (!confirm(`确认为 "${d.domain}" 申请/续签证书?`)) return
|
||||||
|
d._issuing = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/acme/issue/${d.id}`)
|
||||||
|
data.success ? toast.success(`申请成功: ${data.message}`) : toast.error(`申请失败: ${data.message}`)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('操作失败: ' + (e.response?.data?.detail || e.message))
|
||||||
|
} finally {
|
||||||
|
d._issuing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (d) => {
|
||||||
|
if (!confirm(`确认删除域名 "${d.domain}"?`)) return
|
||||||
|
await deleteDomain(d.id)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyDeployCmd = async (d) => {
|
||||||
|
// 根据服务器平台生成不同的部署命令
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
let cmd
|
||||||
|
if (d.platform === 'windows') {
|
||||||
|
cmd = `Invoke-WebRequest "${baseUrl}/api/script/${d.domain}?server=${d.server_name}" -OutFile C:\\scripts\\deploy-cert.ps1`
|
||||||
|
} else {
|
||||||
|
cmd = `curl -fsSL "${baseUrl}/api/script/${d.domain}?server=${d.server_name}" -o /usr/local/bin/deploy-cert.sh && chmod +x /usr/local/bin/deploy-cert.sh`
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(cmd)
|
||||||
|
showCopied.value = true
|
||||||
|
setTimeout(() => { showCopied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">🔐 CertCenter</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">请登录以继续</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
|
||||||
|
<input v-model="username" type="text" required autofocus
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||||
|
placeholder="admin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||||
|
<input v-model="password" type="password" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||||
|
placeholder="密码" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="loading"
|
||||||
|
class="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm disabled:opacity-50">
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const emit = defineEmits(['login-success'])
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/login', {
|
||||||
|
username: username.value,
|
||||||
|
password: password.value,
|
||||||
|
})
|
||||||
|
if (data.ok) {
|
||||||
|
localStorage.setItem('admin_token', data.token)
|
||||||
|
emit('login-success')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.detail || '登录失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold">部署日志</h2>
|
||||||
|
<select v-model="statusFilter" @change="load"
|
||||||
|
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="success">成功</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
<option value="skipped">跳过</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">时间</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">服务器</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">状态</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">消息</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
<tr v-for="log in logs" :key="log.id">
|
||||||
|
<td class="px-6 py-3 text-gray-500">{{ formatTime(log.created_at) }}</td>
|
||||||
|
<td class="px-6 py-3">{{ log.server }}</td>
|
||||||
|
<td class="px-6 py-3">{{ log.domain }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<span :class="statusClass(log.status)" class="px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{{ log.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 text-gray-500">{{ log.message }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!logs.length">
|
||||||
|
<td colspan="5" class="px-6 py-8 text-center text-gray-400">暂无日志</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getLogs } from '../api'
|
||||||
|
|
||||||
|
const logs = ref([])
|
||||||
|
const statusFilter = ref('')
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return '-'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = (status) => ({
|
||||||
|
'bg-green-100 text-green-700': status === 'success',
|
||||||
|
'bg-red-100 text-red-700': status === 'failed',
|
||||||
|
'bg-gray-100 text-gray-700': status === 'skipped',
|
||||||
|
})
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const { data } = await getLogs(statusFilter.value || undefined)
|
||||||
|
logs.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mb-6">脚本预览</h2>
|
||||||
|
|
||||||
|
<!-- 部署命令 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 mb-6">
|
||||||
|
<h3 class="font-semibold mb-3">客户端部署命令</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-3">在业务服务器上执行以下命令下载脚本:</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm overflow-x-auto">
|
||||||
|
<div v-if="isLinux">
|
||||||
|
curl -fsSL \<br />
|
||||||
|
"{{ baseUrl }}/api/script/{{ domain }}?server_name={{ serverName }}" \<br />
|
||||||
|
-o /usr/local/bin/deploy-cert.sh<br /><br />
|
||||||
|
chmod +x /usr/local/bin/deploy-cert.sh<br /><br />
|
||||||
|
# 添加 cron(每 30 分钟检查一次)<br />
|
||||||
|
echo '*/30 * * * * /usr/local/bin/deploy-cert.sh >> /var/log/deploy-cert.log 2>&1' | crontab -
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
Invoke-WebRequest ` <br />
|
||||||
|
-Uri "{{ baseUrl }}/api/script/{{ domain }}?server_name={{ serverName }}" ` <br />
|
||||||
|
-OutFile C:\scripts\deploy-cert.ps1<br /><br />
|
||||||
|
# 添加计划任务(每 30 分钟)<br />
|
||||||
|
schtasks /create /sc minute /mo 30 /tn "CertDeploy-{{ domain }}" /tr "powershell -File C:\scripts\deploy-cert.ps1"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="copyCommand" class="mt-3 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||||
|
📋 复制命令
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 脚本内容 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h3 class="font-semibold">脚本内容</h3>
|
||||||
|
<button @click="downloadScript" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||||
|
⬇️ 下载脚本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg text-sm overflow-x-auto whitespace-pre-wrap">{{ scriptContent }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 复制成功提示 -->
|
||||||
|
<div v-if="showCopied" class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm">
|
||||||
|
已复制到剪贴板
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { getScript, getDomains } from '../api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const domain = route.params.domain
|
||||||
|
const serverName = route.query.server
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
const scriptContent = ref('')
|
||||||
|
const showCopied = ref(false)
|
||||||
|
|
||||||
|
const isLinux = computed(() => {
|
||||||
|
// 从脚本内容判断平台
|
||||||
|
return !scriptContent.value.includes('$ErrorActionPreference')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { data } = await getScript(domain, serverName)
|
||||||
|
scriptContent.value = data
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyCommand = async () => {
|
||||||
|
const cmd = isLinux.value
|
||||||
|
? `curl -fsSL "${baseUrl}/api/script/${domain}?server_name=${serverName}" -o /usr/local/bin/deploy-cert.sh && chmod +x /usr/local/bin/deploy-cert.sh`
|
||||||
|
: `Invoke-WebRequest -Uri "${baseUrl}/api/script/${domain}?server_name=${serverName}" -OutFile C:\\scripts\\deploy-cert.ps1`
|
||||||
|
await navigator.clipboard.writeText(cmd)
|
||||||
|
showCopied.value = true
|
||||||
|
setTimeout(() => { showCopied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadScript = () => {
|
||||||
|
const ext = isLinux.value ? 'sh' : 'ps1'
|
||||||
|
const blob = new Blob([scriptContent.value], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `deploy-cert-${domain}.${ext}`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mb-6">{{ isEdit ? '编辑服务器' : '新增服务器' }}</h2>
|
||||||
|
|
||||||
|
<div class="max-w-xl bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">名称</label>
|
||||||
|
<input v-model="form.name" type="text" required placeholder="如 vps-blog"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">平台</label>
|
||||||
|
<select v-model="form.platform"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
|
||||||
|
<option value="linux">Linux</option>
|
||||||
|
<option value="windows">Windows</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Token</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input v-model="form.token" type="text" required placeholder="认证 Token"
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm" />
|
||||||
|
<button type="button" @click="generateToken"
|
||||||
|
class="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||||
|
生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">IP(可选,备注用)</label>
|
||||||
|
<input v-model="form.ip" type="text" placeholder="如 1.2.3.4"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</button>
|
||||||
|
<router-link to="/servers" class="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||||
|
取消
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { getServer, createServer, updateServer } from '../api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const isEdit = computed(() => !!route.params.id)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
platform: 'linux',
|
||||||
|
token: '',
|
||||||
|
ip: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateToken = () => {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
let token = 'tok_'
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
token += chars[Math.floor(Math.random() * chars.length)]
|
||||||
|
}
|
||||||
|
form.value.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (isEdit.value) {
|
||||||
|
const { data } = await getServer(route.params.id)
|
||||||
|
form.value = data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateServer(route.params.id, form.value)
|
||||||
|
} else {
|
||||||
|
await createServer(form.value)
|
||||||
|
}
|
||||||
|
router.push('/servers')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold">服务器管理</h2>
|
||||||
|
<router-link to="/servers/new" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
||||||
|
+ 新增服务器
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">名称</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">平台</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">IP</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">域名数</th>
|
||||||
|
<th class="px-6 py-3 text-left text-gray-500 font-medium">Token</th>
|
||||||
|
<th class="px-6 py-3 text-right text-gray-500 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
<tr v-for="server in servers" :key="server.id">
|
||||||
|
<td class="px-6 py-3 font-medium">{{ server.name }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<span :class="server.platform === 'linux' ? 'bg-yellow-100 text-yellow-700' : 'bg-blue-100 text-blue-700'" class="px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{{ server.platform }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 text-gray-500">{{ server.ip || '-' }}</td>
|
||||||
|
<td class="px-6 py-3">{{ server.domain_count }}</td>
|
||||||
|
<td class="px-6 py-3">
|
||||||
|
<code class="text-xs bg-gray-100 px-2 py-0.5 rounded">{{ server.token.substring(0, 12) }}...</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 text-right space-x-2">
|
||||||
|
<router-link :to="`/servers/${server.id}/edit`" class="text-blue-600 hover:underline text-xs">编辑</router-link>
|
||||||
|
<button @click="handleDelete(server)" class="text-red-600 hover:underline text-xs">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!servers.length">
|
||||||
|
<td colspan="6" class="px-6 py-8 text-center text-gray-400">暂无服务器</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getServers, deleteServer } from '../api'
|
||||||
|
|
||||||
|
const servers = ref([])
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const { data } = await getServers()
|
||||||
|
servers.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (server) => {
|
||||||
|
if (!confirm(`确认删除服务器 "${server.name}"?\n\n该服务器下的所有域名配置也会被删除。`)) return
|
||||||
|
await deleteServer(server.id)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/admin/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
aiosqlite>=0.20.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
pydantic>=2.9.0
|
||||||
|
pydantic-settings>=2.5.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
|
# ACME 相关
|
||||||
|
acme>=2.10.0
|
||||||
|
cryptography>=43.0.0
|
||||||
|
certbot>=2.10.0
|
||||||
|
requests>=2.31.0
|
||||||
|
dnspython>=2.6.0
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""最小化测试:确认 FastAPI 路由是否正常"""
|
||||||
|
from fastapi import FastAPI, APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# 测试1:直接定义路由
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@app.post("/admin/api/login")
|
||||||
|
async def login_direct(data: LoginRequest):
|
||||||
|
return {"ok": True, "method": "direct", "username": data.username}
|
||||||
|
|
||||||
|
# 测试2:通过 router 定义
|
||||||
|
admin_router = APIRouter()
|
||||||
|
|
||||||
|
@admin_router.post("/login")
|
||||||
|
async def login_router(data: LoginRequest):
|
||||||
|
return {"ok": True, "method": "router", "username": data.username}
|
||||||
|
|
||||||
|
@admin_router.get("/test")
|
||||||
|
async def test_router():
|
||||||
|
return {"msg": "hello from router"}
|
||||||
|
|
||||||
|
app.include_router(admin_router, prefix="/admin/api")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
CertCenter 端到端测试
|
||||||
|
用 Let's Encrypt Staging 环境测试完整的证书签发流程
|
||||||
|
|
||||||
|
用法:
|
||||||
|
1. 先在 WebUI 中配置好 ACME(使用 Staging 环境 + 阿里云 DNS 凭据)
|
||||||
|
2. 添加一台服务器和一个域名
|
||||||
|
3. 执行: python -m tests.test_e2e --domain example.com
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
BASE_URL = "http://127.0.0.1:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def log(step, msg, status="info"):
|
||||||
|
icons = {"info": "ℹ️", "ok": "✅", "fail": "❌", "warn": "⚠️"}
|
||||||
|
print(f" {icons.get(status, ' ')} [{step}] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_alive():
|
||||||
|
"""测试 API 是否存活"""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{BASE_URL}/admin/api/stats", timeout=5)
|
||||||
|
return resp.status_code == 200
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_server(name, platform, token):
|
||||||
|
"""获取或创建服务器"""
|
||||||
|
resp = requests.get(f"{BASE_URL}/admin/api/servers")
|
||||||
|
for s in resp.json():
|
||||||
|
if s["name"] == name:
|
||||||
|
return s["id"]
|
||||||
|
|
||||||
|
resp = requests.post(f"{BASE_URL}/admin/api/servers", json={
|
||||||
|
"name": name,
|
||||||
|
"platform": platform,
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_domain(server_id, domain, cert_dir, check_cmd, reload_cmd):
|
||||||
|
"""获取或创建域名"""
|
||||||
|
resp = requests.get(f"{BASE_URL}/admin/api/domains")
|
||||||
|
for d in resp.json():
|
||||||
|
if d["domain"] == domain:
|
||||||
|
return d["id"]
|
||||||
|
|
||||||
|
resp = requests.post(f"{BASE_URL}/admin/api/domains", json={
|
||||||
|
"server_id": server_id,
|
||||||
|
"domain": domain,
|
||||||
|
"cert_dir": cert_dir,
|
||||||
|
"check_cmd": check_cmd,
|
||||||
|
"reload_cmd": reload_cmd,
|
||||||
|
})
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def configure_acme(email, dns_provider, credentials):
|
||||||
|
"""配置 ACME"""
|
||||||
|
resp = requests.put(f"{BASE_URL}/admin/api/acme/config", json={
|
||||||
|
"acme_server": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
|
"email": email,
|
||||||
|
"dns_provider": dns_provider,
|
||||||
|
"dns_credentials": json.dumps(credentials),
|
||||||
|
"renew_days": 30,
|
||||||
|
})
|
||||||
|
return resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def issue_certificate(domain_id):
|
||||||
|
"""申请证书"""
|
||||||
|
resp = requests.post(f"{BASE_URL}/admin/api/acme/issue/{domain_id}", timeout=180)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def check_cert_info(domain_id):
|
||||||
|
"""检查证书信息"""
|
||||||
|
resp = requests.get(f"{BASE_URL}/admin/api/cert-info/{domain_id}")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_download(domain, server_name):
|
||||||
|
"""测试脚本下载"""
|
||||||
|
resp = requests.get(f"{BASE_URL}/api/script/{domain}", params={"server_name": server_name})
|
||||||
|
return resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_api(domain):
|
||||||
|
"""测试版本接口"""
|
||||||
|
resp = requests.get(f"{BASE_URL}/api/version/{domain}")
|
||||||
|
return resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_download(domain, token):
|
||||||
|
"""测试证书下载"""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
resp = requests.get(f"{BASE_URL}/api/cert/{domain}/fullchain", headers=headers)
|
||||||
|
return resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="CertCenter 端到端测试")
|
||||||
|
parser.add_argument("--domain", required=True, help="测试域名(需已使用阿里云 DNS)")
|
||||||
|
parser.add_argument("--email", default="test@example.com", help="ACME 注册邮箱")
|
||||||
|
parser.add_argument("--ak", required=True, help="阿里云 Access Key")
|
||||||
|
parser.add_argument("--sk", required=True, help="阿里云 Access Secret")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"\n{'=' * 50}")
|
||||||
|
print(f" 端到端测试: {args.domain}")
|
||||||
|
print(f"{'=' * 50}\n")
|
||||||
|
|
||||||
|
# Step 0: 检查 API
|
||||||
|
log("0", "检查 API 服务...")
|
||||||
|
if not test_api_alive():
|
||||||
|
log("0", "API 服务未启动,请先执行 python -m backend.main", "fail")
|
||||||
|
return 1
|
||||||
|
log("0", "API 服务正常", "ok")
|
||||||
|
|
||||||
|
# Step 1: 配置 ACME
|
||||||
|
log("1", "配置 ACME (Staging 环境)...")
|
||||||
|
credentials = {"access_key": args.ak, "access_secret": args.sk}
|
||||||
|
if configure_acme(args.email, "aliyun", credentials):
|
||||||
|
log("1", "ACME 配置完成", "ok")
|
||||||
|
else:
|
||||||
|
log("1", "ACME 配置失败", "fail")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Step 2: 创建测试服务器
|
||||||
|
log("2", "创建测试服务器...")
|
||||||
|
test_token = "tok_e2e_test_" + str(int(time.time()))
|
||||||
|
server_id = get_or_create_server("e2e-test", "linux", test_token)
|
||||||
|
log("2", f"服务器 ID: {server_id}", "ok")
|
||||||
|
|
||||||
|
# Step 3: 创建测试域名
|
||||||
|
log("3", "创建测试域名配置...")
|
||||||
|
cert_dir = f"/tmp/cert-e2e-test/{args.domain}"
|
||||||
|
domain_id = get_or_create_domain(
|
||||||
|
server_id, args.domain,
|
||||||
|
cert_dir=cert_dir,
|
||||||
|
check_cmd="true", # 测试环境用 true 代替 nginx -t
|
||||||
|
reload_cmd="true",
|
||||||
|
)
|
||||||
|
log("3", f"域名 ID: {domain_id}", "ok")
|
||||||
|
|
||||||
|
# Step 4: 申请证书
|
||||||
|
log("4", "申请证书(这需要 1-2 分钟,等待 DNS 验证)...")
|
||||||
|
result = issue_certificate(domain_id)
|
||||||
|
if result.get("success"):
|
||||||
|
log("4", f"证书申请成功: {result.get('message')}", "ok")
|
||||||
|
else:
|
||||||
|
log("4", f"证书申请失败: {result.get('message')}", "fail")
|
||||||
|
log("4", "请检查:1) 域名 DNS 是否托管在阿里云 2) AK/SK 是否正确 3) 网络是否通畅", "warn")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Step 5: 检查证书信息
|
||||||
|
log("5", "检查证书信息...")
|
||||||
|
cert_info = check_cert_info(domain_id)
|
||||||
|
if cert_info.get("cert_info"):
|
||||||
|
info = cert_info["cert_info"]
|
||||||
|
log("5", f"签发者: {info.get('issuer')}", "ok")
|
||||||
|
log("5", f"有效期: {info.get('not_before')} ~ {info.get('not_after')}", "ok")
|
||||||
|
log("5", f"域名: {info.get('san')}", "ok")
|
||||||
|
log("5", f"剩余天数: {cert_info.get('days_left')}", "ok")
|
||||||
|
else:
|
||||||
|
log("5", "无法读取证书信息", "warn")
|
||||||
|
|
||||||
|
# Step 6: 测试版本接口
|
||||||
|
log("6", "测试版本接口...")
|
||||||
|
ok, version = test_version_api(args.domain)
|
||||||
|
if ok:
|
||||||
|
log("6", f"版本号: {version}", "ok")
|
||||||
|
else:
|
||||||
|
log("6", "版本接口异常", "fail")
|
||||||
|
|
||||||
|
# Step 7: 测试脚本生成
|
||||||
|
log("7", "测试脚本生成...")
|
||||||
|
ok, script = test_script_download(args.domain, "e2e-test")
|
||||||
|
if ok and len(script) > 100:
|
||||||
|
log("7", f"脚本生成成功,长度: {len(script)} 字符", "ok")
|
||||||
|
else:
|
||||||
|
log("7", "脚本生成失败", "fail")
|
||||||
|
|
||||||
|
# Step 8: 测试证书下载
|
||||||
|
log("8", "测试证书下载...")
|
||||||
|
if test_cert_download(args.domain, test_token):
|
||||||
|
log("8", "证书下载成功", "ok")
|
||||||
|
else:
|
||||||
|
log("8", "证书下载失败", "fail")
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
print(f"\n{'=' * 50}")
|
||||||
|
print(f" ✅ 端到端测试完成!")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
print(f"\n 证书位置: {cert_dir}/")
|
||||||
|
print(f" Staging 证书不受浏览器信任,仅用于验证流程。")
|
||||||
|
print(f" 确认流程正常后,在 WebUI 中切换到生产环境重新签发。\n")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""
|
||||||
|
CertCenter 部署验证脚本
|
||||||
|
用法: python -m tests.test_setup
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加项目根目录到 path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
PASS = "✅"
|
||||||
|
FAIL = "❌"
|
||||||
|
SKIP = "⏭️"
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, func):
|
||||||
|
"""执行检查并记录结果"""
|
||||||
|
try:
|
||||||
|
ok, msg = func()
|
||||||
|
status = PASS if ok else FAIL
|
||||||
|
results.append((name, status, msg))
|
||||||
|
print(f" {status} {name}: {msg}")
|
||||||
|
except Exception as e:
|
||||||
|
results.append((name, FAIL, str(e)))
|
||||||
|
print(f" {FAIL} {name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_deps():
|
||||||
|
"""检查 Python 依赖"""
|
||||||
|
deps = ["fastapi", "uvicorn", "sqlalchemy", "acme", "cryptography", "jinja2"]
|
||||||
|
missing = []
|
||||||
|
for dep in deps:
|
||||||
|
try:
|
||||||
|
__import__(dep)
|
||||||
|
except ImportError:
|
||||||
|
missing.append(dep)
|
||||||
|
if missing:
|
||||||
|
return False, f"缺少依赖: {', '.join(missing)},执行 pip install -r requirements.txt"
|
||||||
|
return True, "所有依赖已安装"
|
||||||
|
|
||||||
|
|
||||||
|
def test_certbot():
|
||||||
|
"""检查 certbot 是否可用"""
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["certbot", "--version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
return False, "certbot 命令不可用"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "certbot 未安装,执行 pip install certbot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_certbot_dns_plugin():
|
||||||
|
"""检查 certbot DNS 插件"""
|
||||||
|
try:
|
||||||
|
import certbot_dns_alicloud
|
||||||
|
return True, "certbot-dns-alicloud 已安装"
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import certbot_dns_cloudflare
|
||||||
|
return True, "certbot-dns-cloudflare 已安装"
|
||||||
|
except ImportError:
|
||||||
|
return False, "未检测到 DNS 插件,执行 pip install certbot-dns-alicloud"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_file():
|
||||||
|
"""检查 .env 文件"""
|
||||||
|
env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
if not env_path.exists():
|
||||||
|
return False, ".env 文件不存在,执行 cp .env .env"
|
||||||
|
return True, ".env 文件存在"
|
||||||
|
|
||||||
|
|
||||||
|
def test_database():
|
||||||
|
"""检查数据库连接"""
|
||||||
|
from backend.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
db_path = settings.database_url.replace("sqlite+aiosqlite:///", "")
|
||||||
|
if "sqlite" in settings.database_url:
|
||||||
|
return True, f"SQLite 数据库: {db_path}"
|
||||||
|
return True, f"数据库: {settings.database_url}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_dist():
|
||||||
|
"""检查前端构建产物"""
|
||||||
|
dist = Path(__file__).parent.parent / "frontend" / "dist"
|
||||||
|
if dist.exists() and (dist / "index.html").exists():
|
||||||
|
return True, f"前端已构建: {dist}"
|
||||||
|
return False, f"前端未构建,执行 cd frontend && npm install && npm run build"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cert_dir():
|
||||||
|
"""检查证书存储目录"""
|
||||||
|
from backend.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
cert_dir = Path(settings.cert_dir)
|
||||||
|
if cert_dir.exists():
|
||||||
|
return True, f"证书目录存在: {cert_dir}"
|
||||||
|
try:
|
||||||
|
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return True, f"证书目录已创建: {cert_dir}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"无法创建证书目录: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_url():
|
||||||
|
"""检查 BASE_URL 配置"""
|
||||||
|
from backend.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
if "example.com" in settings.base_url:
|
||||||
|
return False, f"BASE_URL 仍为默认值: {settings.base_url},请修改 .env"
|
||||||
|
return True, f"BASE_URL: {settings.base_url}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_letsencrypt_staging():
|
||||||
|
"""测试 Let's Encrypt 测试环境连通性"""
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
return True, f"Staging 环境可达, endpoints: {len(data)} 个"
|
||||||
|
return False, f"HTTP {resp.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"无法连接 Let's Encrypt Staging: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_letsencrypt_production():
|
||||||
|
"""测试 Let's Encrypt 生产环境连通性"""
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://acme-v02.api.letsencrypt.org/directory",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return True, "生产环境可达"
|
||||||
|
return False, f"HTTP {resp.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"无法连接 Let's Encrypt: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_credentials():
|
||||||
|
"""检查 DNS 凭据是否配置"""
|
||||||
|
from backend.database import engine, async_session
|
||||||
|
from backend.models import AcmeConfig
|
||||||
|
from sqlalchemy import select
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(select(AcmeConfig).where(AcmeConfig.id == 1))
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
return False, "ACME 未配置,请在 WebUI 中配置"
|
||||||
|
if not config.email:
|
||||||
|
return False, "邮箱未配置"
|
||||||
|
creds = json.loads(config.dns_credentials or "{}")
|
||||||
|
if not creds.get("access_key") and not creds.get("api_token"):
|
||||||
|
return False, "DNS 凭据未配置"
|
||||||
|
return True, f"DNS 提商: {config.dns_provider}, 邮箱: {config.email}"
|
||||||
|
|
||||||
|
return asyncio.run(_check())
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_server():
|
||||||
|
"""测试 FastAPI 服务是否能启动"""
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "backend.main"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
cwd=str(Path(__file__).parent.parent),
|
||||||
|
)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# 尝试访问
|
||||||
|
try:
|
||||||
|
resp = requests.get("http://127.0.0.1:8000/admin/api/stats", timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
proc.terminate()
|
||||||
|
return True, "服务启动成功,API 正常响应"
|
||||||
|
proc.terminate()
|
||||||
|
return False, f"服务启动但 API 返回 {resp.status_code}"
|
||||||
|
except requests.ConnectionError:
|
||||||
|
proc.terminate()
|
||||||
|
return False, "服务启动失败,端口 8000 无响应"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"启动测试失败: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(" CertCenter 部署验证")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
print("\n📦 环境检查:")
|
||||||
|
check("Python 依赖", test_python_deps)
|
||||||
|
check("Certbot", test_certbot)
|
||||||
|
check("DNS 插件", test_certbot_dns_plugin)
|
||||||
|
check(".env 配置", test_env_file)
|
||||||
|
check("数据库", test_database)
|
||||||
|
check("前端构建", test_frontend_dist)
|
||||||
|
check("证书目录", test_cert_dir)
|
||||||
|
check("BASE_URL", test_base_url)
|
||||||
|
|
||||||
|
print("\n🌐 网络检查:")
|
||||||
|
check("Let's Encrypt Staging", test_letsencrypt_staging)
|
||||||
|
check("Let's Encrypt Production", test_letsencrypt_production)
|
||||||
|
|
||||||
|
print("\n⚙️ 服务检查:")
|
||||||
|
check("FastAPI 服务", test_api_server)
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
passed = sum(1 for _, s, _ in results if s == PASS)
|
||||||
|
failed = sum(1 for _, s, _ in results if s == FAIL)
|
||||||
|
print(f" 结果: {passed} 通过, {failed} 失败")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print("\n 请修复以上 ❌ 标记的问题后重试。")
|
||||||
|
print(" 建议先使用 Let's Encrypt Staging 环境测试。")
|
||||||
|
else:
|
||||||
|
print("\n 所有检查通过!可以开始使用 CertCenter。")
|
||||||
|
print(" 建议首次测试使用 Staging 环境,确认无误后再切换到生产环境。")
|
||||||
|
print("=" * 50 + "\n")
|
||||||
|
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
主服务(CertCenter)
|
||||||
|
|
||||||
|
负责 acme.sh 申请与续签、维护 version 文件、提供 HTTPS 下载接口,以及生成“拉取脚本”。
|
||||||
|
|
||||||
|
拉取脚本(deploy-cert.sh)
|
||||||
|
|
||||||
|
部署到每台服务器,通过 cron/systemd timer 定时执行:检查版本 → 下载 → 比对 → 原子替换 → nginx -t → reload。
|
||||||
|
|
||||||
|
这样每台业务服务器 不需要安装 Python、FastAPI、acme.sh 或任何 Agent,只需要一个 Shell 脚本和 cron。
|
||||||
|
|
||||||
|
### 最终架构
|
||||||
|
|
||||||
|
CertCenter
|
||||||
|
|
||||||
|
acme.sh · AliDNS · version · HTTPS 下载
|
||||||
|
|
||||||
|
HTTPS 拉取
|
||||||
|
|
||||||
|
VPS-A
|
||||||
|
|
||||||
|
blog.example.com
|
||||||
|
|
||||||
|
cron → deploy-cert.sh → reload nginx
|
||||||
|
|
||||||
|
VPS-B
|
||||||
|
|
||||||
|
api.example.com
|
||||||
|
|
||||||
|
cron → deploy-cert.sh → reload nginx
|
||||||
|
|
||||||
|
VPS-C
|
||||||
|
|
||||||
|
git.example.com
|
||||||
|
|
||||||
|
cron → deploy-cert.sh → reload nginx
|
||||||
|
|
||||||
|
### CertCenter 目录
|
||||||
|
|
||||||
|
```
|
||||||
|
certcenter/
|
||||||
|
├── app.py # FastAPI
|
||||||
|
├── config.yaml
|
||||||
|
├── certs/
|
||||||
|
│ └── example.com/
|
||||||
|
│ ├── fullchain.pem
|
||||||
|
│ ├── private.key
|
||||||
|
│ ├── version
|
||||||
|
│ └── metadata.json
|
||||||
|
└── templates/
|
||||||
|
└── deploy-cert.sh.j2 # 脚本模板
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主服务提供的接口
|
||||||
|
|
||||||
|
| 接口 | 用途 |
|
||||||
|
| -------------------------------- | ---------------- |
|
||||||
|
| GET /api/version/{domain} | 返回当前版本号 |
|
||||||
|
| GET /api/cert/{domain}/fullchain | 下载 fullchain.pem |
|
||||||
|
| GET /api/cert/{domain}/private | 下载 private.key |
|
||||||
|
| GET /api/script/{domain} | 动态生成部署脚本 |
|
||||||
|
|
||||||
|
所有接口都使用:
|
||||||
|
|
||||||
|
http
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 版本文件
|
||||||
|
|
||||||
|
`/srv/certs/example.com/version`
|
||||||
|
|
||||||
|
```
|
||||||
|
20260718143000
|
||||||
|
```
|
||||||
|
|
||||||
|
续签成功后,CertCenter 只需要:
|
||||||
|
|
||||||
|
Bash
|
||||||
|
|
||||||
|
```
|
||||||
|
date -u +%Y%m%d%H%M%S > /srv/certs/example.com/version
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端先请求这个小文件,只有版本变化时才下载证书。
|
||||||
|
|
||||||
|
### 生成的拉取脚本(核心)
|
||||||
|
|
||||||
|
下面就是 CertCenter 返回给服务器的脚本内容。你只需要把它保存为 `/usr/local/bin/deploy-cert.sh`。
|
||||||
|
|
||||||
|
Edit
|
||||||
|
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="example.com"
|
||||||
|
BASE_URL="[https://cert.example.com](https://cert.example.com)"
|
||||||
|
TOKEN="REPLACE_WITH_TOKEN"
|
||||||
|
|
||||||
|
CERT_DIR="/etc/nginx/ssl/example.com"
|
||||||
|
TMP_DIR="/tmp/cert-sync-${DOMAIN}"
|
||||||
|
VERSION_FILE="${CERT_DIR}/.version"
|
||||||
|
RELOAD_CMD="systemctl reload nginx"
|
||||||
|
|
||||||
|
mkdir -p "${CERT_DIR}" "${TMP_DIR}"
|
||||||
|
|
||||||
|
auth=(-H "Authorization: Bearer ${TOKEN}")
|
||||||
|
|
||||||
|
# 1. 获取远端版本
|
||||||
|
|
||||||
|
REMOTE_VERSION=$(curl -fsSL "${auth[@]}"
|
||||||
|
"${BASE_URL}/api/version/${DOMAIN}")
|
||||||
|
|
||||||
|
# 2. 获取本地版本
|
||||||
|
|
||||||
|
LOCAL_VERSION=""
|
||||||
|
if [[ -f "${VERSION_FILE}" ]]; then
|
||||||
|
LOCAL_VERSION=$(cat "${VERSION_FILE}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. 版本一致则退出
|
||||||
|
|
||||||
|
if [[ "${REMOTE_VERSION}" == "${LOCAL_VERSION}" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. 下载新证书
|
||||||
|
|
||||||
|
curl -fsSL "${auth[@]}"
|
||||||
|
"${BASE_URL}/api/cert/${DOMAIN}/fullchain"
|
||||||
|
-o "${TMP_DIR}/fullchain.pem"
|
||||||
|
|
||||||
|
curl -fsSL "${auth[@]}"
|
||||||
|
"${BASE_URL}/api/cert/${DOMAIN}/private"
|
||||||
|
-o "${TMP_DIR}/private.key"
|
||||||
|
|
||||||
|
chmod 600 "${TMP_DIR}/private.key"
|
||||||
|
|
||||||
|
# 5. 备份旧证书
|
||||||
|
|
||||||
|
cp -f "${CERT_DIR}/fullchain.pem" "${CERT_DIR}/fullchain.pem.bak" 2>/dev/null || true
|
||||||
|
cp -f "${CERT_DIR}/private.key" "${CERT_DIR}/private.key.bak" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 6. 原子替换
|
||||||
|
|
||||||
|
mv "${TMP_DIR}/fullchain.pem" "${CERT_DIR}/fullchain.pem"
|
||||||
|
mv "${TMP_DIR}/private.key" "${CERT_DIR}/private.key"
|
||||||
|
|
||||||
|
# 7. 校验 Nginx 配置
|
||||||
|
|
||||||
|
if nginx -t; then
|
||||||
|
echo "${REMOTE_VERSION}" > "${VERSION_FILE}"
|
||||||
|
${RELOAD_CMD}
|
||||||
|
echo "[$(date -Is)] certificate updated: ${REMOTE_VERSION}"
|
||||||
|
else
|
||||||
|
echo "Nginx config test failed, rollback" >&2
|
||||||
|
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
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
脚本已经包含:
|
||||||
|
|
||||||
|
* 版本检查(避免重复下载)
|
||||||
|
|
||||||
|
* 下载证书
|
||||||
|
|
||||||
|
* SHA/内容变化隐式通过版本号控制
|
||||||
|
|
||||||
|
* 原子替换(`mv`)
|
||||||
|
|
||||||
|
* `nginx -t` 校验
|
||||||
|
|
||||||
|
* 失败自动回滚
|
||||||
|
|
||||||
|
* 成功后 `systemctl reload nginx`
|
||||||
|
|
||||||
|
### CertCenter 的“生成脚本”接口
|
||||||
|
|
||||||
|
FastAPI 示例(核心逻辑):
|
||||||
|
|
||||||
|
Python
|
||||||
|
|
||||||
|
Run
|
||||||
|
|
||||||
|
```
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
env = Environment(loader=FileSystemLoader("templates"))
|
||||||
|
|
||||||
|
TOKENS = {
|
||||||
|
"server-a": "tok_xxx",
|
||||||
|
"server-b": "tok_yyy",
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/script/{domain}", response_class=PlainTextResponse)
|
||||||
|
def generate_script(domain: str, server: str):
|
||||||
|
token = TOKENS.get(server)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(404, "unknown server")
|
||||||
|
|
||||||
|
tpl = env.get_template("deploy-cert.sh.j2")
|
||||||
|
return tpl.render(
|
||||||
|
domain=domain,
|
||||||
|
base_url="https://cert.example.com",
|
||||||
|
token=token,
|
||||||
|
cert_dir=f"/etc/nginx/ssl/{domain}",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 业务服务器首次部署
|
||||||
|
|
||||||
|
只需要执行一次:
|
||||||
|
|
||||||
|
Bash
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL \
|
||||||
|
"https://cert.example.com/api/script/example.com?server=server-a" \
|
||||||
|
-o /usr/local/bin/deploy-cert.sh
|
||||||
|
|
||||||
|
chmod +x /usr/local/bin/deploy-cert.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
然后添加 cron:
|
||||||
|
|
||||||
|
cron
|
||||||
|
|
||||||
|
```
|
||||||
|
*/30 * * * * /usr/local/bin/deploy-cert.sh >> /var/log/deploy-cert.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主服务续签流程
|
||||||
|
|
||||||
|
Bash
|
||||||
|
|
||||||
|
```
|
||||||
|
# 每天凌晨执行
|
||||||
|
/root/.acme.sh/acme.sh --cron
|
||||||
|
|
||||||
|
# 如果证书更新,安装到统一目录
|
||||||
|
/root/.acme.sh/acme.sh --install-cert \
|
||||||
|
-d example.com \
|
||||||
|
--key-file /srv/certs/example.com/private.key \
|
||||||
|
--fullchain-file /srv/certs/example.com/fullchain.pem
|
||||||
|
|
||||||
|
# 更新版本号
|
||||||
|
date -u +%Y%m%d%H%M%S > /srv/certs/example.com/version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 为什么这个方案最适合你
|
||||||
|
|
||||||
|
极轻量
|
||||||
|
|
||||||
|
业务服务器只需要 Bash + curl + cron,没有常驻进程。
|
||||||
|
|
||||||
|
安全
|
||||||
|
|
||||||
|
AliDNS AccessKey 只保存在 CertCenter,业务服务器只拿下载 Token。
|
||||||
|
|
||||||
|
自动化
|
||||||
|
|
||||||
|
续签后客户端自动感知版本变化并更新证书。
|
||||||
|
|
||||||
|
易扩展
|
||||||
|
|
||||||
|
新增服务器只需执行一次 curl 下载脚本并添加 cron。
|
||||||
Reference in New Issue
Block a user