首次提交

This commit is contained in:
2026-07-18 20:09:26 +08:00
commit 521ad3b635
48 changed files with 7199 additions and 0 deletions
+7
View File
@@ -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
+7
View File
@@ -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
View File
@@ -0,0 +1,14 @@
# Python
__pycache__/
*.pyc
*.db
.env
.venv/
# Frontend
frontend/node_modules/
frontend/dist/
# IDE
.idea/
.vscode/
+56
View File
@@ -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)
+883
View File
@@ -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.14pydantic-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.topCertCenter 自身的域名)
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] 配置 ACMEStaging 环境 + 阿里云 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)调试,确认无误后再切换到生产环境。
View File
+324
View File
@@ -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
+56
View File
@@ -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()}")
+25
View File
@@ -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()
+27
View File
@@ -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
View File
@@ -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)
+84
View File
@@ -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()
View File
+535
View File
@@ -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,
}
+144
View File
@@ -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}
+49
View File
@@ -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
}
+54
View File
@@ -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
BIN
View File
Binary file not shown.
+12
View File
@@ -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>
+2632
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+83
View File
@@ -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>
+54
View File
@@ -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
+54
View File
@@ -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)"
>&times;</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>
+28
View File
@@ -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),
}
}
+6
View File
@@ -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')
+66
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+124
View File
@@ -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>
+174
View File
@@ -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>
+116
View File
@@ -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>
+114
View File
@@ -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>
+126
View File
@@ -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>
+64
View File
@@ -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>
+70
View File
@@ -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>
+90
View File
@@ -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 />
&nbsp;&nbsp;"{{ baseUrl }}/api/script/{{ domain }}?server_name={{ serverName }}" \<br />
&nbsp;&nbsp;-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 />
&nbsp;&nbsp;-Uri "{{ baseUrl }}/api/script/{{ domain }}?server_name={{ serverName }}" ` <br />
&nbsp;&nbsp;-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>
+93
View File
@@ -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>
+67
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+23
View File
@@ -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,
},
})
+15
View File
@@ -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
+31
View File
@@ -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)
View File
+213
View File
@@ -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())
+242
View File
@@ -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())
+272
View File
@@ -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。