增加ssl_manager功能实现创建路由时自动申请并续期ssl免费证书,测试成功
This commit is contained in:
parent
8c8e8dc8e8
commit
7d885471c0
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
121
ssl_manager/CONFIG.md
Normal file
121
ssl_manager/CONFIG.md
Normal file
@ -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. **安全性**:不要将包含敏感信息的配置文件提交到版本控制系统
|
||||
231
ssl_manager/DASHBOARD_TROUBLESHOOT.md
Normal file
231
ssl_manager/DASHBOARD_TROUBLESHOOT.md
Normal file
@ -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
|
||||
133
ssl_manager/FULL_TEST_FLOW.md
Normal file
133
ssl_manager/FULL_TEST_FLOW.md
Normal file
@ -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 访问。
|
||||
228
ssl_manager/HTTP_VERIFY_ISSUE_ANALYSIS.md
Normal file
228
ssl_manager/HTTP_VERIFY_ISSUE_ANALYSIS.md
Normal file
@ -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 验证)= 最可靠,适合生产环境
|
||||
208
ssl_manager/QUICKSTART.md
Normal file
208
ssl_manager/QUICKSTART.md
Normal file
@ -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`
|
||||
349
ssl_manager/README.md
Normal file
349
ssl_manager/README.md
Normal file
@ -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
|
||||
173
ssl_manager/TEST_README.md
Normal file
173
ssl_manager/TEST_README.md
Normal file
@ -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
|
||||
```
|
||||
BIN
ssl_manager/__pycache__/ssl_manager.cpython-313.pyc
Normal file
BIN
ssl_manager/__pycache__/ssl_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ssl_manager/__pycache__/test_ssl_auto.cpython-313.pyc
Normal file
BIN
ssl_manager/__pycache__/test_ssl_auto.cpython-313.pyc
Normal file
Binary file not shown.
39
ssl_manager/certbot_deploy_hook.sh
Executable file
39
ssl_manager/certbot_deploy_hook.sh
Executable file
@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Certbot 部署钩子脚本
|
||||
# 当证书申请或续期成功后,自动同步到 APISIX
|
||||
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SSL_MANAGER="${SCRIPT_DIR}/ssl_manager.py"
|
||||
LOG_FILE="/var/log/apisix-certbot-deploy.log"
|
||||
|
||||
# 日志函数
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# 获取域名(从 certbot 环境变量)
|
||||
DOMAIN="${RENEWED_DOMAINS%% *}"
|
||||
|
||||
if [ -z "$DOMAIN" ]; then
|
||||
# 尝试从证书路径获取
|
||||
if [ -n "$RENEWED_LINEAGE" ]; then
|
||||
DOMAIN=$(basename "$RENEWED_LINEAGE")
|
||||
else
|
||||
log "错误: 无法获取域名"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log "开始部署证书到 APISIX: $DOMAIN"
|
||||
|
||||
# 调用 SSL 管理器同步证书
|
||||
if python3 "$SSL_MANAGER" sync --domain "$DOMAIN"; then
|
||||
log "证书部署成功: $DOMAIN"
|
||||
exit 0
|
||||
else
|
||||
log "证书部署失败: $DOMAIN"
|
||||
exit 1
|
||||
fi
|
||||
9
ssl_manager/config.json
Normal file
9
ssl_manager/config.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"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": false,
|
||||
"webroot_path": "/var/www/certbot"
|
||||
}
|
||||
50
ssl_manager/delete_and_renew_cert.sh
Executable file
50
ssl_manager/delete_and_renew_cert.sh
Executable file
@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# 删除旧证书并重新申请生产环境证书
|
||||
|
||||
DOMAIN="test.jingrowtools.cn"
|
||||
|
||||
echo "=== 删除旧 STAGING 证书 ==="
|
||||
echo "域名: $DOMAIN"
|
||||
echo ""
|
||||
|
||||
# 删除证书
|
||||
echo "1. 删除证书..."
|
||||
certbot delete --cert-name "$DOMAIN" --non-interactive 2>&1
|
||||
|
||||
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 "=== 完成 ==="
|
||||
71
ssl_manager/fix_webroot_route.sh
Executable file
71
ssl_manager/fix_webroot_route.sh
Executable file
@ -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"
|
||||
75
ssl_manager/install.sh
Executable file
75
ssl_manager/install.sh
Executable file
@ -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"
|
||||
68
ssl_manager/quick_test.sh
Executable file
68
ssl_manager/quick_test.sh
Executable file
@ -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
|
||||
1
ssl_manager/requirements.txt
Normal file
1
ssl_manager/requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
requests>=2.28.0
|
||||
237
ssl_manager/route_watcher.py
Executable file
237
ssl_manager/route_watcher.py
Executable file
@ -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()
|
||||
50
ssl_manager/setup_webroot_route.sh
Executable file
50
ssl_manager/setup_webroot_route.sh
Executable file
@ -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 <<EOF
|
||||
{
|
||||
"uri": "/.well-known/acme-challenge/*",
|
||||
"name": "certbot-webroot",
|
||||
"plugins": {
|
||||
"file-logger": {
|
||||
"path": "/var/log/apisix/certbot-access.log"
|
||||
}
|
||||
},
|
||||
"upstream": {
|
||||
"type": "roundrobin",
|
||||
"nodes": {
|
||||
"127.0.0.1:9080": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# 注意:这个路由需要配合 Nginx 或其他静态文件服务器
|
||||
# 或者使用 APISIX 的 serverless 插件直接返回文件内容
|
||||
|
||||
echo "Webroot 路由配置:"
|
||||
echo "$ROUTE_CONFIG" | jq .
|
||||
|
||||
echo ""
|
||||
echo "请手动在 APISIX 中创建此路由,或使用以下命令:"
|
||||
echo ""
|
||||
echo "curl -X PUT '$APISIX_ADMIN_URL/apisix/admin/routes/certbot-webroot' \\"
|
||||
echo " -H 'X-API-KEY: $APISIX_ADMIN_KEY' \\"
|
||||
echo " -H 'Content-Type: application/json' \\"
|
||||
echo " -d '$ROUTE_CONFIG'"
|
||||
echo ""
|
||||
echo "或者配置 Nginx 直接服务静态文件:"
|
||||
echo " location /.well-known/acme-challenge/ {"
|
||||
echo " root $WEBROOT_PATH;"
|
||||
echo " }"
|
||||
392
ssl_manager/ssl_manager.py
Executable file
392
ssl_manager/ssl_manager.py
Executable file
@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
APISIX SSL 证书自动管理脚本
|
||||
功能:
|
||||
1. 申请 Let's Encrypt 证书
|
||||
2. 将证书上传到 APISIX
|
||||
3. 自动续期管理
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import requests
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime, timedelta
|
||||
import base64
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('/var/log/apisix-ssl-manager.log'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 默认配置(可通过环境变量覆盖)
|
||||
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': False, # 默认使用 staging 模式,生产环境改为 False
|
||||
'webroot_path': '/var/www/certbot'
|
||||
}
|
||||
|
||||
|
||||
class APISIXSSLManager:
|
||||
"""APISIX SSL 证书管理器"""
|
||||
|
||||
def __init__(self, config_path: str = None):
|
||||
"""初始化管理器"""
|
||||
# 从环境变量或默认配置加载
|
||||
self.apisix_admin_url = os.getenv('APISIX_ADMIN_URL', DEFAULT_CONFIG['apisix_admin_url'])
|
||||
self.apisix_admin_key = os.getenv('APISIX_ADMIN_KEY', DEFAULT_CONFIG['apisix_admin_key'])
|
||||
self.certbot_path = os.getenv('CERTBOT_PATH', DEFAULT_CONFIG['certbot_path'])
|
||||
self.cert_dir = os.getenv('CERT_DIR', DEFAULT_CONFIG['cert_dir'])
|
||||
self.email = os.getenv('LETSENCRYPT_EMAIL', DEFAULT_CONFIG['letsencrypt_email'])
|
||||
self.staging = os.getenv('LETSENCRYPT_STAGING', str(DEFAULT_CONFIG['letsencrypt_staging'])).lower() == 'true'
|
||||
self.webroot_path = os.getenv('WEBROOT_PATH', DEFAULT_CONFIG['webroot_path'])
|
||||
|
||||
# 如果提供了配置文件,从文件加载(覆盖环境变量和默认值)
|
||||
if config_path and os.path.exists(config_path):
|
||||
self.load_config(config_path)
|
||||
|
||||
# 验证配置
|
||||
self._validate_config()
|
||||
|
||||
def load_config(self, config_path: str):
|
||||
"""从配置文件加载配置(可选,用于覆盖默认配置)"""
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
self.apisix_admin_url = config.get('apisix_admin_url', self.apisix_admin_url)
|
||||
self.apisix_admin_key = config.get('apisix_admin_key', self.apisix_admin_key)
|
||||
self.certbot_path = config.get('certbot_path', self.certbot_path)
|
||||
self.cert_dir = config.get('cert_dir', self.cert_dir)
|
||||
self.email = config.get('letsencrypt_email', self.email)
|
||||
self.staging = config.get('letsencrypt_staging', self.staging)
|
||||
self.webroot_path = config.get('webroot_path', self.webroot_path)
|
||||
|
||||
def _validate_config(self):
|
||||
"""验证配置"""
|
||||
if not self.email:
|
||||
logger.warning("未设置 Let's Encrypt 邮箱,建议设置以便接收证书到期提醒")
|
||||
if not os.path.exists(self.certbot_path):
|
||||
raise FileNotFoundError(f"Certbot 未找到: {self.certbot_path}")
|
||||
|
||||
def _get_apisix_headers(self) -> 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()
|
||||
17
ssl_manager/systemd/apisix-route-watcher.service
Normal file
17
ssl_manager/systemd/apisix-route-watcher.service
Normal file
@ -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
|
||||
13
ssl_manager/systemd/apisix-ssl-renew.service
Normal file
13
ssl_manager/systemd/apisix-ssl-renew.service
Normal file
@ -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
|
||||
13
ssl_manager/systemd/apisix-ssl-renew.timer
Normal file
13
ssl_manager/systemd/apisix-ssl-renew.timer
Normal file
@ -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
|
||||
43
ssl_manager/test_example.sh
Executable file
43
ssl_manager/test_example.sh
Executable file
@ -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
|
||||
590
ssl_manager/test_ssl_auto.py
Executable file
590
ssl_manager/test_ssl_auto.py
Executable file
@ -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()
|
||||
14
ssl_manager/webroot_route.json
Normal file
14
ssl_manager/webroot_route.json
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user