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
## [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
+150
View File
@@ -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
View File
@@ -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
View File
@@ -1,2 +1,2 @@
MASTER_VERSION=4.3.3
AGENT_VERSION=4.3.3
MASTER_VERSION=4.3.4
AGENT_VERSION=4.3.4