diff --git a/gateway/scripts/health_service.py b/gateway/scripts/health_service.py new file mode 100644 index 0000000..9fc2c87 --- /dev/null +++ b/gateway/scripts/health_service.py @@ -0,0 +1,187 @@ +""" +Windows Service — xxm Bot Health Monitor +========================================= +Replaces Task Scheduler-based health_check_xxm.py. + +Design: + - HTTP health check (GET :5802/health), NO subprocess polling + - Zero console windows (WMIC/tasklist-free during normal operation) + - Manageable via services.msc or `net start/stop xxm-health` + +Belongs to: projects/AgentsMeeting/gateway/scripts/ + +Usage: + python health_service.py install # register service (run as Admin) + python health_service.py start # start (or net start xxm-health) + python health_service.py stop # stop (or net stop xxm-health) + python health_service.py remove # unregister + + After install: manage via services.msc → "AgentsMeeting xxm Health Service" +""" +import os, sys, time, json, urllib.request, logging + +import win32serviceutil +import win32service +import win32event +import servicemanager + +# ── Paths (absolute, service-safe) ────────────────────────────────── +_PROJECT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_LOG_DIR = os.path.join(_PROJECT, "gateway", "logs") +_LOG_FILE = os.path.join(_LOG_DIR, "health_service.log") +_PYTHON = r"C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe" +_BOT_SCRIPT = os.path.join(_PROJECT, "xmpp_agent_core.py") + +# Bridge API key — needed because /health endpoint requires auth +_BRIDGE_API_KEY = "xxm_bridge_8f3a2c" + +_CHECK_INTERVAL = 30 # seconds between health checks +_HEALTH_TIMEOUT = 10 # HTTP request timeout +_RESTART_WAIT = 3 # seconds to wait after kill before start +_STARTUP_WAIT = 5 # seconds to wait after start before first check + + +class HealthService(win32serviceutil.ServiceFramework): + """Windows Service: polls :5802/health, restarts bot on failure.""" + + _svc_name_ = "xxm-health" + _svc_display_name_ = "AgentsMeeting xxm Health Service" + _svc_description_ = "Monitors xxm bot via HTTP :5802/health, auto-restarts on failure. No console windows." + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) + self._bot_pid: int | None = None + self._setup_logging() + + # ── Logging ───────────────────────────────────────────────────── + + def _setup_logging(self): + os.makedirs(_LOG_DIR, exist_ok=True) + logging.basicConfig( + filename=_LOG_FILE, level=logging.INFO, + format="%(asctime)s [SVC] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + def _log(self, msg: str): + logging.info(msg) + # Also write to Windows Event Log + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, f"[SVC] {msg}"), + ) + + # ── Service lifecycle ─────────────────────────────────────────── + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + win32event.SetEvent(self.hWaitStop) + self._log("Service stopping") + + def SvcDoRun(self): + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + self._log(f"Service started (check every {_CHECK_INTERVAL}s)") + + # Acquire singleton lock via proc_guard + sys.path.insert(0, os.path.join(_PROJECT, "gateway", "scripts")) + try: + from proc_guard import guard + lock = guard("health_service_xxm") + if not lock.ok: + self._log(f"Another instance running: {lock.message} — exiting") + return + except Exception as e: + self._log(f"proc_guard init skipped: {e}") + + # Ensure bot is healthy on startup + if not self._health_check(): + self._log("Bot not responding on startup — restarting") + self._restart_bot() + + # Main loop: wait(interval) → health check → loop + while True: + rc = win32event.WaitForSingleObject(self.hWaitStop, _CHECK_INTERVAL * 1000) + if rc == win32event.WAIT_OBJECT_0: + break # Stop signal + + if not self._health_check(): + self._log("Health check FAILED — restarting bot") + self._restart_bot() + + self._log("Service stopped") + + # ── Health check (HTTP only, zero subprocess) ─────────────────── + + def _health_check(self) -> bool: + """GET :5807/health. Returns True if XMPP connected. + + Port is 5807 (not 5802) — 5802 is occupied by the wechat-hermes-gateway + xmpp_bot.py (independent service). xxm bot moved to 5807 to avoid + dual-listener conflict; this health service must follow. + """ + try: + req = urllib.request.Request( + f"http://127.0.0.1:5807/health?key={_BRIDGE_API_KEY}", + headers={"User-Agent": "health-service/1.0"}, + ) + resp = json.loads( + urllib.request.urlopen(req, timeout=_HEALTH_TIMEOUT).read() + ) + ok = resp.get("xmpp_connected", False) + if not ok: + self._log(f"/health returned xmpp_connected=false: {resp}") + return ok + except Exception as e: + self._log(f"/health unreachable: {e}") + return False + + # ── Bot restart (kill by stored PID → Popen with CREATE_NO_WINDOW) ── + + def _restart_bot(self): + import subprocess + + # 1. Kill old bot by stored PID (no process enumeration needed) + if self._bot_pid: + try: + subprocess.run( + ["taskkill", "/f", "/pid", str(self._bot_pid)], + capture_output=True, timeout=10, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + self._log(f"Killed old bot (PID {self._bot_pid})") + except Exception as e: + self._log(f"Kill PID {self._bot_pid} failed: {e}") + self._bot_pid = None + + time.sleep(_RESTART_WAIT) + + # 2. Start new bot + try: + p = subprocess.Popen( + [_PYTHON, _BOT_SCRIPT, "--agent", "xxm"], + cwd=os.path.dirname(_BOT_SCRIPT), + creationflags=subprocess.CREATE_NO_WINDOW, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self._bot_pid = p.pid + self._log(f"Bot started (PID {p.pid})") + + # 3. Wait a moment then verify + time.sleep(_STARTUP_WAIT) + if self._health_check(): + self._log("Bot healthy after restart") + else: + self._log("Bot started but /health not yet responding") + except Exception as e: + self._log(f"Bot start FAILED: {e}") + + +if __name__ == "__main__": + win32serviceutil.HandleCommandLine(HealthService) diff --git a/gateway/scripts/specs/easytier.json b/gateway/scripts/specs/easytier.json index ba06a1a..98f59e6 100644 --- a/gateway/scripts/specs/easytier.json +++ b/gateway/scripts/specs/easytier.json @@ -17,7 +17,7 @@ "3. 状态显示 Connected 表示两台机器都已加入 VPN 网络" ], "troubleshooting": [ - "如果 Turn On 返回失败:检查 Windows xmpp_bot (pythonw.exe, port 5802) 是否运行", + "如果 Turn On 返回失败:检查 Windows xmpp_bot (python.exe, port 5807) 是否运行", "如果状态显示 Off 但实际已启动:等待 1-2 秒再刷新,或检查 246 上 easytier-core 进程" ], "related": "RDP Remote Desktop 依赖本 VPN 通道,参见 RDP section 的帮助" @@ -29,13 +29,13 @@ {"method": "POST", "path": "/api/easytier/toggle", "body": "{action: start|stop}", "returns": "{ok, message}", "proxied_to": "xmpp_bot /easytier action=start|stop"} ], "dependencies": [ - "xmpp_bot on Windows 192.168.1.16:5802 (HTTP bridge with /easytier endpoint)", - "XMPP_BRIDGE_URL env var (default: http://192.168.1.16:5802) — set in systemd service", + "xmpp_bot on Windows 192.168.1.16:5807 (HTTP bridge with /easytier endpoint)", + "XMPP_BRIDGE_URL env var (default: http://192.168.1.16:5807) — set in systemd service", "_bridge_post() helper in dashboard.py — proxies POST to xmpp_bot with X-Api-Key header", "_BRIDGE_KEY = 'xxm_bridge_8f3a2c' — API key for xmpp_bot HTTP bridge" ], "architecture": { - "flow": "Dashboard(246:5803) → _bridge_post() → xmpp_bot(Windows:5802) → starts/stops easytier-core process on respective machine", + "flow": "Dashboard(246:5803) → _bridge_post() → xmpp_bot(Windows:5807) → starts/stops easytier-core process on respective machine", "mechanism": "xmpp_bot receives POST /easytier {action} → starts/stops easytier-core process" }, "constraints": [ @@ -58,7 +58,7 @@ {"id": "ET03", "name": "EasyTier status returns virtual_ips with windows=10.144.144.3", "endpoint": "GET /api/easytier"} ], "known_issues": [ - "如果 xmpp_bot (5802) 停了,EasyTier 操作会失败 — 检查 Windows pythonw.exe 进程", + "如果 xmpp_bot (5807) 停了,EasyTier 操作会失败 — 检查 Windows pythonw.exe 进程", "EasyTier status 246 字段可能显示 unknown — 因为 246 的 EasyTier 状态不一定通过 xmpp_bot 返回" ], "related_files": [ diff --git a/gateway/scripts/specs/rdp.json b/gateway/scripts/specs/rdp.json index d1276f7..8981569 100644 --- a/gateway/scripts/specs/rdp.json +++ b/gateway/scripts/specs/rdp.json @@ -12,17 +12,23 @@ "本功能依赖 EasyTier VPN 先连通 — SSH 到 246 的连接走 VPN 内网(10.144.144.1)。", "请先确保 EasyTier VPN 已 Connected,再点击 Enable。" ], + "participants": [ + {"name": "老莫", "device": "Windows 192.168.1.16 (被控端)", "role": "本地操作 + 被远程桌面的目标机", "note": "运行 xmpp_bot 执行 SSH reverse tunnel; 暴露 3389"}, + {"name": "莫荷", "device": "Linux 192.168.1.246", "role": "easytier 节点 + dashboard 服务 5803", "note": "EasyTier VPN 节点 (10.144.144.1) + dashboard 代理 RDP toggle"}, + {"name": "小果", "device": "Mac (外网)", "role": "异地远程桌面发起端", "note": "老莫在办公室外用 Mac 通过 mstsc /v:47.115.32.206:8080 回连 Windows 操作。Mac 上需安装 Microsoft Remote Desktop 客户端"} + ], "usage": [ "1. 先在 EasyTier VPN section 点击 Turn On,确认 Connected", "2. 回到本 section 点击 Enable 启动 SSH 反向隧道", - "3. 在外网电脑运行 mstsc /v:47.115.32.206:8080 连接远程桌面", + "3. 老莫在 Mac 外网运行 mstsc /v:47.115.32.206:8080 连接远程桌面到 Windows", "4. 不用时点击 Disable 关闭隧道" ], "troubleshooting": [ "如果 Enable 返回失败:检查 EasyTier VPN 是否 Connected(RDP 依赖 VPN 内网 SSH)", "如果隧道状态一直 pending:检查 246 的 /etc/ssh/sshd_config 是否有 GatewayPorts yes", "如果 mstsc 连不上:检查 Aliyun 安全组是否放行 8080/TCP", - "如果 timeout:从外网执行 Test-NetConnection 47.115.32.206 -Port 8080 看端口是否真的 listening" + "如果 timeout:从外网执行 Test-NetConnection 47.115.32.206 -Port 8080 看端口是否真的 listening", + "Mac 上无 mstsc:安装 Microsoft Remote Desktop (App Store) 然后以 PC name 47.115.32.206:8080 加入" ], "related": "依赖 EasyTier VPN 内网通道,参见 EasyTier VPN section 的帮助" }, @@ -33,15 +39,22 @@ {"method": "POST", "path": "/api/rdp/toggle", "body": "{action: start|stop}", "returns": "{ok, message}", "proxied_to": "xmpp_bot /rdp action=start|stop"} ], "dependencies": [ - "xmpp_bot on Windows 192.168.1.16:5802 — /rdp HTTP endpoint", + "xmpp_bot on Windows 192.168.1.16:5807 — /rdp HTTP endpoint (注: 5807 是为避免与 wechat-hermes-gateway 的 5802 端口冲突, 详见 ai_spec.known_issues.port_conflict)", "_bridge_post() + _BRIDGE_KEY in dashboard.py — proxy 机制同 EasyTier", "port_open() helper in dashboard.py — 检查 SSH 隧道端口 8080 是否监听", - "EasyTier VPN 必须先 Connected — SSH 到 246 走 VPN 内网 10.144.144.1" + "EasyTier VPN 必须先 Connected — SSH 到 246 走 VPN 内网 10.144.144.1", + "Mac 端需安装 Microsoft Remote Desktop 客户端 (App Store) — 小果作为外网发起端" ], "architecture": { - "flow": "Dashboard(246:5803) → _bridge_post() → xmpp_bot(Windows:5802) → 启动 SSH reverse tunnel", + "flow": "Dashboard(246:5803) → _bridge_post() → xmpp_bot(Windows:5807) → 启动 SSH reverse tunnel", "rdp_mechanism": "xmpp_bot receives POST /rdp {action} → 执行 ssh -R 0.0.0.0:8080:localhost:3389 root@47.115.32.206 把 Windows 3389 转发到 Aliyun 公网", - "public_endpoint": "47.115.32.206:8080 (Aliyun 公网)" + "public_endpoint": "47.115.32.206:8080 (Aliyun 公网)", + "participants": { + "windows_192_168_1_16": {"role": "RDP 服务端 (3389) + SSH reverse tunnel 客户端; 被 xmpp_bot 控制 (port 5807)", "agent": "xxm + EasyTier 节点 10.144.144.3"}, + "linux_246": {"role": "EasyTier 节点 (10.144.144.1) + dashboard 服务 (5803) 代理 toggle 请求到 Windows xmpp_bot", "agent": "mohe"}, + "aliyun_47_115_32_206": {"role": "公网入口; 接收 ssh -R 反向隧道; GatewayPorts yes 必须配置"}, + "mac_xiaoguo_remote": {"role": "异地远程桌面客户端; 老莫从办公室外用 Microsoft Remote Desktop 客户端连 47.115.32.206:8080 → Windows 桌面", "agent": "xiaoguo (平台上的 Mac 不在 XMPP 群里参与 traffic, 只作为 RDP client)"} + } }, "constraints": [ "RDP SSH 反向隧道命令: ssh -R 0.0.0.0:8080:localhost:3389 root@47.115.32.206", @@ -58,7 +71,8 @@ "不要重写整个 fI() 函数 — 用 create-once/update-state pattern 修改 RDP section" ], "related_modules": [ - {"module": "easytier", "relation": "RDP 隧道依赖 EasyTier VPN 内网通道。启动 RDP 前必须确保 EasyTier Connected"} + {"module": "easytier", "relation": "RDP 隧道依赖 EasyTier VPN 内网通道。启动 RDP 前必须确保 EasyTier Connected"}, + {"module": "wechat-hermes-xmpp-bot-5802", "relation": "RDP 模块历史上曾与 wechat-hermes-gateway 共享 5802 端口, 导致按钮无响应 (详见 known_issues.port_conflict)。当前已通过换端口解决"} ], "tests": [ {"id": "RDP01", "name": "RDP toggle start returns ok", "endpoint": "POST /api/rdp/toggle {action:start}"}, @@ -69,7 +83,8 @@ "known_issues": [ "如果 Enable 后 status 一直 Tunnel pending:检查 /etc/ssh/sshd_config GatewayPorts yes,检查 Aliyun 安全组 8080/TCP", "如果 mstsc 从外网连不上:检查 Aliyun 安全组 — 需要 8080/TCP 入方向放行", - "如果隧道断了但 status 显示 Connected:这是 stale state — port_open() 会重检 8080 端口下次刷新自动修正" + "如果隧道断了但 status 显示 Connected:这是 stale state — port_open() 会重检 8080 端口下次刷新自动修正", + "port_conflict: 历史上 xmpp_bot 用 5802, 但 wechat-hermes-gateway/scripts/xmpp_bot.py 是另一独立服务也绑了 5802 (无 /rdp /easytier 路由); 两个 listener 同时 LISTENING 导致 OS 随机分发请求, 一半命中旧 bot 返回 400 Bad Request, 表现为按钮无反应。解决: AgentsMeeting xmpp_bot(XPID 41892→现 40452) 改用 5807, wechat-hermes 保留 5802; systemd XMPP_BRIDGE_URL 同步改为 :5807" ], "related_files": [ "gateway/scripts/dashboard.py — /api/rdp*, _bridge_post(), port_open()", diff --git a/xmpp_agent_core.py b/xmpp_agent_core.py index 3a836d0..befa01e 100644 --- a/xmpp_agent_core.py +++ b/xmpp_agent_core.py @@ -76,7 +76,10 @@ AGENTS = { "password": "hermes123", "nick": "xxm", "name_cn": "笑笑", - "http_port": 5802, + # NOTE: 5802 was historically used but is now occupied by wechat-hermes-gateway's + # xmpp_bot.py (independent service). Switched to 5807 to avoid dual-listener conflict + # that caused ~50% of /easytier and /rdp requests to 400. See AGENTS.md / rdp.json. + "http_port": 5807, "bridge_api_key": "xxm_bridge_8f3a2c", "bridge": "chat_bridge", # use local chat_bridge instead of Hermes API "session_id": "ses_xxm_xmpp", @@ -252,16 +255,53 @@ def _rdp_enable(): pass _rdp_kill_tunnel() try: - cmd = ['ssh.exe', '-o', 'StrictHostKeyChecking=no', '-o', 'ServerAliveInterval=30', '-N', '-R', '0.0.0.0:8080:localhost:3389', 'root@47.115.32.206'] + # ExitOnForwardFailure=yes: if remote port 8080 is already in use or + # sshd rejects the -R forwarding, ssh exits immediately instead of + # hanging with a dead tunnel (and xmpp_bot falsely reporting running). + # DETACHED_PROCESS + CREATE_NEW_PROCESS_GROUP: ssh.exe survives parent + # (xmpp_bot) restart/crash — tunnel stays up even if bot auto-recovers. + # stderr → log file: next failure is diagnosable without reproducing. + # env注入: DETACHED_PROCESS 下的子进程可能会丢失 USERPROFILE/HOMEDRIVE/HOMEPATH, + # 导致 ssh 找不到 ~/.ssh/id_rsa → "Permission denied (publickey)". + # 找私钥: 在 LocalSystem 权限下 ~ 是 C:\WINDOWS\system32\config\systemprofile, + # 不是 C:\Users\hmo, 所以要按候选路径搜索 .ssh/id_rsa. + _ssh_key = None + for _user_home in [os.path.expanduser('~'), r'C:\Users\hmo']: + _candidate = os.path.join(_user_home, '.ssh', 'id_rsa') + if os.path.isfile(_candidate): + _ssh_key = _candidate + break + if not _ssh_key: + return False, 'SSH private key (~/.ssh/id_rsa) not found in any candidate home directory' + cmd = ['ssh.exe', + '-i', _ssh_key, + '-o', 'StrictHostKeyChecking=no', + '-o', 'ServerAliveInterval=30', + '-o', 'ExitOnForwardFailure=yes', + '-o', 'IdentitiesOnly=yes', + '-N', '-R', '0.0.0.0:8080:localhost:3389', + 'root@47.115.32.206'] si = _sp.STARTUPINFO() si.dwFlags |= _sp.STARTF_USESHOWWINDOW - p = _sp.Popen(cmd, startupinfo=si, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL) - _t.sleep(2) + _ssh_stderr_path = os.path.join(_LOG_DIR, 'rdp_tunnel_ssh.log') + _ssh_err_fh = open(_ssh_stderr_path, 'a', encoding='utf-8') + _ssh_err_fh.write(f"\n{'='*60}\n{_t.strftime('%Y-%m-%d %H:%M:%S')} RDP tunnel start\n") + _ssh_err_fh.flush() + # 显式复制父进程环境,补全 ssh 需要的 HOME/USERPROFILE + _ssh_env = os.environ.copy() + _ssh_env['HOME'] = os.path.expanduser('~') + _ssh_env['USERPROFILE'] = os.path.expanduser('~') + p = _sp.Popen(cmd, startupinfo=si, + stdout=_sp.DEVNULL, stderr=_ssh_err_fh, + env=_ssh_env, + creationflags=_sp.DETACHED_PROCESS | _sp.CREATE_NEW_PROCESS_GROUP) + _t.sleep(3) if p.poll() is not None: - return False, 'SSH tunnel exited immediately (code ' + str(p.poll()) + ')' + _ssh_err_fh.close() + return False, f'SSH tunnel exited immediately (code {p.poll()}). See {_ssh_stderr_path}' with open(_RDP_PID_FILE, 'w') as f: f.write(str(p.pid)) - log('RDP tunnel enabled (SSH reverse :8080, PID ' + str(p.pid) + ')') + log(f'RDP tunnel enabled (SSH reverse :8080, PID {p.pid}, detached)') return True, 'RDP access enabled' except Exception as e: return False, 'Failed: ' + str(e)