Files
ykxmz/_build/redesign_admin_page.py
T
xmz7891 b62ac865ea ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性
被控端
- 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%)
- 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败)
- 每分钟回头找局域网,自动切回快 400 倍的路径
- 画面心跳帧:静止时省流量,客户端仍知道流是活的
- 锁屏时给出可操作的提示

手表端
- 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException
- 连接错误翻译成人话(证书/时间/解析/超时分别提示)
- 经中转连上后用服务器报的地址刷新已配对记录
- 新增局域网连接页面、服务器延迟检测

服务器端
- 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502)
- 网络调优脚本 net-tune.sh(开机自动应用)
- 管理页:批量删除、网络体检、下载区
- 隧道改 HTTP/2 + 双连接

文档
- 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
2026-10-06 19:01:05 +08:00

474 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""重做中转管理页的界面。
功能一个不少:
公网地址展示 + 一键复制、统计卡片、设备表格、
单台删除、批量删除、删除全部、登录保护
界面上的改进:
* 头部:品牌 + 状态灯 + 版本/运行时长
* 公网地址做成醒目的大卡片,带「复制」按钮
* 统计卡片换成更清楚的样式
* 表格:在线/离线用徽章,配对码用等宽 chip,行悬停高亮
* 空状态给一句人话,而不是一行灰字
* 加「自动刷新」开关(默认 10 秒一次),省得手动 F5
* 窄屏(手机)表格自动变成卡片式,不用左右拖
* 操作结果用页面内提示条,不再打断式 alert(删除确认仍用 confirm)
⚠️ 两个必须注意的坑:
1. 这段是 f-string,CSS/JS 里的花括号都要写两遍 {{{{ }}}}
2. JS 字符串里的换行要写 \\\\n(源码里两反斜杠),
否则 f-string 会把它变成真换行,整段脚本报错(幽灵按键)
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
RELAY = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay" r"\relay.py")
NEW_PAGE = ''' def _admin_page(self) -> None:
snap = RELAY.snapshot()
rows = []
for device in snap["devices"]:
online = device["online"]
badge = ("<span class='badge on'>在线</span>" if online
else "<span class='badge off'>离线</span>")
rows.append(
"<tr>"
"<td class=pickcell>"
f'<input type=checkbox class=pick value="{_esc(device["deviceId"])}">'
"</td>"
f'<td class=name>'
f'<span class="dot {"on" if online else "off"}"></span>'
f'{_esc(device["name"])}<div class=sub2>'
f'{_esc(device["deviceId"])}</div></td>'
f'<td><code>{_esc(device["code"])}</code></td>'
f'<td>{badge}</td>'
f'<td>{device["idleSeconds"]}s</td>'
f'<td>{device["queuedToClient"]} / {device["queuedToAgent"]}</td>'
f'<td class=num>{_kb(device["bytesIn"])} / {_kb(device["bytesOut"])}</td>'
'<td class=ops><button class="btn del" '
f'data-device="{_esc(device["deviceId"])}" '
f'data-name="{_esc(device["name"])}" '
f'data-code="{_esc(device["code"])}">删除</button></td>'
"</tr>")
online_count = sum(1 for d in snap["devices"] if d["online"])
url_text = public_url() or ""
url_display = _esc(url_text) if url_text else "(还没有地址,隧道可能没起来)"
empty = ('<tr><td colspan=8 class=empty>'
'<div class=big>还没有电脑连上来</div>'
'<div>在被控端双击 ykxmz.exe 设好配对码,'
'手表上输那 6 位数字就能连上。</div></td></tr>')
html = f"""<!doctype html><html lang=zh>
<meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>ykxmz 中转服务器</title>
<style>
*{{box-sizing:border-box}}
:root{{
--bg:#0d1117; --panel:#161b22; --panel2:#1c2128; --border:#30363d;
--fg:#e6edf3; --dim:#8b949e; --muted:#6e7681;
--accent:#2f81f7; --ok:#3fb950; --danger:#f85149; --warn:#d29922;
}}
body{{margin:0;padding:0;background:var(--bg);color:var(--fg);
font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',
'Microsoft YaHei',sans-serif;-webkit-font-smoothing:antialiased}}
.wrap{{max-width:1120px;margin:0 auto;padding:22px 20px 40px}}
/* ---------- 头部 ---------- */
header{{display:flex;align-items:center;gap:12px;
padding-bottom:16px;border-bottom:1px solid var(--border);
margin-bottom:20px;flex-wrap:wrap}}
.logo{{width:38px;height:38px;border-radius:10px;flex:none;
background:linear-gradient(140deg,#2f81f7,#1f6feb);
display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:15px;color:#fff;letter-spacing:-.5px}}
.brand h1{{margin:0;font-size:17px;font-weight:600;letter-spacing:.2px}}
.brand .sub{{color:var(--dim);font-size:12px}}
.meta{{margin-left:auto;text-align:right;color:var(--muted);font-size:12px;
line-height:1.5}}
.meta b{{color:var(--dim);font-weight:500}}
/* ---------- 通用卡片 ---------- */
.panel{{background:var(--panel);border:1px solid var(--border);
border-radius:12px;padding:16px 18px;margin-bottom:16px}}
.panel .label{{color:var(--dim);font-size:12px;margin-bottom:8px;
display:flex;align-items:center;gap:6px}}
/* ---------- 公网地址 ---------- */
.urlrow{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}}
.url{{flex:1;min-width:220px;font:600 15px/1.5 ui-monospace,Consolas,
'Courier New',monospace;color:#79c0ff;word-break:break-all;
background:#0b1220;border:1px solid #1f6feb44;border-radius:8px;
padding:10px 12px}}
.url.empty{{color:var(--muted);font-weight:400;font-style:normal}}
/* ---------- 统计 ---------- */
.stats{{display:grid;gap:12px;margin-bottom:16px;
grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}}
.stat{{background:var(--panel);border:1px solid var(--border);
border-radius:12px;padding:14px 16px;position:relative;overflow:hidden}}
.stat .n{{font-size:26px;font-weight:650;line-height:1.15;
letter-spacing:-.5px}}
.stat .t{{color:var(--dim);font-size:12px;margin-top:2px}}
.stat::after{{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;
background:var(--accent);opacity:.65}}
.stat.ok::after{{background:var(--ok)}}
.stat.warn::after{{background:var(--warn)}}
.stat.mute::after{{background:#484f58}}
/* ---------- 工具条 ---------- */
.toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;
margin-bottom:12px}}
.toolbar .spacer{{flex:1}}
.tip{{color:var(--muted);font-size:12px}}
/* ---------- 按钮 ---------- */
button{{font-family:inherit;font-size:13px;cursor:pointer;
border-radius:8px;padding:7px 14px;border:1px solid var(--border);
background:var(--panel2);color:var(--fg);transition:.12s}}
button:hover{{background:#262c36;border-color:#484f58}}
button:active{{transform:translateY(1px)}}
button:disabled{{opacity:.4;cursor:not-allowed;transform:none}}
button.primary{{background:var(--accent);border-color:var(--accent);
color:#fff;font-weight:600}}
button.primary:hover{{background:#4a95f8;border-color:#4a95f8}}
button.del{{background:#2d1214;border-color:#6e2b2b;color:#ff7b72}}
button.del:hover{{background:#4a1a1e;border-color:#a33;color:#ffa198}}
button.del:disabled{{opacity:.4}}
button.small{{padding:5px 11px;font-size:12px}}
/* ---------- 表格 ---------- */
.tablewrap{{background:var(--panel);border:1px solid var(--border);
border-radius:12px;overflow:hidden}}
table{{width:100%;border-collapse:collapse}}
th,td{{padding:11px 14px;text-align:left;font-size:13px;
border-bottom:1px solid #21262d;vertical-align:middle}}
th{{background:var(--panel2);color:var(--dim);font-weight:500;
font-size:12px;white-space:nowrap}}
tbody tr{{transition:background .1s}}
tbody tr:hover td{{background:#1b222c}}
tbody tr:last-child td{{border-bottom:none}}
td.name{{font-weight:500}}
td.num{{font-family:ui-monospace,Consolas,monospace;color:var(--dim);
font-size:12px}}
td.ops{{text-align:right;white-space:nowrap}}
.sub2{{color:var(--muted);font-size:11px;font-weight:400;
font-family:ui-monospace,Consolas,monospace;margin-top:1px}}
td.empty{{text-align:center;padding:44px 20px;color:var(--dim)}}
td.empty .big{{font-size:15px;color:var(--fg);margin-bottom:6px;font-weight:500}}
code{{background:#21262d;padding:2px 8px;border-radius:6px;
letter-spacing:1.5px;font-family:ui-monospace,Consolas,monospace;
font-size:13px;color:#79c0ff}}
.dot{{display:inline-block;width:7px;height:7px;border-radius:50%;
margin-right:7px;vertical-align:middle;background:#484f58}}
.dot.on{{background:var(--ok);box-shadow:0 0 0 3px #3fb95022}}
.badge{{font-size:11px;padding:2px 8px;border-radius:20px;
border:1px solid transparent;white-space:nowrap}}
.badge.on{{color:var(--ok);background:#3fb9501a;border-color:#3fb95040}}
.badge.off{{color:var(--muted);background:#6e76811a;border-color:#6e768140}}
.pickcell{{width:38px;text-align:center;padding-right:0}}
input[type=checkbox]{{width:15px;height:15px;cursor:pointer;
accent-color:var(--accent);vertical-align:middle}}
tr.picked td{{background:#1c2733}}
tr.picked:hover td{{background:#213042}}
/* ---------- 提示条 ---------- */
#toast{{position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);
background:#1f6feb;color:#fff;padding:11px 20px;border-radius:10px;
font-size:13px;box-shadow:0 8px 24px #0008;opacity:0;
transition:.22s;pointer-events:none;max-width:88vw;text-align:center}}
#toast.show{{opacity:1;transform:translate(-50%,0)}}
#toast.err{{background:#b62324}}
#toast.ok{{background:#1a7f37}}
footer{{color:var(--muted);font-size:12px;margin-top:22px;
padding-top:14px;border-top:1px solid var(--border);line-height:1.7}}
/* ---------- 窄屏:表格变卡片 ---------- */
@media (max-width:720px){{
.wrap{{padding:16px 12px 32px}}
.meta{{margin-left:0;text-align:left;width:100%}}
thead{{display:none}}
tbody tr{{display:block;border-bottom:1px solid var(--border);
padding:12px 14px;position:relative}}
tbody tr:last-child{{border-bottom:none}}
tbody td{{display:flex;justify-content:space-between;gap:12px;
border:none;padding:4px 0;text-align:right}}
tbody td::before{{content:attr(data-l);color:var(--dim);font-size:12px;
text-align:left;flex:none}}
td.pickcell{{position:absolute;right:12px;top:12px;width:auto;
padding:0}}
td.pickcell::before{{content:''}}
td.name{{display:block;text-align:left;padding-right:30px}}
td.name::before{{content:''}}
td.ops{{justify-content:flex-end;padding-top:8px}}
td.ops::before{{content:''}}
td.empty{{display:block}}
td.empty::before{{content:''}}
}}
</style>
<div class=wrap>
<header>
<div class=logo>yx</div>
<div class=brand>
<h1>ykxmz 中转服务器</h1>
<div class=sub>远程控制 · 跨网络中转</div>
</div>
<div class=meta>
<div>版本 <b>{_esc(snap['version'])}</b> · 已运行 <b>{_uptime(snap['uptimeSeconds'])}</b></div>
<div>本进程内存 <b>{snap['memoryKB'] // 1024} MB</b> · 累计请求 <b>{snap['totalRequests']}</b></div>
</div>
</header>
<div class=panel>
<div class=label>当前公网地址 —— 手表「中转设置」里填这个</div>
<div class=urlrow>
<div class="url{' empty' if not url_text else ''}" id=urlBox>{url_display}</div>
<button class="btn primary" id=copyUrl {'disabled' if not url_text else ''}>复制</button>
</div>
</div>
<div class=stats>
<div class=stat><div class=n>{len(snap['devices'])}</div><div class=t>登记设备</div></div>
<div class="stat {'ok' if online_count else 'mute'}"><div class=n>{online_count}</div><div class=t>在线</div></div>
<div class="stat {'warn' if snap['sessions'] else 'mute'}"><div class=n>{snap['sessions']}</div><div class=t>手表会话</div></div>
<div class=stat><div class=n>{len(RELAY.codes)}</div><div class=t>配对码占用</div></div>
</div>
<div class=toolbar>
<button class="btn del" id=delPicked>删除选中</button>
<button class="btn del" id=delAll>删除全部设备</button>
<div class=spacer></div>
<label class=tip style="cursor:pointer;display:flex;align-items:center;gap:6px">
<input type=checkbox id=autoRef checked> 自动刷新(10 秒)
</label>
</div>
<div class=tip style="margin:-4px 0 12px">
删除会连配对码一起清掉,那台设备下次可以重新设码。
</div>
<div class=tablewrap>
<table>
<thead><tr>
<th class=pickcell><input type=checkbox id=pickAll title="全选"></th>
<th>设备</th><th>配对码</th><th>状态</th>
<th>空闲</th><th>待发(表/机)</th><th>流量(入/出)</th><th></th>
</tr></thead>
<tbody>
{''.join(rows) or empty}
</tbody>
</table>
</div>
<footer>
数据来自 /v1/health · 只显示配对码与流量,看不到任何传输内容<br>
中转只是转发字节,不解析、不存储会话内容。
</footer>
</div>
<div id=toast></div>
<script>
const toast = (msg, kind) => {{
const el = document.getElementById('toast');
el.textContent = msg;
el.className = kind ? ('show ' + kind) : 'show';
clearTimeout(el._t);
el._t = setTimeout(() => {{ el.className = ''; }}, 2600);
}};
const post = async (path, payload) => {{
const r = await fetch(path, {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify(payload || {{}})
}});
return r.json();
}};
// ---------------- 单台删除 ----------------
document.querySelectorAll('button.del[data-device]').forEach(b => {{
b.onclick = async () => {{
const name = b.dataset.name, code = b.dataset.code;
if (!confirm('删除设备「' + name + '」(配对码 ' + code + ')?\\\\n\\\\n'
+ '这会:\\\\n 1. 断开它现在的手表连接\\\\n'
+ ' 2. 从服务器删掉这台设备\\\\n'
+ ' 3. 清掉它的配对码绑定')) return;
try {{
const j = await post('/v1/admin/delete', {{deviceId: b.dataset.device}});
if (j.ok) {{
toast('已删除「' + (j.name || name) + '」,清掉配对码 '
+ (j.codesCleared || []).length + ' 条', 'ok');
setTimeout(() => location.reload(), 700);
}} else {{
toast('删除失败:' + (j.error || '未知错误'), 'err');
}}
}} catch (e) {{
toast('请求出错:' + e, 'err');
}}
}};
}});
// ---------------- 批量选择 ----------------
const picks = () => Array.from(document.querySelectorAll('input.pick'));
const picked = () => picks().filter(c => c.checked).map(c => c.value);
function refreshPickState() {{
const chosen = picked().length;
const btn = document.getElementById('delPicked');
if (btn) {{
btn.disabled = chosen === 0;
btn.textContent = chosen ? ('删除选中 (' + chosen + ')') : '删除选中';
}}
picks().forEach(c => {{
const tr = c.closest('tr');
if (tr) tr.classList.toggle('picked', c.checked);
}});
const all = document.getElementById('pickAll');
if (all) {{
const total = picks().length;
all.checked = total > 0 && chosen === total;
all.indeterminate = chosen > 0 && chosen < total;
}}
}}
picks().forEach(c => c.addEventListener('change', refreshPickState));
const pickAll = document.getElementById('pickAll');
if (pickAll) pickAll.addEventListener('change', () => {{
picks().forEach(c => {{ c.checked = pickAll.checked; }});
refreshPickState();
}});
refreshPickState();
// ---------------- 批量删除 ----------------
const delPicked = document.getElementById('delPicked');
if (delPicked) delPicked.onclick = async () => {{
const ids = picked();
if (!ids.length) return;
if (!confirm('删除选中的 ' + ids.length + ' 台设备?\\\\n\\\\n'
+ '会断开它们现在的手表连接,并清掉它们占的配对码。'))
return;
delPicked.disabled = true;
try {{
const j = await post('/v1/admin/delete_many', {{deviceIds: ids}});
if (j.ok) {{
toast('已删除 ' + j.deleted + ' 台设备'
+ (j.failed ? (',' + j.failed + ' 台失败') : ''), 'ok');
setTimeout(() => location.reload(), 700);
}} else {{
toast('删除失败:' + (j.error || '未知错误'), 'err');
delPicked.disabled = false;
}}
}} catch (e) {{
toast('请求出错:' + e, 'err');
delPicked.disabled = false;
}}
}};
// ---------------- 删除全部 ----------------
const delAll = document.getElementById('delAll');
if (delAll) delAll.onclick = async () => {{
if (!confirm('删除**全部**设备(连同所有配对码)?\\\\n\\\\n'
+ '清场用,删完所有电脑都要重新设码。')) return;
try {{
const j = await post('/v1/admin/delete_all');
toast('已删除 ' + (j.deleted || 0) + ' 台设备', 'ok');
setTimeout(() => location.reload(), 700);
}} catch (e) {{
toast('请求出错:' + e, 'err');
}}
}};
// ---------------- 复制公网地址 ----------------
const copyUrl = document.getElementById('copyUrl');
if (copyUrl) copyUrl.onclick = async () => {{
const text = document.getElementById('urlBox').textContent.trim();
try {{
await navigator.clipboard.writeText(text);
toast('地址已复制', 'ok');
}} catch (e) {{
// 非安全上下文(http 且不是 localhost)没有 clipboard API,
// 退回到老办法:选中 + execCommand
const range = document.createRange();
range.selectNodeContents(document.getElementById('urlBox'));
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
try {{ document.execCommand('copy'); toast('地址已复制', 'ok'); }}
catch (e2) {{ toast('复制失败,请手动选中', 'err'); }}
}}
}};
// ---------------- 自动刷新 ----------------
const autoRef = document.getElementById('autoRef');
let autoTimer = null;
function setupAuto() {{
clearInterval(autoTimer);
if (autoRef && autoRef.checked) {{
autoTimer = setInterval(() => {{
// 有勾选/删除操作进行中就先别刷新,免得打断
if (picked().length === 0 && !document.hidden) location.reload();
}}, 10000);
}}
}}
if (autoRef) {{
autoRef.addEventListener('change', () => {{
try {{ localStorage.setItem('lr_autoref', autoRef.checked ? '1' : '0'); }}
catch (e) {{}}
setupAuto();
}});
try {{
const saved = localStorage.getItem('lr_autoref');
if (saved === '0') autoRef.checked = false;
}} catch (e) {{}}
setupAuto();
}}
</script>
</html>"""
self._send(200, html.encode("utf-8"), "text/html; charset=utf-8")
'''
def main() -> int:
text = RELAY.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
start = None
end = None
for index, line in enumerate(lines):
if line.startswith(" def _admin_page(self) -> None:"):
start = index
elif start is not None and index > start and (
line.startswith(" def ")
or line.startswith("def ")
or line.startswith("class ")):
end = index
break
if start is None or end is None:
print(" [失败] 找不到 _admin_page 的边界")
return 1
new_text = "".join(lines[:start]) + NEW_PAGE + "\n" + "".join(lines[end:])
try:
ast.parse(new_text)
except SyntaxError as exc:
print(f" [失败] 生成的代码语法错误: 行 {exc.lineno}: {exc.msg}")
return 1
RELAY.write_text(new_text, encoding="utf-8", newline="\n")
print(f" 已重写 _admin_page({end - start} 行 -> "
f"{NEW_PAGE.count(chr(10))} 行)")
return 0
if __name__ == "__main__":
sys.exit(main())