diff --git a/.env b/.env new file mode 100644 index 0000000..4f74b82 --- /dev/null +++ b/.env @@ -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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..756e07a --- /dev/null +++ b/app.py @@ -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) diff --git a/aws_service.py b/aws_service.py new file mode 100644 index 0000000..648d5f2 --- /dev/null +++ b/aws_service.py @@ -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, + } diff --git a/config/accounts.yaml b/config/accounts.yaml new file mode 100644 index 0000000..b97b9e4 --- /dev/null +++ b/config/accounts.yaml @@ -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 diff --git a/db.py b/db.py new file mode 100644 index 0000000..89d8ae2 --- /dev/null +++ b/db.py @@ -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} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cf74cc4 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5249288 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,170 @@ + + +
+ +通过输入现有服务器 IP,自动销毁实例并用指定 AMI 创建新实例,确保新 IP 不在运维表。
+ + {% if init_error %} +