commit 521ad3b63504610e4d43fd456520bab1829584fc Author: sans Date: Sat Jul 18 20:09:26 2026 +0800 首次提交 diff --git a/.env b/.env new file mode 100644 index 0000000..826a008 --- /dev/null +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..01fb9b8 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18798d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.pyc +*.db +.env +.venv/ + +# Frontend +frontend/node_modules/ +frontend/dist/ + +# IDE +.idea/ +.vscode/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fc98b13 --- /dev/null +++ b/CLAUDE.md @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fbc03b --- /dev/null +++ b/README.md @@ -0,0 +1,883 @@ +# CertCenter - 轻量化证书管理中心 + +一站式 SSL 证书管理平台,集成 ACME 证书申请、自动续签、多服务器分发,通过 Web UI 完成所有操作。 + +--- + +## 目录 + +- [架构概览](#架构概览) +- [环境要求](#环境要求) +- [安装部署](#安装部署) +- [WebUI 操作指南](#webui-操作指南) + - [仪表盘](#1-仪表盘) + - [服务器管理](#2-服务器管理) + - [域名管理](#3-域名管理) + - [ACME 配置](#4-acme-配置) + - [部署日志](#5-部署日志) + - [ACME 日志](#6-acme-日志) +- [测试验证](#测试验证) + - [环境检查](#1-环境检查) + - [端到端测试](#2-端到端测试) +- [客户端部署指南](#客户端部署指南) + - [Linux 服务器](#linux-服务器) + - [Windows 服务器](#windows-服务器) +- [API 参考](#api-参考) + - [客户端 API](#客户端-api) + - [管理 API](#管理-api) +- [配置说明](#配置说明) +- [常见问题](#常见问题) + +--- + +## 架构概览 + +``` +┌─────────────────────────────────────────────────────────┐ +│ CertCenter 服务 │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ WebUI │ │ ACME 引擎 │ │ 数据库 │ │ +│ │ (Vue) │ │ (certbot) │ │ (SQLite) │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ +│ └──────────────┴──────────────┘ │ +│ │ │ +│ FastAPI 后端服务 │ +└──────────────────────┬──────────────────────────────────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ + │ VPS-A │ │ VPS-B │ │ VPS-C │ + │ Linux │ │ Linux │ │ Windows │ + │ cron │ │ cron │ │ schtasks│ + └─────────┘ └─────────┘ └─────────┘ +``` + +**核心流程:** + +1. 在 WebUI 配置 ACME 凭据(阿里云 DNS / Cloudflare) +2. 添加服务器和域名信息 +3. 点击"申请证书",CertCenter 自动完成 DNS 验证并签发证书 +4. 业务服务器通过 cron 定时拉取最新证书 +5. 证书到期前自动续签 + +--- + +## 环境要求 + +### 服务端(CertCenter) + +| 依赖 | 版本 | 说明 | +|------|------|------| +| Python | 3.11 ~ 3.12 | ⚠️ 不支持 3.14(pydantic-core 无预编译 wheel) | +| certbot | 2.10+ | ACME 证书申请 | +| certbot-dns-alicloud | 最新 | 阿里云 DNS 插件(如使用阿里云) | + +### 客户端(业务服务器) + +| 依赖 | 说明 | +|------|------| +| bash / PowerShell | 执行部署脚本 | +| curl / Invoke-WebRequest | 下载证书 | +| cron / schtasks | 定时任务 | + +--- + +## 安装部署 + +### 0. 确认 Python 版本 + +```bash +python --version +# 需要 3.11.x 或 3.12.x,不支持 3.14 +``` + +如果版本不对,先装 Python 3.12: +- 下载地址:https://www.python.org/downloads/release/python-3128/ +- 安装时勾选 "Add python.exe to PATH" + +### 1. 克隆项目 + +```bash +git clone certcenter +cd certcenter +``` + +### 2. 创建虚拟环境并安装依赖 + +```bash +# 创建虚拟环境(指定 Python 3.12) +py -3.12 -m venv .venv + +# 激活虚拟环境 +.venv\Scripts\activate # Windows +# source .venv/bin/activate # Linux/Mac + +# 安装依赖 +pip install -r requirements.txt +``` + +如果使用阿里云 DNS,还需要安装 certbot 插件: + +```bash +pip install certbot-dns-alicloud +``` + +### 3. 安装前端依赖并构建 + +```bash +cd frontend +npm install +npm run build +cd .. +``` + +构建完成后,`frontend/dist/` 目录包含前端静态文件,FastAPI 会自动加载。 + +### 4. 配置环境变量 + +```bash +cp .env .env +``` + +编辑 `.env` 文件: + +```ini +# 数据库路径(SQLite) +DATABASE_URL=sqlite+aiosqlite:///./certcenter.db + +# 证书存储目录 +CERT_DIR=/srv/certs + +# CertCenter 的访问地址(客户端通过此地址拉取证书) +BASE_URL=https://cert.example.com +``` + +### 5. 启动服务 + +```bash +python -m backend.main +``` + +服务默认监听 `http://0.0.0.0:8000`。 + +**首次启动时,系统会自动为 CertCenter 的域名生成一份自签证书**(存放在 `CERT_DIR` 下),确保 HTTPS 可用。后续正式签发证书后会自动替换。 + +### 6. 引导流程(首次使用) + +首次使用时的推荐操作顺序: + +``` +1. 启动 CertCenter(自签证书自动生成) + python -m backend.main + +2. 打开 WebUI,配置 ACME 凭据(阿里云 AK/SK) + +3. 添加域名:cert.zhzp.top(CertCenter 自身的域名) + +4. 点击"申请证书",为 CertCenter 签发正式证书 + +5. 重启 CertCenter,正式证书自动加载 + # 此时所有客户端连接都是受信任的 HTTPS +``` + +**为什么需要这个流程?** + +``` +CertCenter 使用 HTTPS → 需要证书 → 证书由 CertCenter 自己签发 + ↑ + 这就是"引导"问题 +``` + +解决方式:先用自签证书启动 → 签发正式证书 → 替换。客户端脚本已内置 `curl -k` 跳过 TLS 验证,不受影响。 + +### 7. 生产环境部署(可选) + +使用 systemd 托管服务: + +```bash +cat > /etc/systemd/system/certcenter.service << 'EOF' +[Unit] +Description=CertCenter Service +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/certcenter +ExecStart=/opt/certcenter/venv/bin/python -m backend.main +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable --now certcenter +``` + +如果需要 HTTPS 访问 CertCenter 本身,可以使用 nginx 反向代理: + +```nginx +server { + listen 443 ssl; + server_name cert.example.com; + + ssl_certificate /etc/nginx/ssl/cert.example.com/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/cert.example.com/private.key; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +--- + +## 测试验证 + +部署完成后,建议按以下步骤验证系统是否正常工作。 + +### 1. 环境检查 + +运行部署验证脚本,检查所有依赖和配置: + +```bash +python -m tests.test_setup +``` + +该脚本会检查: + +| 检查项 | 说明 | +|--------|------| +| Python 依赖 | fastapi、sqlalchemy、acme、cryptography 等 | +| Certbot | certbot 命令是否可用 | +| DNS 插件 | certbot-dns-alicloud 或 certbot-dns-cloudflare | +| .env 配置 | 环境变量文件是否存在 | +| 数据库 | 数据库连接是否正常 | +| 前端构建 | frontend/dist/ 是否存在 | +| 证书目录 | 存储目录是否可写 | +| BASE_URL | 是否已修改默认值 | +| Let's Encrypt 连通性 | Staging 和 Production 环境是否可达 | +| FastAPI 服务 | 服务能否正常启动 | + +所有检查通过后,进入下一步。 + +### 2. 端到端测试 + +使用 Let's Encrypt **Staging 环境**(测试环境)验证完整的证书签发流程。 + +**前提条件:** + +- 一个使用阿里云 DNS 解析的域名 +- 阿里云 RAM 子账号的 AccessKey(需有 `AliyunDNSFullAccess` 权限) +- CertCenter 服务已启动 + +**执行测试:** + +```bash +python -m tests.test_e2e \ + --domain test.example.com \ + --email admin@example.com \ + --ak YOUR_ACCESS_KEY \ + --sk YOUR_ACCESS_SECRET +``` + +**测试流程:** + +``` +[0] 检查 API 服务是否正常 +[1] 配置 ACME(Staging 环境 + 阿里云 DNS 凭据) +[2] 创建测试服务器 +[3] 创建测试域名配置 +[4] 申请证书(自动完成 DNS-01 验证) +[5] 检查证书信息(签发者、有效期、SAN) +[6] 测试版本接口 +[7] 测试脚本生成 +[8] 测试证书下载 +``` + +**预期输出:** + +``` +================================================== + 端到端测试: test.example.com +================================================== + + ✅ [0] API 服务正常 + ✅ [1] ACME 配置完成 + ✅ [2] 服务器 ID: 1 + ✅ [3] 域名 ID: 1 + ✅ [4] 证书申请成功: Certificate issued, expires: 2026-10-16 00:00:00 + ✅ [5] 签发者: CN=(STAGING) Artificial Apricot R3, O=Let's Encrypt + ✅ [5] 有效期: 2026-07-18 ~ 2026-10-16 + ✅ [5] 域名: ['test.example.com'] + ✅ [5] 剩余天数: 90 + ✅ [6] 版本号: 1 + ✅ [7] 脚本生成成功,长度: 1234 字符 + ✅ [8] 证书下载成功 + +================================================== + ✅ 端到端测试完成! +================================================== + + 证书位置: /tmp/cert-e2e-test/test.example.com/ + Staging 证书不受浏览器信任,仅用于验证流程。 + 确认流程正常后,在 WebUI 中切换到生产环境重新签发。 +``` + +**Staging vs Production:** + +| 环境 | 地址 | 说明 | +|------|------|------| +| Staging | `https://acme-staging-v02.api.letsencrypt.org/directory` | 测试环境,证书不受信任,无速率限制 | +| Production | `https://acme-v02.api.letsencrypt.org/directory` | 生产环境,正式证书,有速率限制 | + +**建议:** 先用 Staging 环境完成全流程测试,确认无误后在 WebUI 中切换到 Production 环境,删除测试域名,重新添加正式域名并签发。 + +--- + +## WebUI 操作指南 + +启动服务后,浏览器访问 `http://服务器IP:8000`(或配置的域名)。 + +### 1. 仪表盘 + +首页展示: + +- **统计卡片**:服务器总数、域名总数、即将过期证书数 +- **最近部署日志**:最近 10 条客户端部署记录 +- **快捷操作**:新增服务器、新增域名 + +### 2. 服务器管理 + +**新增服务器:** + +1. 点击侧边栏"服务器管理" +2. 点击"+ 新增服务器" +3. 填写表单: + - **名称**:服务器标识,如 `vps-blog`、`api-server` + - **平台**:选择 `Linux` 或 `Windows` + - **Token**:点击"生成"按钮自动生成,或手动填写 + - **IP**:可选,仅用于备注 +4. 点击"创建" + +**编辑/删除服务器:** + +- 在列表中点击"编辑"修改信息 +- 点击"删除"会同时删除该服务器下的所有域名配置 + +### 3. 域名管理 + +**新增域名:** + +1. 点击侧边栏"域名管理" +2. 点击"+ 新增域名" +3. 填写表单: + - **所属服务器**:选择域名所在的业务服务器 + - **域名**:如 `example.com`、`api.example.com` + - **证书存放路径**:证书在业务服务器上的存放目录 + - Linux 示例:`/etc/nginx/ssl/example.com` + - Windows 示例:`C:\certs\example.com` + - **校验命令**:替换证书后验证服务是否正常的命令 + - nginx 示例:`nginx -t` + - 其他服务根据实际情况填写 + - **重载命令**:证书更新后重载服务的命令 + - Linux 示例:`systemctl reload nginx` + - Windows 示例:`nginx -s reload` +4. 点击"创建" + +**申请证书:** + +1. 在域名列表中找到目标域名 +2. 点击"申请证书"按钮 +3. 系统自动完成: + - 通过 DNS-01 验证(自动添加 `_acme-challenge` TXT 记录) + - 向 Let's Encrypt 申请证书 + - 保存证书到配置的目录 + - 更新证书到期时间 +4. 申请成功后,"证书到期"列会显示到期日期 + +**复制部署命令:** + +点击"复制部署命令",获取在业务服务器上执行的一键部署命令。 + +**查看脚本:** + +点击"查看脚本",预览将下发到业务服务器的部署脚本内容。 + +### 4. ACME 配置 + +**基本配置:** + +1. 点击侧边栏"ACME 配置" +2. 配置项: + - **ACME 服务器**: + - Let's Encrypt (生产):正式环境使用 + - Let's Encrypt (测试):测试环境,不受速率限制 + - **邮箱**:Let's Encrypt 注册邮箱,用于接收证书到期提醒 + - **DNS 提商**:选择使用的 DNS 服务商 + - **续签提前天数**:证书到期前几天自动续签(默认 30 天) + +**DNS 凭据配置:** + +根据选择的 DNS 提商填写对应的凭据: + +| DNS 提商 | 需要的凭据 | +|----------|-----------| +| 阿里云 DNS | Access Key + Access Secret | +| Cloudflare | API Token | + +**获取阿里云 DNS 凭据:** + +1. 登录 [阿里云控制台](https://ram.console.aliyun.com/) +2. 创建子账号,授予 `AliyunDNSFullAccess` 权限 +3. 创建 AccessKey,记录 Access Key ID 和 Access Key Secret + +**自动续签:** + +点击"🔄 自动续签所有"按钮,系统会: + +1. 检查所有域名的证书到期时间 +2. 对即将过期的证书自动执行续签 +3. 显示每个域名的续签结果 + +### 5. 部署日志 + +查看所有客户端(业务服务器)的证书拉取部署记录: + +- **时间**:部署执行时间 +- **服务器**:执行部署的服务器 +- **域名**:部署的域名 +- **状态**:成功 / 失败 / 跳过 +- **消息**:详细信息 + +支持按状态筛选。 + +### 6. ACME 日志 + +查看所有证书申请/续签操作记录: + +- **时间**:操作时间 +- **域名**:操作的域名 +- **操作**:申请 / 续签 / 吊销 / 注册 +- **状态**:成功 / 失败 / 进行中 +- **消息**:结果信息 +- **详情**:点击"查看"显示完整日志 + +--- + +## 客户端部署指南 + +### Linux 服务器 + +**首次部署(只需执行一次):** + +```bash +# 1. 下载部署脚本(从 WebUI "复制部署命令" 获取) +curl -fsSL "https://cert.example.com/api/script/example.com?server_name=vps-blog" \ + -o /usr/local/bin/deploy-cert.sh + +# 2. 添加执行权限 +chmod +x /usr/local/bin/deploy-cert.sh + +# 3. 添加 cron 定时任务(每 30 分钟检查一次) +echo '*/30 * * * * /usr/local/bin/deploy-cert.sh >> /var/log/deploy-cert.log 2>&1' | crontab - +``` + +**手动执行测试:** + +```bash +/usr/local/bin/deploy-cert.sh +``` + +**查看部署日志:** + +```bash +tail -f /var/log/deploy-cert.log +``` + +**脚本执行流程:** + +``` +1. 请求 CertCenter 获取远端版本号 +2. 对比本地版本号 + ├── 一致 → 退出(无需更新) + └── 不一致 ↓ +3. 下载新证书(fullchain.pem + private.key) +4. 备份旧证书 +5. 替换新证书 +6. 执行校验命令(nginx -t) + ├── 失败 → 回滚旧证书 → 退出 + └── 成功 ↓ +7. 更新本地版本号 +8. 重载服务(systemctl reload nginx) +``` + +### Windows 服务器 + +**首次部署:** + +```powershell +# 1. 创建脚本目录 +New-Item -ItemType Directory -Force -Path C:\scripts + +# 2. 下载部署脚本 +Invoke-WebRequest ` + -Uri "https://cert.example.com/api/script/www.example.com?server_name=win-web" ` + -OutFile C:\scripts\deploy-cert.ps1 + +# 3. 添加计划任务(每 30 分钟) +schtasks /create /sc minute /mo 30 /tn "CertDeploy-www.example.com" /tr "powershell -ExecutionPolicy Bypass -File C:\scripts\deploy-cert.ps1" +``` + +**手动执行测试:** + +```powershell +powershell -ExecutionPolicy Bypass -File C:\scripts\deploy-cert.ps1 +``` + +--- + +## API 参考 + +### 客户端 API + +供业务服务器上的部署脚本调用,使用 Bearer Token 认证。 + +#### 获取证书版本号 + +``` +GET /api/version/{domain} +``` + +- 认证:无需认证 +- 返回:版本号字符串(纯文本) + +**示例:** + +```bash +curl https://cert.example.com/api/version/example.com +# 返回: 3 +``` + +#### 下载证书 + +``` +GET /api/cert/{domain}/fullchain +GET /api/cert/{domain}/private +``` + +- 认证:`Authorization: Bearer ` +- 返回: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 ` +- 参数: + - `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)调试,确认无误后再切换到生产环境。 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/acme_service.py b/backend/acme_service.py new file mode 100644 index 0000000..f159e3b --- /dev/null +++ b/backend/acme_service.py @@ -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 diff --git a/backend/bootstrap.py b/backend/bootstrap.py new file mode 100644 index 0000000..aa1b0f4 --- /dev/null +++ b/backend/bootstrap.py @@ -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()}") diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..16bfb4c --- /dev/null +++ b/backend/config.py @@ -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() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..8f97099 --- /dev/null +++ b/backend/database.py @@ -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) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..83f6489 --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..c7e9250 --- /dev/null +++ b/backend/models.py @@ -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() diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..144ccc9 --- /dev/null +++ b/backend/routers/admin.py @@ -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, + } diff --git a/backend/routers/api.py b/backend/routers/api.py new file mode 100644 index 0000000..5e70ced --- /dev/null +++ b/backend/routers/api.py @@ -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} diff --git a/backend/templates_cert/deploy-cert.ps1.j2 b/backend/templates_cert/deploy-cert.ps1.j2 new file mode 100644 index 0000000..c06d53c --- /dev/null +++ b/backend/templates_cert/deploy-cert.ps1.j2 @@ -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 +} diff --git a/backend/templates_cert/deploy-cert.sh.j2 b/backend/templates_cert/deploy-cert.sh.j2 new file mode 100644 index 0000000..19cb522 --- /dev/null +++ b/backend/templates_cert/deploy-cert.sh.j2 @@ -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 diff --git a/certcenter.db b/certcenter.db new file mode 100644 index 0000000..67f8156 Binary files /dev/null and b/certcenter.db differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..06a68c5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + CertCenter + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..cb7812b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2632 @@ +{ + "name": "certcenter-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "certcenter-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "vite": "^5.4.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7fa0186 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..030f879 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d897552 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..544cf97 --- /dev/null +++ b/frontend/src/api/index.js @@ -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 diff --git a/frontend/src/components/Toast.vue b/frontend/src/components/Toast.vue new file mode 100644 index 0000000..9b2db82 --- /dev/null +++ b/frontend/src/components/Toast.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/frontend/src/composables/useToast.js b/frontend/src/composables/useToast.js new file mode 100644 index 0000000..68d7962 --- /dev/null +++ b/frontend/src/composables/useToast.js @@ -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), + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..54ce204 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..74b38ff --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/views/AcmeLogs.vue b/frontend/src/views/AcmeLogs.vue new file mode 100644 index 0000000..c1fcaf3 --- /dev/null +++ b/frontend/src/views/AcmeLogs.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/src/views/AcmeSettings.vue b/frontend/src/views/AcmeSettings.vue new file mode 100644 index 0000000..af05bce --- /dev/null +++ b/frontend/src/views/AcmeSettings.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..4ebab8a --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/src/views/DomainForm.vue b/frontend/src/views/DomainForm.vue new file mode 100644 index 0000000..7533667 --- /dev/null +++ b/frontend/src/views/DomainForm.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/src/views/Domains.vue b/frontend/src/views/Domains.vue new file mode 100644 index 0000000..501318f --- /dev/null +++ b/frontend/src/views/Domains.vue @@ -0,0 +1,126 @@ + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..368039e --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue new file mode 100644 index 0000000..cde769a --- /dev/null +++ b/frontend/src/views/Logs.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/src/views/Script.vue b/frontend/src/views/Script.vue new file mode 100644 index 0000000..7a8d80e --- /dev/null +++ b/frontend/src/views/Script.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/views/ServerForm.vue b/frontend/src/views/ServerForm.vue new file mode 100644 index 0000000..a2c845c --- /dev/null +++ b/frontend/src/views/ServerForm.vue @@ -0,0 +1,93 @@ + + + diff --git a/frontend/src/views/Servers.vue b/frontend/src/views/Servers.vue new file mode 100644 index 0000000..e33155a --- /dev/null +++ b/frontend/src/views/Servers.vue @@ -0,0 +1,67 @@ + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..bd3eb14 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..b2f2065 --- /dev/null +++ b/frontend/vite.config.js @@ -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, + }, +}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c418e33 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/test_minimal.py b/test_minimal.py new file mode 100644 index 0000000..78b8ae0 --- /dev/null +++ b/test_minimal.py @@ -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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..ecaaf78 --- /dev/null +++ b/tests/test_e2e.py @@ -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()) diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..74067c0 --- /dev/null +++ b/tests/test_setup.py @@ -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()) diff --git a/方案.md b/方案.md new file mode 100644 index 0000000..f96ad55 --- /dev/null +++ b/方案.md @@ -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 +``` + +### 版本文件 + +`/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。