Compare commits

...

2 Commits

Author SHA1 Message Date
0751ce1e6d 1111 2025-10-22 11:42:12 +08:00
b60284f8dc lightail-init 2025-10-22 11:38:04 +08:00
3 changed files with 282 additions and 73 deletions

2
.env
View File

@ -1,3 +1,3 @@
AWS_REGION=ap-east-1 AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=AKIA6JQ45ADS6JTBQ3L3 AWS_ACCESS_KEY_ID=AKIA6JQ45ADS6JTBQ3L3
AWS_SECRET_ACCESS_KEY=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 AWS_SECRET_ACCESS_KEY=QB9nTtc12tDF0qT9StdEL11yx9wlt138tlsLJKDm

220
README.md Normal file
View File

@ -0,0 +1,220 @@
# Lightsail 静态IP 更换工具
一个用于自动更换 AWS Lightsail 实例静态IP的Web工具支持自动区域检测和实例定位。
## 功能特性
- 🔍 **自动区域检测** - 输入静态IP地址后自动定位所属AWS区域
- 🎯 **自动实例定位** - 自动找到绑定的Lightsail实例
- 🆕 **新IP分配** - 自动分配新的静态IP地址
- 🔗 **自动绑定** - 将新静态IP绑定到实例
- 🗑️ **可选释放** - 可选择是否释放旧静态IP
- 📊 **操作日志** - 记录所有操作历史和状态
- 🔄 **重试机制** - 处理临时错误和API限流
- 🌐 **Web界面** - 简洁易用的Web操作界面
## 系统要求
- Python 3.7+
- AWS账户和相应的API权限
- 网络访问AWS Lightsail服务
## 安装步骤
### 1. 克隆项目
```bash
git clone <your-repo-url>
cd Ec2ElasticIpSwapper
```
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 配置AWS凭证
#### 方法一:环境变量(推荐)
创建 `.env` 文件:
```bash
# .env
AWS_REGION=ap-northeast-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=xxxx...
```
#### 方法二AWS CLI配置
```bash
aws configure
```
#### 方法三IAM角色EC2实例
如果运行在EC2实例上可以配置IAM角色。
### 4. 启动服务
```bash
python -m uvicorn app:app --host 0.0.0.0 --port 9099
```
### 5. 访问Web界面
打开浏览器访问:`http://localhost:9099`
## 使用方法
1. **输入当前静态IP** - 在输入框中输入当前绑定在Lightsail实例上的静态IP地址
2. **点击更换按钮** - 系统会自动:
- 检测IP所属区域
- 找到绑定的实例
- 分配新的静态IP
- 绑定到实例
- 释放旧静态IP可选
3. **查看操作日志** - 在下方表格中查看所有操作记录
## 所需AWS权限
确保您的AWS凭证具有以下权限
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"lightsail:GetStaticIps",
"lightsail:AllocateStaticIp",
"lightsail:AttachStaticIp",
"lightsail:DetachStaticIp",
"lightsail:ReleaseStaticIp",
"lightsail:GetInstance",
"lightsail:GetRegions",
"sts:GetCallerIdentity"
],
"Resource": "*"
}
]
}
```
## API接口
### 更换静态IP
```http
POST /api/rotate_by_ip
Content-Type: application/json
{
"current_ip": "1.2.3.4",
"release_old": true
}
```
**响应示例:**
```json
{
"ok": true,
"region": "ap-northeast-1",
"instance_id": "my-instance",
"old_ip": "1.2.3.4",
"new_ip": "5.6.7.8",
"old_released": true
}
```
### 获取操作日志
```http
GET /api/logs
```
### 清空操作日志
```http
POST /api/logs/clear
```
### 健康检查
```http
GET /healthz
```
## 配置说明
### 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `AWS_REGION` | 起始区域(用于获取区域列表) | `us-east-1` |
| `AWS_ACCESS_KEY_ID` | AWS访问密钥ID | - |
| `AWS_SECRET_ACCESS_KEY` | AWS秘密访问密钥 | - |
### 重试配置
代码中内置了重试机制,用于处理临时错误:
- **最大重试次数**: 3次
- **重试间隔**: 0.8秒(指数退避)
- **重试条件**: 实例状态错误、请求限制、限流异常等
## 故障排除
### 常见错误
1. **"静态IP not found in any region"**
- 检查IP地址是否正确
- 确认IP属于当前AWS账户
- 验证AWS凭证权限
2. **"静态IP is not attached to any instance"**
- 确认静态IP已绑定到Lightsail实例
- 检查实例状态是否正常
3. **权限错误**
- 检查AWS凭证是否正确配置
- 确认具有所需的Lightsail权限
### 日志查看
- 在Web界面下方查看操作日志
- 每个操作都会记录时间、区域、实例、IP地址、状态等信息
## 注意事项
- ⚠️ **备份重要数据** - 更换IP可能影响服务连接
- ⚠️ **DNS更新** - 更换IP后需要更新相关DNS记录
- ⚠️ **防火墙规则** - 检查安全组和防火墙规则
- ⚠️ **费用考虑** - 未使用的静态IP会产生费用建议及时释放
## 技术栈
- **后端**: FastAPI + Python
- **AWS SDK**: Boto3
- **前端**: 原生HTML/CSS/JavaScript
- **服务器**: Uvicorn ASGI
## 许可证
MIT License
## 贡献
欢迎提交Issue和Pull Request
## 更新日志
### v1.0.0
- 初始版本
- 支持Lightsail静态IP自动更换
- Web界面和API接口
- 自动区域检测和实例定位

133
app.py
View File

@ -1,6 +1,6 @@
# EC2 EIP Rotator — Auto-Region + Loading Overlay (Optimized) # Lightsail Static IP Rotator — Auto-Region + Loading Overlay (Optimized)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# 只输入“当前 EIP 公网IP”→ 自动定位其所在区域与实例 → 分配新EIP → 绑定 → 可选释放旧EIP # 只输入“当前静态IP公网IP”→ 自动定位其所在区域与实例 → 分配新静态IP → 绑定 → 可选释放旧静态IP
# 启动: # 启动:
# pip install -r requirements.txt # pip install -r requirements.txt
# python -m uvicorn app:app --host 0.0.0.0 --port 9099 # python -m uvicorn app:app --host 0.0.0.0 --port 9099
@ -50,7 +50,7 @@ IPv4_RE = re.compile(
r"(25[0-5]|2[0-4]\d|[01]?\d?\d)$" r"(25[0-5]|2[0-4]\d|[01]?\d?\d)$"
) )
app = FastAPI(title="EC2 EIP Rotator (Auto-Region, Optimized)") app = FastAPI(title="Lightsail Static IP Rotator (Auto-Region, Optimized)")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@ -60,8 +60,8 @@ app.add_middleware(
) )
# ---------------- helpers ---------------- # ---------------- helpers ----------------
def ec2_in(region: str): def lightsail_in(region: str):
return boto3.client("ec2", region_name=region) return boto3.client("lightsail", region_name=region)
def sts_in(region: str): def sts_in(region: str):
return boto3.client("sts", region_name=region) return boto3.client("sts", region_name=region)
@ -78,10 +78,10 @@ def account_id(seed_region: str) -> str:
def list_regions(seed_region: str) -> List[str]: def list_regions(seed_region: str) -> List[str]:
"""从 seed 区域取区域清单;失败则回退 us-east-1。""" """从 seed 区域取区域清单;失败则回退 us-east-1。"""
try: try:
regions = ec2_in(seed_region).describe_regions(AllRegions=False)["Regions"] regions = lightsail_in(seed_region).get_regions()["regions"]
except Exception: except Exception:
regions = ec2_in("us-east-1").describe_regions(AllRegions=False)["Regions"] regions = lightsail_in("us-east-1").get_regions()["regions"]
return [r["RegionName"] for r in regions] return [r["name"] for r in regions]
def json_error(message: str, status: int = 400): def json_error(message: str, status: int = 400):
raise HTTPException(status_code=status, detail=message) raise HTTPException(status_code=status, detail=message)
@ -136,8 +136,8 @@ def rotate_by_ip(body: Dict[str, Any] = Body(...)):
"release_old": true "release_old": true
} }
步骤: 步骤:
1) 枚举区域, describe_addresses(PublicIps=[ip]) 找到归属区 1) 枚举区域, get_static_ips() 找到归属区
2) InstanceId -> allocate_address(Domain='vpc') -> associate -> (可选) release 旧EIP 2) InstanceName -> allocate_static_ip() -> attach_static_ip() -> (可选) release 旧静态IP
""" """
current_ip = (body.get("current_ip") or "").strip() current_ip = (body.get("current_ip") or "").strip()
release_old = bool(body.get("release_old", True)) release_old = bool(body.get("release_old", True))
@ -156,88 +156,77 @@ def rotate_by_ip(body: Dict[str, Any] = Body(...)):
# 1) 自动查找 IP 归属区域 # 1) 自动查找 IP 归属区域
for region in regions: for region in regions:
try: try:
out = ec2_in(region).describe_addresses(PublicIps=[current_ip]) out = lightsail_in(region).get_static_ips()
arr = out.get("Addresses", []) arr = out.get("staticIps", [])
if arr: for static_ip in arr:
home_region = region if static_ip.get("ipAddress") == current_ip:
addr = arr[0] home_region = region
addr = static_ip
break
if home_region:
break break
except ClientError as e: except ClientError as e:
code = e.response.get("Error", {}).get("Code") code = e.response.get("Error", {}).get("Code")
# 不是该区会抛 InvalidAddress.NotFound / InvalidParameterValue,忽略 # 不是该区会抛 NotFoundException,忽略
if code in ("InvalidAddress.NotFound", "InvalidParameterValue"): if code in ("NotFoundException", "InvalidParameterValue"):
continue continue
json_error(str(e), 400) json_error(str(e), 400)
if not home_region or not addr: if not home_region or not addr:
remark = "EIP 不属于当前账号的任一区域,或不是 Elastic IP" remark = "静态IP 不属于当前账号的任一区域,或不是 Lightsail 静态IP"
append_log({ append_log({
"time": now_ts(), "region": "-", "instance_name": "-", "time": now_ts(), "region": "-", "instance_name": "-",
"instance_id": "-", "arn": "-", "old_ip": current_ip, "instance_id": "-", "arn": "-", "old_ip": current_ip,
"new_ip": "-", "retry": 0, "status": "FAIL", "remark": remark, "new_ip": "-", "retry": 0, "status": "FAIL", "remark": remark,
}) })
json_error(f"EIP {current_ip} not found in any region of this account", 404) json_error(f"静态IP {current_ip} not found in any region of this account", 404)
# 2) 在归属区执行更换 # 2) 在归属区执行更换
ec2 = ec2_in(home_region) lightsail = lightsail_in(home_region)
inst_id = addr.get("InstanceId") inst_name = addr.get("attachedTo")
old_alloc = addr.get("AllocationId") old_static_ip_name = addr.get("name")
if not inst_id: if not inst_name:
remark = " EIP 未绑定到任何实例" remark = "静态IP 未绑定到任何实例"
append_log({ append_log({
"time": now_ts(), "region": home_region, "instance_name": "-", "time": now_ts(), "region": home_region, "instance_name": "-",
"instance_id": "-", "arn": "-", "old_ip": current_ip, "instance_id": "-", "arn": "-", "old_ip": current_ip,
"new_ip": "-", "retry": 0, "status": "FAIL", "remark": remark, "new_ip": "-", "retry": 0, "status": "FAIL", "remark": remark,
}) })
json_error(f"EIP {current_ip} is not attached to any instance", 400) json_error(f"静态IP {current_ip} is not attached to any instance", 400)
# 实例名(日志显示) # 实例名(日志显示)
inst_name = inst_id
try: try:
di = ec2.describe_instances(InstanceIds=[inst_id]) di = lightsail.get_instance(instanceName=inst_name)
for r in di.get("Reservations", []): inst_name = di.get("instance", {}).get("name", inst_name)
for ins in r.get("Instances", []):
for t in ins.get("Tags", []):
if t.get("Key") == "Name":
inst_name = t.get("Value")
except Exception: except Exception:
pass pass
try: try:
# allocate new # allocate new static IP
new_addr = with_retry(ec2.allocate_address, Domain="vpc") new_static_ip_name = f"rotated-{int(time.time())}"
new_alloc = new_addr["AllocationId"] with_retry(lightsail.allocate_static_ip, staticIpName=new_static_ip_name)
new_public_ip = new_addr["PublicIp"]
# 获取新静态IP的详细信息
new_static_ip_info = lightsail.get_static_ip(staticIpName=new_static_ip_name)
new_public_ip = new_static_ip_info["staticIp"]["ipAddress"]
# 给新 EIP 打标签(便于审计/回溯) # attach to instance
try:
ec2.create_tags(
Resources=[new_alloc],
Tags=[
{"Key": "RotatedFrom", "Value": current_ip},
{"Key": "RotatedAt", "Value": now_ts()},
{"Key": "Rotator", "Value": "auto-region-ui"},
],
)
except ClientError:
# 标签失败不影响主流程
pass
# associate to instance (force)
with_retry( with_retry(
ec2.associate_address, lightsail.attach_static_ip,
AllocationId=new_alloc, staticIpName=new_static_ip_name,
InstanceId=inst_id, instanceName=inst_name,
AllowReassociation=True,
) )
# optional release old # optional release old
remark = "success" remark = "success"
released = False released = False
if release_old and old_alloc and old_alloc != new_alloc: if release_old and old_static_ip_name and old_static_ip_name != new_static_ip_name:
try: try:
with_retry(ec2.release_address, AllocationId=old_alloc) # 先分离旧静态IP
with_retry(lightsail.detach_static_ip, staticIpName=old_static_ip_name)
# 再释放旧静态IP
with_retry(lightsail.release_static_ip, staticIpName=old_static_ip_name)
released = True released = True
except ClientError as re: except ClientError as re:
remark = f"old_release_error: {re}" remark = f"old_release_error: {re}"
@ -246,8 +235,8 @@ def rotate_by_ip(body: Dict[str, Any] = Body(...)):
"time": now_ts(), "time": now_ts(),
"region": home_region, "region": home_region,
"instance_name": inst_name, "instance_name": inst_name,
"instance_id": inst_id, "instance_id": inst_name, # Lightsail使用实例名作为ID
"arn": f"arn:aws:ec2:{home_region}:{acct}:instance/{inst_id}", "arn": f"arn:aws:lightsail:{home_region}:{acct}:instance/{inst_name}",
"old_ip": current_ip, "old_ip": current_ip,
"new_ip": new_public_ip, "new_ip": new_public_ip,
"retry": 0, "retry": 0,
@ -258,7 +247,7 @@ def rotate_by_ip(body: Dict[str, Any] = Body(...)):
return { return {
"ok": True, "ok": True,
"region": home_region, "region": home_region,
"instance_id": inst_id, "instance_id": inst_name,
"old_ip": current_ip, "old_ip": current_ip,
"new_ip": new_public_ip, "new_ip": new_public_ip,
"old_released": released "old_released": released
@ -269,8 +258,8 @@ def rotate_by_ip(body: Dict[str, Any] = Body(...)):
"time": now_ts(), "time": now_ts(),
"region": home_region, "region": home_region,
"instance_name": inst_name, "instance_name": inst_name,
"instance_id": inst_id, "instance_id": inst_name,
"arn": f"arn:aws:ec2:{home_region}:{acct}:instance/{inst_id}", "arn": f"arn:aws:lightsail:{home_region}:{acct}:instance/{inst_name}",
"old_ip": current_ip, "old_ip": current_ip,
"new_ip": "-", "new_ip": "-",
"retry": 0, "retry": 0,
@ -287,7 +276,7 @@ INDEX_HTML = Template(r"""
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Lightsail IP 更换面板EC2 自动区域</title> <title>Lightsail 静态IP 更换面板自动区域</title>
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
body { margin:0; background:#0b1020; color:#e2e8f0; font-family: system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"PingFang SC","Microsoft YaHei",sans-serif; } body { margin:0; background:#0b1020; color:#e2e8f0; font-family: system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"PingFang SC","Microsoft YaHei",sans-serif; }
@ -328,22 +317,22 @@ INDEX_HTML = Template(r"""
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<h1>Lightsail IP 更换面板EC2 自动区域</h1> <h1>Lightsail 静态IP 更换面板自动区域</h1>
<div class="card hint" style="margin-bottom:12px"> <div class="card hint" style="margin-bottom:12px">
请输入<b>当前绑定在实例上的 EIP 公网IP</b>系统会<b>自动定位所属区域与实例</b>分配<b></b>EIP并绑定然后可选释放旧EIP<br/> 请输入<b>当前绑定在实例上的静态IP公网IP</b>系统会<b>自动定位所属区域与实例</b>分配<b></b>静态IP并绑定然后可选释放旧静态IP<br/>
无需选择区域/实例需权限DescribeAddresses / AllocateAddress / AssociateAddress / ReleaseAddress<br/> 无需选择区域/实例需权限GetStaticIps / AllocateStaticIp / AttachStaticIp / DetachStaticIp / ReleaseStaticIp<br/>
</div> </div>
<div class="card"> <div class="card">
<div class="row"> <div class="row">
<span>当前 EIP 公网IP</span> <span>当前静态IP 公网IP</span>
<input id="inpIp" type="text" placeholder="例如 18.183.203.98" inputmode="numeric" <input id="inpIp" type="text" placeholder="例如 18.183.203.98" inputmode="numeric"
pattern="^(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)$" /> pattern="^(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)$" />
<label class="muted" style="display:none;align-items:center;gap:6px"> <label class="muted" style="display:none;align-items:center;gap:6px">
<input id="chkRelease" type="checkbox" checked/> 更换后释放旧EIP <input id="chkRelease" type="checkbox" checked/> 更换后释放旧静态IP
</label> </label>
<button id="btnRotate" class="primary">换新EIP自动区域</button> <button id="btnRotate" class="primary">换新静态IP自动区域</button>
</div> </div>
</div> </div>
@ -439,17 +428,17 @@ INDEX_HTML = Template(r"""
btnRotate.onclick = async ()=>{ btnRotate.onclick = async ()=>{
const ip = (inpIp.value||'').trim(); const ip = (inpIp.value||'').trim();
if(!ip) return alert('请输入当前 EIP 公网IP'); if(!ip) return alert('请输入当前静态IP 公网IP');
if(!IPv4RE.test(ip)) return alert('请输入有效的 IPv4 地址'); if(!IPv4RE.test(ip)) return alert('请输入有效的 IPv4 地址');
showLoading(`正在为 ${ip} 更换 EIP `); showLoading(`正在为 ${ip} 更换静态IP `);
try{ try{
await api('/api/rotate_by_ip', { await api('/api/rotate_by_ip', {
method: 'POST', method: 'POST',
body: JSON.stringify({ current_ip: ip, release_old: true }) body: JSON.stringify({ current_ip: ip, release_old: true })
}); });
hideLoading(); hideLoading();
alert('换新EIP成功'); alert('换新静态IP成功');
await reloadLogs(); await reloadLogs();
}catch(e){ }catch(e){
hideLoading(); hideLoading();