init
This commit is contained in:
parent
78d7645a8b
commit
b135ed7476
4
.env
Normal file
4
.env
Normal 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
74
app.py
Normal 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
158
aws_service.py
Normal 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
20
config/accounts.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
accounts:
|
||||||
|
- name: 91-500f
|
||||||
|
region: us-east-1
|
||||||
|
access_key_id: AKIASAZ4PZBSBRYB7WFJ
|
||||||
|
secret_access_key: 5b6jGvbTtgFf/wIgKHtrHq2tKrlB8xWmwyCHDKWm
|
||||||
|
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
44
db.py
Normal 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
5
requirements.txt
Normal 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
170
templates/index.html
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user