This commit is contained in:
wangqifan 2026-01-04 18:58:20 +08:00
parent 78d7645a8b
commit b135ed7476
7 changed files with 475 additions and 0 deletions

4
.env Normal file
View File

@ -0,0 +1,4 @@
FLASK_ENV=development
DATABASE_URL=mysql+pymysql://username:password@localhost:3306/ip_ops
AWS_CONFIG_PATH=config/accounts.yaml
IP_RETRY_LIMIT=5

74
app.py Normal file
View File

@ -0,0 +1,74 @@
import os
from typing import Dict
from dotenv import load_dotenv
from flask import Flask, jsonify, render_template, request
from aws_service import (
AWSOperationError,
ConfigError,
AccountConfig,
load_account_configs,
replace_instance_ip,
)
from db import init_db, load_disallowed_ips
load_dotenv()
app = Flask(__name__)
def load_configs() -> Dict[str, AccountConfig]:
config_path = os.getenv("AWS_CONFIG_PATH", "config/accounts.yaml")
return load_account_configs(config_path)
try:
account_configs = load_configs()
init_error = ""
except ConfigError as exc:
account_configs = {}
init_error = str(exc)
retry_limit = int(os.getenv("IP_RETRY_LIMIT", "5"))
try:
init_db()
db_error = ""
except Exception as exc: # noqa: BLE001 - surface DB connection issues to UI
db_error = f"数据库初始化失败: {exc}"
@app.route("/", methods=["GET"])
def index():
if init_error or db_error:
return render_template("index.html", accounts=[], init_error=init_error or db_error)
return render_template("index.html", accounts=account_configs.values(), init_error="")
@app.route("/replace_ip", methods=["POST"])
def replace_ip():
if init_error or db_error:
return jsonify({"error": init_error or db_error}), 500
ip_to_replace = request.form.get("ip_to_replace", "").strip()
account_name = request.form.get("account_name", "").strip()
if not ip_to_replace:
return jsonify({"error": "请输入要替换的IP"}), 400
if account_name not in account_configs:
return jsonify({"error": "无效的账户选择"}), 400
disallowed = load_disallowed_ips()
account = account_configs[account_name]
try:
result = replace_instance_ip(ip_to_replace, account, disallowed, retry_limit)
except AWSOperationError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(result), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)

158
aws_service.py Normal file
View File

@ -0,0 +1,158 @@
import os
from dataclasses import dataclass
from typing import Dict, List, Optional
import boto3
from botocore.exceptions import BotoCoreError, ClientError
import yaml
class ConfigError(Exception):
pass
class AWSOperationError(Exception):
pass
@dataclass
class AccountConfig:
name: str
region: str
access_key_id: str
secret_access_key: str
ami_id: str
instance_type: str
subnet_id: str
security_group_ids: List[str]
key_name: Optional[str] = None
def load_account_configs(path: str) -> Dict[str, AccountConfig]:
if not os.path.exists(path):
raise ConfigError(f"Config file not found at {path}")
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data or "accounts" not in data:
raise ConfigError("accounts.yaml missing 'accounts' list")
accounts = {}
for item in data["accounts"]:
cfg = AccountConfig(
name=item["name"],
region=item["region"],
access_key_id=item["access_key_id"],
secret_access_key=item["secret_access_key"],
ami_id=item["ami_id"],
instance_type=item["instance_type"],
subnet_id=item["subnet_id"],
security_group_ids=item.get("security_group_ids", []),
key_name=item.get("key_name"),
)
accounts[cfg.name] = cfg
return accounts
def ec2_client(account: AccountConfig):
return boto3.client(
"ec2",
region_name=account.region,
aws_access_key_id=account.access_key_id,
aws_secret_access_key=account.secret_access_key,
)
def _find_instance_id_by_ip(client, ip: str) -> Optional[str]:
filters = [
{"Name": "instance-state-name", "Values": ["pending", "running", "stopping", "stopped"]},
]
for field in ["ip-address", "private-ip-address"]:
try:
resp = client.describe_instances(Filters=filters + [{"Name": field, "Values": [ip]}])
except (ClientError, BotoCoreError) as exc:
raise AWSOperationError(f"Failed to describe instances: {exc}") from exc
for reservation in resp.get("Reservations", []):
for instance in reservation.get("Instances", []):
return instance["InstanceId"]
return None
def _wait_for_state(client, instance_id: str, waiter_name: str) -> None:
waiter = client.get_waiter(waiter_name)
waiter.wait(InstanceIds=[instance_id])
def _terminate_instance(client, instance_id: str) -> None:
try:
client.terminate_instances(InstanceIds=[instance_id])
_wait_for_state(client, instance_id, "instance_terminated")
except (ClientError, BotoCoreError) as exc:
raise AWSOperationError(f"Failed to terminate instance {instance_id}: {exc}") from exc
def _provision_instance(client, account: AccountConfig) -> str:
try:
params = {
"ImageId": account.ami_id,
"InstanceType": account.instance_type,
"MinCount": 1,
"MaxCount": 1,
"SubnetId": account.subnet_id,
"SecurityGroupIds": account.security_group_ids,
}
if account.key_name:
params["KeyName"] = account.key_name
resp = client.run_instances(**params)
instance_id = resp["Instances"][0]["InstanceId"]
_wait_for_state(client, instance_id, "instance_running")
return instance_id
except (ClientError, BotoCoreError) as exc:
raise AWSOperationError(f"Failed to create instance: {exc}") from exc
def _get_public_ip(client, instance_id: str) -> str:
try:
resp = client.describe_instances(InstanceIds=[instance_id])
reservations = resp.get("Reservations", [])
if not reservations:
raise AWSOperationError("Instance not found when reading IP")
instance = reservations[0]["Instances"][0]
return instance.get("PublicIpAddress") or ""
except (ClientError, BotoCoreError) as exc:
raise AWSOperationError(f"Failed to fetch public IP: {exc}") from exc
def _recycle_ip_until_free(client, instance_id: str, banned_ips: set[str], retry_limit: int) -> str:
attempts = 0
while attempts < retry_limit:
current_ip = _get_public_ip(client, instance_id)
if current_ip and current_ip not in banned_ips:
return current_ip
try:
client.stop_instances(InstanceIds=[instance_id])
_wait_for_state(client, instance_id, "instance_stopped")
client.start_instances(InstanceIds=[instance_id])
_wait_for_state(client, instance_id, "instance_running")
except (ClientError, BotoCoreError) as exc:
raise AWSOperationError(f"Failed while cycling IP: {exc}") from exc
attempts += 1
raise AWSOperationError("Reached retry limit while attempting to obtain a free IP")
def replace_instance_ip(
ip: str, account: AccountConfig, disallowed_ips: set[str], retry_limit: int = 5
) -> Dict[str, str]:
client = ec2_client(account)
instance_id = _find_instance_id_by_ip(client, ip)
if not instance_id:
raise AWSOperationError(f"No instance found with IP {ip}")
_terminate_instance(client, instance_id)
new_instance_id = _provision_instance(client, account)
new_ip = _recycle_ip_until_free(client, new_instance_id, disallowed_ips, retry_limit)
return {
"terminated_instance_id": instance_id,
"new_instance_id": new_instance_id,
"new_ip": new_ip,
}

20
config/accounts.yaml Normal file
View File

@ -0,0 +1,20 @@
accounts:
- name: 91-500f
region: us-east-1
access_key_id: AKIASAZ4PZBSBRYB7WFJ
secret_access_key: 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
ami_id: ami-xxxxxxxx
instance_type: t3.micro
subnet_id: subnet-xxxxxxxx
security_group_ids:
- sg-xxxxxxxx
key_name: optional-keypair-name
- name: account-two
region: us-west-2
access_key_id: YOUR_ACCESS_KEY_ID
secret_access_key: YOUR_SECRET_ACCESS_KEY
ami_id: ami-yyyyyyyy
instance_type: t3.micro
subnet_id: subnet-yyyyyyyy
security_group_ids:
- sg-yyyyyyyy

44
db.py Normal file
View File

@ -0,0 +1,44 @@
import os
from contextlib import contextmanager
from typing import Iterable
from sqlalchemy import Column, Integer, String, create_engine, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "mysql+pymysql://ec2_mt5:8FmzXj4xcz3AiH2R@163.123.183.106:3306/ip_ops")
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base()
class IPOperation(Base):
__tablename__ = "ip_operations"
id = Column(Integer, primary_key=True, autoincrement=True)
ip_address = Column(String(64), unique=True, nullable=False, index=True)
note = Column(String(255), nullable=True)
def init_db() -> None:
Base.metadata.create_all(bind=engine)
@contextmanager
def db_session():
session = SessionLocal()
try:
yield session
session.commit()
except SQLAlchemyError:
session.rollback()
raise
finally:
session.close()
def load_disallowed_ips() -> set[str]:
with db_session() as session:
rows: Iterable[IPOperation] = session.scalars(select(IPOperation.ip_address))
return {row for row in rows}

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
Flask==3.0.2
boto3==1.34.14
PyMySQL==1.1.0
SQLAlchemy==2.0.25
python-dotenv==1.0.1

170
templates/index.html Normal file
View File

@ -0,0 +1,170 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>AWS IP 替换工具</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: linear-gradient(135deg, #0f172a, #1e293b);
--card: #0b1224;
--accent: #22d3ee;
--accent-2: #a855f7;
--text: #e2e8f0;
--muted: #94a3b8;
--danger: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.shell {
width: 100%;
max-width: 760px;
background: var(--card);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 28px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
}
h1 {
margin: 0 0 6px;
letter-spacing: 0.8px;
}
p.lead {
margin: 0 0 18px;
color: var(--muted);
}
label {
display: block;
font-weight: 600;
margin-bottom: 6px;
color: #cbd5e1;
}
input, select, button {
width: 100%;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-size: 15px;
outline: none;
transition: border-color 0.2s ease, transform 0.1s ease;
}
input:focus, select:focus {
border-color: var(--accent);
transform: translateY(-1px);
}
button {
cursor: pointer;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
border: none;
margin-top: 16px;
}
button:disabled { opacity: 0.6; cursor: not-allowed; }
.field { margin-bottom: 16px; }
.status {
margin-top: 14px;
padding: 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.status.error { border-color: rgba(248, 113, 113, 0.5); color: var(--danger); }
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
.badge {
display: inline-block;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
margin-right: 6px;
font-size: 12px;
}
.muted { color: var(--muted); }
.grid { display: grid; gap: 12px; }
@media (max-width: 600px) {
.shell { padding: 20px; }
}
</style>
</head>
<body>
<div class="shell">
<h1>AWS IP 替换</h1>
<p class="lead">通过输入现有服务器 IP自动销毁实例并用指定 AMI 创建新实例,确保新 IP 不在运维表。</p>
{% if init_error %}
<div class="status error">配置加载失败:{{ init_error }}</div>
{% endif %}
<form id="replace-form" class="grid">
<div class="field">
<label for="ip_to_replace">当前 IP</label>
<input id="ip_to_replace" name="ip_to_replace" type="text" placeholder="例如54.12.34.56 或 10.0.1.23" required>
</div>
<div class="field">
<label for="account_name">AWS 账户</label>
<select id="account_name" name="account_name" required>
{% for account in accounts %}
<option value="{{ account.name }}">{{ account.name }} ({{ account.region }})</option>
{% endfor %}
</select>
<div class="muted" style="margin-top:6px;">账户、AMI、实例类型等从 <span class="mono">config/accounts.yaml</span> 读取。</div>
</div>
<button type="submit" id="submit-btn">开始替换</button>
</form>
<div id="status-box" class="status" style="display:none;"></div>
</div>
<script>
const form = document.getElementById('replace-form');
const statusBox = document.getElementById('status-box');
const submitBtn = document.getElementById('submit-btn');
function setStatus(message, isError = false) {
statusBox.style.display = 'block';
statusBox.className = 'status' + (isError ? ' error' : '');
statusBox.innerHTML = message;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
setStatus('正在执行,请稍候...');
const formData = new FormData(form);
try {
const resp = await fetch('/replace_ip', {
method: 'POST',
body: formData,
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || '请求失败');
}
setStatus(
`<div><span class="badge">旧实例</span><span class="mono">${data.terminated_instance_id}</span></div>` +
`<div><span class="badge">新实例</span><span class="mono">${data.new_instance_id}</span></div>` +
`<div><span class="badge">新 IP</span><span class="mono">${data.new_ip}</span></div>`
);
} catch (err) {
setStatus(err.message, true);
} finally {
submitBtn.disabled = false;
}
});
</script>
</body>
</html>