一些更改
This commit is contained in:
parent
76424cc8c3
commit
d52e3e8db5
1
.env
1
.env
@ -5,3 +5,4 @@ IP_RETRY_LIMIT=5
|
|||||||
APP_USER=admin
|
APP_USER=admin
|
||||||
APP_PASSWORD=Pc9mVTm3kKo0pO
|
APP_PASSWORD=Pc9mVTm3kKo0pO
|
||||||
SECRET_KEY=51aiapi
|
SECRET_KEY=51aiapi
|
||||||
|
ZHIYUN_PASS=PbUeI1MZwep9vp
|
||||||
@ -4,7 +4,7 @@ Flask + boto3 + MySQL 的小工具,用于:
|
|||||||
- 根据输入 IP 查找对应 EC2 实例并终止,使用预设 AMI 创建新实例
|
- 根据输入 IP 查找对应 EC2 实例并终止,使用预设 AMI 创建新实例
|
||||||
- 通过数据库中的 IP-账户映射自动选择 AWS 账户,前端不暴露账户列表
|
- 通过数据库中的 IP-账户映射自动选择 AWS 账户,前端不暴露账户列表
|
||||||
- 如果新公网 IP 落入运维黑名单,自动停机/开机循环更换 IP(受 `IP_RETRY_LIMIT` 控制)
|
- 如果新公网 IP 落入运维黑名单,自动停机/开机循环更换 IP(受 `IP_RETRY_LIMIT` 控制)
|
||||||
- MySQL 存储黑名单 (`ip_operations`)、IP-账户映射 (`ip_account_mapping`)、服务器规格 (`server_specs`,含实例类型/Name/磁盘/安全组/区域/子网/AZ)、IP 替换历史 (`ip_replacement_history`,含 group_id 链路标识,默认取旧 IP)
|
- MySQL 存储黑名单 (`ip_operations`)、IP-账户映射 (`ip_account_mapping`)、服务器规格 (`server_specs`,含实例类型/Name/磁盘/安全组/区域/子网/AZ)、IP 替换历史 (`ip_replacement_history`)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
1) 安装依赖
|
1) 安装依赖
|
||||||
@ -34,7 +34,10 @@ INSERT INTO ip_account_mapping (ip_address, account_name) VALUES ('54.12.34.56',
|
|||||||
4) 配置 AWS 账户
|
4) 配置 AWS 账户
|
||||||
编辑 `config/accounts.yaml`,为每个账户填写:访问密钥、区域、AMI ID、可选子网/安全组/密钥名等(实例类型无需配置,后端按源实例类型创建;若能读取到源实例的子网与安全组,将复用它们,否则回落到配置文件;密钥名若不存在会自动忽略重试)。
|
编辑 `config/accounts.yaml`,为每个账户填写:访问密钥、区域、AMI ID、可选子网/安全组/密钥名等(实例类型无需配置,后端按源实例类型创建;若能读取到源实例的子网与安全组,将复用它们,否则回落到配置文件;密钥名若不存在会自动忽略重试)。
|
||||||
|
|
||||||
5) 启动
|
6) 配置初始服务器
|
||||||
|
在url/mapping_page配置初始服务器。
|
||||||
|
|
||||||
|
6) 启动
|
||||||
```bash
|
```bash
|
||||||
flask --app app run --host 0.0.0.0 --port 5000
|
flask --app app run --host 0.0.0.0 --port 5000
|
||||||
# 或 python app.py
|
# 或 python app.py
|
||||||
@ -44,7 +47,7 @@ flask --app app run --host 0.0.0.0 --port 5000
|
|||||||
1) 页面输入需要替换的 IP,后端用 `ip_account_mapping` 定位账户并读取对应 AWS 配置
|
1) 页面输入需要替换的 IP,后端用 `ip_account_mapping` 定位账户并读取对应 AWS 配置
|
||||||
2) 在该账户中查找公/私网 IP 匹配的实例,读取实例类型、Name、根盘大小/类型、安全组(ID/名称)、区域/子网/AZ,并记录到 `server_specs`;若实例未找到则回退使用数据库中已存的规格
|
2) 在该账户中查找公/私网 IP 匹配的实例,读取实例类型、Name、根盘大小/类型、安全组(ID/名称)、区域/子网/AZ,并记录到 `server_specs`;若实例未找到则回退使用数据库中已存的规格
|
||||||
3) 按记录的规格创建新实例(实例类型、磁盘类型/大小、安全组、子网/AZ),如新公网 IP 在 `ip_operations` 黑名单中,则停机/开机循环直至获得可用 IP(或达到重试上限);旧实例的终止异步触发,不会阻塞新实例创建
|
3) 按记录的规格创建新实例(实例类型、磁盘类型/大小、安全组、子网/AZ),如新公网 IP 在 `ip_operations` 黑名单中,则停机/开机循环直至获得可用 IP(或达到重试上限);旧实例的终止异步触发,不会阻塞新实例创建
|
||||||
4) 记录 IP 替换历史到 `ip_replacement_history`,group_id 默认用旧 IP;前端主页可跳转到历史页按 IP/group 查询链路;同时更新 `server_specs` 中的 IP 规格为最新 IP
|
4) 记录 IP 替换历史到 `ip_replacement_history`,前端可查看最近 100 条替换记录;同时更新 `server_specs` 中的 IP 规格为最新 IP
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
- 真实环境会产生终止/创建实例等成本操作,先在测试账户验证流程
|
- 真实环境会产生终止/创建实例等成本操作,先在测试账户验证流程
|
||||||
|
|||||||
71
app.py
71
app.py
@ -21,8 +21,11 @@ from db import (
|
|||||||
get_server_spec,
|
get_server_spec,
|
||||||
init_db,
|
init_db,
|
||||||
load_disallowed_ips,
|
load_disallowed_ips,
|
||||||
|
list_account_mappings,
|
||||||
update_ip_account_mapping,
|
update_ip_account_mapping,
|
||||||
upsert_server_spec,
|
upsert_server_spec,
|
||||||
|
upsert_account_mapping,
|
||||||
|
delete_account_mapping,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -33,6 +36,7 @@ app.secret_key = os.getenv("SECRET_KEY", "please-change-me")
|
|||||||
|
|
||||||
APP_USER = os.getenv("APP_USER", "")
|
APP_USER = os.getenv("APP_USER", "")
|
||||||
APP_PASSWORD = os.getenv("APP_PASSWORD", "")
|
APP_PASSWORD = os.getenv("APP_PASSWORD", "")
|
||||||
|
ZHIYUN_PASS = os.getenv("ZHIYUN_PASS", "")
|
||||||
|
|
||||||
|
|
||||||
def login_required(fn):
|
def login_required(fn):
|
||||||
@ -45,6 +49,16 @@ def login_required(fn):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def zhiyun_required(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not session.get("zhiyun_authed"):
|
||||||
|
return jsonify({"error": "未通过校验密码"}), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def load_configs() -> Dict[str, AccountConfig]:
|
def load_configs() -> Dict[str, AccountConfig]:
|
||||||
config_path = os.getenv("AWS_CONFIG_PATH", "config/accounts.yaml")
|
config_path = os.getenv("AWS_CONFIG_PATH", "config/accounts.yaml")
|
||||||
return load_account_configs(config_path)
|
return load_account_configs(config_path)
|
||||||
@ -96,6 +110,63 @@ def logout():
|
|||||||
return redirect(url_for("login"))
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/mapping/auth", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def mapping_auth():
|
||||||
|
pwd = request.form.get("password", "")
|
||||||
|
if pwd and pwd == ZHIYUN_PASS:
|
||||||
|
session["zhiyun_authed"] = True
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
session.pop("zhiyun_authed", None)
|
||||||
|
return jsonify({"error": "密码错误"}), 403
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/mapping_page", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def mapping_page():
|
||||||
|
return render_template("mapping.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/mapping/list", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@zhiyun_required
|
||||||
|
def mapping_list():
|
||||||
|
try:
|
||||||
|
data = list_account_mappings()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return jsonify({"error": f"读取失败: {exc}"}), 500
|
||||||
|
return jsonify({"items": data})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/mapping/upsert", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@zhiyun_required
|
||||||
|
def mapping_upsert():
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
account = request.form.get("account", "").strip()
|
||||||
|
if not ip or not account:
|
||||||
|
return jsonify({"error": "IP 和账户名不能为空"}), 400
|
||||||
|
try:
|
||||||
|
upsert_account_mapping(ip, account)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return jsonify({"error": f"保存失败: {exc}"}), 500
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/mapping/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@zhiyun_required
|
||||||
|
def mapping_delete():
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
if not ip:
|
||||||
|
return jsonify({"error": "IP 不能为空"}), 400
|
||||||
|
try:
|
||||||
|
delete_account_mapping(ip)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return jsonify({"error": f"删除失败: {exc}"}), 500
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/replace_ip", methods=["POST"])
|
@app.route("/replace_ip", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def replace_ip():
|
def replace_ip():
|
||||||
|
|||||||
28
db.py
28
db.py
@ -100,6 +100,34 @@ def get_account_by_ip(ip: str) -> Optional[str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_account_mappings() -> List[Dict[str, str]]:
|
||||||
|
with db_session() as session:
|
||||||
|
rows: Iterable[IPAccountMapping] = session.scalars(
|
||||||
|
select(IPAccountMapping).order_by(IPAccountMapping.id.desc())
|
||||||
|
)
|
||||||
|
return [{"ip_address": row.ip_address, "account_name": row.account_name} for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_account_mapping(ip: str, account_name: str) -> None:
|
||||||
|
with db_session() as session:
|
||||||
|
record = session.scalar(
|
||||||
|
select(IPAccountMapping).where(IPAccountMapping.ip_address == ip)
|
||||||
|
)
|
||||||
|
if record:
|
||||||
|
record.account_name = account_name
|
||||||
|
else:
|
||||||
|
session.add(IPAccountMapping(ip_address=ip, account_name=account_name))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_account_mapping(ip: str) -> None:
|
||||||
|
with db_session() as session:
|
||||||
|
record = session.scalar(
|
||||||
|
select(IPAccountMapping).where(IPAccountMapping.ip_address == ip)
|
||||||
|
)
|
||||||
|
if record:
|
||||||
|
session.delete(record)
|
||||||
|
|
||||||
|
|
||||||
def update_ip_account_mapping(old_ip: str, new_ip: str, account_name: str) -> None:
|
def update_ip_account_mapping(old_ip: str, new_ip: str, account_name: str) -> None:
|
||||||
with db_session() as session:
|
with db_session() as session:
|
||||||
existing_mapping = session.scalar(
|
existing_mapping = session.scalar(
|
||||||
|
|||||||
185
templates/mapping.html
Normal file
185
templates/mapping.html
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>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;
|
||||||
|
}
|
||||||
|
body { margin:0; min-height:100vh; background:var(--bg); color:var(--text); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; padding:24px; }
|
||||||
|
.shell { max-width: 820px; margin:0 auto; background:var(--card); border:1px solid rgba(255,255,255,0.08); border-radius:14px; padding:24px; box-shadow:0 25px 60px rgba(0,0,0,0.45); }
|
||||||
|
h1 { margin:0 0 10px; }
|
||||||
|
label { display:block; font-weight:600; color:#cbd5e1; margin:8px 0 4px; }
|
||||||
|
input { width:100%; padding:10px 12px; border-radius:10px; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.04); color:var(--text); }
|
||||||
|
button { padding:10px 14px; border:none; border-radius:10px; cursor:pointer; background:linear-gradient(135deg,var(--accent),var(--accent-2)); color:var(--text); font-weight:700; }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:16px; }
|
||||||
|
th, td { padding:10px; border-bottom:1px solid rgba(255,255,255,0.08); text-align:left; }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.status { margin-top:12px; padding:10px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); }
|
||||||
|
.error { color: var(--danger); }
|
||||||
|
.row { display:flex; gap:10px; }
|
||||||
|
.row > div { flex:1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<h1>IP-账户映射管理</h1>
|
||||||
|
<p class="muted" style="margin-top:0;">操作需输入额外密码(ZHIYUN_PASS)。通过校验后可增删改。</p>
|
||||||
|
|
||||||
|
<div class="status" id="auth-box">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="auth-pass">校验密码</label>
|
||||||
|
<input id="auth-pass" type="password" placeholder="输入 ZHIYUN_PASS">
|
||||||
|
</div>
|
||||||
|
<div style="align-self:flex-end;">
|
||||||
|
<button id="auth-btn" style="width:100%;">校验</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="auth-msg" class="muted" style="margin-top:6px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="form-area" style="display:none; margin-top:16px;">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="ip">IP 地址</label>
|
||||||
|
<input id="ip" placeholder="例如:54.12.34.56">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="account">账户名</label>
|
||||||
|
<input id="account" placeholder="与 config/accounts.yaml 中的 name 对应">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px; display:flex; gap:10px;">
|
||||||
|
<button id="save-btn">新增/更新</button>
|
||||||
|
<button id="delete-btn" style="background:#ef4444;">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="msg" class="status" style="display:none;"></div>
|
||||||
|
<table id="table" style="display:none;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>IP</th><th>账户</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const authBtn = document.getElementById('auth-btn');
|
||||||
|
const authPass = document.getElementById('auth-pass');
|
||||||
|
const authMsg = document.getElementById('auth-msg');
|
||||||
|
const formArea = document.getElementById('form-area');
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
const table = document.getElementById('table');
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
const saveBtn = document.getElementById('save-btn');
|
||||||
|
const delBtn = document.getElementById('delete-btn');
|
||||||
|
const ipInput = document.getElementById('ip');
|
||||||
|
const accountInput = document.getElementById('account');
|
||||||
|
|
||||||
|
function showMsg(text, isError=false) {
|
||||||
|
msg.style.display = 'block';
|
||||||
|
msg.className = 'status' + (isError ? ' error' : '');
|
||||||
|
msg.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auth() {
|
||||||
|
authBtn.disabled = true;
|
||||||
|
authMsg.textContent = '校验中...';
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('password', authPass.value);
|
||||||
|
const resp = await fetch('/mapping/auth', { method:'POST', body: formData });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error || '校验失败');
|
||||||
|
authMsg.textContent = '校验成功,可进行操作';
|
||||||
|
formArea.style.display = 'block';
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
authMsg.textContent = err.message;
|
||||||
|
formArea.style.display = 'none';
|
||||||
|
table.style.display = 'none';
|
||||||
|
} finally {
|
||||||
|
authBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/mapping/list');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error || '获取失败');
|
||||||
|
const items = data.items || [];
|
||||||
|
tbody.innerHTML = items.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td>${item.ip_address}</td>
|
||||||
|
<td>${item.account_name}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
table.style.display = 'table';
|
||||||
|
} catch (err) {
|
||||||
|
showMsg(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const ip = ipInput.value.trim();
|
||||||
|
const acc = accountInput.value.trim();
|
||||||
|
if (!ip || !acc) {
|
||||||
|
showMsg('IP 和账户名不能为空', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('ip', ip);
|
||||||
|
formData.append('account', acc);
|
||||||
|
const resp = await fetch('/mapping/upsert', { method:'POST', body: formData });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error || '保存失败');
|
||||||
|
showMsg('保存成功');
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
showMsg(err.message, true);
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
const ip = ipInput.value.trim();
|
||||||
|
if (!ip) {
|
||||||
|
showMsg('删除前请填写 IP', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('ip', ip);
|
||||||
|
const resp = await fetch('/mapping/delete', { method:'POST', body: formData });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error || '删除失败');
|
||||||
|
showMsg('删除成功');
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
showMsg(err.message, true);
|
||||||
|
} finally {
|
||||||
|
delBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authBtn.addEventListener('click', auth);
|
||||||
|
saveBtn.addEventListener('click', save);
|
||||||
|
delBtn.addEventListener('click', remove);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user