feat: 全舰队切换 Bot 凭证,换 Bot 无需卸载重装 (closes #102)

This commit is contained in:
hotyue
2026-08-26 02:36:27 +00:00
parent 790d2e6906
commit 3174124d6e
4 changed files with 250 additions and 9 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## [v4.3.2] - 2026-07-24
### ✨ Features ### ✨ Features
+150
View File
@@ -97,6 +97,11 @@ import os
import html import html
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import urllib.error
import json
import base64
import re
import fcntl
import hmac import hmac
import hashlib import hashlib
import time import time
@@ -486,6 +491,151 @@ fi
self.end_headers() self.end_headers()
self.wfile.write(f"500 Internal Error: {str(e)}\n".encode('utf-8')) 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: else:
self.send_response(404) self.send_response(404)
self.end_headers() self.end_headers()
+83 -1
View File
@@ -287,6 +287,16 @@ while true; do
fi fi
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 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 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
fi fi
@@ -391,7 +401,7 @@ while true; do
NODE_COUNT=$(db_exec "SELECT COUNT(*) FROM nodes WHERE chat_id='$CHAT_ID';") NODE_COUNT=$(db_exec "SELECT COUNT(*) FROM nodes WHERE chat_id='$CHAT_ID';")
if [ "$IS_OFFICIAL_GATEWAY" != "true" ]; then 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 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\"}]]" 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 fi
@@ -409,6 +419,78 @@ while true; do
render_ui "$CHAT_ID" "$MSG_ID" "$WARNING_MSG" "$CONFIRM_BTNS" 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") "all_ota_execute")
if [ -z "$CB_ID" ]; then send_msg "$CHAT_ID" "⛔ 安全拦截:非法特权执行环境。"; continue; fi 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';") NODE_DATA=$(db_exec "SELECT node_name, agent_ip, agent_port FROM nodes WHERE chat_id='$CHAT_ID' AND enable_ota='true';")
+2 -2
View File
@@ -1,2 +1,2 @@
MASTER_VERSION=4.3.3 MASTER_VERSION=4.3.4
AGENT_VERSION=4.3.3 AGENT_VERSION=4.3.4