diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfcb250 --- /dev/null +++ b/README.md @@ -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 +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界面 diff --git a/app.py b/app.py index 22076d2..d21325b 100644 --- a/app.py +++ b/app.py @@ -15,6 +15,8 @@ from __future__ import annotations import os import re import time +import signal +import sys from datetime import datetime, timezone 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")) -# 轻量重试参数(关联/释放时用) +# 轻量重试参数(关联/释放/重启时用) RETRY_ON = { "IncorrectInstanceState", "RequestLimitExceeded", "Throttling", "ThrottlingException", "DependencyViolation", + "InvalidInstanceID.NotFound", + "InvalidInstanceState", } MAX_RETRY = 3 RETRY_BASE_SLEEP = 0.8 # s @@ -118,6 +122,115 @@ def clear_logs(): LOGS.clear() 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") def healthz(): return {"ok": True, "seed_region": SEED_REGION} @@ -355,6 +468,7 @@ INDEX_HTML = Template(r"""
+
@@ -384,6 +498,7 @@ INDEX_HTML = Template(r""" const btnRotate = $('#btnRotate'); const btnReload = $('#btnReload'); const btnClear = $('#btnClear'); + const btnRestart = $('#btnRestart'); const tbody = $('#tblLogs tbody'); const overlay = $('#overlay'); const loaderTxt = $('#loaderText'); @@ -465,6 +580,27 @@ INDEX_HTML = Template(r""" 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(); })();