From 7d885471c034c031fca98768820822554abe8b76 Mon Sep 17 00:00:00 2001 From: jingrow Date: Thu, 1 Jan 2026 15:21:37 +0000 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0ssl=5Fmanager=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=9E=E7=8E=B0=E5=88=9B=E5=BB=BA=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E7=94=B3=E8=AF=B7=E5=B9=B6=E7=BB=AD?= =?UTF-8?q?=E6=9C=9Fssl=E5=85=8D=E8=B4=B9=E8=AF=81=E4=B9=A6=EF=BC=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard_conf/conf.yaml | 8 +- docker-compose.yml | 1 + ssl_manager/CONFIG.md | 121 ++++ ssl_manager/DASHBOARD_TROUBLESHOOT.md | 231 +++++++ ssl_manager/FULL_TEST_FLOW.md | 133 ++++ ssl_manager/HTTP_VERIFY_ISSUE_ANALYSIS.md | 228 +++++++ ssl_manager/QUICKSTART.md | 208 ++++++ ssl_manager/README.md | 349 +++++++++++ ssl_manager/TEST_README.md | 173 +++++ .../__pycache__/ssl_manager.cpython-313.pyc | Bin 0 -> 18623 bytes .../__pycache__/test_ssl_auto.cpython-313.pyc | Bin 0 -> 28488 bytes ssl_manager/certbot_deploy_hook.sh | 39 ++ ssl_manager/config.json | 9 + ssl_manager/delete_and_renew_cert.sh | 50 ++ ssl_manager/fix_webroot_route.sh | 71 +++ ssl_manager/install.sh | 75 +++ ssl_manager/quick_test.sh | 68 ++ ssl_manager/requirements.txt | 1 + ssl_manager/route_watcher.py | 237 +++++++ ssl_manager/setup_webroot_route.sh | 50 ++ ssl_manager/ssl_manager.py | 392 ++++++++++++ .../systemd/apisix-route-watcher.service | 17 + ssl_manager/systemd/apisix-ssl-renew.service | 13 + ssl_manager/systemd/apisix-ssl-renew.timer | 13 + ssl_manager/test_example.sh | 43 ++ ssl_manager/test_ssl_auto.py | 590 ++++++++++++++++++ ssl_manager/webroot_route.json | 14 + 27 files changed, 3130 insertions(+), 4 deletions(-) create mode 100644 ssl_manager/CONFIG.md create mode 100644 ssl_manager/DASHBOARD_TROUBLESHOOT.md create mode 100644 ssl_manager/FULL_TEST_FLOW.md create mode 100644 ssl_manager/HTTP_VERIFY_ISSUE_ANALYSIS.md create mode 100644 ssl_manager/QUICKSTART.md create mode 100644 ssl_manager/README.md create mode 100644 ssl_manager/TEST_README.md create mode 100644 ssl_manager/__pycache__/ssl_manager.cpython-313.pyc create mode 100644 ssl_manager/__pycache__/test_ssl_auto.cpython-313.pyc create mode 100755 ssl_manager/certbot_deploy_hook.sh create mode 100644 ssl_manager/config.json create mode 100755 ssl_manager/delete_and_renew_cert.sh create mode 100755 ssl_manager/fix_webroot_route.sh create mode 100755 ssl_manager/install.sh create mode 100755 ssl_manager/quick_test.sh create mode 100644 ssl_manager/requirements.txt create mode 100755 ssl_manager/route_watcher.py create mode 100755 ssl_manager/setup_webroot_route.sh create mode 100755 ssl_manager/ssl_manager.py create mode 100644 ssl_manager/systemd/apisix-route-watcher.service create mode 100644 ssl_manager/systemd/apisix-ssl-renew.service create mode 100644 ssl_manager/systemd/apisix-ssl-renew.timer create mode 100755 ssl_manager/test_example.sh create mode 100755 ssl_manager/test_ssl_auto.py create mode 100644 ssl_manager/webroot_route.json diff --git a/dashboard_conf/conf.yaml b/dashboard_conf/conf.yaml index ee2b50a..0e0b171 100644 --- a/dashboard_conf/conf.yaml +++ b/dashboard_conf/conf.yaml @@ -15,10 +15,10 @@ authentication: secret expire_time: 3600 users: - - username: admin - password: admin - - username: user - password: user + - username: jingrow2025 + password: JA9d#3kL8pQz!2Xv + - username: jingrowuser + password: JA9d#3kL8pQz!1VA plugin_attr: prometheus: export_addr: diff --git a/docker-compose.yml b/docker-compose.yml index e6d665b..3790860 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: restart: always volumes: - ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro + - /var/www/certbot:/var/www/certbot:ro depends_on: - etcd ##network_mode: host diff --git a/ssl_manager/CONFIG.md b/ssl_manager/CONFIG.md new file mode 100644 index 0000000..999c58b --- /dev/null +++ b/ssl_manager/CONFIG.md @@ -0,0 +1,121 @@ +# 配置说明 + +## 配置方式 + +配置已直接定义在 Python 文件中,**不需要** `config.json` 文件。 + +### 方式一:直接修改 Python 文件(推荐) + +编辑 Python 文件中的 `DEFAULT_CONFIG` 字典: + +#### ssl_manager.py + +```python +DEFAULT_CONFIG = { + 'apisix_admin_url': 'http://localhost:9180', + 'apisix_admin_key': '8206e6e42b6b53243c52a767cc633137', + 'certbot_path': '/usr/bin/certbot', + 'cert_dir': '/etc/letsencrypt/live', + 'letsencrypt_email': 'admin@jingrowtools.cn', # 修改为你的邮箱 + 'letsencrypt_staging': True, # 生产环境改为 False + 'webroot_path': '/var/www/certbot' +} +``` + +#### test_ssl_auto.py + +```python +DEFAULT_TEST_CONFIG = { + 'apisix_admin_url': 'http://localhost:9180', + 'apisix_admin_key': '8206e6e42b6b53243c52a767cc633137', + 'webroot_path': '/var/www/certbot', + 'letsencrypt_staging': True +} +``` + +### 方式二:使用环境变量(推荐用于生产环境) + +通过环境变量覆盖默认配置: + +```bash +export APISIX_ADMIN_URL="http://localhost:9180" +export APISIX_ADMIN_KEY="your-admin-key" +export LETSENCRYPT_EMAIL="your-email@example.com" +export LETSENCRYPT_STAGING="false" +export WEBROOT_PATH="/var/www/certbot" +``` + +### 方式三:使用配置文件(可选,向后兼容) + +如果仍想使用 `config.json`,可以创建它,配置会覆盖默认值: + +```json +{ + "apisix_admin_url": "http://localhost:9180", + "apisix_admin_key": "your-admin-key", + "certbot_path": "/usr/bin/certbot", + "cert_dir": "/etc/letsencrypt/live", + "letsencrypt_email": "your-email@example.com", + "letsencrypt_staging": false, + "webroot_path": "/var/www/certbot" +} +``` + +然后使用 `--config` 参数: + +```bash +python3 ssl_manager.py request --domain example.com --config config.json +``` + +## 配置优先级 + +1. **环境变量**(最高优先级) +2. **配置文件**(如果使用 `--config` 参数) +3. **Python 文件中的 DEFAULT_CONFIG**(默认值) + +## 配置项说明 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| `apisix_admin_url` | APISIX Admin API 地址 | `http://localhost:9180` | +| `apisix_admin_key` | APISIX Admin API 密钥 | `8206e6e42b6b53243c52a767cc633137` | +| `certbot_path` | Certbot 可执行文件路径 | `/usr/bin/certbot` | +| `cert_dir` | 证书存储目录 | `/etc/letsencrypt/live` | +| `letsencrypt_email` | Let's Encrypt 邮箱 | `admin@jingrowtools.cn` | +| `letsencrypt_staging` | 是否使用 staging 模式 | `True`(测试环境) | +| `webroot_path` | Webroot 验证文件路径 | `/var/www/certbot` | + +## 快速配置 + +### 修改邮箱和切换到生产环境 + +编辑 `ssl_manager.py`: + +```python +DEFAULT_CONFIG = { + # ... 其他配置 ... + 'letsencrypt_email': 'your-email@example.com', # 修改这里 + 'letsencrypt_staging': False, # 改为 False 使用生产环境 + # ... 其他配置 ... +} +``` + +### 修改 APISIX Admin Key + +编辑 `ssl_manager.py`: + +```python +DEFAULT_CONFIG = { + 'apisix_admin_key': 'your-new-admin-key', # 修改这里 + # ... 其他配置 ... +} +``` + +## 注意事项 + +1. **Staging 模式**:默认使用 staging 模式(测试环境),不会消耗生产环境配额 +2. **生产环境**:切换到生产环境前,确保: + - 域名 DNS 已正确解析 + - 80 端口可访问 + - 验证路径可访问 +3. **安全性**:不要将包含敏感信息的配置文件提交到版本控制系统 diff --git a/ssl_manager/DASHBOARD_TROUBLESHOOT.md b/ssl_manager/DASHBOARD_TROUBLESHOOT.md new file mode 100644 index 0000000..a42ab7b --- /dev/null +++ b/ssl_manager/DASHBOARD_TROUBLESHOOT.md @@ -0,0 +1,231 @@ +# Dashboard 看不到路由和证书的排查指南 + +## 问题现象 + +测试脚本成功创建了路由和证书,但在 Dashboard 面板中看不到。 + +## 验证数据确实存在 + +### 通过 Admin API 验证 + +```bash +# 检查路由 +curl -s http://localhost:9180/apisix/admin/routes/test.jingrowtools.cn \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' + +# 检查 SSL +curl -s http://localhost:9180/apisix/admin/ssls/00000000000000000023 \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' +``` + +### 通过 etcd 验证 + +```bash +# 检查路由 +docker exec apisix-etcd-1 etcdctl get /apisix/routes/test.jingrowtools.cn + +# 检查 SSL +docker exec apisix-etcd-1 etcdctl get /apisix/ssls/00000000000000000023 +``` + +## 可能的原因和解决方案 + +### 1. Dashboard 缓存问题 ⚠️ **最常见** + +**原因:** Dashboard 可能缓存了数据,没有实时刷新。 + +**解决方案:** +- **刷新浏览器页面**(F5 或 Ctrl+R) +- **清除浏览器缓存**(Ctrl+Shift+Delete) +- **硬刷新**(Ctrl+Shift+R 或 Ctrl+F5) +- **等待几秒钟**后再次查看(Dashboard 可能有轮询间隔) + +### 2. Dashboard 过滤条件 + +**原因:** Dashboard 可能设置了过滤条件,隐藏了某些路由。 + +**解决方案:** +- 检查 Dashboard 的搜索/过滤框,确保没有输入过滤条件 +- 检查路由列表的显示选项(如:显示所有、只显示启用的等) +- 尝试搜索 `test.jingrowtools.cn` 看是否能找到 + +### 3. Dashboard 连接问题 + +**原因:** Dashboard 可能连接到了不同的 etcd 实例。 + +**检查方法:** +```bash +# 检查 Dashboard 日志 +docker logs apisix-dashboard --tail 50 | grep -i etcd + +# 检查 Dashboard 配置 +cat /home/jingrow/apisix/dashboard_conf/conf.yaml +``` + +**解决方案:** +- 确保 Dashboard 配置的 etcd 地址正确 +- 重启 Dashboard:`docker restart apisix-dashboard` + +### 4. 路由/证书状态问题 + +**原因:** Dashboard 可能只显示特定状态的路由/证书。 + +**检查方法:** +```bash +# 检查路由状态 +curl -s http://localhost:9180/apisix/admin/routes/test.jingrowtools.cn \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' | \ + python3 -c "import sys, json; print(json.load(sys.stdin)['value']['status'])" + +# 检查 SSL 状态 +curl -s http://localhost:9180/apisix/admin/ssls/00000000000000000023 \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' | \ + python3 -c "import sys, json; print(json.load(sys.stdin)['value']['status'])" +``` + +**解决方案:** +- 确保 `status: 1`(启用状态) +- 如果状态为 0,需要启用 + +### 5. Dashboard 版本兼容性问题 + +**原因:** Dashboard 版本可能与 APISIX 版本不兼容。 + +**检查方法:** +```bash +# 检查 Dashboard 版本 +docker exec apisix-dashboard cat /usr/local/apisix-dashboard/version 2>/dev/null || \ + docker exec apisix-dashboard ls /usr/local/apisix-dashboard/ + +# 检查 APISIX 版本 +docker exec apisix-apisix-1 apisix version +``` + +### 6. 权限问题 + +**原因:** Dashboard 用户可能没有查看权限。 + +**解决方案:** +- 检查 Dashboard 登录用户权限 +- 尝试使用管理员账户登录 + +## 快速排查步骤 + +### 步骤 1: 验证数据存在 + +```bash +# 检查路由是否存在 +curl -s http://localhost:9180/apisix/admin/routes \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' | \ + python3 -c "import sys, json; routes=json.load(sys.stdin)['list']; \ + test=[r for r in routes if 'test.jingrowtools.cn' in str(r.get('value', {}).get('name', ''))]; \ + print(f'找到测试路由: {len(test)} 个')" + +# 检查 SSL 是否存在 +curl -s http://localhost:9180/apisix/admin/ssls \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' | \ + python3 -c "import sys, json; ssls=json.load(sys.stdin)['list']; \ + test=[s for s in ssls if 'test.jingrowtools.cn' in str(s.get('value', {}).get('snis', []))]; \ + print(f'找到测试 SSL: {len(test)} 个')" +``` + +### 步骤 2: 刷新 Dashboard + +1. 在浏览器中按 `Ctrl+Shift+R` 硬刷新 +2. 等待 5-10 秒 +3. 再次查看路由和 SSL 列表 + +### 步骤 3: 检查 Dashboard 连接 + +```bash +# 重启 Dashboard +docker restart apisix-dashboard + +# 等待启动 +sleep 5 + +# 检查日志 +docker logs apisix-dashboard --tail 20 +``` + +### 步骤 4: 直接通过 Dashboard API 查询 + +```bash +# 访问 Dashboard API(需要登录 token) +# 在浏览器中打开开发者工具(F12),查看 Network 请求 +# 找到 Dashboard API 的请求,查看返回的数据 +``` + +## 常见解决方案 + +### 方案 1: 强制刷新(最简单) + +1. 打开 Dashboard +2. 按 `Ctrl+Shift+R` 硬刷新 +3. 等待几秒钟 +4. 再次查看 + +### 方案 2: 重启 Dashboard + +```bash +docker restart apisix-dashboard +``` + +等待 10 秒后刷新浏览器。 + +### 方案 3: 检查路由状态 + +如果路由状态为 0(禁用),需要启用: + +```bash +# 启用路由 +curl -X PUT http://localhost:9180/apisix/admin/routes/test.jingrowtools.cn \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' \ + -H 'Content-Type: application/json' \ + -d '{ + "uri": "/*", + "name": "test.jingrowtools.cn", + "host": "test.jingrowtools.cn", + "status": 1, + ... + }' +``` + +## 验证脚本 + +创建一个验证脚本检查 Dashboard 是否能访问数据: + +```bash +#!/bin/bash +# 验证 Dashboard 数据访问 + +echo "检查路由..." +curl -s http://localhost:9180/apisix/admin/routes \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' | \ + python3 -c "import sys, json; data=json.load(sys.stdin); \ + print(f'总路由数: {len(data[\"list\"])}'); \ + test=[r for r in data['list'] if 'test' in str(r.get('value', {}).get('name', ''))]; \ + print(f'测试路由数: {len(test)}')" + +echo "" +echo "检查 SSL..." +curl -s http://localhost:9180/apisix/admin/ssls \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' | \ + python3 -c "import sys, json; data=json.load(sys.stdin); \ + print(f'总 SSL 数: {len(data[\"list\"])}'); \ + test=[s for s in data['list'] if 'test' in str(s.get('value', {}).get('snis', []))]; \ + print(f'测试 SSL 数: {len(test)}')" +``` + +## 总结 + +**最可能的原因:** +1. ✅ Dashboard 缓存 - 需要刷新浏览器 +2. ✅ Dashboard 过滤条件 - 检查搜索框 +3. ✅ 数据状态 - 确保 status=1 + +**快速解决:** +1. 硬刷新浏览器(Ctrl+Shift+R) +2. 等待 5-10 秒 +3. 检查搜索/过滤条件 +4. 如果还不行,重启 Dashboard diff --git a/ssl_manager/FULL_TEST_FLOW.md b/ssl_manager/FULL_TEST_FLOW.md new file mode 100644 index 0000000..4df7d05 --- /dev/null +++ b/ssl_manager/FULL_TEST_FLOW.md @@ -0,0 +1,133 @@ +# 完整测试流程说明 + +## 是的,测试脚本执行的是完整的创建路由并自动申请 SSL 证书的流程 + +### 测试流程步骤 + +当使用 `python3 test_ssl_auto.py --domain test.jingrowtools.cn` 时,脚本会依次执行: + +#### 1. 检查系统依赖 ✅ +- 检查 Certbot 是否安装 +- 检查 Python 模块是否可用 +- 检查 OpenSSL 是否安装 + +#### 2. 检查 APISIX 服务 ✅ +- 验证 APISIX Admin API 是否可访问 +- 检查连接和认证 + +#### 3. 检查 Webroot 目录 ✅ +- 检查 `/var/www/certbot` 目录是否存在 +- 创建 `.well-known/acme-challenge` 子目录 + +#### 4. 检查/创建 Webroot 路由 ✅ **自动创建验证路由** +- 检查是否存在该域名的 webroot 路由 +- **如果不存在,自动创建**: + - 路由 ID: `certbot-webroot-{domain}` + - Host: 指定的域名 + - URI: `/.well-known/acme-challenge/*` + - Priority: 10000(最高优先级) + - 功能: 自动读取验证文件并返回 + +#### 5. 测试验证路径 ⚠️ **测试验证路由是否工作** +- 创建测试文件 +- 通过 APISIX 访问验证路径 +- 验证路由是否正确返回文件内容 + +#### 6. 创建测试路由 ✅ **创建用户指定的域名路由** +- **自动创建域名路由**: + - 路由 ID: 域名本身 + - Host: 指定的域名 + - URI: `/*` + - Upstream: 配置的后端服务 + - 这是实际业务路由 + +#### 7. 申请 SSL 证书 ✅ **自动申请证书** +- 使用 Certbot 申请 Let's Encrypt 证书 +- 自动处理验证流程 +- 证书保存到 `/etc/letsencrypt/live/{domain}/` + +#### 8. 同步证书到 APISIX ✅ **自动上传证书** +- 读取证书文件 +- 通过 APISIX Admin API 创建/更新 SSL 配置 +- 关联到域名 + +#### 9. 验证证书信息 ✅ +- 使用 OpenSSL 验证证书 +- 显示证书详细信息 + +## 完整流程示例 + +```bash +# 执行测试 +python3 test_ssl_auto.py --domain test.jingrowtools.cn --no-cleanup + +# 流程输出: +[步骤 1/9] 检查系统依赖 ✅ +[步骤 2/9] 检查 APISIX 服务 ✅ +[步骤 3/9] 检查 Webroot 目录 ✅ +[步骤 4/9] 检查/创建 Webroot 路由 ✅ + → 自动创建: certbot-webroot-test-jingrowtools-cn +[步骤 5/9] 测试验证路径 ✅ +[步骤 6/9] 创建测试路由 ✅ + → 自动创建: test.jingrowtools.cn (业务路由) +[步骤 7/9] 申请 SSL 证书 ✅ + → Certbot 自动申请证书 +[步骤 8/9] 同步证书到 APISIX ✅ + → 证书自动上传到 APISIX +[步骤 9/9] 验证证书信息 ✅ +``` + +## 自动化的部分 + +### ✅ 自动创建验证路由 +- 无需手动配置 webroot 路由 +- 脚本自动检测并创建 +- 确保验证路径可访问 + +### ✅ 自动创建业务路由 +- 根据指定域名自动创建路由 +- 配置默认的 upstream +- 路由立即可用 + +### ✅ 自动申请证书 +- 调用 Certbot 自动申请 +- 处理所有验证流程 +- 无需手动干预 + +### ✅ 自动同步证书 +- 读取证书文件 +- 自动上传到 APISIX +- 自动关联域名 + +## 权限问题处理 + +如果遇到权限问题(如测试输出中的错误),脚本已优化: + +1. **自动尝试使用 sudo**:如果直接写入失败,会尝试使用 sudo +2. **容错处理**:即使验证路径测试失败,也会继续执行证书申请(Certbot 会自动处理文件创建) +3. **提示信息**:提供清晰的权限修复建议 + +### 修复权限(推荐) + +```bash +# 一次性修复权限 +sudo mkdir -p /var/www/certbot/.well-known/acme-challenge +sudo chown -R $USER:$USER /var/www/certbot +sudo chmod -R 755 /var/www/certbot +``` + +## 总结 + +**是的,测试脚本执行的是完整的自动化流程:** + +1. ✅ **自动创建验证路由**(webroot 路由) +2. ✅ **自动创建业务路由**(用户指定的域名) +3. ✅ **自动申请 SSL 证书**(Let's Encrypt) +4. ✅ **自动同步证书到 APISIX**(上传并配置) + +整个过程完全自动化,只需要: +- 提供域名 +- 确保 DNS 已解析 +- 运行测试脚本 + +测试完成后,域名路由和 SSL 证书都已配置完成,可以直接使用 HTTPS 访问。 diff --git a/ssl_manager/HTTP_VERIFY_ISSUE_ANALYSIS.md b/ssl_manager/HTTP_VERIFY_ISSUE_ANALYSIS.md new file mode 100644 index 0000000..4a66d55 --- /dev/null +++ b/ssl_manager/HTTP_VERIFY_ISSUE_ANALYSIS.md @@ -0,0 +1,228 @@ +# HTTP-01 验证失败根源分析 + +## 问题现象 + +Let's Encrypt 在申请证书时返回: +``` +Domain: jingrowtools.cn +Type: unauthorized +Detail: 125.89.136.224: Invalid response from http://jingrowtools.cn/.well-known/acme-challenge/xxx: 404 +``` + +## 根本原因分析 + +### 1. **路由匹配优先级问题** ⚠️ **核心问题** + +**问题描述:** +- APISIX 路由匹配规则:`host` 匹配 > `uri` 匹配 > `priority` 优先级 +- 当前配置: + - `jingrowtools.cn` 路由:`host: "jingrowtools.cn"`, `uri: "/*"`, `priority: 0` + - `certbot-webroot` 路由:`uri: "/.well-known/acme-challenge/*"`, `priority: 9999`, **没有 host 限制** + +**匹配流程:** +1. 请求 `http://jingrowtools.cn/.well-known/acme-challenge/xxx` +2. APISIX 先匹配 `host: "jingrowtools.cn"` → 找到 `jingrowtools.cn` 路由 +3. 在该路由内匹配 `uri: "/*"` → **匹配成功** ✅ +4. 请求被转发到 upstream `192.168.10.2:8201` +5. **webroot 路由永远不会被匹配到** ❌ + +**根本原因:** +- `host` 匹配优先级高于 `uri` 匹配 +- 即使 webroot 路由的 `priority` 更高,但 `host` 匹配会先命中 `jingrowtools.cn` 路由 +- webroot 路由没有设置 `host`,导致无法优先匹配 + +### 2. **容器文件系统隔离问题** + +**问题描述:** +- APISIX 运行在 Docker 容器中 +- Certbot 在宿主机上运行 +- 验证文件写入到 `/var/www/certbot`(宿主机) +- 但 APISIX 容器内无法访问宿主机文件系统 + +**解决方案:** +- 需要在 `docker-compose.yml` 中挂载卷: + ```yaml + volumes: + - /var/www/certbot:/var/www/certbot:ro + ``` + +### 3. **Serverless 函数执行问题** + +**问题描述:** +- 使用 `serverless-pre-function` 插件读取文件 +- Lua 函数需要访问容器内的文件系统 +- 如果文件路径不正确或权限不足,会返回 404 + +**当前函数:** +```lua +local path = "/var/www/certbot/.well-known/acme-challenge/" .. string.match(ctx.var.uri, "/.well-known/acme-challenge/(.+)") +local file = io.open(path, "r") +``` + +**潜在问题:** +- 路径拼接可能不正确 +- 文件权限问题 +- 容器内文件不存在 + +## 解决方案 + +### 方案一:修复路由匹配(推荐)✅ + +**修改 webroot 路由,添加 host 匹配并提高优先级:** + +```bash +curl -X PUT 'http://localhost:9180/apisix/admin/routes/certbot-webroot' \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' \ + -H 'Content-Type: application/json' \ + -d '{ + "uri": "/.well-known/acme-challenge/*", + "name": "certbot-webroot", + "priority": 10000, + "host": "jingrowtools.cn", + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function(conf, ctx) local token = string.match(ctx.var.uri, \"/.well-known/acme-challenge/(.+)\"); local path = \"/var/www/certbot/.well-known/acme-challenge/\" .. token; local file = io.open(path, \"r\"); if file then local content = file:read(\"*all\"); file:close(); ngx.header.content_type = \"text/plain\"; ngx.say(content); else ngx.status = 404; ngx.say(\"File not found: \" .. path); end end" + ] + } + }, + "status": 1 + }' +``` + +**关键修改:** +1. ✅ 添加 `"host": "jingrowtools.cn"` - 确保只匹配该域名 +2. ✅ 提高 `priority` 到 10000 - 确保优先匹配 +3. ✅ 修复 Lua 函数路径处理 + +### 方案二:修改主路由,排除验证路径 + +**在 `jingrowtools.cn` 路由中添加条件,排除验证路径:** + +```json +{ + "uri": "/*", + "name": "jingrowtools.cn", + "host": "jingrowtools.cn", + "vars": [ + ["uri", "!~", "^/.well-known/acme-challenge/"] + ], + ... +} +``` + +这样验证路径就不会被主路由匹配。 + +### 方案三:使用 DNS-01 验证(最可靠)✅✅ + +**优点:** +- 不依赖 HTTP 服务 +- 不需要配置 webroot 路由 +- 适合容器化环境 +- 支持通配符证书 + +**缺点:** +- 需要 DNS API 访问权限 +- 配置稍复杂 + +**实现:** +需要修改 `ssl_manager.py`,支持 DNS-01 验证。 + +### 方案四:使用独立的验证服务 + +**创建一个简单的 HTTP 服务专门处理验证:** + +```python +# 简单的验证服务 +from http.server import HTTPServer, BaseHTTPRequestHandler +import os + +class ACMEHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path.startswith('/.well-known/acme-challenge/'): + token = self.path.split('/')[-1] + file_path = f'/var/www/certbot/.well-known/acme-challenge/{token}' + if os.path.exists(file_path): + with open(file_path, 'r') as f: + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(f.read().encode()) + else: + self.send_response(404) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + +if __name__ == '__main__': + server = HTTPServer(('0.0.0.0', 8080), ACMEHandler) + server.serve_forever() +``` + +然后在 APISIX 中创建一个路由指向这个服务。 + +## 推荐解决步骤 + +### 立即修复(方案一) + +1. **确保容器挂载了 webroot 目录:** + ```yaml + # docker-compose.yml + volumes: + - /var/www/certbot:/var/www/certbot:ro + ``` + +2. **更新 webroot 路由配置(添加 host 和更高优先级):** + ```bash + # 使用上面的方案一配置 + ``` + +3. **测试验证路径:** + ```bash + echo "test-token" | sudo tee /var/www/certbot/.well-known/acme-challenge/test-token + curl http://jingrowtools.cn/.well-known/acme-challenge/test-token + # 应该返回: test-token + ``` + +4. **重新申请证书:** + ```bash + cd /home/jingrow/apisix/ssl_manager + python3 ssl_manager.py request --domain jingrowtools.cn + ``` + +## 验证流程说明 + +Let's Encrypt HTTP-01 验证流程: + +1. **Certbot 生成随机 token** + - 例如:`yUXNhDPsvsjEwYjxishk_XZ6kGZFRqn22FSUYcfuZQY` + +2. **写入验证文件** + - 路径:`/var/www/certbot/.well-known/acme-challenge/{token}` + - 内容:`{token}.{account_key_thumbprint}` + +3. **Let's Encrypt 服务器访问** + - URL: `http://jingrowtools.cn/.well-known/acme-challenge/{token}` + - 期望:返回文件内容 + +4. **APISIX 路由匹配** + - ❌ **当前问题**:匹配到 `jingrowtools.cn` 路由,转发到 upstream + - ✅ **应该**:匹配到 `certbot-webroot` 路由,返回文件内容 + +5. **验证结果** + - 成功:Let's Encrypt 收到正确内容 → 颁发证书 + - 失败:收到 404 或其他错误 → 拒绝申请 + +## 总结 + +**HTTP 验证失败的根本原因:** + +1. **主要问题**:路由匹配优先级 - `host` 匹配优先于 `uri` 匹配,导致验证请求被错误路由 +2. **次要问题**:容器文件系统隔离 - 需要正确挂载卷 +3. **潜在问题**:Serverless 函数路径处理 - 需要确保路径正确 + +**最佳解决方案:** +- 方案一(修复路由匹配)+ 容器卷挂载 = 最简单有效 +- 方案三(DNS-01 验证)= 最可靠,适合生产环境 diff --git a/ssl_manager/QUICKSTART.md b/ssl_manager/QUICKSTART.md new file mode 100644 index 0000000..c33535a --- /dev/null +++ b/ssl_manager/QUICKSTART.md @@ -0,0 +1,208 @@ +# 快速开始指南 + +## 1. 安装依赖 + +```bash +cd /home/jingrow/apisix/ssl_manager + +# 安装 Python 依赖(推荐使用 uv,更快) +# 方法一:使用 uv(推荐) +uv pip install --system -r requirements.txt + +# 方法二:使用 pip3 +# pip3 install -r requirements.txt + +# 运行安装脚本(可选,会自动设置权限和 systemd 服务) +sudo bash install.sh +``` + +## 2. 配置 + +配置已直接定义在 Python 文件中,**不需要** `config.json`。 + +**修改配置:** + +编辑 `ssl_manager.py` 中的 `DEFAULT_CONFIG`: + +```python +DEFAULT_CONFIG = { + 'apisix_admin_url': 'http://localhost:9180', + 'apisix_admin_key': '8206e6e42b6b53243c52a767cc633137', + 'letsencrypt_email': 'your-email@example.com', # 修改为你的邮箱 + 'letsencrypt_staging': True, # 首次使用建议 True(测试环境) + # ... 其他配置 ... +} +``` + +**首次使用建议设置 `letsencrypt_staging: True` 进行测试!** + +详细配置说明请查看 `CONFIG.md` + +## 3. 创建 Webroot 目录 + +```bash +sudo mkdir -p /var/www/certbot +sudo chown -R www-data:www-data /var/www/certbot +``` + +## 4. 配置 APISIX 支持 HTTP-01 验证 + +Let's Encrypt 需要通过 HTTP-01 验证域名所有权。需要确保 `/.well-known/acme-challenge/` 路径可以访问。 + +### 方法一:使用 Nginx(如果 APISIX 前面有 Nginx) + +在 Nginx 配置中添加: + +```nginx +location /.well-known/acme-challenge/ { + root /var/www/certbot; +} +``` + +### 方法二:在 APISIX 中创建路由 + +使用 APISIX Admin API 创建路由: + +```bash +curl -X PUT 'http://localhost:9180/apisix/admin/routes/certbot-webroot' \ + -H 'X-API-KEY: 8206e6e42b6b53243c52a767cc633137' \ + -H 'Content-Type: application/json' \ + -d '{ + "uri": "/.well-known/acme-challenge/*", + "name": "certbot-webroot", + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function(conf, ctx) local file = io.open(\"/var/www/certbot\" .. ctx.var.uri, \"r\"); if file then local content = file:read(\"*all\"); file:close(); ngx.header.content_type = \"text/plain\"; ngx.say(content); end end" + ] + } + } + }' +``` + +或者使用静态文件服务插件(如果可用)。 + +## 5. 测试申请证书(Staging 环境) + +```bash +# 测试申请证书 +python3 ssl_manager.py request --domain your-domain.com + +# 检查证书 +python3 ssl_manager.py check --domain your-domain.com +``` + +## 6. 配置自动续期 + +### 使用 systemd timer(推荐) + +```bash +# 安装 systemd 服务 +sudo cp systemd/apisix-ssl-renew.* /etc/systemd/system/ +sudo systemctl daemon-reload + +# 启用并启动定时器 +sudo systemctl enable apisix-ssl-renew.timer +sudo systemctl start apisix-ssl-renew.timer + +# 查看状态 +sudo systemctl status apisix-ssl-renew.timer +``` + +### 或使用 Certbot 自动续期 + +编辑 Certbot 续期配置,添加部署钩子: + +```bash +sudo nano /etc/letsencrypt/renewal/your-domain.com.conf +``` + +在 `[renewalparams]` 部分添加: + +```ini +deploy_hook = /home/jingrow/apisix/ssl_manager/certbot_deploy_hook.sh +``` + +## 7. 启动路由监听服务(可选) + +自动监听新路由并申请证书: + +```bash +# 使用 systemd +sudo cp systemd/apisix-route-watcher.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable apisix-route-watcher.service +sudo systemctl start apisix-route-watcher.service + +# 或手动运行 +python3 route_watcher.py +``` + +## 8. 切换到生产环境 + +测试通过后,修改 `ssl_manager.py` 中的 `DEFAULT_CONFIG`: + +```python +DEFAULT_CONFIG = { + # ... 其他配置 ... + 'letsencrypt_staging': False, # 改为 False 使用生产环境 + # ... 其他配置 ... +} +``` + +然后重新申请证书: + +```bash +# 删除测试证书 +sudo rm -rf /etc/letsencrypt/live/your-domain.com +sudo rm -rf /etc/letsencrypt/archive/your-domain.com +sudo rm -rf /etc/letsencrypt/renewal/your-domain.com.conf + +# 申请生产证书 +python3 ssl_manager.py request --domain your-domain.com +``` + +## 常用命令 + +```bash +# 申请证书 +python3 ssl_manager.py request --domain example.com + +# 续期证书 +python3 ssl_manager.py renew --domain example.com + +# 续期所有证书 +python3 ssl_manager.py renew-all + +# 同步证书到 APISIX +python3 ssl_manager.py sync --domain example.com + +# 检查证书过期时间 +python3 ssl_manager.py check --domain example.com + +# 查看日志 +tail -f /var/log/apisix-ssl-manager.log +tail -f /var/log/apisix-route-watcher.log +``` + +## 故障排查 + +### 证书申请失败 + +1. 检查域名 DNS 解析:`nslookup your-domain.com` +2. 检查 80 端口是否可访问:`curl http://your-domain.com/.well-known/acme-challenge/test` +3. 查看 Certbot 日志:`sudo tail -f /var/log/letsencrypt/letsencrypt.log` +4. 使用 staging 模式测试:`letsencrypt_staging: true` + +### 证书无法同步到 APISIX + +1. 检查 APISIX Admin API:`curl http://localhost:9180/apisix/admin/routes -H 'X-API-KEY: your-key'` +2. 检查 Admin Key 是否正确 +3. 查看 SSL 管理器日志:`tail -f /var/log/apisix-ssl-manager.log` + +### 自动续期不工作 + +1. 检查 systemd timer:`sudo systemctl status apisix-ssl-renew.timer` +2. 手动测试续期:`sudo certbot renew --dry-run` +3. 检查部署钩子权限:`ls -l certbot_deploy_hook.sh` diff --git a/ssl_manager/README.md b/ssl_manager/README.md new file mode 100644 index 0000000..f54048d --- /dev/null +++ b/ssl_manager/README.md @@ -0,0 +1,349 @@ +# APISIX SSL 证书自动管理 + +基于 Certbot + APISIX Admin API 的 SSL 证书自动申请和续期方案。 + +## 功能特性 + +- ✅ 自动申请 Let's Encrypt 免费 SSL 证书 +- ✅ 自动将证书同步到 APISIX +- ✅ 自动续期证书(90 天有效期) +- ✅ 监听路由创建,自动为新域名申请证书 +- ✅ 支持多域名证书 +- ✅ 支持测试环境(staging) + +## 目录结构 + +``` +ssl_manager/ +├── ssl_manager.py # SSL 证书管理主脚本 +├── route_watcher.py # 路由监听服务 +├── certbot_deploy_hook.sh # Certbot 部署钩子 +├── config.json # 配置文件 +├── requirements.txt # Python 依赖 +└── README.md # 说明文档 +``` + +## 安装步骤 + +### 1. 安装 Certbot + +```bash +# Ubuntu/Debian +sudo apt-get update +sudo apt-get install certbot + +# CentOS/RHEL +sudo yum install certbot +``` + +### 2. 安装 Python 依赖 + +```bash +cd /home/jingrow/apisix/ssl_manager + +# 方法一:使用 uv(推荐,更快) +uv pip install --system -r requirements.txt + +# 方法二:使用 pip3 +pip3 install -r requirements.txt +``` + +### 3. 配置 + +配置已直接定义在 Python 文件中,**不需要** `config.json`。 + +**方式一:直接修改 Python 文件(推荐)** + +编辑 `ssl_manager.py` 中的 `DEFAULT_CONFIG`: + +```python +DEFAULT_CONFIG = { + 'apisix_admin_url': 'http://localhost:9180', + 'apisix_admin_key': '你的APISIX管理密钥', # 修改这里 + 'letsencrypt_email': 'your-email@example.com', # 修改这里 + 'letsencrypt_staging': False, # 生产环境改为 False + # ... 其他配置 ... +} +``` + +**方式二:使用环境变量** + +```bash +export APISIX_ADMIN_KEY="your-admin-key" +export LETSENCRYPT_EMAIL="your-email@example.com" +export LETSENCRYPT_STAGING="false" +``` + +详细配置说明请查看 `CONFIG.md` + +### 4. 创建 Webroot 目录 + +Certbot 需要 webroot 目录用于验证: + +```bash +sudo mkdir -p /var/www/certbot +sudo chown -R www-data:www-data /var/www/certbot +``` + +### 5. 配置 APISIX 支持 Webroot 验证 + +需要在 APISIX 中配置一个路由,将 `/.well-known/acme-challenge/` 请求转发到 webroot 目录。 + +## 使用方法 + +### 手动申请证书 + +```bash +# 申请单个域名证书 +python3 ssl_manager.py request --domain example.com + +# 申请多域名证书 +python3 ssl_manager.py request --domain example.com --additional-domains www.example.com api.example.com +``` + +### 手动续期证书 + +```bash +# 续期单个域名 +python3 ssl_manager.py renew --domain example.com + +# 续期所有证书 +python3 ssl_manager.py renew-all +``` + +### 同步现有证书到 APISIX + +```bash +python3 ssl_manager.py sync --domain example.com +``` + +### 检查证书过期时间 + +```bash +python3 ssl_manager.py check --domain example.com +``` + +### 启动路由监听服务 + +```bash +# 持续监听(每 60 秒检查一次) +python3 route_watcher.py + +# 只执行一次 +python3 route_watcher.py --once + +# 自定义检查间隔 +python3 route_watcher.py --interval 30 +``` + +## 自动续期配置 + +### 方法一:使用 Certbot 自动续期 + 部署钩子 + +1. 配置 Certbot 自动续期: + +```bash +# 编辑 Certbot 续期配置 +sudo nano /etc/letsencrypt/renewal/example.com.conf +``` + +在配置文件中添加部署钩子: + +```ini +[renewalparams] +deploy_hook = /home/jingrow/apisix/ssl_manager/certbot_deploy_hook.sh +``` + +2. 测试续期: + +```bash +sudo certbot renew --dry-run +``` + +3. 设置 systemd timer(推荐): + +创建 `/etc/systemd/system/certbot-renew.timer`: + +```ini +[Unit] +Description=Certbot Renewal Timer + +[Timer] +OnCalendar=daily +RandomizedDelaySec=3600 +Persistent=true + +[Install] +WantedBy=timers.target +``` + +创建 `/etc/systemd/system/certbot-renew.service`: + +```ini +[Unit] +Description=Certbot Renewal Service +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/certbot renew --quiet --deploy-hook /home/jingrow/apisix/ssl_manager/certbot_deploy_hook.sh +``` + +启用定时器: + +```bash +sudo systemctl enable certbot-renew.timer +sudo systemctl start certbot-renew.timer +``` + +### 方法二:使用自定义脚本 + systemd timer + +创建 `/etc/systemd/system/apisix-ssl-renew.service`: + +```ini +[Unit] +Description=APISIX SSL Certificate Renewal +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /home/jingrow/apisix/ssl_manager/ssl_manager.py renew-all +User=root +``` + +创建 `/etc/systemd/system/apisix-ssl-renew.timer`: + +```ini +[Unit] +Description=APISIX SSL Certificate Renewal Timer + +[Timer] +OnCalendar=weekly +RandomizedDelaySec=3600 +Persistent=true + +[Install] +WantedBy=timers.target +``` + +启用: + +```bash +sudo systemctl enable apisix-ssl-renew.timer +sudo systemctl start apisix-ssl-renew.timer +``` + +## 路由监听服务 + +路由监听服务会自动检测新创建的路由,并为其中的域名申请证书。 + +### 启动服务 + +```bash +# 使用 systemd(推荐) +sudo systemctl start apisix-route-watcher.service + +# 或使用 screen/tmux +screen -S route-watcher +python3 route_watcher.py +``` + +### 创建 systemd 服务 + +创建 `/etc/systemd/system/apisix-route-watcher.service`: + +```ini +[Unit] +Description=APISIX Route Watcher Service +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /home/jingrow/apisix/ssl_manager/route_watcher.py +Restart=always +RestartSec=10 +User=root + +[Install] +WantedBy=multi-user.target +``` + +启用服务: + +```bash +sudo systemctl enable apisix-route-watcher.service +sudo systemctl start apisix-route-watcher.service +``` + +## 环境变量配置 + +也可以通过环境变量配置: + +```bash +export APISIX_ADMIN_URL="http://localhost:9180" +export APISIX_ADMIN_KEY="your-admin-key" +export LETSENCRYPT_EMAIL="your-email@example.com" +export LETSENCRYPT_STAGING="false" +export WEBROOT_PATH="/var/www/certbot" +``` + +## 注意事项 + +1. **首次使用建议使用 staging 模式测试**: + ```json + "letsencrypt_staging": true + ``` + +2. **确保 80 端口可访问**:Let's Encrypt 需要验证域名所有权,通常使用 HTTP-01 验证方式。 + +3. **DNS 验证**:如果无法使用 HTTP-01 验证,可以配置 DNS-01 验证(需要修改脚本)。 + +4. **证书存储**:证书存储在 `/etc/letsencrypt/live/` 目录,请确保有备份。 + +5. **日志位置**: + - SSL 管理器日志:`/var/log/apisix-ssl-manager.log` + - 路由监听日志:`/var/log/apisix-route-watcher.log` + - Certbot 部署钩子日志:`/var/log/apisix-certbot-deploy.log` + +## 故障排查 + +### 证书申请失败 + +1. 检查域名 DNS 解析是否正确 +2. 检查 80 端口是否可访问 +3. 检查 webroot 目录权限 +4. 查看 Certbot 日志:`/var/log/letsencrypt/letsencrypt.log` + +### 证书无法同步到 APISIX + +1. 检查 APISIX Admin API 是否可访问 +2. 检查 Admin Key 是否正确 +3. 查看 SSL 管理器日志 + +### 自动续期不工作 + +1. 检查 systemd timer 状态:`systemctl status certbot-renew.timer` +2. 手动测试续期:`certbot renew --dry-run` +3. 检查部署钩子脚本权限:`chmod +x certbot_deploy_hook.sh` + +## 测试 + +### 测试环境(staging) + +```json +"letsencrypt_staging": true +``` + +Staging 环境有更高的申请频率限制,适合测试。 + +### 生产环境 + +测试通过后,设置: + +```json +"letsencrypt_staging": false +``` + +## 许可证 + +Apache License 2.0 diff --git a/ssl_manager/TEST_README.md b/ssl_manager/TEST_README.md new file mode 100644 index 0000000..7b21206 --- /dev/null +++ b/ssl_manager/TEST_README.md @@ -0,0 +1,173 @@ +# SSL 证书自动申请测试脚本使用说明 + +## 功能 + +`test_ssl_auto.py` 是一个完整的测试脚本,用于测试从路由创建到 SSL 证书申请的整个流程。 + +## 测试步骤 + +脚本会依次执行以下步骤,每个步骤都会显示成功或失败: + +1. **检查系统依赖** - 检查 certbot、Python 模块等 +2. **检查 APISIX 服务** - 验证 APISIX Admin API 是否可访问 +3. **检查 Webroot 目录** - 检查验证文件目录是否存在 +4. **检查/创建 Webroot 路由** - 检查或创建验证路由 +5. **测试验证路径** - 测试验证路径是否可访问 +6. **创建测试路由** - 创建测试域名路由 +7. **申请 SSL 证书** - 使用 Let's Encrypt 申请证书 +8. **同步证书到 APISIX** - 将证书上传到 APISIX +9. **验证证书信息** - 验证证书是否有效 + +## 使用方法 + +### 基本用法 + +```bash +# 使用自动生成的测试域名(测试完成后自动清理) +python3 test_ssl_auto.py + +# 指定测试域名 +python3 test_ssl_auto.py --domain test.example.com + +# 指定配置文件 +python3 test_ssl_auto.py --config config.json --domain test.example.com +``` + +### 选项说明 + +- `--domain, -d`: 指定测试域名(不指定则自动生成) +- `--config, -c`: 指定配置文件路径(默认使用环境变量或默认配置) +- `--cleanup`: 测试完成后清理测试数据(路由和 SSL 配置) +- `--no-cleanup`: 测试完成后不清理测试数据 + +### 示例 + +```bash +# 示例1: 快速测试(自动生成域名,自动清理) +python3 test_ssl_auto.py + +# 示例2: 测试指定域名(保留测试数据) +python3 test_ssl_auto.py --domain test.jingrowtools.cn --no-cleanup + +# 示例3: 使用配置文件,测试完成后清理 +python3 test_ssl_auto.py --config config.json --domain test.jingrowtools.cn --cleanup +``` + +## 输出说明 + +### 成功输出示例 + +``` +✅ 检查系统依赖 - 成功 +✅ 检查 APISIX 服务 - 成功 +✅ 检查 Webroot 目录 - 成功 +... +``` + +### 失败输出示例 + +``` +❌ 检查系统依赖 - 失败 +❌ 检查 APISIX 服务 - 异常: Connection refused +... +``` + +## 注意事项 + +1. **域名解析**: 测试域名必须 DNS 解析到当前服务器,否则证书申请会失败 +2. **Staging 模式**: 默认使用 staging 模式(测试环境),不会消耗生产环境配额 +3. **权限要求**: 某些操作需要 root 权限(如创建证书文件) +4. **清理数据**: 使用自动生成的域名时,默认会清理测试数据;指定域名时,默认保留数据 + +## 测试流程 + +``` +开始测试 + ↓ +检查依赖 → 失败 → 结束 + ↓ 成功 +检查 APISIX → 失败 → 结束 + ↓ 成功 +检查 Webroot → 失败 → 结束 + ↓ 成功 +检查/创建路由 → 失败 → 结束 + ↓ 成功 +测试验证路径 → 失败 → 结束 + ↓ 成功 +创建测试路由 → 失败 → 结束 + ↓ 成功 +申请证书 → 失败 → 结束 + ↓ 成功 +同步证书 → 失败 → 结束 + ↓ 成功 +验证证书 → 完成 + ↓ +清理数据(可选) + ↓ +显示测试总结 +``` + +## 故障排查 + +### 1. Certbot 未安装 + +```bash +sudo apt-get install certbot +``` + +### 2. APISIX 服务不可访问 + +```bash +# 检查 APISIX 是否运行 +docker ps | grep apisix + +# 检查端口 +netstat -tlnp | grep 9180 +``` + +### 3. Webroot 目录不存在 + +```bash +sudo mkdir -p /var/www/certbot/.well-known/acme-challenge +sudo chown -R www-data:www-data /var/www/certbot +``` + +### 4. 验证路径不可访问 + +- 检查 webroot 路由是否正确配置 +- 检查路由 priority 是否足够高 +- 检查路由 host 是否匹配 + +## 完整测试示例 + +```bash +# 1. 准备测试域名(确保 DNS 已解析) +export TEST_DOMAIN="test-$(date +%s).jingrowtools.cn" + +# 2. 运行测试 +python3 test_ssl_auto.py --domain $TEST_DOMAIN --no-cleanup + +# 3. 检查结果 +curl -v https://$TEST_DOMAIN + +# 4. 清理(如果需要) +python3 ssl_manager.py sync --domain $TEST_DOMAIN # 先同步证书 +# 然后手动删除路由和 SSL 配置 +``` + +## 与生产环境集成 + +测试通过后,可以: + +1. 修改 `config.json` 设置 `letsencrypt_staging: false` +2. 使用 `ssl_manager.py` 申请生产证书 +3. 配置自动续期 + +```bash +# 申请生产证书 +python3 ssl_manager.py request --domain your-domain.com + +# 启用自动续期 +sudo systemctl enable apisix-ssl-renew.timer +sudo systemctl start apisix-ssl-renew.timer +``` diff --git a/ssl_manager/__pycache__/ssl_manager.cpython-313.pyc b/ssl_manager/__pycache__/ssl_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a09d1e7c984f73b34f06bb5358834b8ecd69f89 GIT binary patch literal 18623 zcmc(HYjhLWx#&n5y(LSsWJ`VvKL7!;02>F)E5_KEhYj(d1OkaFY}qI^k~5M5xJ^rY z+Qd$i;G_wdBz{tm+q#yb{){(5QX~pr`a~4`U)d?ohF+9cos+L&IuF zoph&mhnCgu$YFEHy?CdN)g6?zTLm&w*K!K#%TxD4iIG8lrEr(JmQ##;4bYAWAQtnb z^c*mX+n1)-@MWQwL(C}FNvzm=*?cJIoi9MLW=6@V7&W6|v`h}8WAxQ>)?%T6216mm zSV*yj2lE+I{XU9v!z>>xET`5a&|9f)akto(7y2Gls2cWcXSVONGR!XP z^|!w_^{by_xiQLr>_6tm6_Lmp_+bX`1(J+|CdXzs8&^5uf6on^|vor zcX@(K0@fy92Yb9XnCPlnU2UBndy2<1HU7-hrIG3WF)KfWs;1hSz-V{PIC_^i5aj5s-j1M6%qcqEK~KxH zOWlDEytf6cc5BsApF3IX@jT(_NxMJd2?X5T0N7XNc*4y(di>oEcds|#J!TICdhAEs zK4@jDp&F==lHQ!G%g-Kh2RX%Ix39Cu!v^~D4hMt1^$zIW;qE!?4+QJity;U1v#wpe zvd&ZIsa<`j?$DZ=)wMMpYgW7OtGlnGqpqfARn2{UdPgY0Iu3b#jt&nSJme4dt_8&gMNQcpt{4?3Uy($oNcLNQa=BVco>-Psq z|Bl2WBA4X$m67dxkb-SX;g*H%Du%6^&(H^@wPHqENIgbBCW6h%-^=ixl{0c!rV2)J zP_a8f`D&`Q#(VpIpE(56mrB@i^DCJ-6|p#dl@fz;M2m>klQ10!Q^-9g^Dz-S1*gdh-Rs@*6+rmg}LX0-J!6a{aD z2uEzc>&Q82^y@O{R|_LJMS`qx!%%5BEdoU9+Fq0@3niyW5I0FY@Vri{y+nXWT~n}D zgeXh32qocZLFDLdhEOilPxt@x^w}RxKYJ><^o0%MYS`Mkz0Kup-^FSHFr#{B(|$Gw zt2K>H?R&TE-s{@au(yTPVU+@_TwAxdvwFNW>}uM}G_^Ih@87f6)zsRseHV-Gj5DU! zGkY7hZExGgB263}WJ4ZK^H9^4_T2(ze0gm)Jty`DI9azR=uTKH z*1UZip_5QW=-@|}@~l)62(lnW{6{@3Cr_|a7IAVk;lnb|F_3NnPTk{oce?n!&*@!H zxO==P=n^#`_vnD9r;Ekc7r^~)wQ>%}VgC`2BOxFXGRy%o%$1N~>E-I)^+dHkD7} zl(Z+AjQ{}xN(>uF5T~i?tW0+S3=+VQWF{#T0-eBWkY|vz00tT`XatzlH4Q_O*)D)V z3K(#cW~4L>$!A{xgA6d>CeBD{7?QKJ00uc=z)hWz(l986rUfu40E0<@NnO)0Bn?6sFhr+)qX^`AX;?bTB^E)HEk_l5v7z=_yw z9EdPG!q*7DX|t(0+TZK(ar9#WKjg@8I0eK~99xU$v6eK>)(8-R2xKRyDXn2}`A}d&TQTzZWL^O>fuYBLe6YVcUb*b- zj@NgD9SxUjV~zVFjr*dN`}?;}Xcpa}#k%;c>UGuVrt#X?`dyLryQ0=s>{1dhE+5`; zX2(e1SnY+4=Ql=+8~V3C+Zrz@8!kLk7_Pi;-1I@=uL`3DTcLVKylByI{h9jk;`QTu zKREcSgVCZLP`xX@5c01KR8D0~Tk!{N#q9v@nCBWdZ8b?gF{!qe%R7>60vd3h2n(5n z!pkTn;hLF}%Owvac8ZlGw`&R>*iHhwl{%2zjM*4Rdr~7v^@h>2ZSMreBo*+k51?2j zi7=)ty>h6UBvbQ)@zjeUl%9>e?PLYKGXuYY=6Y+J zO~K--649(siPn$p3><{@mn^@M*z9k}SJb?3% zSkNn=$Ss<;pfjcOZU76kL3V<=sxzMKJJ~m>$vxRN&=xK_JfZPU>I^534jeuCoq_KR zb;e2^ky1xYw=$wzIhk7#Uc7F+GFHDcQol32ZdbUnHJaNN*0jw^DE%rHWicmXJ;4y` zo3os7DUs!5g~CG12|Xj#;!4UU2`vJ!>(5`9K6xr(gj*ZXw!|AS*)PnT|NitF@AiQa z+1umoaHHAnKptTe_bK<;0mu$oSD&WQ?*r{8Xy1Fh*JBg$`p#y+oHk>e(FF{}m9Rz+ zd)%F1D|40eXK+5$gNO-@qZ1Se>@e` z_6^uZiM32L*5pnC+=(RY9;e6-`@<)8f~`tQ5-5pDi3XvNW#VkJVygnk?3~Q;n*K?##-OGBP07nVlgv>Cj z>A+Lt&rSbwX!^BL;4^FuG@?e1E)-4zT|&5S=Z`S`#`){-zFBVt#)P|-*abWb+5oql z)Prn?t;I6U!d~H&O~*Ps#O-BM@&dOB8+H7OcO_jmN>b_q4#p{9co&w~wFpX_JZLoF zPUY0tDBOrV0bFz_u1O*?o73UIJZ8ia;1V1n<+iBKfOrIu1pW*e$ne6_q2Tb7XP%4} z+Q(#(!n%IlRa3=?KVn+buZ$bZM;?h7SNAKf>MSu`SwvSB(=CeV7L8ioE_XA|I8rT&sT0OQoyt*-507APdZZyx*8l`2@kT>WZF-{n&Mh)>Y>+ln2o_O`xpyn$H zmAiNf+76FMCJdFMqIhZ9aMzixR}T-Wv8`%WK^0WR&E>#3OgX?gv^ig?q08rns@vBL zm7g0b|NT}Dr7!rB62lk*0c*zGV@$YU-Gruo(rEtoSt&H$4z$2?ez-)_XrVr=F>I-m zd_!}^cvRE@O!V@eL?kL9YyJo(24QnskJ(vpwMEsdKcA8(dIncV}( z*asl%Kz%?+Nx#l4;ayHqdy;zzAix`dnF~8PYr7=1(|xp4+jrn~J~?oq6z5W;s1i!$)V^*;jt*>}!l}q+elSYnf^GyJnA9TLliG!V z2cA>q6wTe)`^AY&H12E;!BbNUMhiGmH79a$qGg=osJExH!_9Vr zSDp^|ya5*XCc7Q7KBM6LApUQ3djuRe&^QAwEf=&9Ife)Pq2P@x&_Fo$%&YItoD$*^ zB=o^+to2q-?1gdIgE#^TR2G>Y=`-E`DyXW08inB!p-*4#d@5{W_EG40<9FB{ZAo6H z@G+5@{y~VS)LZw?YY_xTp9F##%^~!t@m-SIut0zS*=Eyna@Nz^;|9@yS^*1N4LoA3 z=kbsS{7dY8c&i2nR4^0(kpOxbkwLWA*X8FVy&(v&^g91A%jE6kRCwiWK~C9m*wgV%3_g%~!?6RseqX@DqOlXe*O8DsaN&hdUZ|Hh4lx!H z^e6O2Zt*;QF)Q2?i1s_cEQ!XLYdKx|Lg|QPgozq#F~h2eVbxg0SYOnzrC$aDA-{OA zC2q3Djioo`biS%zHJd|Oieu%Y?0zCCQe|8o9iMcA|*1Xpf;EO%KXcUd%dMZbKO zUZgCTGMAnH_KV*h*?vVFG4JeexvDeAbQKX@#YiBkTQ;T7KdpX29WGrvoslgid%ZhV4jBxQEYr=<6IP* z!>0S^b{|y0Bk4iX61w`Lx>b_`U8~3H#+HVQ*H7q?5F}Ugf+k#6Gu9HTZHd&jglo26 zF<#mC`$vBF$d%pUl7kcaN8;wjEMGMJK4%R5qU+D7I1%gH(G`ya@zRN$-stSaNoV z-69lvK&u)=ZdSkD9Qx^O#yEJ565<}{ml&xQV96k|>1@vm!cpo-n%h>Y6&SOc0>%E$ zLPL#01Ea<5FpHVVnPm2J89#{pq@c^@1p|>sL-ZMcAOgKk3QB}5n>b+%MDV-sD%x^0 zMH|d0iiV7o3O4xUPI+>EVI}}ROCV#PnK7bnMGM`ElW^?$;+gtJ3non zv-L2;43uV&3%7GcVRt?V)YQyc=fi`Lh3A;_RbHMKs0^q7|zEr5&@OlVtZ!@ zZAhLtUP{(mLnw1rSf^ijf9m2JAk;~Dv z1QiL75-I@<0{37>1ivg0gi4mn*~{xnO~-n@tf!M#1=37X)`$4z2~N&QI*xR5qE7Tx z1wuVRPz6w+L;T9k%l<5Or!k?K5YyA!g>ENL7DrJQsJCF~z)#^dg160BFu1f|{wH-w zTw~-NZuSXHHHdqyA*Lybfd5yMAJ>@U5a~&H-r}0FxTYYk$s5^w_TjS+4+c;7z1SCC z(*l)4b+ZPgPSr1&&8IZ_lN(NMh--8wcMR+p{MLk~;!7EoThQN-s2ZY&+=IvepjmVi z49)_Glqq#5)dT85-^ha_Eu%Ze0vC>*KXzI2&XZ&Qu&H@Mv2B(v0zsNrcKXp5AC2pc z@v@3|UO_y+Fm5Tjsg~vE^f%AyDSduSXN~BrBMnhqRW|8;wb&XfUKuG~IhG$SUOTA# zld(8nR2C~*6De9V)^$Y|*|av z3DdTCku^;RXui;Tz7?$RlEw-BR$lBE#B@s|x}|f4zhg``dMsS9c0#l6k4DR6rf%RE z6O9JK1vL|z+BDr@IlQcoB@K=y2lYvf1@ccd<(NBGY*R@7wM2>eV#_v(7LbAbfKm|zyumQg1q=$$FhWKsZ6jXWYFrAbiQNhjxk zm!Mfp$bBY8OC%`Bd%UJe&=fuBFUv@bE|Z>aecBvm>tTQyNE(|dbQuy<4-ym|ZX_k- zob)c3bDh$;SimGT`lJogZasfZlXE5#!nj6Gr}3hMld>M4=O`P5A=v|>v7C7WeEYCFH1s;0XSWj@FYwRK7Z|%AMk|mp34lmLeOQCD||yzeB`8w z4SenFcQ8we3-$!weh;(nV>VYbu>XX0yCAd4(oDjqu(ltw0nCV{hq^Rx>G4*cflS^U zMqfV9!kY*4e*jBhF=6u%4@mKIBjMD9ji>YVS*@I~?%XV_drFa$V$NZceL_(^OI!YH z+41%(i?3M1i`pkl%w5@Wh832b(^kQJeOTDw*eatwR#`BwD2MzLnQH55$tQZtR=eaA zyA;ZEB1!0oBs>=NqL-Tqcc5F8e&R z*`#F0p@Rq2s{rZGsS;5E=)*PvQ~;SX*?0W5%jdV0y9YcpppGSUtN^!UfxVXrsD>30 zz+De|R<5aE4oZ&6GDHt+&uF8@#r=x7&V2H_1K;H}79rqSQaiS9Y)80g^MvO9xUsOm z4fbwPMXYdHq;T0E3Rm>+i0eSPKpiKhE05~R!Tm8@cBX7l7B62MD_#+yLl*8!P#_^tAU89mi8@%U!eVgqq7Hj!qhv~s*XMXwSwdWyzome2LqtI}^0~SZZv^X`!ub;nM z8c3NJ?6(0$Gn~FiF=N87QCSE7Bsr6T@1Ahq06q~c0RHtG1IK+Gd|)T#S(_h;%>q?| zFCIsL7jLq}OiLrCrK9;#)5?D3pVTG@&cuxNh|xaUby*gv+jgZO%d_3$h0VF($&Jq7`a0x zC~FWwK(>=q?%+uhDO*HCU|89lFN{i1FBvtiHy@qm8bCWRBN3;IMu-Dp1Q;W*2a;a= zgfb*cdF->fL>R48HkaLkG9>oN8f4wD?(v$Y4CRDSA)EQ>iJ9Ry`2~9YgQxK<#_9KN zoPL+x290f;9M2VjE108?ty#IQPXfk$pA4*gFs}O)b}&luDC1sOuzdhdY;E>iP~t=z zZ{V)&GgTe3R&`pdTI#D>>#G=R)qZQ$!yzj)Ei|y8?$~lNIqC`M#cMIBPJyR7*a>8C zQt)kid*K2{9D=MIn-ow5!B$|hsMhd`5ZPGrw2jqPiPrmSE%)BgOUKTa4 zh?!SM%&W(0qUQU?MGlLxm-v5ouM8qYh13!LvHd-2i)mg5dUTa!5**A+yyS?4ws9? z1;9EmLo<|Jff>pT5*uNWqZ7W$(TCvU3f>EXyO4bbGZeh+Da_7bhHset8Dw0Z%Y|VS zZ->hrgf(?26!ZjKuD2+{w9!M=M4Hqf1|Z^cDrTvpHs|Wu#-4g3IXPvEX>)W#f3u`R_!k zn{Q#=?CMq0g3-oV3NGU?6kNg&?B%cfAGwA1H@if208=qb!R2!870HL|NHM(c;NP%n zwpX+i+9k6TT*l~Ic$wWR(o4;w^e8h{IlBKX-7TQmJXQ|WWre_T;Bp}AbjWr#`{jnhpt0h=}8B$w-XU z0TJv6hzlM+oLT@X@u}1uAo!1{Gv&t4G`H7j8 zdPtH@a%86lYKt=ngxi#qy4$!;sqj2*P}3O^gjLcg=|(A^ZH@}a93A}!%u(Tgc#g7s z>yUkpgb{qw|D@R{BC~Oh!Z)`l%iBm)kuXig!tF-tLHUDYjamj+MZaF zF#y~$rNY~|hdq&Y(ssd>SoRNFiPo<{S=t_fcW#^ioc~2DaS_hOHjx{+V0H?GRdNs9 ztNcGaA1LuNQg&*D5qv7AN}$aBU7%fc02Kz3+EI>Yq|}GBcHr^tD>Itm)~CM9I;?Q2 z+H2A~x3?s%**zbkVAV(ke^z?$UX6L<1>=S?{gv#>t@bL8Db8z1K0^A?~f83`} zZ0`uc%cJqkI=eQcvc&k zGrmij5C6_x-o;Tc3V|OhjQ@zonQ+)Aj{WwDS6F%LIn6zoa2S%KU+n)7Naejm_d+V# z&4ko+%lM9@rUjJKP4{|hpP_2Mz3PZP&GIIJjOl^5rrtj@{l;6c_8=aOc9IqDZs6Gl z{!;?-4+5s2dk2s05pO{90}lT2LI|3OWRTAe=6enZRWk!;ufP5*za)j1Xe;6r@Sc0! zY{0|iG_c*FBXE>?58kqz1{RD9T1g$JB0zW_;7u-Ej~T{1CkY&f)eT=+2HF3P%}tmQ z3(tq8=P^4487Bn??2!Nqte+2`^L8Jae7w6r;`1Nn=uS7FL(f4M@f@J*)}~=EA_5s9 zhvjG_h?6Hi!Xz;f`1CW>ff0E2CB*kMf{WmL(k}Sg-UD&-go{9l*j+uIt|0lGi1-r{ z8Shr0@yiGHe?j+6=qlIl zsEecO(i2-}Wwg{ZnOi!Z02I5($RB{jv>x|8b%)(_dD>I!gGoL+x=J%+XM z^jS&Egns*^-gsK^qGHJKk~*GW63br|$zL^Ad13ka<>SHF<_9C2AB^Td)W2;K;_xlc zwEWAG7akaS{Bw=%s@8Dwz`%i_ZBgw#p>;&^9b@zb^?7wPf5XD)c1E>J2pP4J{Mu-K zUH`Utn~= zAx}x`guX5LbT6xgBZ9?s6Z-qo>V+q1j%v%l-ULPSH!b`m+u*ahyP?Y~j^$NH@~TIJ zZ-48pZ$Lny`84s9{tR&Ra2|uum$~Cl${q1`Q|G zVXqSW;NzFa}&Z~FVK4fFafA8i<|3FkQ`6f4vE4J7*A4qzPj!xeeE*Ha&fw-_Ff zQ=gc&$l&s?MKzHBHb?t_NcP(@a5Kn$Th$bf^;|jR@eGbyyiN+`hzLvkx#s-Hbhf z*&)a{IeyEA|9wM_XQz6+hlsnAlLe3W;+Jj2hlS1&-mis@308sqWQW`VPhBmihO4)u zk^D;uj^5tZyqidt0V_(aZE`K`1F_#x(h{f44hBr?syhZ3}=zb`5&_#>_OVKXdOx{y5P{|5|0 BPWJ!+ literal 0 HcmV?d00001 diff --git a/ssl_manager/__pycache__/test_ssl_auto.cpython-313.pyc b/ssl_manager/__pycache__/test_ssl_auto.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8553bf0c9d3dab440203c3fcf5f10a38c0c8c05e GIT binary patch literal 28488 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 + +if [ $? -eq 0 ]; then + echo "✅ 证书删除成功" +else + echo "⚠️ 证书删除失败或证书不存在" +fi + +echo "" +echo "=== 重新申请生产环境证书 ===" +echo "使用当前配置(staging=False)重新申请..." +echo "" + +# 使用 ssl_manager 重新申请 +python3 -c " +from ssl_manager import APISIXSSLManager + +mgr = APISIXSSLManager() +print(f'当前配置: staging={mgr.staging}') +print() + +if mgr.staging: + print('❌ 警告: 配置仍然是 staging=True') + print('请先修改 ssl_manager.py 中的 letsencrypt_staging=False') + exit(1) +else: + print('✅ 配置正确: staging=False (生产环境)') + print() + print('开始申请证书...') + result = mgr.request_certificate('$DOMAIN') + if result: + print('✅ 证书申请成功!') + else: + print('❌ 证书申请失败') + exit(1) +" + +echo "" +echo "=== 完成 ===" diff --git a/ssl_manager/fix_webroot_route.sh b/ssl_manager/fix_webroot_route.sh new file mode 100755 index 0000000..ff3dfc2 --- /dev/null +++ b/ssl_manager/fix_webroot_route.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# 修复 webroot 路由配置,解决 HTTP-01 验证问题 + +set -e + +APISIX_ADMIN_URL="${APISIX_ADMIN_URL:-http://localhost:9180}" +APISIX_ADMIN_KEY="${APISIX_ADMIN_KEY:-8206e6e42b6b53243c52a767cc633137}" + +echo "修复 webroot 路由配置..." + +# 获取所有需要配置的域名(从路由中提取) +DOMAINS=$(curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes" \ + -H "X-API-KEY: ${APISIX_ADMIN_KEY}" \ + | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + routes = data.get('list', []) + domains = set() + for r in routes: + host = r.get('value', {}).get('host') + if host and host not in ['localhost', '127.0.0.1']: + domains.add(host) + print(' '.join(domains)) +except: + print('') +" 2>/dev/null || echo "") + +if [ -z "$DOMAINS" ]; then + echo "未找到域名,使用默认配置" + DOMAINS="jingrowtools.cn" +fi + +echo "找到域名: $DOMAINS" + +# 创建统一的 webroot 路由(适用于所有域名,不指定 host) +echo "创建统一的 webroot 验证路由(适用于所有域名)..." + +ROUTE_ID="certbot-webroot" + +# 创建/更新 webroot 路由 +RESPONSE=$(curl -s -X PUT "${APISIX_ADMIN_URL}/apisix/admin/routes/${ROUTE_ID}" \ + -H "X-API-KEY: ${APISIX_ADMIN_KEY}" \ + -H "Content-Type: application/json" \ + -d "{ + \"uri\": \"/.well-known/acme-challenge/*\", + \"name\": \"certbot-webroot\", + \"priority\": 10000, + \"plugins\": { + \"serverless-pre-function\": { + \"phase\": \"rewrite\", + \"functions\": [ + \"return function(conf, ctx) local uri = ctx.var.uri; local token = string.match(uri, '/%.well%-known/acme%-challenge/(.+)'); if not token then ngx.status = 404; ngx.say('Token not found in URI: ' .. (uri or 'nil')); return; end; local path = '/var/www/certbot/.well-known/acme-challenge/' .. token; local file = io.open(path, 'r'); if file then local content = file:read('*all'); file:close(); ngx.header.content_type = 'text/plain'; ngx.say(content); else ngx.status = 404; ngx.say('File not found: ' .. path); end end\" + ] + } + }, + \"status\": 1 + }") + +if echo "$RESPONSE" | grep -q '"value"'; then + echo "✅ Webroot 路由配置成功(适用于所有域名)" +else + echo "❌ Webroot 路由配置失败: $RESPONSE" +fi + +echo "" +echo "修复完成!" +echo "" +echo "测试验证路径:" +echo " echo 'test-token' | sudo tee /var/www/certbot/.well-known/acme-challenge/test-token" +echo " curl http://jingrowtools.cn/.well-known/acme-challenge/test-token" diff --git a/ssl_manager/install.sh b/ssl_manager/install.sh new file mode 100755 index 0000000..cce6734 --- /dev/null +++ b/ssl_manager/install.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# APISIX SSL 证书管理器安装脚本 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SYSTEMD_DIR="/etc/systemd/system" + +echo "开始安装 APISIX SSL 证书管理器..." + +# 检查 Python3 +if ! command -v python3 &> /dev/null; then + echo "错误: 未找到 python3,请先安装 Python 3" + exit 1 +fi + +# 检查 Certbot +if ! command -v certbot &> /dev/null; then + echo "警告: 未找到 certbot,请先安装 certbot" + echo "Ubuntu/Debian: sudo apt-get install certbot" + echo "CentOS/RHEL: sudo yum install certbot" +fi + +# 安装 Python 依赖 +echo "安装 Python 依赖..." +if command -v uv &> /dev/null; then + echo "使用 uv 安装依赖..." + uv pip install --system -r "$SCRIPT_DIR/requirements.txt" +else + echo "使用 pip3 安装依赖..." + pip3 install -r "$SCRIPT_DIR/requirements.txt" +fi + +# 创建必要的目录 +echo "创建必要的目录..." +sudo mkdir -p /var/www/certbot +sudo mkdir -p /var/lib/apisix-ssl-manager +sudo mkdir -p /var/log + +# 设置权限 +echo "设置文件权限..." +sudo chmod +x "$SCRIPT_DIR/ssl_manager.py" +sudo chmod +x "$SCRIPT_DIR/route_watcher.py" +sudo chmod +x "$SCRIPT_DIR/certbot_deploy_hook.sh" +sudo chown -R www-data:www-data /var/www/certbot 2>/dev/null || true + +# 安装 systemd 服务 +echo "安装 systemd 服务..." +if [ -d "$SCRIPT_DIR/systemd" ]; then + sudo cp "$SCRIPT_DIR/systemd/"*.service "$SYSTEMD_DIR/" + sudo cp "$SCRIPT_DIR/systemd/"*.timer "$SYSTEMD_DIR/" 2>/dev/null || true + + # 重新加载 systemd + sudo systemctl daemon-reload + + echo "systemd 服务已安装" + echo "" + echo "启用服务:" + echo " sudo systemctl enable apisix-ssl-renew.timer" + echo " sudo systemctl start apisix-ssl-renew.timer" + echo " sudo systemctl enable apisix-route-watcher.service" + echo " sudo systemctl start apisix-route-watcher.service" +fi + +echo "" +echo "安装完成!" +echo "" +echo "下一步:" +echo "1. 如需修改配置,编辑 Python 文件中的 DEFAULT_CONFIG:" +echo " - ssl_manager.py(主脚本配置)" +echo " - test_ssl_auto.py(测试脚本配置)" +echo " 或通过环境变量覆盖配置" +echo "2. 测试申请证书: python3 $SCRIPT_DIR/ssl_manager.py request --domain example.com" +echo "3. 启用自动续期: sudo systemctl enable apisix-ssl-renew.timer" +echo "4. 启动路由监听: sudo systemctl enable apisix-route-watcher.service" diff --git a/ssl_manager/quick_test.sh b/ssl_manager/quick_test.sh new file mode 100755 index 0000000..8333fe6 --- /dev/null +++ b/ssl_manager/quick_test.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# 快速测试脚本 - 测试 SSL 证书自动申请流程 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "==========================================" +echo "APISIX SSL 证书自动申请 - 快速测试" +echo "==========================================" +echo "" + +echo "配置信息:" +echo " APISIX Admin URL: http://localhost:9180(默认)" +echo " Webroot 路径: /var/www/certbot" +echo " Staging 模式: 是(测试环境)" +echo " 提示: 可通过环境变量或修改 Python 文件中的 DEFAULT_CONFIG 来修改配置" +echo "" + +# 提示输入域名 +read -p "请输入测试域名(留空使用自动生成): " TEST_DOMAIN + +if [ -z "$TEST_DOMAIN" ]; then + echo "使用自动生成的测试域名..." + AUTO_DOMAIN=true +else + echo "使用指定域名: $TEST_DOMAIN" + AUTO_DOMAIN=false +fi + +echo "" +echo "开始测试..." +echo "" + +# 运行测试 +if [ "$AUTO_DOMAIN" = true ]; then + # 自动生成域名,测试完成后清理 + python3 "$SCRIPT_DIR/test_ssl_auto.py" --cleanup +else + # 指定域名,测试完成后不清理(保留数据) + python3 "$SCRIPT_DIR/test_ssl_auto.py" --domain "$TEST_DOMAIN" --no-cleanup +fi + +TEST_RESULT=$? + +echo "" +if [ $TEST_RESULT -eq 0 ]; then + echo "==========================================" + echo "✅ 测试完成!所有步骤都成功" + echo "==========================================" + + if [ "$AUTO_DOMAIN" = false ]; then + echo "" + echo "测试数据已保留,可以继续使用:" + echo " 域名: $TEST_DOMAIN" + echo " 路由: http://localhost:9180/apisix/admin/routes/$TEST_DOMAIN" + echo " SSL: http://localhost:9180/apisix/admin/ssls" + echo "" + echo "如需清理测试数据,请运行:" + echo " python3 $SCRIPT_DIR/test_ssl_auto.py --domain $TEST_DOMAIN --cleanup" + fi +else + echo "==========================================" + echo "❌ 测试失败,请查看上面的错误信息" + echo "==========================================" +fi + +exit $TEST_RESULT diff --git a/ssl_manager/requirements.txt b/ssl_manager/requirements.txt new file mode 100644 index 0000000..a8608b2 --- /dev/null +++ b/ssl_manager/requirements.txt @@ -0,0 +1 @@ +requests>=2.28.0 diff --git a/ssl_manager/route_watcher.py b/ssl_manager/route_watcher.py new file mode 100755 index 0000000..8171d12 --- /dev/null +++ b/ssl_manager/route_watcher.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +APISIX 路由监听服务 +监听路由创建事件,自动为域名申请 SSL 证书 +""" + +import os +import sys +import json +import time +import logging +import requests +from typing import Set, Optional +import sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from ssl_manager import APISIXSSLManager + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/var/log/apisix-route-watcher.log'), + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + + +class RouteWatcher: + """路由监听器""" + + def __init__(self, config_path: str = None): + """初始化监听器""" + self.ssl_manager = APISIXSSLManager(config_path) + + # 从环境变量或配置获取 APISIX 配置 + self.apisix_admin_url = os.getenv('APISIX_ADMIN_URL', 'http://localhost:9180') + self.apisix_admin_key = os.getenv('APISIX_ADMIN_KEY', '8206e6e42b6b53243c52a767cc633137') + + # 已处理的域名集合 + self.processed_domains: Set[str] = set() + + # 加载已处理的域名 + self._load_processed_domains() + + def _get_apisix_headers(self): + """获取 APISIX Admin API 请求头""" + return { + 'X-API-KEY': self.apisix_admin_key, + 'Content-Type': 'application/json' + } + + def _load_processed_domains(self): + """加载已处理的域名列表""" + state_file = '/var/lib/apisix-ssl-manager/processed_domains.json' + if os.path.exists(state_file): + try: + with open(state_file, 'r') as f: + self.processed_domains = set(json.load(f)) + logger.info(f"加载已处理域名: {len(self.processed_domains)} 个") + except Exception as e: + logger.warning(f"加载已处理域名失败: {e}") + + def _save_processed_domains(self): + """保存已处理的域名列表""" + state_file = '/var/lib/apisix-ssl-manager/processed_domains.json' + os.makedirs(os.path.dirname(state_file), exist_ok=True) + try: + with open(state_file, 'w') as f: + json.dump(list(self.processed_domains), f) + except Exception as e: + logger.error(f"保存已处理域名失败: {e}") + + def get_all_routes(self) -> list: + """获取所有路由""" + try: + response = requests.get( + f"{self.apisix_admin_url}/apisix/admin/routes", + headers=self._get_apisix_headers(), + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + return data.get('list', []) + else: + logger.error(f"获取路由失败: {response.status_code}") + return [] + + except Exception as e: + logger.error(f"获取路由异常: {e}") + return [] + + def get_all_ssls(self) -> list: + """获取所有 SSL 配置""" + try: + response = requests.get( + f"{self.apisix_admin_url}/apisix/admin/ssls", + headers=self._get_apisix_headers(), + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + return data.get('list', []) + else: + logger.error(f"获取 SSL 配置失败: {response.status_code}") + return [] + + except Exception as e: + logger.error(f"获取 SSL 配置异常: {e}") + return [] + + def extract_domains_from_route(self, route: dict) -> Set[str]: + """从路由中提取域名""" + domains = set() + + # 从 hosts 字段提取 + hosts = route.get('value', {}).get('hosts', []) + if hosts: + domains.update(hosts) + + # 从 uri 字段提取(如果包含域名) + uri = route.get('value', {}).get('uri', '') + if uri and '.' in uri and not uri.startswith('/'): + # 可能是域名格式 + parts = uri.split('/') + if parts[0] and '.' in parts[0]: + domains.add(parts[0]) + + # 从 match 字段提取 + match = route.get('value', {}).get('match', {}) + if isinstance(match, dict): + for key, value in match.items(): + if 'host' in key.lower() and isinstance(value, str): + domains.add(value) + + return domains + + def extract_domains_from_ssl(self, ssl: dict) -> Set[str]: + """从 SSL 配置中提取域名""" + domains = set() + snis = ssl.get('value', {}).get('snis', []) + if snis: + domains.update(snis) + return domains + + def should_request_cert(self, domain: str) -> bool: + """判断是否需要申请证书""" + # 跳过已处理的域名 + if domain in self.processed_domains: + return False + + # 跳过本地域名 + if domain in ['localhost', '127.0.0.1', '0.0.0.0']: + return False + + # 跳过 IP 地址 + if domain.replace('.', '').isdigit(): + return False + + # 检查是否已有 SSL 配置 + ssls = self.get_all_ssls() + for ssl in ssls: + ssl_domains = self.extract_domains_from_ssl(ssl) + if domain in ssl_domains: + logger.info(f"域名已有 SSL 配置: {domain}") + self.processed_domains.add(domain) + return False + + return True + + def process_new_domains(self): + """处理新域名""" + routes = self.get_all_routes() + new_domains = set() + + # 从路由中提取所有域名 + for route in routes: + domains = self.extract_domains_from_route(route) + new_domains.update(domains) + + # 处理需要申请证书的域名 + for domain in new_domains: + if self.should_request_cert(domain): + logger.info(f"发现新域名,准备申请证书: {domain}") + try: + if self.ssl_manager.request_certificate(domain): + logger.info(f"证书申请成功: {domain}") + self.processed_domains.add(domain) + self._save_processed_domains() + else: + logger.error(f"证书申请失败: {domain}") + except Exception as e: + logger.error(f"处理域名异常 {domain}: {e}") + + def run(self, interval: int = 60): + """运行监听服务""" + logger.info(f"路由监听服务启动,检查间隔: {interval} 秒") + + while True: + try: + self.process_new_domains() + time.sleep(interval) + except KeyboardInterrupt: + logger.info("收到停止信号,退出服务") + break + except Exception as e: + logger.error(f"监听服务异常: {e}") + time.sleep(interval) + + +def main(): + """主函数""" + import argparse + + parser = argparse.ArgumentParser(description='APISIX 路由监听服务') + parser.add_argument('--interval', '-i', type=int, default=60, + help='检查间隔(秒),默认 60') + parser.add_argument('--config', '-c', help='配置文件路径(可选,用于覆盖默认配置)') + parser.add_argument('--once', action='store_true', + help='只执行一次,不持续监听') + + args = parser.parse_args() + + watcher = RouteWatcher(args.config) + + if args.once: + watcher.process_new_domains() + else: + watcher.run(args.interval) + + +if __name__ == '__main__': + main() diff --git a/ssl_manager/setup_webroot_route.sh b/ssl_manager/setup_webroot_route.sh new file mode 100755 index 0000000..5822316 --- /dev/null +++ b/ssl_manager/setup_webroot_route.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# 设置 APISIX Webroot 路由脚本 +# 用于 Let's Encrypt HTTP-01 验证 + +set -e + +APISIX_ADMIN_URL="${APISIX_ADMIN_URL:-http://localhost:9180}" +APISIX_ADMIN_KEY="${APISIX_ADMIN_KEY:-8206e6e42b6b53243c52a767cc633137}" +WEBROOT_PATH="${WEBROOT_PATH:-/var/www/certbot}" + +echo "配置 APISIX Webroot 路由用于 Let's Encrypt 验证..." + +# 创建 webroot 路由配置 +ROUTE_CONFIG=$(cat < Dict[str, str]: + """获取 APISIX Admin API 请求头""" + return { + 'X-API-KEY': self.apisix_admin_key, + 'Content-Type': 'application/json' + } + + def read_cert_files(self, domain: str) -> Optional[Dict[str, str]]: + """读取证书文件""" + domain_cert_dir = Path(self.cert_dir) / domain + + cert_file = domain_cert_dir / 'fullchain.pem' + key_file = domain_cert_dir / 'privkey.pem' + + if not cert_file.exists() or not key_file.exists(): + logger.error(f"证书文件不存在: {domain_cert_dir}") + return None + + try: + with open(cert_file, 'r') as f: + cert_content = f.read() + with open(key_file, 'r') as f: + key_content = f.read() + + return { + 'cert': cert_content, + 'key': key_content + } + except Exception as e: + logger.error(f"读取证书文件失败: {e}") + return None + + def upload_cert_to_apisix(self, domain: str, cert_content: str, key_content: str) -> bool: + """将证书上传到 APISIX""" + # 生成 SSL ID(使用域名作为 ID) + ssl_id = domain.replace('.', '_').replace('*', 'wildcard') + + # 构建 SSL 配置(创建时不包含 id) + ssl_config = { + "snis": [domain], + "cert": cert_content, + "key": key_content + } + + # 检查是否已存在 + check_url = f"{self.apisix_admin_url}/apisix/admin/ssls/{ssl_id}" + headers = self._get_apisix_headers() + + try: + # 先检查是否存在 + response = requests.get(check_url, headers=headers, timeout=10) + + if response.status_code == 200: + # 更新现有证书(更新时需要 id) + logger.info(f"更新 APISIX SSL 配置: {domain}") + ssl_config["id"] = ssl_id + response = requests.put( + f"{self.apisix_admin_url}/apisix/admin/ssls/{ssl_id}", + headers=headers, + json=ssl_config, + timeout=10 + ) + else: + # 创建新证书(创建时不需要 id,APISIX 会自动生成) + logger.info(f"创建 APISIX SSL 配置: {domain}") + response = requests.post( + f"{self.apisix_admin_url}/apisix/admin/ssls", + headers=headers, + json=ssl_config, + timeout=10 + ) + + if response.status_code in [200, 201]: + logger.info(f"证书上传成功: {domain}") + return True + else: + logger.error(f"证书上传失败: {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"上传证书到 APISIX 失败: {e}") + return False + + def request_certificate(self, domain: str, additional_domains: List[str] = None) -> bool: + """申请 Let's Encrypt 证书""" + domains = [domain] + if additional_domains: + domains.extend(additional_domains) + + # 构建 certbot 命令 + cmd = [ + self.certbot_path, + 'certonly', + '--webroot', + '--webroot-path', self.webroot_path, + '--non-interactive', + '--agree-tos', + '--email', self.email if self.email else 'admin@example.com', + '--cert-name', domain, + ] + + if self.staging: + cmd.append('--staging') + + # 添加域名 + for d in domains: + cmd.extend(['-d', d]) + + logger.info(f"申请证书: {domain}, 命令: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + timeout=300 + ) + + if result.returncode == 0: + logger.info(f"证书申请成功: {domain}") + # 读取证书并上传到 APISIX + cert_data = self.read_cert_files(domain) + if cert_data: + return self.upload_cert_to_apisix(domain, cert_data['cert'], cert_data['key']) + else: + logger.error(f"无法读取证书文件: {domain}") + return False + else: + logger.error(f"证书申请失败: {result.stderr}") + return False + + except subprocess.TimeoutExpired: + logger.error(f"证书申请超时: {domain}") + return False + except Exception as e: + logger.error(f"证书申请异常: {e}") + return False + + def renew_certificate(self, domain: str) -> bool: + """续期证书""" + cmd = [ + self.certbot_path, + 'renew', + '--cert-name', domain, + '--non-interactive', + '--webroot', + '--webroot-path', self.webroot_path, + ] + + if self.staging: + cmd.append('--staging') + + logger.info(f"续期证书: {domain}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + timeout=300 + ) + + if result.returncode == 0: + logger.info(f"证书续期成功: {domain}") + # 读取新证书并上传到 APISIX + cert_data = self.read_cert_files(domain) + if cert_data: + return self.upload_cert_to_apisix(domain, cert_data['cert'], cert_data['key']) + else: + logger.error(f"无法读取续期后的证书文件: {domain}") + return False + else: + logger.error(f"证书续期失败: {result.stderr}") + return False + + except Exception as e: + logger.error(f"证书续期异常: {e}") + return False + + def renew_all_certificates(self) -> Dict[str, bool]: + """续期所有证书""" + results = {} + + # 获取所有证书 + cert_dir = Path(self.cert_dir) + if not cert_dir.exists(): + logger.warning(f"证书目录不存在: {cert_dir}") + return results + + # 查找所有证书目录 + for domain_dir in cert_dir.iterdir(): + if domain_dir.is_dir(): + domain = domain_dir.name + results[domain] = self.renew_certificate(domain) + + return results + + def sync_cert_to_apisix(self, domain: str) -> bool: + """同步现有证书到 APISIX(不申请新证书)""" + cert_data = self.read_cert_files(domain) + if cert_data: + return self.upload_cert_to_apisix(domain, cert_data['cert'], cert_data['key']) + else: + logger.error(f"无法读取证书文件: {domain}") + return False + + def check_cert_expiry(self, domain: str) -> Optional[datetime]: + """检查证书过期时间""" + cert_file = Path(self.cert_dir) / domain / 'fullchain.pem' + if not cert_file.exists(): + return None + + try: + result = subprocess.run( + ['openssl', 'x509', '-in', str(cert_file), '-noout', '-enddate'], + capture_output=True, + text=True, + check=True + ) + + # 解析日期 + date_str = result.stdout.strip().split('=')[1] + expiry_date = datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z') + return expiry_date + + except Exception as e: + logger.error(f"检查证书过期时间失败: {e}") + return None + + +def main(): + """主函数""" + import argparse + + parser = argparse.ArgumentParser(description='APISIX SSL 证书管理器') + parser.add_argument('action', choices=['request', 'renew', 'renew-all', 'sync', 'check'], + help='操作类型') + parser.add_argument('--domain', '-d', help='域名') + parser.add_argument('--config', '-c', help='配置文件路径(可选,用于覆盖默认配置)') + parser.add_argument('--additional-domains', '-a', nargs='+', help='额外域名') + + args = parser.parse_args() + + # 初始化管理器 + try: + manager = APISIXSSLManager(args.config) + except Exception as e: + logger.error(f"初始化失败: {e}") + sys.exit(1) + + # 执行操作 + try: + if args.action == 'request': + if not args.domain: + logger.error("申请证书需要指定域名 (--domain)") + sys.exit(1) + success = manager.request_certificate(args.domain, args.additional_domains) + sys.exit(0 if success else 1) + + elif args.action == 'renew': + if not args.domain: + logger.error("续期证书需要指定域名 (--domain)") + sys.exit(1) + success = manager.renew_certificate(args.domain) + sys.exit(0 if success else 1) + + elif args.action == 'renew-all': + results = manager.renew_all_certificates() + failed = [d for d, s in results.items() if not s] + if failed: + logger.error(f"以下域名续期失败: {', '.join(failed)}") + sys.exit(1) + else: + logger.info("所有证书续期成功") + sys.exit(0) + + elif args.action == 'sync': + if not args.domain: + logger.error("同步证书需要指定域名 (--domain)") + sys.exit(1) + success = manager.sync_cert_to_apisix(args.domain) + sys.exit(0 if success else 1) + + elif args.action == 'check': + if not args.domain: + logger.error("检查证书需要指定域名 (--domain)") + sys.exit(1) + expiry = manager.check_cert_expiry(args.domain) + if expiry: + days_left = (expiry - datetime.now()).days + logger.info(f"证书过期时间: {expiry.strftime('%Y-%m-%d %H:%M:%S')}") + logger.info(f"剩余天数: {days_left} 天") + if days_left < 30: + logger.warning(f"证书即将过期,建议续期") + else: + logger.error("无法获取证书过期时间") + sys.exit(1) + + except Exception as e: + logger.error(f"执行操作失败: {e}") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/ssl_manager/systemd/apisix-route-watcher.service b/ssl_manager/systemd/apisix-route-watcher.service new file mode 100644 index 0000000..b4b430b --- /dev/null +++ b/ssl_manager/systemd/apisix-route-watcher.service @@ -0,0 +1,17 @@ +[Unit] +Description=APISIX Route Watcher Service +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /home/jingrow/apisix/ssl_manager/route_watcher.py +Restart=always +RestartSec=10 +User=root +StandardOutput=journal +StandardError=journal +Environment="APISIX_ADMIN_URL=http://localhost:9180" +Environment="APISIX_ADMIN_KEY=8206e6e42b6b53243c52a767cc633137" + +[Install] +WantedBy=multi-user.target diff --git a/ssl_manager/systemd/apisix-ssl-renew.service b/ssl_manager/systemd/apisix-ssl-renew.service new file mode 100644 index 0000000..09eef85 --- /dev/null +++ b/ssl_manager/systemd/apisix-ssl-renew.service @@ -0,0 +1,13 @@ +[Unit] +Description=APISIX SSL Certificate Renewal Service +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /home/jingrow/apisix/ssl_manager/ssl_manager.py renew-all +User=root +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/ssl_manager/systemd/apisix-ssl-renew.timer b/ssl_manager/systemd/apisix-ssl-renew.timer new file mode 100644 index 0000000..e61674a --- /dev/null +++ b/ssl_manager/systemd/apisix-ssl-renew.timer @@ -0,0 +1,13 @@ +[Unit] +Description=APISIX SSL Certificate Renewal Timer +Requires=apisix-ssl-renew.service + +[Timer] +OnCalendar=weekly +# 每周一凌晨 3:00 执行,随机延迟 0-1 小时 +OnCalendar=Mon *-*-* 03:00:00 +RandomizedDelaySec=3600 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ssl_manager/test_example.sh b/ssl_manager/test_example.sh new file mode 100755 index 0000000..7225857 --- /dev/null +++ b/ssl_manager/test_example.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# 测试脚本使用示例 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "APISIX SSL 证书自动申请测试" +echo "================================" +echo "" + +# 示例1: 使用自动生成的测试域名 +echo "示例1: 使用自动生成的测试域名(测试完成后自动清理)" +echo "python3 $SCRIPT_DIR/test_ssl_auto.py" +echo "" + +# 示例2: 指定测试域名 +echo "示例2: 指定测试域名" +echo "python3 $SCRIPT_DIR/test_ssl_auto.py --domain test.example.com" +echo "" + +# 示例3: 使用环境变量配置(可选) +echo "示例3: 使用环境变量配置(可选)" +echo "export APISIX_ADMIN_URL='http://localhost:9180'" +echo "export APISIX_ADMIN_KEY='your-key'" +echo "python3 $SCRIPT_DIR/test_ssl_auto.py --domain test.example.com" +echo "" + +# 示例4: 测试完成后不清理(保留测试数据) +echo "示例4: 测试完成后不清理(保留测试数据)" +echo "python3 $SCRIPT_DIR/test_ssl_auto.py --domain test.example.com --no-cleanup" +echo "" + +# 示例5: 强制清理 +echo "示例5: 强制清理测试数据" +echo "python3 $SCRIPT_DIR/test_ssl_auto.py --domain test.example.com --cleanup" +echo "" + +echo "运行测试..." +echo "" + +# 实际运行测试(使用默认配置,staging 模式) +python3 "$SCRIPT_DIR/test_ssl_auto.py" --cleanup diff --git a/ssl_manager/test_ssl_auto.py b/ssl_manager/test_ssl_auto.py new file mode 100755 index 0000000..3d5238b --- /dev/null +++ b/ssl_manager/test_ssl_auto.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +APISIX SSL 证书自动申请测试脚本 +完整测试从路由创建到证书申请的整个流程 +""" + +import os +import sys +import json +import time +import subprocess +import requests +from pathlib import Path +from typing import Dict, Tuple, Optional +from datetime import datetime + +# 导入 ssl_manager 的配置和管理器 +from ssl_manager import DEFAULT_CONFIG, APISIXSSLManager + +# 颜色输出 +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + RESET = '\033[0m' + BOLD = '\033[1m' + +def print_success(msg: str): + print(f"{Colors.GREEN}✅ {msg}{Colors.RESET}") + +def print_error(msg: str): + print(f"{Colors.RED}❌ {msg}{Colors.RESET}") + +def print_warning(msg: str): + print(f"{Colors.YELLOW}⚠️ {msg}{Colors.RESET}") + +def print_info(msg: str): + print(f"{Colors.BLUE}ℹ️ {msg}{Colors.RESET}") + +def print_step(step: int, total: int, msg: str): + print(f"\n{Colors.BOLD}[步骤 {step}/{total}] {msg}{Colors.RESET}") + print("-" * 60) + +class SSLTestRunner: + def __init__(self, config_path: str = None): + """初始化测试运行器""" + # 使用 APISIXSSLManager 来获取配置,确保与 ssl_manager.py 一致 + # 如果提供了配置文件,传递给 APISIXSSLManager + ssl_manager = APISIXSSLManager(config_path=config_path) + + # 从 ssl_manager 获取配置 + self.apisix_admin_url = ssl_manager.apisix_admin_url + self.apisix_admin_key = ssl_manager.apisix_admin_key + self.webroot_path = ssl_manager.webroot_path + self.staging = ssl_manager.staging + self.email = ssl_manager.email + self.cert_dir = ssl_manager.cert_dir + + # 保存 ssl_manager 实例,用于同步证书 + self.ssl_manager = ssl_manager + + self.test_domain = f"test-{int(time.time())}.jingrowtools.cn" + self.test_results = [] + + def _get_headers(self): + """获取 APISIX Admin API 请求头""" + return { + 'X-API-KEY': self.apisix_admin_key, + 'Content-Type': 'application/json' + } + + def test_step(self, step_num: int, total: int, name: str, test_func) -> bool: + """执行测试步骤""" + print_step(step_num, total, name) + try: + result = test_func() + if result: + print_success(f"{name} - 成功") + self.test_results.append((name, True, None)) + return True + else: + print_error(f"{name} - 失败") + self.test_results.append((name, False, "测试返回 False")) + return False + except Exception as e: + print_error(f"{name} - 异常: {str(e)}") + self.test_results.append((name, False, str(e))) + return False + + def check_dependencies(self) -> bool: + """检查依赖""" + print_info("检查系统依赖...") + + # 检查 certbot + try: + result = subprocess.run(['which', 'certbot'], capture_output=True, text=True) + if result.returncode == 0: + print_success(f"Certbot 已安装: {result.stdout.strip()}") + else: + print_error("Certbot 未安装") + return False + except Exception as e: + print_error(f"检查 Certbot 失败: {e}") + return False + + # 检查 Python 模块 + try: + import requests + print_success(f"Python requests 模块已安装: {requests.__version__}") + except ImportError: + print_error("Python requests 模块未安装") + return False + + # 检查 openssl + try: + result = subprocess.run(['which', 'openssl'], capture_output=True, text=True) + if result.returncode == 0: + print_success(f"OpenSSL 已安装: {result.stdout.strip()}") + else: + print_warning("OpenSSL 未安装(可选)") + except: + pass + + return True + + def check_apisix_service(self) -> bool: + """检查 APISIX 服务状态""" + print_info("检查 APISIX 服务...") + + try: + response = requests.get( + f"{self.apisix_admin_url}/apisix/admin/routes", + headers=self._get_headers(), + timeout=5 + ) + + if response.status_code == 200: + print_success(f"APISIX Admin API 可访问: {self.apisix_admin_url}") + return True + else: + print_error(f"APISIX Admin API 返回错误: {response.status_code}") + return False + except requests.exceptions.ConnectionError: + print_error(f"无法连接到 APISIX Admin API: {self.apisix_admin_url}") + print_info("请确保 APISIX 服务正在运行") + return False + except Exception as e: + print_error(f"检查 APISIX 服务失败: {e}") + return False + + def check_webroot_directory(self) -> bool: + """检查 webroot 目录""" + print_info(f"检查 webroot 目录: {self.webroot_path}") + + webroot_path = Path(self.webroot_path) + challenge_path = webroot_path / '.well-known' / 'acme-challenge' + + # 检查目录是否存在 + if not webroot_path.exists(): + print_error(f"Webroot 目录不存在: {self.webroot_path}") + print_info("请运行: sudo mkdir -p /var/www/certbot") + return False + + # 创建验证目录 + challenge_path.mkdir(parents=True, exist_ok=True) + print_success(f"验证目录已准备: {challenge_path}") + + # 检查权限 + if os.access(str(challenge_path), os.W_OK): + print_success("目录可写") + else: + print_warning("目录可能不可写,请检查权限") + + return True + + def check_webroot_route(self, domain: str) -> bool: + """检查 webroot 验证路由""" + print_info(f"检查 webroot 验证路由: {domain}...") + + # 为每个域名创建独立的 webroot 路由 + route_id = f"certbot-webroot-{domain}" + + try: + response = requests.get( + f"{self.apisix_admin_url}/apisix/admin/routes/{route_id}", + headers=self._get_headers(), + timeout=5 + ) + + if response.status_code == 200: + route_data = response.json().get('value', {}) + print_success(f"Webroot 路由存在: {route_id}") + print_info(f" Host: {route_data.get('host', 'None (所有域名)')}") + print_info(f" URI: {route_data.get('uri')}") + print_info(f" Priority: {route_data.get('priority', 0)}") + return True + else: + print_warning(f"Webroot 路由不存在: {route_id}") + print_info("将尝试创建 webroot 路由(适用于所有域名)...") + return self.create_webroot_route(domain) + except Exception as e: + print_error(f"检查 webroot 路由失败: {e}") + return False + + def create_webroot_route(self, domain: str) -> bool: + """创建 webroot 验证路由(为每个域名创建独立路由)""" + print_info(f"创建 webroot 路由: {domain}") + + # 为每个域名创建独立的 webroot 路由,确保 host 匹配 + route_id = f"certbot-webroot-{domain}" + route_config = { + "uri": "/.well-known/acme-challenge/*", + "name": route_id, + "host": domain, # 设置 host,确保正确匹配 + "priority": 10000, # 高优先级,在域名路由之前匹配 + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function(conf, ctx) local uri = ngx.var.uri; if not uri then uri = ctx.var.uri; end local token = string.match(uri, '^/%.well%-known/acme%-challenge/(.+)$'); if not token then ngx.status = 404; ngx.say('Token not found. URI: ' .. (uri or 'nil')); return; end; local path = '/var/www/certbot/.well-known/acme-challenge/' .. token; local file = io.open(path, 'r'); if file then local content = file:read('*all'); file:close(); ngx.header.content_type = 'text/plain'; ngx.say(content); else ngx.status = 404; ngx.say('File not found: ' .. path); end end" + ] + } + }, + "status": 1 + } + + try: + response = requests.put( + f"{self.apisix_admin_url}/apisix/admin/routes/{route_id}", + headers=self._get_headers(), + json=route_config, + timeout=10 + ) + + if response.status_code in [200, 201]: + print_success(f"Webroot 路由创建成功: {route_id}") + return True + else: + print_error(f"创建 webroot 路由失败: {response.status_code} - {response.text}") + return False + except Exception as e: + print_error(f"创建 webroot 路由异常: {e}") + return False + + def test_verification_path(self, domain: str) -> bool: + """测试验证路径是否可访问""" + print_info(f"测试验证路径: {domain}") + + test_token = f"test-{int(time.time())}" + test_file = Path(self.webroot_path) / '.well-known' / 'acme-challenge' / test_token + test_content = f"test-content-{test_token}" + + try: + # 创建测试文件(尝试使用 sudo 或直接写入) + test_file.parent.mkdir(parents=True, exist_ok=True) + + # 尝试直接写入 + try: + test_file.write_text(test_content) + print_success(f"测试文件已创建: {test_file}") + except PermissionError: + # 如果权限不足,尝试使用 sudo + print_warning("权限不足,尝试使用 sudo 创建测试文件...") + try: + # 使用临时文件避免 shell 注入 + import tempfile + with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp: + tmp.write(test_content) + tmp_path = tmp.name + + subprocess.run( + ['sudo', 'cp', tmp_path, str(test_file)], + check=True, + timeout=5 + ) + subprocess.run( + ['sudo', 'chmod', '644', str(test_file)], + check=True, + timeout=5 + ) + os.unlink(tmp_path) # 清理临时文件 + print_success(f"测试文件已创建(使用 sudo): {test_file}") + except Exception as e: + print_error(f"无法创建测试文件: {e}") + print_warning("提示: 请运行以下命令修复权限:") + print_info(" sudo mkdir -p /var/www/certbot/.well-known/acme-challenge") + print_info(" sudo chown -R $USER:$USER /var/www/certbot") + print_warning("或者跳过验证路径测试,直接进行证书申请(Certbot 会自动处理文件创建)") + # 不返回 False,而是继续执行(Certbot 会处理文件创建) + return True + + # 测试本地访问 + time.sleep(1) # 等待路由生效 + try: + response = requests.get( + f"http://localhost:9080/.well-known/acme-challenge/{test_token}", + headers={"Host": domain}, + timeout=5 + ) + + if response.status_code == 200 and response.text.strip() == test_content: + print_success(f"验证路径测试通过: {response.text.strip()}") + # 清理测试文件 + try: + if test_file.exists(): + test_file.unlink() + except: + try: + subprocess.run(['sudo', 'rm', str(test_file)], timeout=5, check=False) + except: + pass + return True + else: + print_warning(f"验证路径测试失败: status={response.status_code}, content={response.text[:100]}") + print_info("继续执行,Certbot 会自动处理验证文件...") + # 清理测试文件 + try: + if test_file.exists(): + subprocess.run(['sudo', 'rm', str(test_file)], timeout=5, check=False) + except: + pass + # 不返回 False,允许继续执行(Certbot 会处理) + return True + except Exception as e: + print_warning(f"验证路径访问异常: {e}") + print_info("继续执行,Certbot 会自动处理验证文件...") + return True + except Exception as e: + print_error(f"测试验证路径异常: {e}") + return False + + def create_test_route(self, domain: str) -> bool: + """创建测试路由""" + print_info(f"创建测试路由: {domain}") + + route_config = { + "uri": "/*", + "name": domain, + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], + "host": domain, + "upstream": { + "nodes": [ + { + "host": "192.168.10.2", + "port": 8201, + "weight": 1 + } + ], + "type": "roundrobin", + "scheme": "http", + "pass_host": "pass" + }, + "status": 1 + } + + try: + response = requests.put( + f"{self.apisix_admin_url}/apisix/admin/routes/{domain}", + headers=self._get_headers(), + json=route_config, + timeout=10 + ) + + if response.status_code in [200, 201]: + print_success(f"测试路由创建成功: {domain}") + return True + else: + print_error(f"创建测试路由失败: {response.status_code} - {response.text}") + return False + except Exception as e: + print_error(f"创建测试路由异常: {e}") + return False + + def request_certificate(self, domain: str) -> bool: + """申请证书""" + print_info(f"申请证书: {domain} (staging={self.staging})") + + cmd = [ + self.ssl_manager.certbot_path, + 'certonly', + '--webroot', + '--webroot-path', self.webroot_path, + '--non-interactive', + '--agree-tos', + '--email', self.email, + '--cert-name', domain, + '-d', domain + ] + + if self.staging: + cmd.append('--staging') + + try: + print_info(f"执行命令: {' '.join(cmd)}") + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0: + print_success(f"证书申请成功: {domain}") + print_info(result.stdout) + return True + else: + print_error(f"证书申请失败: {result.stderr}") + return False + except subprocess.TimeoutExpired: + print_error("证书申请超时") + return False + except Exception as e: + print_error(f"证书申请异常: {e}") + return False + + def sync_certificate_to_apisix(self, domain: str) -> bool: + """同步证书到 APISIX(使用 ssl_manager 的方法)""" + print_info(f"同步证书到 APISIX: {domain}") + + # 先读取证书文件,然后使用 ssl_manager 的方法来上传证书 + try: + # 使用 ssl_manager 的 read_cert_files 方法读取证书 + cert_data = self.ssl_manager.read_cert_files(domain) + if not cert_data: + print_error(f"无法读取证书文件: {domain}") + return False + + # 调用 upload_cert_to_apisix 并传递证书内容 + result = self.ssl_manager.upload_cert_to_apisix( + domain, + cert_data['cert'], + cert_data['key'] + ) + if result: + print_success(f"证书已同步到 APISIX: {domain}") + return True + else: + print_error(f"证书同步失败: {domain}") + return False + except Exception as e: + print_error(f"证书同步异常: {e}") + return False + + def verify_certificate(self, domain: str) -> bool: + """验证证书""" + print_info(f"验证证书: {domain}") + + cert_file = Path('/etc/letsencrypt/live') / domain / 'fullchain.pem' + + if not cert_file.exists(): + print_error("证书文件不存在") + return False + + try: + result = subprocess.run( + ['openssl', 'x509', '-in', str(cert_file), '-noout', '-subject', '-dates'], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0: + print_success("证书信息:") + for line in result.stdout.strip().split('\n'): + print_info(f" {line}") + return True + else: + print_error(f"读取证书信息失败: {result.stderr}") + return False + except Exception as e: + print_error(f"验证证书异常: {e}") + return False + + def cleanup(self, domain: str, cleanup_route: bool = True, cleanup_ssl: bool = True): + """清理测试数据""" + print_info("清理测试数据...") + + if cleanup_route: + try: + # 删除测试路由 + response = requests.delete( + f"{self.apisix_admin_url}/apisix/admin/routes/{domain}", + headers=self._get_headers(), + timeout=10 + ) + if response.status_code in [200, 204]: + print_success(f"测试路由已删除: {domain}") + except: + pass + + if cleanup_ssl: + try: + # 删除 SSL 配置 + ssl_id = domain.replace('.', '_') + response = requests.delete( + f"{self.apisix_admin_url}/apisix/admin/ssls/{ssl_id}", + headers=self._get_headers(), + timeout=10 + ) + if response.status_code in [200, 204]: + print_success(f"SSL 配置已删除: {ssl_id}") + except: + pass + + def run_full_test(self, domain: str = None, cleanup: bool = False): + """运行完整测试""" + if not domain: + domain = self.test_domain + + print(f"\n{Colors.BOLD}{'='*60}") + print(f"APISIX SSL 证书自动申请测试") + print(f"测试域名: {domain}") + print(f"Staging 模式: {self.staging}") + print(f"{'='*60}{Colors.RESET}\n") + + steps = [ + (1, "检查系统依赖", lambda: self.check_dependencies()), + (2, "检查 APISIX 服务", lambda: self.check_apisix_service()), + (3, "检查 Webroot 目录", lambda: self.check_webroot_directory()), + (4, "检查/创建 Webroot 路由", lambda: self.check_webroot_route(domain)), + (5, "测试验证路径", lambda: self.test_verification_path(domain)), + (6, "创建测试路由", lambda: self.create_test_route(domain)), + (7, "申请 SSL 证书", lambda: self.request_certificate(domain)), + (8, "同步证书到 APISIX", lambda: self.sync_certificate_to_apisix(domain)), + (9, "验证证书信息", lambda: self.verify_certificate(domain)), + ] + + success_count = 0 + for step_num, step_name, test_func in steps: + if self.test_step(step_num, len(steps), step_name, test_func): + success_count += 1 + else: + print_warning("测试中断,后续步骤可能无法执行") + break + + # 打印总结 + print(f"\n{Colors.BOLD}{'='*60}") + print(f"测试总结") + print(f"{'='*60}{Colors.RESET}\n") + + print(f"总步骤数: {len(steps)}") + print(f"成功: {Colors.GREEN}{success_count}{Colors.RESET}") + print(f"失败: {Colors.RED}{len(steps) - success_count}{Colors.RESET}") + + print(f"\n详细结果:") + for name, success, error in self.test_results: + status = f"{Colors.GREEN}✅ 成功{Colors.RESET}" if success else f"{Colors.RED}❌ 失败{Colors.RESET}" + print(f" {status} - {name}") + if error: + print(f" 错误: {error}") + + # 清理 + if cleanup and success_count == len(steps): + print(f"\n{Colors.YELLOW}清理测试数据...{Colors.RESET}") + self.cleanup(domain) + + return success_count == len(steps) + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description='APISIX SSL 证书自动申请测试脚本') + parser.add_argument('--domain', '-d', help='测试域名(不指定则自动生成)') + parser.add_argument('--config', '-c', help='配置文件路径(可选,用于覆盖默认配置)') + parser.add_argument('--cleanup', action='store_true', help='测试完成后清理测试数据') + parser.add_argument('--no-cleanup', action='store_true', help='测试完成后不清理测试数据') + + args = parser.parse_args() + + runner = SSLTestRunner(args.config) + + if args.domain: + domain = args.domain + else: + domain = runner.test_domain + print_warning(f"未指定域名,使用自动生成的测试域名: {domain}") + print_info("注意:此域名需要 DNS 解析到当前服务器才能申请证书") + + cleanup = args.cleanup or (not args.no_cleanup and not args.domain) + + success = runner.run_full_test(domain, cleanup=cleanup) + + sys.exit(0 if success else 1) + + +if __name__ == '__main__': + main() diff --git a/ssl_manager/webroot_route.json b/ssl_manager/webroot_route.json new file mode 100644 index 0000000..e27be06 --- /dev/null +++ b/ssl_manager/webroot_route.json @@ -0,0 +1,14 @@ +{ + "uri": "/.well-known/acme-challenge/*", + "name": "certbot-webroot", + "priority": 10000, + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function(conf, ctx) local uri = ctx.var.uri; local token = string.match(uri, '/%.well%-known/acme%-challenge/(.+)'); if not token then ngx.status = 404; ngx.say('Token not found in URI: ' .. (uri or 'nil')); return; end; local path = '/var/www/certbot/.well-known/acme-challenge/' .. token; local file = io.open(path, 'r'); if file then local content = file:read('*all'); file:close(); ngx.header.content_type = 'text/plain'; ngx.say(content); else ngx.status = 404; ngx.say('File not found: ' .. path); end end" + ] + } + }, + "status": 1 +}