From 811d2dd6dbf50cb7afd575db5a435cdf43ea6f1d Mon Sep 17 00:00:00 2001 From: hmo Date: Wed, 26 Aug 2026 02:40:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(rdp):=20robust=20RDP=20enable=20=E2=80=94?= =?UTF-8?q?=20step-machine=20+=20deep=20health=20check=20+=20self-heal=20+?= =?UTF-8?q?=20end-to-end=20verify=20+=20live=20progress=20&=20structured?= =?UTF-8?q?=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: