Compare commits

...

1 Commits

Author SHA1 Message Date
8c7d447c05 Add EC2 instance restart functionality and update UI 2025-10-24 14:16:01 +08:00
2 changed files with 326 additions and 1 deletions

189
README.md Normal file
View File

@ -0,0 +1,189 @@
# EC2 EIP Rotator - 自动区域EIP更换工具
一个基于FastAPI的AWS EC2 Elastic IP自动更换工具支持自动区域检测和Web界面操作。
## 功能特性
- 🔍 **自动区域检测**只需输入当前EIP公网IP自动定位所属区域和实例
- 🚀 **一键更换**自动分配新EIP并绑定到实例
- 🗑️ **可选释放**可选择是否释放旧的EIP
- 📊 **操作记录**:完整的操作日志记录和查看
- 🔄 **软重启**:支持服务器软重启功能
- 🎨 **现代UI**响应式Web界面支持深色主题
## 系统要求
- Python 3.8+
- AWS账户和相应的API权限
- 网络连接用于访问AWS服务
## 安装步骤
### 1. 克隆项目
```bash
git clone <your-repo-url>
cd Ec2ElasticIpSwapper
```
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 配置环境变量
创建 `.env` 文件并配置AWS凭证
```env
# AWS配置仅用作起始区域获取区域清单
AWS_REGION=ap-northeast-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=xxxx...
```
**注意**`AWS_REGION` 仅用作获取区域清单的起始区域,真正的操作区域由自动检测结果决定。
## 启动服务
```bash
python -m uvicorn app:app --host 0.0.0.0 --port 9099
```
服务启动后,访问 `http://localhost:9099` 即可使用Web界面。
## 使用方法
### Web界面操作
1. **打开浏览器**访问 `http://localhost:9099`
2. **输入当前EIP**:在"当前 EIP 公网IP"字段输入要更换的EIP地址
3. **点击"换新EIP"**:系统会自动:
- 检测EIP所属区域和实例
- 分配新的EIP
- 绑定到实例
- 可选释放旧EIP
4. **查看记录**:在"更换记录"表格中查看所有操作历史
### API接口
#### 更换EIP
```bash
curl -X POST http://localhost:9099/api/rotate_by_ip \
-H "Content-Type: application/json" \
-d '{"current_ip": "1.2.3.4", "release_old": true}'
```
#### 查看日志
```bash
curl http://localhost:9099/api/logs
```
#### 清空日志
```bash
curl -X POST http://localhost:9099/api/logs/clear
```
#### 重启服务器
```bash
curl -X POST http://localhost:9099/api/restart
```
#### 健康检查
```bash
curl http://localhost:9099/healthz
```
## 权限要求
确保您的AWS凭证具有以下权限
- `ec2:DescribeAddresses` - 查询EIP信息
- `ec2:DescribeRegions` - 获取区域列表
- `ec2:DescribeInstances` - 查询实例信息
- `ec2:AllocateAddress` - 分配新EIP
- `ec2:AssociateAddress` - 绑定EIP到实例
- `ec2:ReleaseAddress` - 释放EIP
- `ec2:CreateTags` - 为新EIP添加标签
## 项目结构
```
Ec2ElasticIpSwapper/
├── app.py # 主应用文件
├── requirements.txt # Python依赖
├── README.md # 项目说明
└── .env # 环境变量配置(需要创建)
```
## 技术架构
- **后端框架**FastAPI
- **AWS SDK**boto3
- **前端**原生HTML/CSS/JavaScript
- **模板引擎**Jinja2
- **服务器**Uvicorn
## 功能说明
### 自动区域检测
系统会自动遍历所有AWS区域查找指定EIP的归属区域无需手动选择区域。
### 重试机制
对于偶发的状态错误和限流系统会自动重试最多3次确保操作成功率。
### 操作日志
所有操作都会记录到内存日志中,包括:
- 操作时间
- 区域信息
- 实例信息
- 旧IP和新IP
- 操作状态
- 错误信息
### 软重启功能
支持通过Web界面或API进行服务器软重启使用SIGTERM信号优雅关闭。
## 故障排除
### 常见问题
1. **EIP未找到**
- 确认EIP属于当前AWS账户
- 确认EIP是Elastic IP而非普通公网IP
2. **权限不足**
- 检查AWS凭证配置
- 确认具有必要的EC2权限
3. **网络连接问题**
- 确认网络连接正常
- 检查防火墙设置
### 日志查看
通过Web界面的"更换记录"表格或API接口 `/api/logs` 查看详细的操作日志。
## 开发说明
### 本地开发
```bash
# 安装开发依赖
pip install -r requirements.txt
# 启动开发服务器
python -m uvicorn app:app --host 0.0.0.0 --port 9099 --reload
```
### 生产部署
建议使用进程管理器如systemd、supervisor或Docker进行部署。
## 许可证
本项目采用MIT许可证。
## 贡献
欢迎提交Issue和Pull Request来改进这个项目。
## 更新日志
- **v1.0.0**初始版本支持自动区域检测和EIP更换
- **v1.1.0**添加软重启功能和改进的UI界面

138
app.py
View File

@ -15,6 +15,8 @@ from __future__ import annotations
import os import os
import re import re
import time import time
import signal
import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
@ -31,13 +33,15 @@ load_dotenv()
# 仅用作“起始区域”去拿区域清单;真正的操作区域由自动探测结果决定 # 仅用作“起始区域”去拿区域清单;真正的操作区域由自动探测结果决定
SEED_REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1")) SEED_REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
# 轻量重试参数(关联/释放时用) # 轻量重试参数(关联/释放/重启时用)
RETRY_ON = { RETRY_ON = {
"IncorrectInstanceState", "IncorrectInstanceState",
"RequestLimitExceeded", "RequestLimitExceeded",
"Throttling", "Throttling",
"ThrottlingException", "ThrottlingException",
"DependencyViolation", "DependencyViolation",
"InvalidInstanceID.NotFound",
"InvalidInstanceState",
} }
MAX_RETRY = 3 MAX_RETRY = 3
RETRY_BASE_SLEEP = 0.8 # s RETRY_BASE_SLEEP = 0.8 # s
@ -118,6 +122,115 @@ def clear_logs():
LOGS.clear() LOGS.clear()
return {"ok": True} return {"ok": True}
@app.post("/api/restart")
def restart_ec2_instance(body: Dict[str, Any] = Body(...)):
"""
重启AWS EC2实例功能
根据IP地址找到对应的EC2实例并重启
"""
current_ip = (body.get("current_ip") or "").strip()
if not current_ip:
json_error("current_ip required", 422)
if not is_ipv4(current_ip):
json_error(f"invalid IPv4: {current_ip}", 422)
acct = account_id(SEED_REGION)
regions = list_regions(SEED_REGION)
home_region: Optional[str] = None
addr: Optional[Dict[str, Any]] = None
# 1) 自动查找 IP 归属区域
for region in regions:
try:
out = ec2_in(region).describe_addresses(PublicIps=[current_ip])
arr = out.get("Addresses", [])
if arr:
home_region = region
addr = arr[0]
break
except ClientError as e:
code = e.response.get("Error", {}).get("Code")
# 不是该区会抛 InvalidAddress.NotFound / InvalidParameterValue忽略
if code in ("InvalidAddress.NotFound", "InvalidParameterValue"):
continue
json_error(str(e), 400)
if not home_region or not addr:
remark = "EIP 不属于当前账号的任一区域,或不是 Elastic IP"
append_log({
"time": now_ts(), "region": "-", "instance_name": "-",
"instance_id": "-", "arn": "-", "old_ip": current_ip,
"new_ip": "-", "retry": 0, "status": "FAIL", "remark": remark,
})
json_error(f"EIP {current_ip} not found in any region of this account", 404)
# 2) 获取实例信息
ec2 = ec2_in(home_region)
inst_id = addr.get("InstanceId")
if not inst_id:
remark = "该 EIP 未绑定到任何实例"
append_log({
"time": now_ts(), "region": home_region, "instance_name": "-",
"instance_id": "-", "arn": "-", "old_ip": current_ip,
"new_ip": "-", "retry": 0, "status": "FAIL", "remark": remark,
})
json_error(f"EIP {current_ip} is not attached to any instance", 400)
# 实例名(日志显示)
inst_name = inst_id
try:
di = ec2.describe_instances(InstanceIds=[inst_id])
for r in di.get("Reservations", []):
for ins in r.get("Instances", []):
for t in ins.get("Tags", []):
if t.get("Key") == "Name":
inst_name = t.get("Value")
except Exception:
pass
try:
# 重启EC2实例
with_retry(ec2.reboot_instances, InstanceIds=[inst_id])
append_log({
"time": now_ts(),
"region": home_region,
"instance_name": inst_name,
"instance_id": inst_id,
"arn": f"arn:aws:ec2:{home_region}:{acct}:instance/{inst_id}",
"old_ip": current_ip,
"new_ip": current_ip, # IP不变
"retry": 0,
"status": "OK",
"remark": "EC2实例重启成功",
})
return {
"ok": True,
"region": home_region,
"instance_id": inst_id,
"instance_name": inst_name,
"message": "EC2实例重启成功"
}
except ClientError as e:
append_log({
"time": now_ts(),
"region": home_region,
"instance_name": inst_name,
"instance_id": inst_id,
"arn": f"arn:aws:ec2:{home_region}:{acct}:instance/{inst_id}",
"old_ip": current_ip,
"new_ip": "-",
"retry": 0,
"status": "FAIL",
"remark": f"重启失败: {str(e)}",
})
json_error(f"重启EC2实例失败: {str(e)}", 400)
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
return {"ok": True, "seed_region": SEED_REGION} return {"ok": True, "seed_region": SEED_REGION}
@ -355,6 +468,7 @@ INDEX_HTML = Template(r"""
<div> <div>
<button id="btnReload">刷新记录</button> <button id="btnReload">刷新记录</button>
<button id="btnClear" class="danger">清空记录</button> <button id="btnClear" class="danger">清空记录</button>
<button id="btnRestart" class="danger">重启EC2实例</button>
</div> </div>
</div> </div>
<table id="tblLogs"> <table id="tblLogs">
@ -384,6 +498,7 @@ INDEX_HTML = Template(r"""
const btnRotate = $('#btnRotate'); const btnRotate = $('#btnRotate');
const btnReload = $('#btnReload'); const btnReload = $('#btnReload');
const btnClear = $('#btnClear'); const btnClear = $('#btnClear');
const btnRestart = $('#btnRestart');
const tbody = $('#tblLogs tbody'); const tbody = $('#tblLogs tbody');
const overlay = $('#overlay'); const overlay = $('#overlay');
const loaderTxt = $('#loaderText'); const loaderTxt = $('#loaderText');
@ -465,6 +580,27 @@ INDEX_HTML = Template(r"""
await reloadLogs(); await reloadLogs();
}; };
btnRestart.onclick = async ()=>{
const ip = (inpIp.value||'').trim();
if(!ip) return alert('请先输入当前 EIP 公网IP');
if(!IPv4RE.test(ip)) return alert('请输入有效的 IPv4 地址');
if(!confirm(`确定要重启绑定到 ${ip} 的EC2实例吗`)) return;
showLoading(`正在重启绑定到 ${ip} 的EC2实例...`);
try{
await api('/api/restart', {
method:'POST',
body: JSON.stringify({ current_ip: ip })
});
hideLoading();
alert('EC2实例重启成功');
await reloadLogs();
}catch(e){
hideLoading();
alert('重启失败:' + e.message);
}
};
reloadLogs(); reloadLogs();
})(); })();
</script> </script>