fix: RDP tunnel SSH auth under LocalSystem — port 5807 + key path search + detached process

- http_port 5802→5807: 5802 occupied by wechat-hermes-gateway xmpp_bot, dual-listener caused ~50% request 400s
- _rdp_enable(): DETACHED_PROCESS so tunnel survives bot restart/crash
- ExitOnForwardFailure=yes so dead forward exits immediately instead of hanging
- stderr→rdp_tunnel_ssh.log for diagnosability
- -i explicit key path with multi-home search (LocalSystem ~ = systemprofile, not C:\Users\hmo)
- env injection HOME/USERPROFILE for ssh subprocess
- IdentitiesOnly=yes so -i key is used exclusively
- health_service.py: /health port 5802→5807 (was causing 30s restart loop)
- easytier.json + rdp.json: all 5802→5807 references updated
- rdp.json: add xiaoguo/Mac as RDP participant + architecture participant
This commit is contained in:
hmo
2026-07-15 13:30:34 +08:00
parent 47e323c8fc
commit ac34556588
4 changed files with 261 additions and 19 deletions
+187
View File
@@ -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)
+5 -5
View File
@@ -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": [
+23 -8
View File
@@ -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 是否 ConnectedRDP 依赖 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()",
+46 -6
View File
@@ -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)