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