feat(rdp): robust RDP enable — step-machine + deep health check + self-heal + end-to-end verify + live progress & structured log

This commit is contained in:
hmo
2026-08-26 02:40:06 +08:00
parent 7036b667b1
commit 811d2dd6db
3 changed files with 390 additions and 77 deletions
+21
View File
@@ -1376,6 +1376,27 @@ def api_rdp_toggle():
return jsonify({"ok": False, "error": str(e)}) 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 本地采集) # OpenCode Go Usage Monitor — 4个账号用量配额监控 (246 本地采集)
# See gateway/scripts/specs/usage_monitor.json # See gateway/scripts/specs/usage_monitor.json
+39 -2
View File
@@ -384,10 +384,11 @@ async function fI(){
var rd=document.getElementById('rdp-section'); var rd=document.getElementById('rdp-section');
if(!rd){ if(!rd){
rd=document.createElement('div');rd.id='rdp-section';rd.className='ps';rd.style.marginTop='16px'; rd=document.createElement('div');rd.id='rdp-section';rd.className='ps';rd.style.marginTop='16px';
rd.innerHTML='<h2>RDP Remote Desktop<span class="help-btn" onclick="showModuleHelp(\'rdp\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'rdp\',\'ai\')" title="AI Spec">§</span></h2><div class=psv><div class=i><span class="d" id=rdp-st></span>RDP <span id=rdp-info></span></div><div class=i><span class="d" id=rdp-tun></span>Tunnel</div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-rdp-on>Enable</button><button class="btn x" id=btn-rdp-off>Disable</button><span id=rdp-status-txt style="font-size:12px;color:var(--dim)">-</span></div>'; rd.innerHTML='<h2>RDP Remote Desktop<span class="help-btn" onclick="showModuleHelp(\'rdp\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'rdp\',\'ai\')" title="AI Spec">§</span></h2><div class=psv><div class=i><span class="d" id=rdp-st></span>RDP <span id=rdp-info></span></div><div class=i><span class="d" id=rdp-tun></span>Tunnel</div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-rdp-on>Enable</button><button class="btn x" id=btn-rdp-off>Disable</button><span id=rdp-status-txt style="font-size:12px;color:var(--dim)">-</span><a href="javascript:void(0)" id="rdp-log-link" style="font-size:11px;color:var(--dim);margin-left:auto">日志</a></div><div id="rdp-progress" style="margin-top:10px;display:none;font-size:12px"><div id="rdp-prog-msg" style="color:var(--accent);margin-bottom:6px;font-weight:600"></div><div id="rdp-prog-steps" style="line-height:1.7"></div></div><div id="rdp-logbox" style="margin-top:8px;display:none;font-size:11px;color:var(--dim);background:rgba(0,0,0,.2);padding:8px;border-radius:6px;max-height:180px;overflow:auto;white-space:pre-wrap;font-family:monospace"></div>';
ci.appendChild(rd); 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('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()}); try{var e4=await fetch('/api/rdp').then(function(r){return r.json()});
document.getElementById('rdp-st').className='d '+(e4.rdp_enabled?'ok':'stopped'); 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'; document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_enabled?'Tunnel pending':'Disconnected';
}catch(e4){} }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 '<div style="color:'+color+'">'+icon+' '+s.name+' <span style="color:#778;font-size:11px">'+(s.detail||'')+'</span></div>';
}).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) --- */ /* --- OpenCode Go Usage section: create once, update state each cycle (no flicker) --- */
var us=document.getElementById('usage-section'); var us=document.getElementById('usage-section');
if(!us){ if(!us){
+322 -67
View File
@@ -306,85 +306,107 @@ def _rdp_health_restore():
return ok return ok
def _rdp_enable(): def _rdp_enable():
"""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:
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_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 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:
# Step 1: 开注册表
try: try:
k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_SET_VALUE) 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.SetValueEx(k, 'fDenyTSConnections', 0, _wr.REG_DWORD, 0)
_wr.CloseKey(k) _wr.CloseKey(k)
mark('registry', 'ok', 'fDenyTSConnections=0')
except Exception as e: except Exception as e:
log(f'RDP enable registry error: {e}') mark('registry', 'fail', str(e))
# Step 2: 加入 RDP 用户组
try: try:
_sp.run(['net', 'localgroup', 'Remote Desktop Users', 'hmo', '/add'], capture_output=True, timeout=5) _sp.run(['net', 'localgroup', 'Remote Desktop Users', 'hmo', '/add'], capture_output=True, timeout=8)
except Exception: mark('rdp_users', 'ok', 'hmo 已加入 Remote Desktop Users')
pass 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() _rdp_kill_tunnel()
# 收养现存隧道:如果有 ssh 进程已在跑 -R 8080 转发(比如 bot 崩溃后留下的 _t.sleep(4) # 等阿里云端 8080 转发随旧 ssh 断开而释放,避免新隧道 forwarding failed
# detached ssh、或状态文件丢失后的野生隧道),直接写入 pid 文件收养,
# 不再起新进程——否则新 ssh 会因 8080 被占报 "remote port forwarding failed"
# 导致 dashboard 永远显示 disabled2026-07-23 事故)。
_existing = _rdp_find_tunnel_process() _existing = _rdp_find_tunnel_process()
if _existing: if _existing:
_rdp_adopt_tunnel(_existing) _rdp_adopt_tunnel(_existing)
log(f'RDP: tunnel already running (PID {_existing}), adopted — no new ssh needed') mark('tunnel', 'ok', '收养已有隧道 PID %d' % _existing)
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: else:
log('RDP: health check passed') ok, msg = _rdp_start_tunnel()
try: mark('tunnel', 'ok' if ok else 'fail', msg)
# ExitOnForwardFailure=yes: if remote port 8080 is already in use or if not ok:
# sshd rejects the -R forwarding, ssh exits immediately instead of _rdp_write_progress('failed', 'tunnel', '隧道建立失败', steps, msg)
# hanging with a dead tunnel (and xmpp_bot falsely reporting running). _rdp_log('done', 'failed', msg)
# DETACHED_PROCESS + CREATE_NEW_PROCESS_GROUP: ssh.exe survives parent return
# (xmpp_bot) restart/crash — tunnel stays up even if bot auto-recovers.
# stderr → log file: next failure is diagnosable without reproducing. # Step 6: 端到端验证
# env注入: DETACHED_PROCESS 下的子进程可能会丢失 USERPROFILE/HOMEDRIVE/HOMEPATH, _t.sleep(2)
# 导致 ssh 找不到 ~/.ssh/id_rsa → "Permission denied (publickey)". if _rdp_verify_endtoend():
# 找私钥: 在 LocalSystem 权限下 ~ 是 C:\WINDOWS\system32\config\systemprofile, mark('verify', 'ok', '端到端握手通过 (47.115.32.206:8080)')
# 不是 C:\Users\hmo, 所以要按候选路径搜索 .ssh/id_rsa. _rdp_write_progress('done', 'done', '✓ RDP 已就绪,可连接 47.115.32.206:8080', steps, '')
_ssh_key = None _rdp_log('done', 'ok', 'RDP fully enabled and verified')
for _user_home in [os.path.expanduser('~'), r'C:\Users\hmo']: else:
_candidate = os.path.join(_user_home, '.ssh', 'id_rsa') mark('verify', 'warn', '端到端验证未通过(隧道通但RDP握手失败,可能需重试)')
if os.path.isfile(_candidate): _rdp_write_progress('done', 'done', '隧道已建立,端到端验证未通过,请稍后重试连接', steps, '')
_ssh_key = _candidate _rdp_log('done', 'warn', 'tunnel up but end-to-end verify failed')
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'
except Exception as e: 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(): def _rdp_disable():
import subprocess as _sp, winreg as _wr import subprocess as _sp, winreg as _wr
@@ -469,6 +491,235 @@ def _rdp_status():
pass 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} 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 ── # ── OpenCode Go Usage Monitor helpers ──
# Reads cached aggregation from gateway/temp/usage_stats.json. # Reads cached aggregation from gateway/temp/usage_stats.json.
# Triggers asynchronous collection by spawning usage_collector.py in a daemon thread. # 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}) self._reply(200, {'ok': ok, 'message': msg})
elif action == 'status': elif action == 'status':
self._reply(200, _rdp_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: 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 return
# /usage endpoint — OpenCode Go usage monitor (read cache / trigger collection) # /usage endpoint — OpenCode Go usage monitor (read cache / trigger collection)
# Pattern mirrors /rdp and /easytier. Two actions: # Pattern mirrors /rdp and /easytier. Two actions: