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?§
-
';
+ rd.innerHTML='RDP Remote Desktop?§
';
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: