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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 永远显示 disabled(2026-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:
|
||||||
|
|||||||
Reference in New Issue
Block a user