增加ssl_manager功能实现创建路由时自动申请并续期ssl免费证书,测试成功

This commit is contained in:
jingrow 2026-01-01 15:21:37 +00:00
parent 8c8e8dc8e8
commit 7d885471c0
27 changed files with 3130 additions and 4 deletions

View File

@ -15,10 +15,10 @@ authentication:
secret secret
expire_time: 3600 expire_time: 3600
users: users:
- username: admin - username: jingrow2025
password: admin password: JA9d#3kL8pQz!2Xv
- username: user - username: jingrowuser
password: user password: JA9d#3kL8pQz!1VA
plugin_attr: plugin_attr:
prometheus: prometheus:
export_addr: export_addr:

View File

@ -23,6 +23,7 @@ services:
restart: always restart: always
volumes: volumes:
- ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro - ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
- /var/www/certbot:/var/www/certbot:ro
depends_on: depends_on:
- etcd - etcd
##network_mode: host ##network_mode: host

121
ssl_manager/CONFIG.md Normal file
View 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. **安全性**:不要将包含敏感信息的配置文件提交到版本控制系统

View 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

View 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 访问。

View 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
View 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
View 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
View 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
```

Binary file not shown.

Binary file not shown.

View 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
View 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"
}

View 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 "=== 完成 ==="

View 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
View 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
View 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

View File

@ -0,0 +1 @@
requests>=2.28.0

237
ssl_manager/route_watcher.py Executable file
View 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()

View 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
View 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:
# 创建新证书(创建时不需要 idAPISIX 会自动生成)
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()

View 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

View 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

View 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
View 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
View 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()

View 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
}