diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f15faa6..7bcc99fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/core/agent_daemon.sh b/core/agent_daemon.sh index 194302e5..437e31a5 100755 --- a/core/agent_daemon.sh +++ b/core/agent_daemon.sh @@ -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() diff --git a/master/tg_master.sh b/master/tg_master.sh index ee3b9306..3e346ac7 100755 --- a/master/tg_master.sh +++ b/master/tg_master.sh @@ -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';") diff --git a/version.txt b/version.txt index 0ce6d0dc..2b935f38 100644 --- a/version.txt +++ b/version.txt @@ -1,2 +1,2 @@ -MASTER_VERSION=4.3.3 -AGENT_VERSION=4.3.3 +MASTER_VERSION=4.3.4 +AGENT_VERSION=4.3.4