diff --git a/README.md b/README.md index 56c1700..1010559 100644 --- a/README.md +++ b/README.md @@ -17,25 +17,33 @@ pip install -r requirements.txt 2) 配置环境变量 复制 `.env.example` 为 `.env`,按需修改: - `DATABASE_URL`:MySQL 连接串,例如 `mysql+pymysql://user:pass@localhost:3306/ip_ops` -- `AWS_CONFIG_PATH`:AWS 账户配置文件,默认 `config/accounts.yaml` - `IP_RETRY_LIMIT`:新 IP 与黑名单冲突时的停机/开机重试次数 3) 准备数据库 -创建数据库并授权,首次运行会自动建表 `ip_operations`、`ip_account_mapping`、`server_specs`、`ip_replacement_history`: +创建数据库并授权,首次运行会自动建表 `aws_accounts`、`ip_operations`、`ip_account_mapping`、`server_specs`、`ip_replacement_history`: ```sql CREATE DATABASE ip_ops DEFAULT CHARACTER SET utf8mb4; GRANT ALL ON ip_ops.* TO 'user'@'%' IDENTIFIED BY 'pass'; ``` +`aws_accounts` 存储 AWS 账户凭据及默认 AMI/网络配置,可随时新增/更新记录后立即生效(无需重启)。 `ip_account_mapping` 记录 IP 与账户名映射(运行前请先写入),例如: ```sql INSERT INTO ip_account_mapping (ip_address, account_name) VALUES ('54.12.34.56', 'account_a'); ``` 4) 配置 AWS 账户 -编辑 `config/accounts.yaml`,为每个账户填写:访问密钥、区域、AMI ID、可选子网/安全组/密钥名等(实例类型无需配置,后端按源实例类型创建;若能读取到源实例的子网与安全组,将复用它们,否则回落到配置文件;密钥名若不存在会自动忽略重试)。 +通过 SQL 或数据库管理工具向 `aws_accounts` 写入账户信息: +```sql +INSERT INTO aws_accounts ( + name, region, access_key_id, secret_access_key, ami_id, subnet_id, security_group_ids, key_name +) VALUES ( + 'account_a', 'us-east-1', 'AKIA...', 'SECRET...', 'ami-xxxx', 'subnet-xxxx', 'sg-1,sg-2', 'my-key' +); +``` +`security_group_ids` 用逗号分隔,可留空;`subnet_id`/`key_name` 可选。新增或更新记录后,接口会实时使用最新配置。 -6) 配置初始服务器 -在url/mapping_page配置初始服务器。 +5) 配置初始服务器映射 +在 `/mapping_page` 录入 IP -> 账户名映射,或直接写入 `ip_account_mapping`。 6) 启动 ```bash diff --git a/app.py b/app.py index 468fc3d..9d1a61c 100644 --- a/app.py +++ b/app.py @@ -7,13 +7,12 @@ from flask import Flask, jsonify, render_template, request, redirect, url_for, s from aws_service import ( AWSOperationError, - ConfigError, AccountConfig, - load_account_configs, replace_instance_ip, ) from db import ( add_replacement_history, + delete_aws_account, get_account_by_ip, get_replacement_history, get_history_by_ip_or_group, @@ -21,11 +20,13 @@ from db import ( get_server_spec, init_db, load_disallowed_ips, + list_aws_accounts, list_account_mappings, update_ip_account_mapping, upsert_server_spec, upsert_account_mapping, delete_account_mapping, + upsert_aws_account, ) @@ -59,17 +60,36 @@ def zhiyun_required(fn): return wrapper -def load_configs() -> Dict[str, AccountConfig]: - config_path = os.getenv("AWS_CONFIG_PATH", "config/accounts.yaml") - return load_account_configs(config_path) +def admin_required(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + if not session.get("admin_authed"): + return jsonify({"error": "未通过管理员校验"}), 403 + return fn(*args, **kwargs) + + return wrapper -try: - account_configs = load_configs() - init_error = "" -except ConfigError as exc: - account_configs = {} - init_error = str(exc) +def load_account_configs_from_db() -> tuple[Dict[str, AccountConfig], str]: + try: + accounts = list_aws_accounts() + except Exception as exc: # noqa: BLE001 - surface DB errors to UI + return {}, f"读取 AWS 账户配置失败: {exc}" + if not accounts: + return {}, "数据库中未找到任何 AWS 账户配置" + configs: Dict[str, AccountConfig] = {} + for item in accounts: + configs[item["name"]] = 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"], + subnet_id=item.get("subnet_id"), + security_group_ids=item.get("security_group_ids", []), + key_name=item.get("key_name"), + ) + return configs, "" retry_limit = int(os.getenv("IP_RETRY_LIMIT", "5")) @@ -83,9 +103,11 @@ except Exception as exc: # noqa: BLE001 - surface DB connection issues to UI @app.route("/", methods=["GET"]) @login_required 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="") + if db_error: + return render_template("index.html", accounts=[], init_error=db_error) + accounts, config_error = load_account_configs_from_db() + error_msg = db_error or config_error + return render_template("index.html", accounts=accounts.values(), init_error=error_msg) @app.route("/login", methods=["GET", "POST"]) @@ -116,8 +138,10 @@ def mapping_auth(): pwd = request.form.get("password", "") if pwd and pwd == ZHIYUN_PASS: session["zhiyun_authed"] = True + session["admin_authed"] = True # 复用同一口令 return jsonify({"ok": True}) session.pop("zhiyun_authed", None) + session.pop("admin_authed", None) return jsonify({"error": "密码错误"}), 403 @@ -170,8 +194,12 @@ def mapping_delete(): @app.route("/replace_ip", methods=["POST"]) @login_required def replace_ip(): - if init_error or db_error: - return jsonify({"error": init_error or db_error}), 500 + if db_error: + return jsonify({"error": db_error}), 500 + + account_configs, config_error = load_account_configs_from_db() + if config_error: + return jsonify({"error": config_error}), 500 ip_to_replace = request.form.get("ip_to_replace", "").strip() @@ -182,7 +210,7 @@ def replace_ip(): if not account_name: return jsonify({"error": "数据库中未找到该IP对应的账户映射"}), 400 if account_name not in account_configs: - return jsonify({"error": f"账户 {account_name} 未在配置文件中定义"}), 400 + return jsonify({"error": f"账户 {account_name} 未在数据库中配置"}), 400 disallowed = load_disallowed_ips() fallback_spec = get_server_spec(ip_to_replace) @@ -272,5 +300,81 @@ def history_chains(): return jsonify({"items": records}) +@app.route("/admin", methods=["GET"]) +@login_required +def admin_page(): + return render_template("admin.html") + + +@app.route("/admin/auth", methods=["POST"]) +@login_required +def admin_auth(): + pwd = request.form.get("password", "") + if pwd and pwd == ZHIYUN_PASS: + session["admin_authed"] = True + session["zhiyun_authed"] = True # 复用映射接口的校验 + return jsonify({"ok": True}) + session.pop("admin_authed", None) + session.pop("zhiyun_authed", None) + return jsonify({"error": "密码错误"}), 403 + + +@app.route("/admin/aws/list", methods=["GET"]) +@login_required +@admin_required +def admin_aws_list(): + try: + items = list_aws_accounts() + except Exception as exc: # noqa: BLE001 + return jsonify({"error": f"读取失败: {exc}"}), 500 + # 避免前端泄露敏感字段,可自行删减;这里仍返回全部以便编辑 + return jsonify({"items": items}) + + +@app.route("/admin/aws/upsert", methods=["POST"]) +@login_required +@admin_required +def admin_aws_upsert(): + name = request.form.get("name", "").strip() + region = request.form.get("region", "").strip() + access_key_id = request.form.get("access_key_id", "").strip() + secret_access_key = request.form.get("secret_access_key", "").strip() + ami_id = request.form.get("ami_id", "").strip() + subnet_id = request.form.get("subnet_id", "").strip() or None + sg_raw = request.form.get("security_group_ids", "").strip() + key_name = request.form.get("key_name", "").strip() or None + if not (name and region and access_key_id and secret_access_key and ami_id): + return jsonify({"error": "name/region/access_key_id/secret_access_key/ami_id 不能为空"}), 400 + security_group_ids = [s.strip() for s in sg_raw.split(",") if s.strip()] if sg_raw else [] + try: + upsert_aws_account( + name=name, + region=region, + access_key_id=access_key_id, + secret_access_key=secret_access_key, + ami_id=ami_id, + subnet_id=subnet_id, + security_group_ids=security_group_ids, + key_name=key_name, + ) + except Exception as exc: # noqa: BLE001 + return jsonify({"error": f"保存失败: {exc}"}), 500 + return jsonify({"ok": True}) + + +@app.route("/admin/aws/delete", methods=["POST"]) +@login_required +@admin_required +def admin_aws_delete(): + name = request.form.get("name", "").strip() + if not name: + return jsonify({"error": "name 不能为空"}), 400 + try: + delete_aws_account(name) + except Exception as exc: # noqa: BLE001 + return jsonify({"error": f"删除失败: {exc}"}), 500 + return jsonify({"ok": True}) + + if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/aws_service.py b/aws_service.py index bfebc99..b5b7cfa 100644 --- a/aws_service.py +++ b/aws_service.py @@ -1,11 +1,9 @@ -import os from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional, TypedDict import boto3 from botocore.exceptions import BotoCoreError, ClientError -import yaml class ConfigError(Exception): @@ -41,29 +39,6 @@ class AccountConfig: 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"], - subnet_id=item.get("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", diff --git a/db.py b/db.py index e246572..ffcca09 100644 --- a/db.py +++ b/db.py @@ -14,6 +14,26 @@ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) Base = declarative_base() +def _now_cn() -> datetime: + return datetime.now(timezone(timedelta(hours=8))) + + +class AWSAccount(Base): + __tablename__ = "aws_accounts" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(128), unique=True, nullable=False, index=True) + region = Column(String(64), nullable=False) + access_key_id = Column(String(128), nullable=False) + secret_access_key = Column(String(256), nullable=False) + ami_id = Column(String(128), nullable=False) + subnet_id = Column(String(128), nullable=True) + security_group_ids = Column(String(512), nullable=True) + key_name = Column(String(128), nullable=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=_now_cn) + updated_at = Column(DateTime(timezone=True), nullable=False, default=_now_cn, onupdate=_now_cn) + + class IPOperation(Base): __tablename__ = "ip_operations" @@ -74,6 +94,12 @@ def init_db() -> None: Base.metadata.create_all(bind=engine) +def _split_csv(value: Optional[str]) -> List[str]: + if not value: + return [] + return [part for part in value.split(",") if part] + + @contextmanager def db_session(): session = SessionLocal() @@ -93,6 +119,64 @@ def load_disallowed_ips() -> set[str]: return {row for row in rows} +def list_aws_accounts() -> List[Dict[str, object]]: + with db_session() as session: + rows: Iterable[AWSAccount] = session.scalars( + select(AWSAccount).order_by(AWSAccount.id.desc()) + ) + return [ + { + "name": row.name, + "region": row.region, + "access_key_id": row.access_key_id, + "secret_access_key": row.secret_access_key, + "ami_id": row.ami_id, + "subnet_id": row.subnet_id, + "security_group_ids": _split_csv(row.security_group_ids), + "key_name": row.key_name, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + ] + + +def upsert_aws_account( + *, + name: str, + region: str, + access_key_id: str, + secret_access_key: str, + ami_id: str, + subnet_id: Optional[str] = None, + security_group_ids: Optional[List[str]] = None, + key_name: Optional[str] = None, +) -> None: + with db_session() as session: + record = session.scalar(select(AWSAccount).where(AWSAccount.name == name)) + payload = { + "region": region, + "access_key_id": access_key_id, + "secret_access_key": secret_access_key, + "ami_id": ami_id, + "subnet_id": subnet_id, + "security_group_ids": ",".join(security_group_ids or []), + "key_name": key_name, + } + if record: + for key, val in payload.items(): + setattr(record, key, val) + else: + session.add(AWSAccount(name=name, **payload)) + + +def delete_aws_account(name: str) -> None: + with db_session() as session: + record = session.scalar(select(AWSAccount).where(AWSAccount.name == name)) + if record: + session.delete(record) + + def get_account_by_ip(ip: str) -> Optional[str]: with db_session() as session: return session.scalar( @@ -146,10 +230,6 @@ def update_ip_account_mapping(old_ip: str, new_ip: str, account_name: str) -> No session.add(IPAccountMapping(ip_address=new_ip, account_name=account_name)) -def _now_cn() -> datetime: - return datetime.now(timezone(timedelta(hours=8))) - - def upsert_server_spec( *, ip_address: str, diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..a3e0d20 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,327 @@ + + +
+ +输入 .env 的 ZHIYUN_PASS 校验后可管理 AWS 账户与 IP 映射。
+ +