mirror of
https://github.com/hotyue/IP-Sentinel.git
synced 2026-08-29 20:16:52 +08:00
feat: 全舰队切换 Bot 凭证,换 Bot 无需卸载重装 (closes #102)
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [v4.3.4] - 2026-08-26
|
||||
|
||||
### ✨ Features
|
||||
- **全舰队切换 Bot 凭证** (#102) — 更换 TG Bot 不再需要逐台卸载重装。Master 主菜单新增「🔁 全舰队切换 Bot 凭证」按钮:填写新 Token + Chat ID 后,司令部先 getMe 验证,再向所有开启 OTA 权限的节点批量下发切换指令;各节点自动完成凭证验证、向新 Bot 推送注册回执、原子重写本地配置,Chat ID 变更时自动重启守护进程完成 HMAC 密钥轮换
|
||||
- **Agent 端新增 /trigger_reconfig 路由** — 复用 b64 安全 Base64 载荷与 HMAC 签名鉴权;ENABLE_OTA=false 的节点自动熔断(与 Master 下发范围对齐);先推注册后改配置,任一步失败旧凭证保持完好
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **修复 TG API 无效凭证返回 HTTP 401 时 Agent 误报 500** — urllib 对非 2xx 抛异常,现捕获 HTTPError 并解析响应体,正确回传 403 + 失败原因
|
||||
|
||||
## [v4.3.2] - 2026-07-24
|
||||
|
||||
### ✨ Features
|
||||
|
||||
@@ -97,6 +97,11 @@ import os
|
||||
import html
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
import base64
|
||||
import re
|
||||
import fcntl
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
@@ -486,6 +491,151 @@ fi
|
||||
self.end_headers()
|
||||
self.wfile.write(f"500 Internal Error: {str(e)}\n".encode('utf-8'))
|
||||
|
||||
# 路由 9: 全舰队 Bot 凭证切换 (Issue #102)
|
||||
elif req_path == '/trigger_reconfig':
|
||||
import json
|
||||
import base64
|
||||
import re
|
||||
import fcntl
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
b64_payload = q.get('b64', [''])[0]
|
||||
|
||||
if not b64_payload:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"400 Bad Request: Missing payload\n")
|
||||
return
|
||||
|
||||
try:
|
||||
# [防线/容灾] 还原安全 Base64 编码
|
||||
pad = len(b64_payload) % 4
|
||||
if pad > 0:
|
||||
b64_payload += '=' * (4 - pad)
|
||||
b64_payload = b64_payload.replace('-', '+').replace('_', '/')
|
||||
payload = json.loads(base64.b64decode(b64_payload).decode('utf-8'))
|
||||
new_token = str(payload.get('token', '')).strip()
|
||||
new_chat_id = str(payload.get('chat_id', '')).strip()
|
||||
|
||||
# [格式清洗] 强校验凭证形态,屏蔽注入与手误
|
||||
if not re.match(r'^\d{6,}:[A-Za-z0-9_-]{30,}$', new_token):
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"400 Bad Request: Invalid token format\n")
|
||||
return
|
||||
if not re.match(r'^-?\d{5,}$', new_chat_id):
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"400 Bad Request: Invalid chat id\n")
|
||||
return
|
||||
|
||||
# [配置快照] 读取当前本地凭证
|
||||
config_mem = {}
|
||||
config_path = '/opt/ip_sentinel/config.conf'
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if '=' in line and not line.startswith('#'):
|
||||
key, val = line.split('=', 1)
|
||||
config_mem[key] = val.strip('"\'')
|
||||
|
||||
# [熔断器] 复用 OTA 权限作为切换闸门 (与 Master 端下发范围对齐)
|
||||
if config_mem.get('ENABLE_OTA', 'false').lower() != 'true':
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"403 Forbidden: Reconfig disabled (ENABLE_OTA=false)\n")
|
||||
return
|
||||
|
||||
old_chat_id = config_mem.get('CHAT_ID', '')
|
||||
local_ver = config_mem.get('AGENT_VERSION', 'unknown')
|
||||
|
||||
# [步骤 1] getMe 验证新 Bot Token,手误凭证在此拦截
|
||||
# [防线/容灾] TG API 对无效凭证返回 HTTP 401,urlopen 会抛异常,需捕获后解析响应体
|
||||
def tg_api_call(url, payload=None):
|
||||
headers = {'User-Agent': f'IP-Sentinel-Agent/{local_ver}'}
|
||||
data = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode('utf-8')
|
||||
headers['Content-Type'] = 'application/json'
|
||||
req = urllib.request.Request(url, data=data, headers=headers)
|
||||
try:
|
||||
return json.loads(urllib.request.urlopen(req, timeout=8).read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as he:
|
||||
try:
|
||||
return json.loads(he.read().decode('utf-8'))
|
||||
except Exception:
|
||||
return {'ok': False, 'description': f'HTTP {he.code}'}
|
||||
|
||||
me_resp = tg_api_call(f"https://api.telegram.org/bot{new_token}/getMe")
|
||||
if not me_resp.get('ok'):
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
self.wfile.write(f"403 Forbidden: New bot getMe failed: {me_resp.get('description', 'unknown')}\n".encode('utf-8'))
|
||||
return
|
||||
|
||||
# [步骤 2] 向新 Bot 推送注册回执 (先推后改,失败时旧凭证保持完好)
|
||||
node_name = config_mem.get('NODE_NAME', '')
|
||||
comm_ip = config_mem.get('COMM_IP', config_mem.get('PUBLIC_IP', ''))
|
||||
agent_port = config_mem.get('AGENT_PORT', '9527')
|
||||
node_alias = config_mem.get('NODE_ALIAS', node_name)
|
||||
reg_msg = "#REGISTER#|{}|{}|{}|{}|{}|{}".format(
|
||||
config_mem.get('REGION_CODE', 'UNKNOWN'),
|
||||
node_name, comm_ip, agent_port, node_alias,
|
||||
config_mem.get('ENABLE_OTA', 'false')
|
||||
)
|
||||
|
||||
send_resp = tg_api_call(
|
||||
f"https://api.telegram.org/bot{new_token}/sendMessage",
|
||||
{'chat_id': new_chat_id, 'text': reg_msg}
|
||||
)
|
||||
if not send_resp.get('ok'):
|
||||
self.send_response(403)
|
||||
self.end_headers()
|
||||
self.wfile.write(f"403 Forbidden: Registration push failed: {send_resp.get('description', 'unknown')}\n".encode('utf-8'))
|
||||
return
|
||||
|
||||
# [步骤 3] flock 独占锁原子重写本地凭证三件套
|
||||
with open(config_path, 'r+', encoding='utf-8', errors='ignore') as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
lines = f.readlines()
|
||||
|
||||
new_pairs = [
|
||||
('TG_TOKEN', new_token),
|
||||
('CHAT_ID', new_chat_id),
|
||||
('TG_API_URL', f"https://api.telegram.org/bot{new_token}/sendMessage")
|
||||
]
|
||||
for key, new_val in new_pairs:
|
||||
prefix = f"{key}="
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith(prefix):
|
||||
lines[i] = f'{prefix}"{new_val}"\n'
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
lines.append(f'{prefix}"{new_val}"\n')
|
||||
|
||||
f.seek(0)
|
||||
f.writelines(lines)
|
||||
f.truncate()
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
# [先应答] 在重启前把成功回执交还给 Master
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Action Accepted: trigger_reconfig\n")
|
||||
|
||||
# [步骤 4] CHAT_ID 变更意味着 HMAC PSK 轮换,延迟 3 秒重启守护进程
|
||||
# [防线/容灾] pkill pattern 用字符类 webhoo[k] 避免匹配到本包装进程自身的命令行
|
||||
if new_chat_id != old_chat_id:
|
||||
os.system("nohup bash -c 'sleep 3 && (systemctl restart ip-sentinel-agent-daemon.service 2>/dev/null || (pkill -f \"core/webhoo[k].py\"; nohup bash /opt/ip_sentinel/core/agent_daemon.sh >/dev/null 2>&1 &))' >/dev/null 2>&1 &")
|
||||
|
||||
except Exception as e:
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
self.wfile.write(f"500 Internal Error: {str(e)}\n".encode('utf-8'))
|
||||
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
+89
-7
@@ -279,13 +279,23 @@ while true; do
|
||||
# [业务流 B] 拦截并解析别名重命名回执
|
||||
# ----------------------------------------------------------
|
||||
if [[ "$REPLY_TO_TEXT" == *"✏️ 请回复本消息以重命名节点:"* ]]; then
|
||||
TARGET_NODE=$(echo "$REPLY_TO_TEXT" | grep -v "✏️" | grep -v "仅限" | tr -d '\` ' | tr -cd 'a-zA-Z0-9_.-' | head -n 1)
|
||||
NEW_ALIAS=$(echo "$TEXT" | sed 's/_/-/g' | tr -d '"'\''\`\$\|&;<>\n\r:' | cut -c 1-30)
|
||||
TARGET_NODE=$(echo "$REPLY_TO_TEXT" | grep -v "✏️" | grep -v "仅限" | tr -d '\` ' | tr -cd 'a-zA-Z0-9_.-' | head -n 1)
|
||||
NEW_ALIAS=$(echo "$TEXT" | sed 's/_/-/g' | tr -d '"'\''\`\$\|&;<>\n\r:' | cut -c 1-30)
|
||||
|
||||
if [ -n "$TARGET_NODE" ] && [ -n "$NEW_ALIAS" ]; then
|
||||
TEXT="do_rename:${TARGET_NODE}:${NEW_ALIAS}"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$TARGET_NODE" ] && [ -n "$NEW_ALIAS" ]; then
|
||||
TEXT="do_rename:${TARGET_NODE}:${NEW_ALIAS}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# [业务流 B2] 拦截并解析 Bot 凭证切换回执 (Issue #102)
|
||||
# ----------------------------------------------------------
|
||||
if [[ "$REPLY_TO_TEXT" == *"🔁 请回复本消息填写新 Bot 凭证:"* ]]; then
|
||||
RECONFIG_INPUT=$(echo "$TEXT" | tr -cd '0-9A-Za-z:_ \n-' | tr -s ' \n' ' ' | sed 's/^ //; s/ $//')
|
||||
if [ -n "$RECONFIG_INPUT" ]; then
|
||||
TEXT="do_reconfig:${RECONFIG_INPUT}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$CB_ID" ]; then
|
||||
curl -s --connect-timeout 5 -m 10 -X POST "https://api.telegram.org/bot${TG_TOKEN}/answerCallbackQuery" -d "callback_query_id=${CB_ID}" > /dev/null
|
||||
@@ -391,7 +401,7 @@ while true; do
|
||||
NODE_COUNT=$(db_exec "SELECT COUNT(*) FROM nodes WHERE chat_id='$CHAT_ID';")
|
||||
|
||||
if [ "$IS_OFFICIAL_GATEWAY" != "true" ]; then
|
||||
BTNS="[${BTN_MASTER_OTA}[{\"text\":\"🌍 进入全球雷达 (管理节点)\",\"callback_data\":\"list_nodes\"}], [{\"text\":\"🚀 唤醒全局巡逻\",\"callback_data\":\"all_run\"}, {\"text\":\"📊 获取全局简报\",\"callback_data\":\"all_reports\"}], [{\"text\":\"🔄 全网节点 OTA 热重载\",\"callback_data\":\"all_ota_confirm\"}], [{\"text\":\"🌟 前往 GitHub 点亮星标\",\"url\":\"https://github.com/hotyue/IP-Sentinel\"}]]"
|
||||
BTNS="[${BTN_MASTER_OTA}[{\"text\":\"🌍 进入全球雷达 (管理节点)\",\"callback_data\":\"list_nodes\"}], [{\"text\":\"🚀 唤醒全局巡逻\",\"callback_data\":\"all_run\"}, {\"text\":\"📊 获取全局简报\",\"callback_data\":\"all_reports\"}], [{\"text\":\"🔄 全网节点 OTA 热重载\",\"callback_data\":\"all_ota_confirm\"}, {\"text\":\"🔁 全舰队切换 Bot 凭证\",\"callback_data\":\"reconfig_confirm\"}], [{\"text\":\"🌟 前往 GitHub 点亮星标\",\"url\":\"https://github.com/hotyue/IP-Sentinel\"}]]"
|
||||
else
|
||||
BTNS="[[{\"text\":\"🌍 进入全球雷达 (管理节点)\",\"callback_data\":\"list_nodes\"}], [{\"text\":\"🚀 唤醒全局巡逻\",\"callback_data\":\"all_run\"}, {\"text\":\"📊 获取全局简报\",\"callback_data\":\"all_reports\"}], [{\"text\":\"🌟 前往 GitHub 点亮星标\",\"url\":\"https://github.com/hotyue/IP-Sentinel\"}]]"
|
||||
fi
|
||||
@@ -409,6 +419,78 @@ while true; do
|
||||
render_ui "$CHAT_ID" "$MSG_ID" "$WARNING_MSG" "$CONFIRM_BTNS"
|
||||
;;
|
||||
|
||||
"reconfig_confirm")
|
||||
if [ -z "$CB_ID" ]; then send_msg "$CHAT_ID" "⛔ 安全拦截:非法特权执行环境。"; continue; fi
|
||||
CONFIRM_BTNS="[[{\"text\":\"🚨 确认切换,填写新凭证\",\"callback_data\":\"reconfig_input\"}], [{\"text\":\"取消操作\",\"callback_data\":\"/start\"}]]"
|
||||
WARNING_MSG="☢️ **【最高指令:全舰队切换 Bot 凭证】**\n\n此操作将向您名下**所有开启 OTA 权限的节点**下发凭证切换指令,各节点会:\n1. 用新 Token 验证身份并向新 Bot 发送注册回执。\n2. 原子重写本地凭证 (Token / Chat ID / API 地址)。\n3. 若 Chat ID 变更,自动重启守护进程完成密钥轮换。\n\n⚠️ **风险提示**:\n1. 切换完成后请**立即停止旧 Bot 的司令部进程**,防止双司令部抢注。\n2. 未开启 OTA 权限的节点不会收到指令,需手动 SSH 处理。\n3. 趋势历史 (ip_trend_log) 保留在旧库,新司令部从零开始。\n\n**是否确定执行切换?**"
|
||||
render_ui "$CHAT_ID" "$MSG_ID" "$WARNING_MSG" "$CONFIRM_BTNS"
|
||||
;;
|
||||
|
||||
"reconfig_input")
|
||||
if [ -z "$CB_ID" ]; then send_msg "$CHAT_ID" "⛔ 安全拦截:非法特权执行环境。"; continue; fi
|
||||
CHAT_ID=$(echo "$CHAT_ID" | tr -cd '0-9-')
|
||||
curl -s -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"chat_id\":\"$CHAT_ID\",\"text\":\"🔁 请回复本消息填写新 Bot 凭证:\\n格式: \\`新Token 新ChatID\\` (空格分隔)\\n示例: \\`123456789:AAH... 123456789\\`\",\"parse_mode\":\"Markdown\",\"reply_markup\":{\"force_reply\":true}}" > /dev/null
|
||||
;;
|
||||
|
||||
"do_reconfig:*")
|
||||
RECONFIG_INPUT=$(echo "${TEXT#*:}" | tr -s ' ' | sed 's/^ //; s/ $//')
|
||||
CHAT_ID=$(echo "$CHAT_ID" | tr -cd '0-9-')
|
||||
|
||||
NEW_TOKEN=$(echo "$RECONFIG_INPUT" | awk '{print $1}')
|
||||
NEW_CHAT_ID=$(echo "$RECONFIG_INPUT" | awk '{print $2}')
|
||||
|
||||
# [格式清洗] 强校验凭证形态
|
||||
if ! [[ "$NEW_TOKEN" =~ ^[0-9]{6,}:[A-Za-z0-9_-]{30,}$ ]] || ! [[ "$NEW_CHAT_ID" =~ ^-?[0-9]{5,}$ ]]; then
|
||||
render_msg "$CHAT_ID" "$MSG_ID" "⛔ **凭证格式校验失败**%0AToken 形如 \`123456789:AAH...\`,Chat ID 为纯数字。请重新回复。"
|
||||
continue
|
||||
fi
|
||||
|
||||
# [步骤 0] Master 端先 getMe 验证新 Token,手误凭证在此拦截,不浪费全舰队流量
|
||||
render_msg "$CHAT_ID" "$MSG_ID" "⏳ 正在验证新 Bot Token 有效性..."
|
||||
ME_RESULT=$(curl -s --connect-timeout 5 -m 10 "https://api.telegram.org/bot${NEW_TOKEN}/getMe")
|
||||
if ! echo "$ME_RESULT" | grep -q '"ok":true'; then
|
||||
render_msg "$CHAT_ID" "$MSG_ID" "❌ **新 Token 验证失败**%0A$(echo "$ME_RESULT" | jq -r '.description // .error_code' 2>/dev/null)%0A凭证未下发,请重新填写。"
|
||||
continue
|
||||
fi
|
||||
NEW_BOT_NAME=$(echo "$ME_RESULT" | jq -r '.result.username // "未知"' 2>/dev/null)
|
||||
|
||||
NODE_DATA=$(db_exec "SELECT node_name, agent_ip, agent_port FROM nodes WHERE chat_id='$CHAT_ID' AND enable_ota='true';")
|
||||
if [ -z "$NODE_DATA" ]; then
|
||||
render_msg "$CHAT_ID" "$MSG_ID" "⚠️ 您名下暂无开启 OTA 权限的记录节点,无需切换。"
|
||||
continue
|
||||
fi
|
||||
|
||||
# [载荷封装] 安全 Base64 (URL 友好)
|
||||
RECONFIG_JSON=$(printf '{"token":"%s","chat_id":"%s"}' "$NEW_TOKEN" "$NEW_CHAT_ID")
|
||||
RECONFIG_B64=$(echo -n "$RECONFIG_JSON" | base64 | tr -d '\n' | tr '+/' '-_')
|
||||
|
||||
render_msg "$CHAT_ID" "$MSG_ID" "📢 **司令部指令下达:正在向全舰队切换 Bot 凭证...**%0A目标 Bot: \`@$NEW_BOT_NAME\`%0A*(各节点成功后会主动向新 Bot 发送注册回执,请注意查收)*"
|
||||
|
||||
SUCCESS_COUNT=0
|
||||
FAIL_LIST=""
|
||||
TOTAL_COUNT=$(echo "$NODE_DATA" | grep -c '|')
|
||||
|
||||
while IFS='|' read -r NNAME AIP APORT; do
|
||||
[ -z "$NNAME" ] && continue
|
||||
RESPONSE=$(call_agent "$AIP" "$APORT" "/trigger_reconfig" "b64=${RECONFIG_B64}")
|
||||
if [[ "$RESPONSE" == *"Action Accepted"* ]]; then
|
||||
SUCCESS_COUNT=$((SUCCESS_COUNT+1))
|
||||
else
|
||||
FAIL_LIST="${FAIL_LIST}\`$NNAME\` (${AIP}) → ${RESPONSE}%0A"
|
||||
fi
|
||||
sleep 1.2
|
||||
done <<< "$NODE_DATA"
|
||||
|
||||
if [ -z "$FAIL_LIST" ]; then
|
||||
SUMMARY="✅ **全舰队切换完成**%0A成功: ${SUCCESS_COUNT}/${TOTAL_COUNT} 台%0A%0A📋 **后续操作清单**:%0A1. 前往新 Bot 查看注册回执 (各节点已自动发送)。%0A2. 在新机器上部署新司令部 (或直接迁移本库)。%0A3. 确认新司令部接管后,**停止旧司令部的 tg_master 进程**。"
|
||||
else
|
||||
SUMMARY="⚠️ **切换完成 (部分失败)**%0A成功: ${SUCCESS_COUNT}/${TOTAL_COUNT} 台%0A%0A❌ **失败清单 (需手动 SSH 处理)**:%0A${FAIL_LIST}%0A📋 失败节点请手动更新 /opt/ip_sentinel/config.conf 并重启 daemon。"
|
||||
fi
|
||||
send_msg "$CHAT_ID" "$SUMMARY"
|
||||
;;
|
||||
|
||||
"all_ota_execute")
|
||||
if [ -z "$CB_ID" ]; then send_msg "$CHAT_ID" "⛔ 安全拦截:非法特权执行环境。"; continue; fi
|
||||
NODE_DATA=$(db_exec "SELECT node_name, agent_ip, agent_port FROM nodes WHERE chat_id='$CHAT_ID' AND enable_ota='true';")
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
MASTER_VERSION=4.3.3
|
||||
AGENT_VERSION=4.3.3
|
||||
MASTER_VERSION=4.3.4
|
||||
AGENT_VERSION=4.3.4
|
||||
|
||||
Reference in New Issue
Block a user