From df3a6300843d60964fc10cda6b25ea762f37c50e Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 24 Aug 2026 05:40:29 +0800 Subject: [PATCH 1/6] fix: disable SenseNova+Kimi usage collectors (CDP consent spam) --- gateway/scripts/dashboard.py | 38 +++++++++----------- gateway/scripts/usage_collector_sensenova.py | 6 ++++ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/gateway/scripts/dashboard.py b/gateway/scripts/dashboard.py index b3c57d0..487f786 100644 --- a/gateway/scripts/dashboard.py +++ b/gateway/scripts/dashboard.py @@ -1462,17 +1462,17 @@ def api_usage_refresh(): if proc.returncode != 0 and proc.stderr: log.warning(f"usage_collector stderr:\n{proc.stderr[-1000:]}") - # 2. Kimi 远程采集(SSH 到 Windows)+ scp 回 246 - try: - _collect_kimi_usage() - except Exception as e: - log.warning(f"usage: Kimi collection failed: {e}") + # 2. Kimi 远程采集(已禁用 by xxm 2026-08-22,CDP弹窗骚扰) + # try: + # _collect_kimi_usage() + # except Exception as e: + # log.warning(f"usage: Kimi collection failed: {e}") - # 3. SenseNova 远程采集(SSH 到 Windows,CDP 刮 console 余量) - try: - _collect_sensenova_usage() - except Exception as e: - log.warning(f"usage: SenseNova collection failed: {e}") + # 3. SenseNova 远程采集(已禁用 by xxm 2026-08-22) + # try: + # _collect_sensenova_usage() + # except Exception as e: + # log.warning(f"usage: SenseNova collection failed: {e}") except Exception as e: log.error(f"usage: collector crash: {e}") finally: @@ -1666,17 +1666,11 @@ def _start_usage_auto_timer(): if proc.returncode != 0 and proc.stderr: log.warning(f"usage auto-timer stderr:\n{proc.stderr[-800:]}") - # Kimi/SenseNova:每 30 分钟刷新一次(SSH 到 Windows + CDP,较重) - tick += 1 - if tick % _KIMI_INTERVAL_MULT == 0: - try: - _collect_kimi_usage() - except Exception as e: - log.warning(f"usage auto-timer: Kimi failed: {e}") - try: - _collect_sensenova_usage() - except Exception as e: - log.warning(f"usage auto-timer: SenseNova failed: {e}") + # Kimi/SenseNova auto-timer disabled by xxm 2026-08-22 (CDP弹窗骚扰) + # tick += 1 + # if tick % _KIMI_INTERVAL_MULT == 0: + # _collect_kimi_usage() + # _collect_sensenova_usage() _usage_collector_running = False except Exception as e: log.error(f"usage auto-timer error: {e}") @@ -2006,7 +2000,7 @@ def api_wechat_trigger_login(): import urllib.request as _ur try: req = _ur.Request( - "http://127.0.0.1:3001/api/bot/login", + "http://127.0.0.1:3001/api/bot/login?token=mowechat_fixed_token_001", data=b"", headers={"Content-Type": "application/json"}, method="POST", diff --git a/gateway/scripts/usage_collector_sensenova.py b/gateway/scripts/usage_collector_sensenova.py index d3b3738..351addd 100644 --- a/gateway/scripts/usage_collector_sensenova.py +++ b/gateway/scripts/usage_collector_sensenova.py @@ -25,6 +25,12 @@ GATEWAY_DIR = SCRIPT_DIR.parent TEMP_DIR = GATEWAY_DIR / "temp" os.makedirs(str(TEMP_DIR), exist_ok=True) +# 停止标记:存在此文件时脚本立即退出 +STOP_FILE = SCRIPT_DIR / "STOP_SENSNOVA_COLLECT" +if STOP_FILE.exists(): + print(f"[STOPPED] {STOP_FILE.name} exists, exiting.") + sys.exit(0) + OUTPUT_FILE = TEMP_DIR / "usage_stats_sensenova.json" PROVIDER = "sensenova" From 7036b667b19e28cdcec3aeb00c02ae2e8e23e6cc Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 24 Aug 2026 05:53:22 +0800 Subject: [PATCH 2/6] refactor: article_processor as external service, disable wechat_qr_notifier --- gateway/scripts/service_registry.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/gateway/scripts/service_registry.py b/gateway/scripts/service_registry.py index 60c4504..769398a 100644 --- a/gateway/scripts/service_registry.py +++ b/gateway/scripts/service_registry.py @@ -28,17 +28,17 @@ SERVICES = [ }, { "name": "article_processor", - "script": os.path.join(os.path.dirname(BASE), "self-growing-knowledge", - "scripts", "article_processor.py"), + "script": None, # 外部服务,由 self-growing-knowledge 项目管理,AgentsMeeting 不再启动 "args": [], - "workdir": os.path.join(os.path.dirname(BASE), "self-growing-knowledge"), - "pid_file": os.path.join(TEMP, ".article_processor.pid"), + "workdir": None, + "pid_file": None, "port": 5810, - "health_url": "http://127.0.0.1:5810/health", + "health_url": "http://192.168.1.16:5810/health", "accept_401": False, "depends_on": [], - "log_files": lambda: [os.path.join(GATEWAY_DIR, "logs", "article_processor.log")], - "description": "微信文章全文抓取服务", + "remote": True, # 标记为远程外部服务 + "log_files": lambda: [], + "description": "微信文章全文抓取服务(外部,self-growing-knowledge)", }, { "name": "dashboard", From 811d2dd6dbf50cb7afd575db5a435cdf43ea6f1d Mon Sep 17 00:00:00 2001 From: hmo Date: Wed, 26 Aug 2026 02:40:06 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(rdp):=20robust=20RDP=20enable=20?= =?UTF-8?q?=E2=80=94=20step-machine=20+=20deep=20health=20check=20+=20self?= =?UTF-8?q?-heal=20+=20end-to-end=20verify=20+=20live=20progress=20&=20str?= =?UTF-8?q?uctured=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/scripts/dashboard.py | 21 ++ gateway/scripts/templates/dashboard.html | 41 ++- xmpp_agent_core.py | 405 ++++++++++++++++++----- 3 files changed, 390 insertions(+), 77 deletions(-) diff --git a/gateway/scripts/dashboard.py b/gateway/scripts/dashboard.py index 487f786..1f67e77 100644 --- a/gateway/scripts/dashboard.py +++ b/gateway/scripts/dashboard.py @@ -1376,6 +1376,27 @@ def api_rdp_toggle(): return jsonify({"ok": False, "error": str(e)}) +@app.route("/api/rdp/progress") +def api_rdp_progress(): + """RDP enable 实时进度 — proxy to xmpp_bot on Windows (polls rdp_progress.json).""" + try: + data = _bridge_post("/rdp", {"action": "progress"}, timeout=8) + return jsonify(data) + except Exception as e: + return jsonify({"ok": False, "state": "unknown", "message": "progress 查询失败", "error": str(e), "steps": []}) + + +@app.route("/api/rdp/enable_log") +def api_rdp_enable_log(): + """RDP enable 结构化日志 — proxy to xmpp_bot on Windows (for post-mortem debug).""" + try: + n = request.args.get("lines", 80, type=int) + data = _bridge_post("/rdp", {"action": "enable_log", "lines": n}, timeout=8) + return jsonify(data) + except Exception as e: + return jsonify({"ok": False, "error": str(e), "lines": []}) + + # ════════════════════════════════════════════════════════════ # OpenCode Go Usage Monitor — 4个账号用量配额监控 (246 本地采集) # See gateway/scripts/specs/usage_monitor.json diff --git a/gateway/scripts/templates/dashboard.html b/gateway/scripts/templates/dashboard.html index 2f2020f..65ce8d5 100644 --- a/gateway/scripts/templates/dashboard.html +++ b/gateway/scripts/templates/dashboard.html @@ -384,10 +384,11 @@ async function fI(){ var rd=document.getElementById('rdp-section'); if(!rd){ rd=document.createElement('div');rd.id='rdp-section';rd.className='ps';rd.style.marginTop='16px'; - rd.innerHTML='

RDP Remote Desktop?§

RDP
Tunnel
-
'; + rd.innerHTML='

RDP Remote Desktop?§

RDP
Tunnel
-日志
'; ci.appendChild(rd); - document.getElementById('btn-rdp-on').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'start'})}).then(function(){fI()}).catch(function(){toast('RDP Fail','err')})}; + document.getElementById('btn-rdp-on').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'start'})}).then(function(){startRdpProgress()}).catch(function(){toast('RDP Fail','err')})}; document.getElementById('btn-rdp-off').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'stop'})}).then(function(){fI()}).catch(function(){toast('RDP Fail','err')})}; + document.getElementById('rdp-log-link').onclick=function(){var b=document.getElementById('rdp-logbox');if(b.style.display==='none'||!b.style.display){b.style.display='block';loadRdpLog()}else{b.style.display='none'}}; } try{var e4=await fetch('/api/rdp').then(function(r){return r.json()}); document.getElementById('rdp-st').className='d '+(e4.rdp_enabled?'ok':'stopped'); @@ -399,6 +400,42 @@ async function fI(){ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_enabled?'Tunnel pending':'Disconnected'; }catch(e4){} + /* --- RDP enable 实时进度轮询 + 结构化日志 --- */ + var _rdpProgTimer=null; + function startRdpProgress(){ + var box=document.getElementById('rdp-progress'); if(box) box.style.display='block'; + if(_rdpProgTimer) clearInterval(_rdpProgTimer); + pollRdpProgress(); + _rdpProgTimer=setInterval(pollRdpProgress,1500); + } + async function pollRdpProgress(){ + try{ + var d=await fetch('/api/rdp/progress').then(function(r){return r.json()}); + var msg=document.getElementById('rdp-prog-msg'); + if(msg) msg.textContent=(d.state==='running'?'⏳ ':'')+(d.message||''); + var st=document.getElementById('rdp-prog-steps'); + if(st && d.steps){ + st.innerHTML=d.steps.map(function(s){ + var icon=s.status==='ok'?'✓':(s.status==='fail'?'✗':(s.status==='warn'?'⚠':(s.status==='skip'?'—':'…'))); + var color=s.status==='ok'?'#4caf50':(s.status==='fail'?'#f44336':(s.status==='warn'?'#ff9800':'#9aa')); + return '
'+icon+' '+s.name+' '+(s.detail||'')+'
'; + }).join(''); + } + if(d.state==='done'||d.state==='failed'){ + if(_rdpProgTimer){clearInterval(_rdpProgTimer);_rdpProgTimer=null;} + if(msg) msg.textContent=(d.state==='done'?'✓ ':'✗ ')+(d.message||''); + fI(); + } + }catch(e){} + } + async function loadRdpLog(){ + try{ + var d=await fetch('/api/rdp/enable_log?lines=80').then(function(r){return r.json()}); + var b=document.getElementById('rdp-logbox'); + if(b) b.textContent=(d.lines&&d.lines.length)?d.lines.join('\n'):'(暂无日志)'; + }catch(e){var b=document.getElementById('rdp-logbox');if(b)b.textContent='日志加载失败';} + } + /* --- OpenCode Go Usage section: create once, update state each cycle (no flicker) --- */ var us=document.getElementById('usage-section'); if(!us){ diff --git a/xmpp_agent_core.py b/xmpp_agent_core.py index 909d07d..056e411 100644 --- a/xmpp_agent_core.py +++ b/xmpp_agent_core.py @@ -306,85 +306,107 @@ def _rdp_health_restore(): return ok def _rdp_enable(): - import subprocess as _sp, winreg as _wr, time as _t + """Robust RDP enable — async step-machine. Writes live progress to + rdp_progress.json + structured lines to rdp_enable.log, returns immediately + with 'started'; dashboard polls /rdp action=progress for live steps.""" + import threading as _th, json as _j try: - k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_SET_VALUE) - _wr.SetValueEx(k, 'fDenyTSConnections', 0, _wr.REG_DWORD, 0) - _wr.CloseKey(k) - except Exception as e: - log(f'RDP enable registry error: {e}') - try: - _sp.run(['net', 'localgroup', 'Remote Desktop Users', 'hmo', '/add'], capture_output=True, timeout=5) + if os.path.exists(_RDP_PROGRESS_FILE): + with open(_RDP_PROGRESS_FILE, encoding='utf-8') as f: + cur = _j.load(f) + if cur.get('state') == 'running': + return True, 'RDP enable 已在进行中(查看进度)' except Exception: pass - _rdp_kill_tunnel() - # 收养现存隧道:如果有 ssh 进程已在跑 -R 8080 转发(比如 bot 崩溃后留下的 - # detached ssh、或状态文件丢失后的野生隧道),直接写入 pid 文件收养, - # 不再起新进程——否则新 ssh 会因 8080 被占报 "remote port forwarding failed", - # 导致 dashboard 永远显示 disabled(2026-07-23 事故)。 - _existing = _rdp_find_tunnel_process() - if _existing: - _rdp_adopt_tunnel(_existing) - log(f'RDP: tunnel already running (PID {_existing}), adopted — no new ssh needed') - return True, f'RDP access enabled (adopted existing tunnel, PID {_existing})' - # Health check: if RDP not responding, auto-restore - _t.sleep(2) - if not _rdp_health_check(): - log('RDP: initial health check FAILED — triggering auto-restore') - _rdp_health_restore() - else: - log('RDP: health check passed') + _rdp_write_progress('running', 'init', _RDP_STEP_MSG['init'], []) + _rdp_log('init', 'start', 'RDP enable requested') + _th.Thread(target=_rdp_enable_run, daemon=True).start() + return True, 'RDP enable 已启动(后台执行中,可看实时进度)' + +def _rdp_enable_run(): + """The actual step-machine, runs in a daemon thread. Each step is recorded + to rdp_progress.json (live) and rdp_enable.log (structured, for later debug).""" + import subprocess as _sp, winreg as _wr, time as _t + steps = [] + def mark(name, status, detail=''): + steps.append({'name': name, 'status': status, 'detail': detail, 'ts': _rdp_now()}) + _rdp_write_progress('running', name, _RDP_STEP_MSG.get(name, name), steps, detail) + _rdp_log(name, status, detail) + try: - # 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 - _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: - _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(f'RDP tunnel enabled (SSH reverse :8080, PID {p.pid}, detached)') - return True, 'RDP access enabled' + # Step 1: 开注册表 + try: + k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_SET_VALUE) + _wr.SetValueEx(k, 'fDenyTSConnections', 0, _wr.REG_DWORD, 0) + _wr.CloseKey(k) + mark('registry', 'ok', 'fDenyTSConnections=0') + except Exception as e: + mark('registry', 'fail', str(e)) + + # Step 2: 加入 RDP 用户组 + try: + _sp.run(['net', 'localgroup', 'Remote Desktop Users', 'hmo', '/add'], capture_output=True, timeout=8) + mark('rdp_users', 'ok', 'hmo 已加入 Remote Desktop Users') + except Exception as e: + mark('rdp_users', 'warn', str(e)) + + # Step 3: 深度健康检查 + hc = _rdp_deep_health_check() + mark('health_check', 'ok' if hc['healthy'] else 'warn', + 'x224=%s svc=%s listen=%s 卡死=%d nla=%s cert=%s' % ( + hc['x224'], hc['termservice'], hc['port_listen'], hc['stale_sessions'], hc['nla'], hc['cert_ok'])) + + # Step 4: 自愈(如需) + if not hc['healthy']: + _rdp_log('restore', 'start', 'issues: ' + '; '.join(hc['issues'])) + cleared = _rdp_kill_stale_rdp_sessions() + if cleared: + _rdp_log('restore', 'detail', '清理卡死会话: ' + ','.join(cleared)) + _rdp_health_restore() + hc2 = _rdp_deep_health_check() + # NLA 卡死处理:连接能到但建不了会话且 NLA 开 → 临时关 NLA 再重启(隧道内网场景可逆) + if not hc2['healthy'] and hc2.get('nla') == 1: + _rdp_log('restore', 'detail', '仍异常且NLA开启 → 临时关闭NLA并重启TermService') + _rdp_set_nla(False) + _t.sleep(1) + _sp.run(['net', 'stop', 'TermService', '/y'], capture_output=True, timeout=30) + _t.sleep(2) + _sp.run(['net', 'start', 'TermService'], capture_output=True, timeout=30) + _t.sleep(3) + mark('restore', 'ok', '自愈完成: ' + ('; '.join(hc['issues']) or 'no-op')) + else: + mark('restore', 'skip', '健康,无需自愈') + + # Step 5: 建立隧道 + _rdp_kill_tunnel() + _t.sleep(4) # 等阿里云端 8080 转发随旧 ssh 断开而释放,避免新隧道 forwarding failed + _existing = _rdp_find_tunnel_process() + if _existing: + _rdp_adopt_tunnel(_existing) + mark('tunnel', 'ok', '收养已有隧道 PID %d' % _existing) + else: + ok, msg = _rdp_start_tunnel() + mark('tunnel', 'ok' if ok else 'fail', msg) + if not ok: + _rdp_write_progress('failed', 'tunnel', '隧道建立失败', steps, msg) + _rdp_log('done', 'failed', msg) + return + + # Step 6: 端到端验证 + _t.sleep(2) + if _rdp_verify_endtoend(): + mark('verify', 'ok', '端到端握手通过 (47.115.32.206:8080)') + _rdp_write_progress('done', 'done', '✓ RDP 已就绪,可连接 47.115.32.206:8080', steps, '') + _rdp_log('done', 'ok', 'RDP fully enabled and verified') + else: + mark('verify', 'warn', '端到端验证未通过(隧道通但RDP握手失败,可能需重试)') + _rdp_write_progress('done', 'done', '隧道已建立,端到端验证未通过,请稍后重试连接', steps, '') + _rdp_log('done', 'warn', 'tunnel up but end-to-end verify failed') except Exception as e: - return False, 'Failed: ' + str(e) + mark('done', 'fail', str(e)) + _rdp_write_progress('failed', 'done', '启动失败: ' + str(e), steps, str(e)) + _rdp_log('done', 'failed', str(e)) + def _rdp_disable(): import subprocess as _sp, winreg as _wr @@ -469,6 +491,235 @@ def _rdp_status(): pass return {'ok': True, 'tunnel_running': tunnel_on, 'rdp_enabled': rdp_on, 'rdp_port': 3389, 'tunnel_host': 'root@47.115.32.206', 'tunnel_port': 8080} +# ============================================================ +# RDP Robustness — step-machine + progress + structured log +# 让 enable 按钮"点了就能用":深度检查 + 自愈 + 端到端验证 + 实时进度 +# ============================================================ +_RDP_PROGRESS_FILE = os.path.join(os.path.dirname(__file__), 'gateway', 'scripts', 'rdp_progress.json') +_RDP_ENABLE_LOG = os.path.join(_LOG_DIR, 'rdp_enable.log') + +_RDP_STEP_MSG = { + 'init': '开始启动 RDP 远程桌面', + 'registry': '开启远程桌面注册表', + 'rdp_users': '加入远程桌面用户组', + 'health_check': '深度健康检查(TermService/端口/卡死连接/NLA/证书)', + 'restore': '检测到异常,正在自我修复', + 'tunnel': '建立 SSH 反向隧道', + 'verify': '端到端连通性验证', + 'done': '完成', +} + +def _rdp_now(): + import time as _t + return _t.strftime('%Y-%m-%d %H:%M:%S') + +def _rdp_log(step, status, detail=''): + """Append a structured line to rdp_enable.log AND mirror to bot log.""" + line = ("[%s] [%s] %s %s" % (_rdp_now(), step.upper(), status.upper(), detail)).rstrip() + try: + with open(_RDP_ENABLE_LOG, 'a', encoding='utf-8') as f: + f.write(line + '\n') + except Exception: + pass + log('RDP ' + line) + +def _rdp_write_progress(state, step, message, steps=None, detail=''): + """Write rdp_progress.json for the dashboard to poll live steps.""" + import json as _j + prog = {'state': state, 'step': step, 'message': message, + 'detail': detail, 'steps': steps or [], 'updated': _rdp_now()} + try: + with open(_RDP_PROGRESS_FILE, 'w', encoding='utf-8') as f: + _j.dump(prog, f, ensure_ascii=False, indent=1) + except Exception: + pass + +def _rdp_progress(): + """Return current enable progress (for /rdp action=progress).""" + import json as _j + try: + if os.path.exists(_RDP_PROGRESS_FILE): + with open(_RDP_PROGRESS_FILE, encoding='utf-8') as f: + d = _j.load(f) + d['ok'] = True + return d + except Exception: + pass + return {'ok': True, 'state': 'idle', 'step': '', 'message': '无进行中的任务', 'steps': [], 'detail': ''} + +def _rdp_read_enable_log(lines=80): + """Tail the structured enable log (for /rdp action=enable_log).""" + try: + if os.path.exists(_RDP_ENABLE_LOG): + with open(_RDP_ENABLE_LOG, encoding='utf-8', errors='replace') as f: + data = f.readlines() + return {'ok': True, 'lines': [l.rstrip('\n') for l in data[-lines:]]} + except Exception as e: + return {'ok': False, 'error': str(e), 'lines': []} + return {'ok': True, 'lines': []} + +def _rdp_deep_health_check(): + """Deep RDP health check — unlike _rdp_health_check (X.224/TCP only), this + also checks TermService, 3389 listen, stale/close-wait sessions, NLA setting + and cert validity, so it can detect "connects but stuck at configuring" + (NLA stall / dead session) that the shallow check blindly passes.""" + import subprocess as _sp + res = {'x224': _rdp_health_check(), 'termservice': False, 'port_listen': False, + 'stale_sessions': 0, 'nla': None, 'cert_ok': None, 'healthy': False, 'issues': []} + try: + r = _sp.run(['sc', 'query', 'TermService'], capture_output=True, text=True, timeout=8) + res['termservice'] = 'RUNNING' in r.stdout + except Exception: + pass + try: + r = _sp.run(['powershell', '-NoProfile', '-Command', + "if(Get-NetTCPConnection -LocalPort 3389 -State Listen -EA SilentlyContinue){'yes'}"], + capture_output=True, text=True, timeout=12) + res['port_listen'] = 'yes' in r.stdout + except Exception: + pass + try: + r = _sp.run(['powershell', '-NoProfile', '-Command', + "@(Get-NetTCPConnection -LocalPort 3389 -EA SilentlyContinue | Where-Object {$_.State -in 'CloseWait','FinWait1','FinWait2','LastAck','TimeWait'}).Count"], + capture_output=True, text=True, timeout=12) + n = r.stdout.strip() + res['stale_sessions'] = int(n) if n.isdigit() else 0 + except Exception: + pass + try: + import winreg as _wr + k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp', 0, _wr.KEY_READ) + v, _ = _wr.QueryValueEx(k, 'UserAuthentication') + _wr.CloseKey(k) + res['nla'] = int(v) + except Exception: + pass + try: + r = _sp.run(['powershell', '-NoProfile', '-Command', + "$c=Get-ChildItem 'Cert:\\LocalMachine\\Remote Desktop' -EA SilentlyContinue | Where-Object {$_.HasPrivateKey -and $_.NotAfter -gt (Get-Date)} | Select-Object -First 1; if($c){'ok'}else{'none'}"], + capture_output=True, text=True, timeout=12) + res['cert_ok'] = 'ok' in r.stdout + except Exception: + pass + issues = [] + if not res['termservice']: issues.append('TermService未运行') + if not res['port_listen']: issues.append('3389未监听') + if not res['x224']: issues.append('X.224握手失败') + if res['stale_sessions'] > 0: issues.append('卡死连接x%d' % res['stale_sessions']) + if res['cert_ok'] is False: issues.append('RDP证书异常') + res['issues'] = issues + res['healthy'] = bool(res['x224'] and res['termservice'] and res['port_listen'] + and res['stale_sessions'] == 0 and res['cert_ok'] is not False) + return res + +def _rdp_kill_stale_rdp_sessions(): + """Log off half-dead/disconnected RDP sessions and clear close-wait conns that + block new session setup (a common cause of 'stuck at configuring').""" + import subprocess as _sp + cleared = [] + try: + r = _sp.run(['qwinsta'], capture_output=True, text=True, timeout=8) + for line in r.stdout.splitlines(): + if 'rdp-tcp#' in line and ('Disc' in line or '断开' in line or 'Down' in line): + parts = line.split() + for p in parts: + if p.isdigit() and int(p) > 0: + _sp.run(['logoff', p], capture_output=True, timeout=5) + cleared.append('session#' + p) + break + except Exception: + pass + return cleared + +def _rdp_set_nla(enabled): + """Set NLA (UserAuthentication) on RDP-Tcp. enabled=True→1, False→0.""" + import winreg as _wr + try: + k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp', 0, _wr.KEY_SET_VALUE) + _wr.SetValueEx(k, 'UserAuthentication', 0, _wr.REG_DWORD, 1 if enabled else 0) + _wr.CloseKey(k) + return True + except Exception: + return False + +def _rdp_start_tunnel(): + """Start the SSH reverse tunnel (extracted from old _rdp_enable body). + Retries when the Aliyun-side 8080 forwarding is still held by a just-killed + previous tunnel ('remote port forwarding failed for listen port 8080') — + that port needs a few seconds to be released after the old ssh dies.""" + import subprocess as _sp, time as _t, os + _retry_waits = [8, 12, 18, 25] # 递增等待:覆盖阿里云端 8080 转发随旧 ssh 断开而缓慢释放的时间 + for _attempt in range(5): + try: + _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 私钥 ~/.ssh/id_rsa 未找到' + 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 + _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('\n' + '=' * 60 + '\n' + _t.strftime('%Y-%m-%d %H:%M:%S') + ' RDP tunnel start (attempt %d)\n' % (_attempt + 1)) + _ssh_err_fh.flush() + _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: + _ssh_err_fh.close() + # ssh 立即退出:判断是否 8080 被旧隧道残留占用(可重试) + _fwd_fail = False + try: + with open(_ssh_stderr_path, encoding='utf-8', errors='replace') as _ef: + _tail = _ef.read()[-600:] + _fwd_fail = ('forwarding failed' in _tail) or ('8080' in _tail and 'listen' in _tail) + except Exception: + pass + if _fwd_fail and _attempt < 4: + _wait = _retry_waits[_attempt] + _rdp_log('tunnel', 'warn', '8080 被旧隧道残留占用,等待 %ds 后重试 (%d/5)' % (_wait, _attempt + 1)) + _rdp_write_progress('running', 'tunnel', 'SSH 反向隧道', None, '8080 残留占用,等待 %ds 重试 (%d/5)' % (_wait, _attempt + 1)) + _t.sleep(_wait) + continue + return False, 'SSH 隧道立即退出(code %s),见 %s' % (p.poll(), _ssh_stderr_path) + with open(_RDP_PID_FILE, 'w') as f: + f.write(str(p.pid)) + return True, '隧道已建立 PID %d' % p.pid + except Exception as e: + if _attempt < 4: + _t.sleep(_retry_waits[_attempt]) + continue + return False, '隧道建立异常: ' + str(e) + return False, 'SSH 隧道多次失败(8080 可能被旧隧道残留占用,请稍后重试)' + +def _rdp_verify_endtoend(): + """After tunnel is up, verify full chain via public endpoint 47.115.32.206:8080 + with an X.224 handshake (proves 8080→tunnel→3389→RDP all work).""" + import socket as _sk + try: + _s = _sk.create_connection(('47.115.32.206', 8080), timeout=8) + _s.sendall(bytes.fromhex('030000130ee00000000000010008000b000000')) + _s.settimeout(8) + _resp = _s.recv(1024) + _s.close() + if len(_resp) >= 6 and _resp[5] in (0xd0, 0x03): + return True + except Exception: + pass + return False + + # ── OpenCode Go Usage Monitor helpers ── # Reads cached aggregation from gateway/temp/usage_stats.json. # Triggers asynchronous collection by spawning usage_collector.py in a daemon thread. @@ -1049,8 +1300,12 @@ class _BridgeHandler(http.server.BaseHTTPRequestHandler): self._reply(200, {'ok': ok, 'message': msg}) elif action == 'status': self._reply(200, _rdp_status()) + elif action == 'progress': + self._reply(200, _rdp_progress()) + elif action == 'enable_log': + self._reply(200, _rdp_read_enable_log(body.get('lines', 80))) else: - self._reply(400, {'ok': False, 'error': 'action must be start|stop|status'}) + self._reply(400, {'ok': False, 'error': 'action must be start|stop|status|progress|enable_log'}) return # /usage endpoint — OpenCode Go usage monitor (read cache / trigger collection) # Pattern mirrors /rdp and /easytier. Two actions: From 345abd782e66d50a1c04a95ad4dd9d7016f13264 Mon Sep 17 00:00:00 2001 From: hmo Date: Wed, 26 Aug 2026 02:48:32 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(rdp):=20disable=20NLA=20by=20default=20?= =?UTF-8?q?in=20enable=20step=20=E2=80=94=20CredSSP=20pre-auth=20is=20the?= =?UTF-8?q?=20root=20cause=20of=20'stuck=20at=20configuring';=20tunnel=20i?= =?UTF-8?q?s=20already=20SSH-encrypted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xmpp_agent_core.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/xmpp_agent_core.py b/xmpp_agent_core.py index 056e411..53a2f8d 100644 --- a/xmpp_agent_core.py +++ b/xmpp_agent_core.py @@ -334,7 +334,8 @@ def _rdp_enable_run(): _rdp_log(name, status, detail) try: - # Step 1: 开注册表 + # Step 1: 开注册表(开RDP + 隧道场景统一关NLA——SSH已加密,NLA的CredSSP预认证 + # 正是"卡正在配置远程电脑"的根源:连接到达TermService但NLA协商挂起、建不了会话) try: k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_SET_VALUE) _wr.SetValueEx(k, 'fDenyTSConnections', 0, _wr.REG_DWORD, 0) @@ -342,6 +343,13 @@ def _rdp_enable_run(): mark('registry', 'ok', 'fDenyTSConnections=0') except Exception as e: mark('registry', 'fail', str(e)) + try: + k2 = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp', 0, _wr.KEY_SET_VALUE) + _wr.SetValueEx(k2, 'UserAuthentication', 0, _wr.REG_DWORD, 0) + _wr.CloseKey(k2) + mark('registry', 'ok', 'NLA已关闭(UserAuthentication=0,隧道场景)') + except Exception as e: + mark('registry', 'warn', '关NLA失败: ' + str(e)) # Step 2: 加入 RDP 用户组 try: From 9b53ba68724bbd6fe932eb8b158e4f5e234166cc Mon Sep 17 00:00:00 2001 From: hmo Date: Wed, 26 Aug 2026 02:58:22 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(rdp):=20disable=20HW=20graphics=20adapt?= =?UTF-8?q?er=20(software=20rendering)=20in=20enable=20step=20=E2=80=94=20?= =?UTF-8?q?DisplayLink/multi-GPU=20WDDM=20init=20conflict=20is=20a=20top?= =?UTF-8?q?=20cause=20of=20'stuck=20at=20configuring'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xmpp_agent_core.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/xmpp_agent_core.py b/xmpp_agent_core.py index 53a2f8d..615080b 100644 --- a/xmpp_agent_core.py +++ b/xmpp_agent_core.py @@ -350,6 +350,17 @@ def _rdp_enable_run(): mark('registry', 'ok', 'NLA已关闭(UserAuthentication=0,隧道场景)') except Exception as e: mark('registry', 'warn', '关NLA失败: ' + str(e)) + # 隧道/多GPU/DisplayLink 环境统一禁用硬件图形适配器(软件渲染): + # DisplayLink USB 显示扩展坞/NVIDIA 多GPU 与 RDP WDDM 图形初始化冲突, + # 是"卡正在配置远程电脑"(WDDM启用后挂起)的高频根因。软件渲染绕开它。 + try: + import subprocess as _sp2 + _sp2.run(['powershell', '-NoProfile', '-Command', + "New-Item -Path 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Terminal Services' -Force | Out-Null; Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Terminal Services' -Name 'bEnumerateHWBeforeSW' -Value 0 -Type DWord"], + capture_output=True, timeout=10) + mark('registry', 'ok', '已禁用硬件图形适配器(软件渲染,绕开DisplayLink/多GPU冲突)') + except Exception as e: + mark('registry', 'warn', '禁用硬件图形失败: ' + str(e)) # Step 2: 加入 RDP 用户组 try: From 0df627ca1b800d30d03166a48319d51f54b162f3 Mon Sep 17 00:00:00 2001 From: hmo Date: Wed, 26 Aug 2026 04:02:05 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(wechat-qr):=20stop=20auto-refresh=20of?= =?UTF-8?q?=20login=20QR=20=E2=80=94=20manual=20get/refresh=20only,=20with?= =?UTF-8?q?=20cache=20(no=20more=2010s=20auto-fetch=20+=205min=20auto-chan?= =?UTF-8?q?ge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/scripts/dashboard.py | 28 ++++++++++++++----- gateway/scripts/templates/dashboard.html | 35 +++++++++++++++--------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/gateway/scripts/dashboard.py b/gateway/scripts/dashboard.py index 1f67e77..987bbfe 100644 --- a/gateway/scripts/dashboard.py +++ b/gateway/scripts/dashboard.py @@ -611,14 +611,21 @@ def _augment_wechat_status(entry): entry["qr_timestamp"] = None entry["session_age_hours"] = 0 - # 1. Fetch QR code (always available) + # 二维码【不再自动拉取】:避免 dashboard 每10秒轮询时自动刷新二维码(用户要求改手动获取)。 + # 只从"手动获取缓存"读取——用户点"获取登录二维码"后由 /api/wechat/status 写入; + # 且仅当缓存未过期(二维码约5分钟有效)才返回,过期视为无 → 前端显示"获取登录二维码"按钮。 try: - req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001") - html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace") - m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html) - if m: - entry["qr_url"] = m.group(1) - entry["qr_timestamp"] = now.isoformat() + cache_file = str(_GATEWAY_DIR / "temp" / "wechat_qr_cache.json") + if os.path.exists(cache_file): + with open(cache_file, encoding="utf-8") as f: + qc = json.load(f) + qurl = qc.get("qr_url") + qts = qc.get("ts", "") + if qurl and qts: + age_sec = (now - datetime.fromisoformat(qts)).total_seconds() + if age_sec < 300: # 二维码约5分钟有效 + entry["qr_url"] = qurl + entry["qr_timestamp"] = qts except Exception: pass @@ -1876,6 +1883,13 @@ def api_wechat_status(): if m: result["qr_url"] = m.group(1) result["qr_timestamp"] = now.isoformat() + # 手动获取到二维码 → 写入缓存,dashboard infra status 据此显示(避免自动拉取刷新) + try: + cache_file = str(_GATEWAY_DIR / "temp" / "wechat_qr_cache.json") + with open(cache_file, "w", encoding="utf-8") as _cf: + json.dump({"qr_url": m.group(1), "ts": now.isoformat()}, _cf, ensure_ascii=False) + except Exception: + pass except Exception as e: result["message"] = f"QR page fetch failed: {e}" diff --git a/gateway/scripts/templates/dashboard.html b/gateway/scripts/templates/dashboard.html index 65ce8d5..0728e49 100644 --- a/gateway/scripts/templates/dashboard.html +++ b/gateway/scripts/templates/dashboard.html @@ -332,16 +332,10 @@ async function fI(){ h+='\u4e8c\u7ef4\u7801\u7ea65\u5206\u949f\u6709\u6548'; h+=''; h+='
'; - h+='

\u83ab\u8377\u5fae\u4fe1\u9700\u8981\u626b\u7801\u767b\u5f55

'; - h+='

'+esc(s.message||'')+'\u3002\u7528\u5fae\u4fe1\u626b\u63cf\u5de6\u4fa7\u4e8c\u7ef4\u7801\u91cd\u65b0\u767b\u5f55\u3002

'; - h+='

\u94fe\u63a5: '+esc(s.qr_url||'')+'

'; - h+='
'; - setTimeout(function(){ - var el=document.getElementById('wechat-qr-age'); - if(el)el.innerHTML='\u4e8c\u7ef4\u7801\u5df2\u8fc7\u671f'; - var btn=document.getElementById('wechat-qr-refresh'); - if(btn)btn.textContent='\u5df2\u8fc7\u671f\uff0c\u70b9\u51fb\u5237\u65b0'; - },300000); + h+='

莫荷微信需要扫码登录

'; + h+='

'+esc(s.message||'')+'。用微信扫描左侧二维码重新登录。

'; + h+='

链接: '+esc(s.qr_url||'')+'

'; + h+=''; }else{ h+='
'; h+=''; @@ -682,6 +676,14 @@ function inMd(t){return t.replace(/\*\*([^*]+)\*\*/g,'$1').repl init();loadAll();setInterval(loadAll,10000);setInterval(fK,15000); /* --- WeChat QR: 获取二维码(触发容器 /api/bot/login) + 刷新 --- */ +function renderWechatQR(qrUrl){ + var box=document.getElementById('wechat-qr-get-status'); + if(box) box.innerHTML='' + +'
用微信扫描上方二维码登录(约5分钟有效,过期请点下方"刷新二维码")
' + +''; + var getBtn=document.getElementById('wechat-qr-get'); + if(getBtn) getBtn.style.display='none'; +} async function getWechatQR(){ var st=document.getElementById('wechat-qr-get-status'); var btn=document.getElementById('wechat-qr-get'); @@ -697,7 +699,7 @@ async function getWechatQR(){ await new Promise(res=>setTimeout(res,2000)); try{ var rs=await fetch('/api/wechat/status'),sd=await rs.json(); - if(sd.qr_url){location.reload();return;} + if(sd.qr_url){renderWechatQR(sd.qr_url);return;} }catch(e){} } if(st)st.innerHTML='\u4e8c\u7ef4\u7801\u672a\u751f\u6210\uff0c\u8bf7\u91cd\u8bd5'; @@ -712,10 +714,17 @@ async function getWechatQR(){ } } async function refreshWechatQR(){ + var st=document.getElementById('wechat-qr-get-status'); + if(st && !document.getElementById('wechat-qr-img')) st.innerHTML='正在刷新二维码...'; try{ await fetch('/api/wechat/trigger-login',{method:'POST'}); - // 刷新后等几秒重新拉状态 - setTimeout(function(){location.reload();},3000); + for(var i=0;i<10;i++){ + await new Promise(res=>setTimeout(res,2000)); + try{ + var rs=await fetch('/api/wechat/status'),sd=await rs.json(); + if(sd.qr_url){ renderWechatQR(sd.qr_url); return; } + }catch(e){} + } }catch(e){} } \ No newline at end of file