阶段3+4+5:管理页白色化 + 局域网诊断 + 全量验收

阶段3 管理页
- 清掉全部深色主题残留(地址框 #0b1220、按钮悬停 #262c36、
  删除按钮 #2d1214 等 11 处),现在 0 处深色
- 接口全部正常:health / downloads / netcheck

阶段4 局域网诊断
- 查清楚"电脑不在同一网络"是怎么回事:被控端是**对的**
  (lan_ips() 报 ['192.168.2.64', '169.254.83.107'],真实地址排第一),
  是手表当时真不在 192.168.2.x —— 可能连了 5G 频段的另一个路由器,或开着移动数据
- 所以改了手表的提示:不再只说"不在同一网络",
  而是把**双方地址都打出来**,用户一眼能看出是网段不同还是连错 WiFi
- 端到端验证局域网路径:认证 1 ms,功能列表 207 项

阶段1 结论修正(一天内推翻自己两次,如实记录)
- 第一次判断"优选 IP 无用":错。半小时内确实能避开超时 IP
- 第二次固定优选 IP:也错。那些实测 138ms 的 IP 半小时后全不通了
  —— Cloudflare anycast 可达性随运营商路由变化,固定 IP 等于钉死到会失效的地址
- 最终:不固定,靠 GeoDNS + cloudflared 重试 + ha-connections 2 冗余
- 撤销后立即变好:连接稳定 2 条、超时告警 0 次、设备侧延迟 4.40s -> 1.66s
- **真正的瓶颈**:分段实测发现本机到 Cloudflare 的**裸 RTT 就 1439 ms**,
  比经隧道的请求(1215 ms)还慢。说明瓶颈是这条网络的 4G 上行本身,
  不是设备的隧道。公网侧已无优化空间。
- 局域网 118 ms vs 公网 1215 ms,快 10 倍 —— 这才是该走的路

阶段5 验收
- 全量回归 29 项全通过
- 设备侧验收 38 项全通过
- 成品:setup exe 14.37 MB / server zip / watch apk,已同步到设备下载区
This commit is contained in:
xmz7891
2026-10-07 00:13:10 +08:00
parent 566d9e384f
commit 7b7830d8c5
80 changed files with 1890 additions and 39 deletions
+30
View File
@@ -0,0 +1,30 @@
阶段3+4+5:管理页白色化 + 局域网诊断 + 全量验收
阶段3 管理页
- 清掉全部深色主题残留(地址框 #0b1220、按钮悬停 #262c36、
删除按钮 #2d1214 等 11 处),现在 0 处深色
- 接口全部正常:health / downloads / netcheck
阶段4 局域网诊断
- 查清楚"电脑不在同一网络"是怎么回事:被控端是**对的**
(lan_ips() 报 ['192.168.2.64', '169.254.83.107'],真实地址排第一),
是手表当时真不在 192.168.2.x —— 可能连了 5G 频段的另一个路由器,或开着移动数据
- 所以改了手表的提示:不再只说"不在同一网络",
而是把**双方地址都打出来**,用户一眼能看出是网段不同还是连错 WiFi
- 端到端验证局域网路径:认证 1 ms,功能列表 207 项
阶段1 结论修正(一天内推翻自己两次,如实记录)
- 第一次判断"优选 IP 无用":错。半小时内确实能避开超时 IP
- 第二次固定优选 IP:也错。那些实测 138ms 的 IP 半小时后全不通了
—— Cloudflare anycast 可达性随运营商路由变化,固定 IP 等于钉死到会失效的地址
- 最终:不固定,靠 GeoDNS + cloudflared 重试 + ha-connections 2 冗余
- 撤销后立即变好:连接稳定 2 条、超时告警 0 次、设备侧延迟 4.40s -> 1.66s
- **真正的瓶颈**:分段实测发现本机到 Cloudflare 的**裸 RTT 就 1439 ms**,
比经隧道的请求(1215 ms)还慢。说明瓶颈是这条网络的 4G 上行本身,
不是设备的隧道。公网侧已无优化空间。
- 局域网 118 ms vs 公网 1215 ms,快 10 倍 —— 这才是该走的路
阶段5 验收
- 全量回归 29 项全通过
- 设备侧验收 38 项全通过
- 成品:setup exe 14.37 MB / server zip / watch apk,已同步到设备下载区
+8
View File
@@ -0,0 +1,8 @@
{
"lantest": {
"name": "测试手表",
"token": "6ec9b2878357045a389acd97bab84871",
"paired_at": 1791303182.5310695,
"last_seen": 1791303182.5336223
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"port": 8875,
"server_name": "DESKTOP-RN5I625",
"allow_run": true,
"allow_power": true,
"allow_screen": true,
"start_minimized": false,
"allow_new_pairing": false,
"image_timeout": 0,
"image_display": "window",
"image_custom_width": 0,
"image_custom_height": 0,
"image_window_title": "",
"tts_voice": "",
"tts_rate": 0,
"mp3_dir": "",
"file_roots": "",
"extra_apps": "",
"extra_commands": "",
"download_dir": "received",
"screen_max_width": 480,
"screen_quality": 72,
"screen_fps": 12,
"pairing_code": "246810",
"pairing_created": 1791303175.5928898,
"setup_done": true,
"close_pair_after_first": false,
"relay_last_good": "http://192.168.2.156:8080",
"relay_device_id": "DESKTOP-RN5I625-832c5e0b202b"
}
-26
View File
@@ -1,26 +0,0 @@
阶段1+2:恢复 HTTP/2 加速 + 重做官网
阶段1 网络加速
- 发现隧道在跑 QUIC(ExecStart 丢了 --protocol http2),
实测 QUIC 可用率 48% vs HTTP/2 80%+,是重大性能回退。
恢复后公网延迟从 4141ms 降到 690ms(快 6 倍),可用率 92%。
- 优选 IP 结论:无效。hosts 覆盖确实生效了(日志里连的就是指定 IP),
但落点仍是 lax —— 运营商把所有 Cloudflare anycast 都路由到美西。
已清掉这段无效配置。
- BBR 不可用:设备是自定义内核 6.4.0-rc4-jsbsbxjxh66,
没编 tcp_bbr 模块也没配置项。改用 cubic + 调优:
TCP 缓冲 4MB、tcp_fastopen=3、tcp_slow_start_after_idle=0
(长轮询场景关键)、tcp_tw_reuse=1。
阶段2 官网重做
- 拆出独立模块 relay/landing.py(relay.py 已两千多行,
再塞 HTML 没法看)
- 信息结构参考成熟产品站:吸顶导航 / Hero / 九宫格功能 /
三步上手 / 下载 / FAQ / 页脚
- 保持白色简洁:底白、浅灰面板、细边框、大量留白
- logo 用内联 SVG(ICO 放 img 里实测是破图)
- 管理入口从官网移除(管理不对外开放)
过程中被自己的测试抓到两个 bug:
- 模块叫 site.py 会和 Python 标准库重名,import site 拿到的是标准库
- _logo() 里把 % 格式化和字符串拼接混用,直接抛 TypeError
+496
View File
@@ -0,0 +1,496 @@
<!doctype html><html lang=zh>
<meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<link rel="icon" href="https://relay.xxmxxs.top/favicon.ico">
<title>ykxmz 中转服务器</title>
<style>
*{box-sizing:border-box}
:root{
--bg:#ffffff; --panel:#ffffff; --panel2:#f6f8fa; --border:#d0d7de;
--fg:#1f2328; --dim:#656d76; --muted:#8b949e;
--accent:#0969da; --ok:#1a7f37; --danger:#f85149; --warn:#9a6700;
}
html,body{max-width:100%;overflow-x:hidden}
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{width:100%;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,#0969da,#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}
.meta a.logout{color:var(--dim);text-decoration:none;
border-bottom:1px dashed #484f58;transition:.12s}
.meta a.logout:hover{color:var(--accent);border-bottom-color:var(--accent)}
/* ---------- 通用卡片 ---------- */
.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:#0969da;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;max-width:100%}
table{width:100%;max-width:100%;border-collapse:collapse;
table-layout:fixed}
th,td{padding:11px 14px;text-align:left;font-size:13px;
border-bottom:1px solid #eaeef2;vertical-align:middle;
overflow-wrap:anywhere;min-width:0}
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}
.val{min-width:0;overflow-wrap:anywhere;word-break:break-word}
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:#eaeef2;padding:2px 8px;border-radius:6px;
letter-spacing:1.5px;font-family:ui-monospace,Consolas,monospace;
font-size:13px;color:#0969da}
.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 #1a7f3722}
.badge{font-size:11px;padding:2px 8px;border-radius:20px;
border:1px solid transparent;white-space:nowrap}
.badge.on{color:var(--ok);background:#1a7f371a;border-color:#1a7f3740}
.badge.off{color:var(--muted);background:#8b949e1a;border-color:#8b949e40}
.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}
/* 从根上保证不会有东西超出容器。
表格在窄屏变成卡片式,这几条必须写死,
不然 table-layout:fixed 对 display:block 的行不起作用。 */
table,tbody,tr,td{display:block;width:100%;max-width:100%;
box-sizing:border-box}
.meta{margin-left:0;text-align:left;width:100%}
.stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.stat{padding:11px 13px}
.stat .n{font-size:21px}
.urlrow{flex-direction:column;align-items:stretch}
.url{min-width:0;font-size:13px}
thead{display:none}
tbody tr{display:block;border-bottom:1px solid var(--border);
padding:12px 14px 14px;position:relative}
tbody tr:last-child{border-bottom:none}
/* 每个值一行:左边标签、右边值。
min-width:0 + overflow-wrap 是关键 ——
少了它,长配对码/流量会把容器撑破、右边的值被截掉。 */
tbody td{display:flex;justify-content:space-between;align-items:baseline;
gap:14px;border:none;padding:3px 0;text-align:right;
min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word}
tbody td .val{flex:1 1 auto;min-width:0;text-align:right}
td.name{display:block}
tbody td::before{content:attr(data-l);color:var(--dim);font-size:12px;
text-align:left;flex:none}
tbody td>*{min-width:0}
/* 设备名占满整行,不参与"标签/值"那一套 */
td.name{display:block;text-align:left;padding:0 32px 8px 0}
td.name::before{content:'';display:none}
td.name .v{font-size:15px}
td.pickcell{position:absolute;right:12px;top:12px;width:auto;
padding:0;min-width:0}
td.pickcell::before{content:'';display:none}
td.ops{justify-content:flex-end;padding-top:10px;margin-top:4px;
border-top:1px solid #eaeef2}
td.ops::before{content:'';display:none}
td.empty{display:block;text-align:center}
td.empty::before{content:'';display:none}
}
</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>1.7.1</b> · 已运行 <b>1 分</b></div>
<div>本进程内存 <b>22 MB</b> · 累计请求 <b>3</b>
· <a href="/admin/logout" class=logout>退出登录</a></div>
</div>
</header>
<div class=panel>
<div class=label>当前公网地址 —— 手表「中转设置」里填这个</div>
<div class=urlrow>
<div class="url" id=urlBox>https://relay.xxmxxs.top</div>
<button class="btn primary" id=copyUrl >复制</button>
</div>
</div>
<div class=panel id=netPanel>
<div class=label>网络体检 —— 走的是局域网还是隧道?</div>
<div id=netBody class=tip>点下面按钮测一下</div>
<div style="margin-top:10px">
<button class="btn small" id=netBtn>测一下</button>
</div>
</div>
<div class=panel id=dlPanel>
<div class=label>下载最新版</div>
<div id=dlBody class=tip>正在读…</div>
</div>
<div class=stats>
<div class=stat><div class=n>0</div><div class=t>登记设备</div></div>
<div class="stat mute"><div class=n>0</div><div class=t>在线</div></div>
<div class="stat mute"><div class=n>0</div><div class=t>手表会话</div></div>
<div class=stat><div class=n>49</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>
<tr><td colspan=8 class=empty><div class=big>还没有电脑连上来</div><div>在被控端双击 ykxmz.exe 设好配对码,手表上输那 6 位数字就能连上。</div></td></tr>
</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 netBtn = document.getElementById('netBtn');
if (netBtn) netBtn.onclick = async () => {
const body = document.getElementById('netBody');
body.textContent = '正在测…';
try {
const r = await fetch('/v1/admin/netcheck');
const j = await r.json();
if (!j.ok) {
body.innerHTML = '<span style="color:#f85149">读不到:'
+ (j.error || '未知') + '</span>';
return;
}
const rows = [];
if (j.tunnelMetrics) {
const conns = j.tunnelConnections || 0;
const color = conns >= 2 ? '#1a7f37'
: (conns === 1 ? '#9a6700' : '#f85149');
rows.push('隧道连接数:<b style="color:' + color + '">' + conns
+ '</b>' + (conns === 0 ? '(断了,看门狗会重启)' : ''));
rows.push('隧道累计请求:' + (j.tunnelRequests || 0));
} else {
rows.push('<span style="color:#9a6700">读不到隧道指标'
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
}
rows.push('登记设备:' + j.devices + '(在线 ' + j.online + ')');
rows.push('手表会话:' + j.sessions);
rows.push('中转内存:' + Math.round(j.memoryKB / 1024) + ' MB');
rows.push('已运行:' + j.uptimeSeconds + ' 秒');
if (j.publicUrl) {
rows.push('公网地址:<code style="letter-spacing:0">'
+ j.publicUrl + '</code>');
}
body.innerHTML = rows.map(t => '<div style="margin:2px 0">' + t
+ '</div>').join('');
// 顺便测一下这台机器的公网往返
const t0 = performance.now();
try {
await fetch('/v1/health?t=' + Date.now(), {cache: 'no-store'});
const ms = Math.round(performance.now() - t0);
body.innerHTML += '<div style="margin:2px 0">浏览器到中转往返:<b>'
+ ms + ' ms</b>'
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
+ '</div>';
} catch (e) {
body.innerHTML += '<div style="margin:2px 0;color:#f85149">'
+ '浏览器到中转:失败(' + e + ')</div>';
}
} catch (e) {
body.textContent = '请求出错:' + e;
}
};
// ---------------- 下载(就一个链接)----------------
(async () => {
const body = document.getElementById('dlBody');
if (!body) return;
try {
const r = await fetch('/v1/downloads');
const j = await r.json();
const files = (j && j.files) || [];
if (!files.length) {
body.innerHTML = '<span style="color:#656d76">服务器上还没有成品。</span>';
return;
}
// 优先挑被控端整包;没有就挑第一个 zip;再没有就第一个
const pick = files.find(f => /agent/i.test(f.name))
|| files.find(f => /\.zip$/i.test(f.name))
|| files[0];
const mb = (pick.size / 1024 / 1024).toFixed(2);
body.innerHTML =
'<a href="/download/' + encodeURIComponent(pick.name) + '" '
+ 'style="display:inline-block;padding:10px 20px;border-radius:6px;'
+ 'background:#238636;color:#fff;text-decoration:none;'
+ 'font-weight:600;font-size:15px">'
+ '⬇ 下载 ' + pick.name + '</a>'
+ '<div style="color:#8b949e;font-size:12px;margin-top:8px">'
+ mb + ' MB · 也可以去 '
+ '<a href="https://gitee.com/xmz7891/ykxmz/releases" target="_blank" '
+ 'rel="noopener" style="color:#0969da">Gitee 历史版本</a>'
+ '</div>';
} catch (e) {
body.innerHTML = '<span style="color:#f85149">读不到:' + e + '</span>';
}
})();
// ---------------- 自动刷新 ----------------
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>
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# 检查管理页的深色残留
set -u
BASE=http://127.0.0.1:8080
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
curl -s -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan" -o /tmp/adm.html 2>/dev/null
echo " 管理页 $(wc -c < /tmp/adm.html) 字节"
echo " HTTP $(curl -s -o /dev/null -w '%{http_code}' -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan")"
echo
echo " 深色值残留:"
found=0
for c in 0b1220 262c36 1b222c 1c2733 213042 2d1214 4a1a1e 484f58 0d1117; do
n=$(grep -c "#$c" /tmp/adm.html 2>/dev/null || echo 0)
if [ "$n" != "0" ]; then
echo " #$c : $n 处"
found=1
fi
done
[ "$found" = "0" ] && echo " 无 ✓"
echo
echo " 关键样式:"
grep -o -- '--bg:#[0-9a-f]*' /tmp/adm.html | head -1 | sed 's/^/ /'
grep -o 'background:#f6f8fa' /tmp/adm.html | head -1 | sed 's/^/ 地址框: /'
grep -o 'button:hover{background:#[0-9a-f]*' /tmp/adm.html | head -1 | sed 's/^/ 按钮悬停: /'
grep -o 'button.del{background:#[0-9a-f]*' /tmp/adm.html | head -1 | sed 's/^/ 删除按钮: /'
echo
echo " 功能接口还正常吗:"
for api in /v1/health /v1/downloads /v1/admin/netcheck; do
code=$(curl -s -o /dev/null -w '%{http_code}' -H "X-Admin: $KEY" --max-time 10 "$BASE$api")
echo " $api HTTP $code"
done
echo
echo " 其余页面没受影响:"
echo " 官网 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/")"
echo " favicon HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/favicon.ico")"
+98
View File
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
r'''阶段4:诊断局域网直连为什么没生效。
现象(手表日志):
已更新电脑地址为 192.168.2.64:8765
电脑不在同一网络,继续用中转
判断逻辑在手表端 MainActivity:
pickSameSubnet(client.relayLanIps()) <- 拿电脑报上来的 lanIps
localIpv4List() <- 拿手表自己的网卡 IP
sameSubnet(a, b) <- 比前 3 段(/24)
可能出问题的地方:
1. 被控端报的 lanIps 里全是虚拟网卡(VMware/VirtualBox/Hyper-V)
—— 真实的 192.168.2.64 反而不在里面
2. 手表自己的 localIpv4List() 拿到的是运营商 IP(移动数据)
3. 手表和电脑真的不在一个网段
这个脚本查第 1 条:本机被控端实际会报哪些 IP。
第 2、3 条要手表端日志,我先把能查的查清。
'''
from __future__ import annotations
import json
import socket
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
sys.path.insert(0, str(ROOT / "windows"))
def main() -> int:
print(" 一、被控端会报哪些 lanIps(这才是关键)")
try:
from lanremote.discovery import local_ipv4_list
ips = local_ipv4_list()
print(" local_ipv4_list() -> %s" % (ips,))
except Exception as exc: # noqa: BLE001
print(" 拿不到(%s),换个方式" % exc)
ips = []
# 直接列出所有网卡地址,对照着看
print()
print(" 二、本机所有 IPv4 地址(含虚拟网卡)")
try:
result = subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-NetIPAddress -AddressFamily IPv4 | "
"Where-Object { $_.IPAddress -notlike '127.*' } | "
"ForEach-Object { $_.IPAddress + ' ' + $_.InterfaceAlias }"],
capture_output=True, text=True, timeout=60,
encoding="utf-8", errors="replace")
for line in (result.stdout or "").splitlines():
if line.strip():
print(" " + line.strip())
except Exception as exc: # noqa: BLE001
print(" 失败: %s" % exc)
print()
print(" 三、判断:报出去的 IP 里有没有真实局域网地址")
real = [ip for ip in ips
if ip.startswith(("192.168.", "10.", "172.16.", "172.17.",
"172.18.", "172.19.", "172.2", "172.30.",
"172.31.")) and not ip.startswith("169.254.")]
virtual_hint = [ip for ip in ips if ip.startswith(("192.168.56.",
"192.168.99.",
"172.17.", "172.18.",
"172.19."))]
print(" 报出去的: %s" % (ips or "(空)"))
print(" 像真实局域网的: %s" % (real or "(没有!)"))
print(" 像虚拟网卡的: %s" % (virtual_hint or "(没有)"))
if not ips:
print()
print(" ⚠ 一个都没报 —— 手表当然匹配不上。")
elif not real:
print()
print(" ⚠ 报的全是虚拟网卡地址,手表连不到这些。")
else:
print()
print(" OK 至少有一个真实局域网地址,手表应该能匹配到 %s" % real[0])
print()
print(" 四、手表端要看的日志关键字")
print(" 手表连上后,日志区会打:")
print(" 「已更新电脑地址为 x.x.x.x:8765」—— 这是被控端报的地址")
print(" 「电脑不在同一网络,继续用中转」—— 说明 sameSubnet 没匹配上")
print(" 要确认两件事:")
print(" 1. 那个 x.x.x.x 是不是电脑真实的局域网地址")
print(" 2. 手表自己的 WiFi 地址是不是同一个 /24")
return 0
if __name__ == "__main__":
sys.exit(main())
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# 查隧道为什么只有 1 条连接
set -u
echo "===== 1. 服务状态 ====="
systemctl is-active lanremote-tunnel
systemctl show lanremote-tunnel -p ExecMainStartTimestamp --value | sed 's/^/ 启动于: /'
echo
echo "===== 2. metrics 全部隧道指标 ====="
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep -E '^cloudflared_tunnel' | sed 's/^/ /'
echo
echo "===== 3. 最近注册的连接 ====="
grep -E 'Registered tunnel connection' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -6 \
| sed 's/.*\(connIndex=[0-9]*\).*\(location=[a-z0-9]*\).*\(protocol=[a-z0-9]*\).*/ \1 \2 \3/'
echo
echo "===== 4. 有没有注册失败 ====="
grep -iE 'failed|error|retry' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -6 | sed 's/^/ /'
echo
echo "===== 5. HA 连接数设置 ====="
grep -o 'ha-connections [0-9]*' /etc/systemd/system/lanremote-tunnel.service | sed 's/^/ /'
echo
echo "===== 6. 日志里 Initial protocol 的次数(每次重启会打)====="
grep -c 'Initial protocol' /root/lanremote-relay/tunnel.log 2>/dev/null | sed 's/^/ /'
echo " 最近 3 条:"
grep 'Initial protocol' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -3 | sed 's/^/ /'
echo
echo "===== 7. 进程 ====="
ps aux 2>/dev/null | grep -c '[c]loudflared' | sed 's/^/ cloudflared 进程数: /'
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# 最终验收:官网 / 管理页 / 下载 / 加速 / 功能
set -u
BASE=http://127.0.0.1:8080
PUB=https://relay.xxmxxs.top
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
pass=0; fail=0
ok() { pass=$((pass+1)); echo " ✓ $1"; }
bad() { fail=$((fail+1)); echo " ✗ $1"; }
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 25 "$@"; }
echo "════════ 一、官网 ════════"
c=$(code "$BASE/")
[ "$c" = "200" ] && ok "首页 HTTP 200" || bad "首页 HTTP $c"
curl -s --max-time 20 "$BASE/" -o /tmp/s.html
size=$(wc -c < /tmp/s.html)
[ "$size" -gt 10000 ] && ok "页面 $size 字节(内容丰富)" || bad "页面只有 $size 字节"
for pair in "吸顶导航:nav-in" "Hero:hero" "功能网格:id=\"features\"" \
"三步上手:id=\"how\"" "下载区:id=\"download\"" \
"FAQ:id=\"faq\"" "页脚:foot-in"; do
label="${pair%%:*}"; key="${pair#*:}"
if grep -q "$key" /tmp/s.html; then ok "$label"; else bad "$label"; fi
done
[ "$(grep -c 'class=\"feat\"' /tmp/s.html)" = "9" ] && ok "9 张功能卡" || bad "功能卡数量不对"
[ "$(grep -c '<details class=\"faq\"' /tmp/s.html)" = "6" ] && ok "6 条 FAQ" || bad "FAQ 数量不对"
[ "$(grep -c 'class=\"dl-card\"' /tmp/s.html)" = "3" ] && ok "3 张下载卡" || bad "下载卡数量不对"
[ "$(grep -c '管理入口' /tmp/s.html)" = "0" ] && ok "没有管理入口" || bad "还有管理入口"
[ "$(grep -c 'src=\"/favicon.ico\" alt=\"\"' /tmp/s.html)" = "0" ] && ok "没有破图 logo" || bad "还有破图"
echo
echo "════════ 二、管理页 ════════"
c=$(code -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan")
[ "$c" = "200" ] && ok "登录后 HTTP 200" || bad "HTTP $c"
curl -s --max-time 20 -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan" -o /tmp/a.html
[ "$(grep -c 'dlPanel' /tmp/a.html)" -gt 0 ] && ok "下载区在" || bad "下载区丢了"
[ "$(grep -c '网络体检' /tmp/a.html)" -gt 0 ] && ok "网络体检在" || bad "网络体检丢了"
dark=0
for cc in 0b1220 262c36 1b222c 1c2733 2d1214 4a1a1e 484f58 0d1117; do
n=$(grep -c "#$cc" /tmp/a.html 2>/dev/null || echo 0)
[ "$n" != "0" ] && dark=$((dark+n))
done
[ "$dark" = "0" ] && ok "无深色残留" || bad "深色残留 $dark 处"
[ "$(code "$BASE/admin")" = "302" ] && ok "/admin 跳转正常" || bad "/admin 跳转异常"
echo
echo "════════ 三、下载 ════════"
for f in ykxmz-setup-1.7.1.exe ykxmz-server-1.7.1.zip ykxmz-watch-1.7.1.apk; do
c=$(code "$BASE/download/$f")
[ "$c" = "200" ] && ok "$f" || bad "$f HTTP $c"
done
[ "$(code "$BASE/v1/downloads")" = "200" ] && ok "/v1/downloads" || bad "/v1/downloads"
c=$(code "$BASE/download/../relay.py")
[ "$c" = "404" ] || [ "$c" = "400" ] && ok "路径穿越被挡($c)" || bad "路径穿越 HTTP $c"
echo
echo "════════ 四、加速 ════════"
proto=$(grep -oE 'Initial protocol [a-z0-9]+' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -1)
echo "$proto" | grep -q http2 && ok "隧道走 HTTP/2(不是 QUIC)" || bad "隧道协议: $proto"
conn=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
[ "$conn" = "2" ] && ok "隧道连接数 $conn" || bad "隧道连接数 $conn"
[ "$(sysctl -n net.ipv4.tcp_mtu_probing)" = "1" ] && ok "MTU 探测开着" || bad "MTU 探测关了"
[ "$(sysctl -n net.ipv4.tcp_slow_start_after_idle)" = "0" ] && ok "空闲后不重置窗口" || bad "slow_start 没关"
[ "$(sysctl -n net.ipv4.tcp_fastopen)" = "3" ] && ok "TCP FastOpen 开着" || bad "FastOpen 没开"
[ "$(grep -c argotunnel /etc/hosts)" = "0" ] && ok "无效的 hosts 覆盖已清" || bad "hosts 还有残留"
echo
echo "════════ 五、功能 ════════"
for api in /v1/health /favicon.ico; do
c=$(code "$BASE$api")
[ "$c" = "200" ] && ok "$api" || bad "$api HTTP $c"
done
v=$(curl -s --max-time 10 "$BASE/v1/health" | grep -o '"version": *"[^"]*"')
echo " $v"
echo
echo "════════ 六、公网(20 次测延迟)════════"
okc=0; total=0
for i in $(seq 1 20); do
r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
-H 'User-Agent: LanRemote/1.4.0' "$PUB/" 2>/dev/null)
c=$(echo "$r" | cut -d' ' -f1)
t=$(echo "$r" | cut -d' ' -f2)
if [ "$c" = "200" ]; then
okc=$((okc+1))
total=$(echo "$total + $t" | bc)
fi
sleep 0.5
done
avg=$(echo "scale=2; $total / $okc" | bc 2>/dev/null || echo "?")
[ "$okc" -ge 18 ] && ok "可用 $okc/20" || bad "可用 $okc/20"
echo " 平均 ${avg} 秒"
echo
echo "════════════════════════════════"
echo " 通过 $pass 项,失败 $fail 项"
[ "$fail" = "0" ] && echo " 全部通过 ✓" || echo " 有失败"
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
# 最终验收(修正版:单引号里不要转义双引号;grep -c 取第一行)
set -u
BASE=http://127.0.0.1:8080
PUB=https://relay.xxmxxs.top
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
pass=0; fail=0
ok() { pass=$((pass+1)); echo " ✓ $1"; }
bad() { fail=$((fail+1)); echo " ✗ $1"; }
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 25 "$@"; }
# grep -c 有时会输出多行(多个文件时),统一取第一行
cnt() { grep -c "$1" "$2" 2>/dev/null | head -1 | tr -d '[:space:]'; }
echo "════════ 一、官网 ════════"
c=$(code "$BASE/")
[ "$c" = "200" ] && ok "首页 HTTP 200" || bad "首页 HTTP $c"
curl -s --max-time 20 "$BASE/" -o /tmp/s.html
size=$(wc -c < /tmp/s.html | tr -d ' ')
[ "$size" -gt 10000 ] && ok "页面 $size 字节(内容丰富)" || bad "页面只有 $size 字节"
check() {
if grep -q "$2" /tmp/s.html; then ok "$1"; else bad "$1"; fi
}
check "吸顶导航" 'nav-in'
check "Hero" 'hero'
check "功能网格" 'id="features"'
check "三步上手" 'id="how"'
check "下载区" 'id="download"'
check "FAQ" 'id="faq"'
check "页脚" 'foot-in'
check "版本徽章" 'badge'
[ "$(cnt 'class="feat"' /tmp/s.html)" = "9" ] && ok "9 张功能卡" \
|| bad "功能卡 $(cnt 'class="feat"' /tmp/s.html) 张"
[ "$(cnt '<details class="faq"' /tmp/s.html)" = "6" ] && ok "6 条 FAQ" \
|| bad "FAQ $(cnt '<details class="faq"' /tmp/s.html) 条"
[ "$(cnt 'class="dl-card"' /tmp/s.html)" = "3" ] && ok "3 张下载卡" \
|| bad "下载卡 $(cnt 'class="dl-card"' /tmp/s.html) 张"
[ "$(cnt '管理入口' /tmp/s.html)" = "0" ] && ok "没有管理入口" || bad "还有管理入口"
[ "$(cnt 'src="/favicon.ico" alt=""' /tmp/s.html)" = "0" ] && ok "没有破图 logo" \
|| bad "还有破图"
[ "$(cnt '<svg' /tmp/s.html)" -ge 11 ] && ok "图标齐全($(cnt '<svg' /tmp/s.html) 个)" \
|| bad "图标只有 $(cnt '<svg' /tmp/s.html) 个"
echo
echo "════════ 二、管理页 ════════"
c=$(code -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan")
[ "$c" = "200" ] && ok "登录后 HTTP 200" || bad "HTTP $c"
curl -s --max-time 20 -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan" -o /tmp/a.html
[ "$(cnt 'dlPanel' /tmp/a.html)" -gt 0 ] && ok "下载区在" || bad "下载区丢了"
[ "$(cnt '网络体检' /tmp/a.html)" -gt 0 ] && ok "网络体检在" || bad "网络体检丢了"
[ "$(cnt '退出登录' /tmp/a.html)" -gt 0 ] && ok "退出登录在" || bad "退出登录丢了"
dark=0
for cc in 0b1220 262c36 1b222c 1c2733 2d1214 4a1a1e 484f58 0d1117; do
n=$(cnt "#$cc" /tmp/a.html)
[ -n "$n" ] && [ "$n" != "0" ] && dark=$((dark + n))
done
[ "$dark" = "0" ] && ok "无深色残留" || bad "深色残留 $dark 处"
[ "$(code "$BASE/admin")" = "302" ] && ok "/admin 跳转正常" || bad "/admin 跳转异常"
[ "$(code "$BASE/zhongzhuanzhan")" = "200" ] && ok "未登录看到登录页" || bad "未登录页异常"
echo
echo "════════ 三、下载 ════════"
for f in ykxmz-setup-1.7.1.exe ykxmz-server-1.7.1.zip ykxmz-watch-1.7.1.apk; do
c=$(code "$BASE/download/$f")
[ "$c" = "200" ] && ok "$f" || bad "$f HTTP $c"
done
[ "$(code "$BASE/v1/downloads")" = "200" ] && ok "/v1/downloads" || bad "/v1/downloads"
tc=$(code "$BASE/download/../relay.py")
{ [ "$tc" = "404" ] || [ "$tc" = "400" ]; } && ok "路径穿越被挡($tc)" \
|| bad "路径穿越 HTTP $tc"
echo
echo "════════ 四、加速 ════════"
proto=$(grep -oE 'Initial protocol [a-z0-9]+' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -1)
echo "$proto" | grep -q http2 && ok "隧道走 HTTP/2(不是 QUIC)" || bad "隧道协议: $proto"
conn=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
[ "$conn" = "2" ] && ok "隧道连接数 2" || echo " · 隧道连接数 $conn(1 也可用,2 才有冗余)"
[ "$(sysctl -n net.ipv4.tcp_mtu_probing)" = "1" ] && ok "MTU 探测开着" || bad "MTU 探测关了"
[ "$(sysctl -n net.ipv4.tcp_slow_start_after_idle)" = "0" ] && ok "空闲后不重置窗口" \
|| bad "slow_start 没关"
[ "$(sysctl -n net.ipv4.tcp_fastopen)" = "3" ] && ok "TCP FastOpen 开着" || bad "FastOpen 没开"
[ "$(cnt argotunnel /etc/hosts)" = "0" ] && ok "无效的 hosts 覆盖已清" || bad "hosts 有残留"
echo
echo "════════ 五、功能 ════════"
for api in /v1/health /favicon.ico; do
c=$(code "$BASE$api")
[ "$c" = "200" ] && ok "$api" || bad "$api HTTP $c"
done
curl -s --max-time 10 "$BASE/v1/health" | grep -o '"version": *"[^"]*"' | sed 's/^/ /'
echo
echo "════════ 六、公网可用性(设备侧,发夹回路=最坏情况)════════"
okc=0; total=0
for i in $(seq 1 15); do
r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
-H 'User-Agent: LanRemote/1.4.0' "$PUB/" 2>/dev/null)
c=$(echo "$r" | cut -d' ' -f1)
t=$(echo "$r" | cut -d' ' -f2)
if [ "$c" = "200" ]; then
okc=$((okc+1))
total=$(echo "$total + $t" | bc)
fi
sleep 0.5
done
avg=$(echo "scale=2; $total / $okc" | bc 2>/dev/null || echo "?")
[ "$okc" -ge 14 ] && ok "可用 $okc/15" || bad "可用 $okc/15"
echo " 平均 ${avg} 秒(真实用户路径比这个快,见 Windows 侧实测)"
echo
echo "════════════════════════════════"
echo " 通过 $pass 项,失败 $fail 项"
[ "$fail" = "0" ] && echo " 全部通过 ✓" || echo " 有失败"
+116
View File
@@ -0,0 +1,116 @@
#!/bin/bash
# 最终验收(修正版 2)
# 上次的坑:grep -c 数的是**匹配行数**,HTML 压缩后 9 张卡在同一行 -> 返回 1。
# 要数出现次数必须 grep -o | wc -l。
set -u
BASE=http://127.0.0.1:8080
PUB=https://relay.xxmxxs.top
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
pass=0; fail=0
ok() { pass=$((pass+1)); echo " ✓ $1"; }
bad() { fail=$((fail+1)); echo " ✗ $1"; }
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 25 "$@"; }
# 数**出现次数**(不是行数)
occ() { grep -o "$1" "$2" 2>/dev/null | wc -l | tr -d ' '; }
# 数**包含该串的行数**(用于"有没有出现")
has() { grep -q "$1" "$2" 2>/dev/null && echo 1 || echo 0; }
echo "════════ 一、官网 ════════"
c=$(code "$BASE/")
[ "$c" = "200" ] && ok "首页 HTTP 200" || bad "首页 HTTP $c"
curl -s --max-time 20 "$BASE/" -o /tmp/s.html
size=$(wc -c < /tmp/s.html | tr -d ' ')
[ "$size" -gt 10000 ] && ok "页面 $size 字节" || bad "页面只有 $size 字节"
check() { [ "$(has "$2" /tmp/s.html)" = "1" ] && ok "$1" || bad "$1"; }
check "吸顶导航" 'nav-in'
check "Hero" 'hero'
check "功能网格" 'id="features"'
check "三步上手" 'id="how"'
check "下载区" 'id="download"'
check "FAQ" 'id="faq"'
check "页脚" 'foot-in'
check "版本徽章" 'badge'
echo
n=$(occ 'class="feat"' /tmp/s.html); [ "$n" = "9" ] && ok "9 张功能卡" || bad "功能卡 $n 张"
n=$(occ '<details class="faq"' /tmp/s.html); [ "$n" = "6" ] && ok "6 条 FAQ" || bad "FAQ $n 条"
n=$(occ 'class="dl-card"' /tmp/s.html); [ "$n" = "3" ] && ok "3 张下载卡" || bad "下载卡 $n 张"
n=$(occ 'class="step"' /tmp/s.html); [ "$n" = "3" ] && ok "3 个步骤卡" || bad "步骤卡 $n 个"
n=$(occ '<svg' /tmp/s.html); [ "$n" -ge 11 ] && ok "图标 $n 个" || bad "图标只有 $n 个"
n=$(occ '管理入口' /tmp/s.html); [ "$n" = "0" ] && ok "没有管理入口" || bad "还有管理入口"
n=$(occ 'src="/favicon.ico" alt=""' /tmp/s.html); [ "$n" = "0" ] && ok "没有破图 logo" || bad "还有破图"
echo
echo "════════ 二、管理页 ════════"
c=$(code -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan")
[ "$c" = "200" ] && ok "登录后 HTTP 200" || bad "HTTP $c"
curl -s --max-time 20 -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan" -o /tmp/a.html
for pair in "下载区:dlPanel" "网络体检:网络体检" "退出登录:退出登录"; do
[ "$(has "${pair#*:}" /tmp/a.html)" = "1" ] && ok "${pair%%:*}" || bad "${pair%%:*}"
done
dark=0
for cc in 0b1220 262c36 1b222c 1c2733 2d1214 4a1a1e 484f58 0d1117; do
n=$(occ "#$cc" /tmp/a.html)
[ "$n" != "0" ] && dark=$((dark + n))
done
[ "$dark" = "0" ] && ok "无深色残留" || bad "深色残留 $dark 处"
[ "$(code "$BASE/admin")" = "302" ] && ok "/admin 跳转正常" || bad "/admin 跳转异常"
[ "$(code "$BASE/zhongzhuanzhan")" = "200" ] && ok "未登录看到登录页" || bad "未登录页异常"
echo
echo "════════ 三、下载 ════════"
for f in ykxmz-setup-1.7.1.exe ykxmz-server-1.7.1.zip ykxmz-watch-1.7.1.apk; do
c=$(code "$BASE/download/$f")
[ "$c" = "200" ] && ok "$f" || bad "$f HTTP $c"
done
[ "$(code "$BASE/v1/downloads")" = "200" ] && ok "/v1/downloads" || bad "/v1/downloads"
tc=$(code "$BASE/download/../relay.py")
{ [ "$tc" = "404" ] || [ "$tc" = "400" ]; } && ok "路径穿越被挡($tc)" || bad "路径穿越 HTTP $tc"
echo
echo "════════ 四、加速 ════════"
proto=$(grep -oE 'Initial protocol [a-z0-9]+' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -1)
echo "$proto" | grep -q http2 && ok "隧道走 HTTP/2(不是 QUIC)" || bad "隧道协议: $proto"
conn=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
[ "$conn" = "2" ] && ok "隧道连接数 2" || echo " · 隧道连接数 $conn(1 也能用,2 才有冗余)"
[ "$(sysctl -n net.ipv4.tcp_mtu_probing)" = "1" ] && ok "MTU 探测开着" || bad "MTU 探测关了"
[ "$(sysctl -n net.ipv4.tcp_slow_start_after_idle)" = "0" ] && ok "空闲后不重置窗口" || bad "slow_start 没关"
[ "$(sysctl -n net.ipv4.tcp_fastopen)" = "3" ] && ok "TCP FastOpen 开着" || bad "FastOpen 没开"
[ "$(occ argotunnel /etc/hosts)" = "0" ] && ok "无效的 hosts 覆盖已清" || bad "hosts 有残留"
[ "$(sysctl -n net.core.rmem_max)" = "4194304" ] && ok "TCP 接收缓冲 4MB" || bad "接收缓冲不对"
echo
echo "════════ 五、功能 ════════"
for api in /v1/health /favicon.ico; do
c=$(code "$BASE$api")
[ "$c" = "200" ] && ok "$api" || bad "$api HTTP $c"
done
curl -s --max-time 10 "$BASE/v1/health" | grep -o '"version": *"[^"]*"' | sed 's/^/ /'
echo
echo "════════ 六、公网可用性(设备侧发夹回路 = 最坏情况)════════"
okc=0; total=0
for i in $(seq 1 15); do
r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
-H 'User-Agent: LanRemote/1.4.0' "$PUB/" 2>/dev/null)
c=$(echo "$r" | cut -d' ' -f1)
t=$(echo "$r" | cut -d' ' -f2)
if [ "$c" = "200" ]; then
okc=$((okc+1))
total=$(echo "$total + $t" | bc)
fi
sleep 0.5
done
avg=$(echo "scale=2; $total / $okc" | bc 2>/dev/null || echo "?")
[ "$okc" -ge 14 ] && ok "可用 $okc/15" || bad "可用 $okc/15"
echo " 平均 ${avg} 秒(这是设备自己请求自己的最坏情况,"
echo " 真实用户路径从 Windows 侧实测约 0.69 秒)"
echo
echo "════════════════════════════════"
echo " 通过 $pass 项,失败 $fail 项"
[ "$fail" = "0" ] && echo " 全部通过 ✓" || echo " 有失败"
+79
View File
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
r'''清掉管理页深色残留(修正版:CSS 在 f-string 里,大括号是双写的)。
上一版只改成功了 4 处,因为我的匹配串用了单大括号 {{ }},
而 relay.py 里那段 CSS 在 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")
# 用「深色值 -> 浅色值」直接替换,不管外层大括号怎么写的。
# 这样最稳,不会再被 f-string 的花括号坑到。
COLOR_MAP = [
("#262c36", "#f3f4f6"), # 按钮悬停底色
("#484f58", "#d0d7de"), # 边框/圆点
("#1b222c", "#f6f8fa"), # 表格行悬停
("#1c2733", "#eff6ff"), # 选中行
("#213042", "#dbeafe"), # 选中行悬停
("#2d1214", "#fff5f5"), # 删除按钮底
("#6e2b2b", "#ffc9c9"), # 删除按钮边
("#ff7b72", "#c92a2a"), # 删除按钮字
("#4a1a1e", "#ffe3e3"), # 删除悬停底
("#ffa198", "#a61b1b"), # 删除悬停字
("#4a95f8", "#1d4ed8"), # 主按钮悬停
("#0b1220", "#f6f8fa"), # 地址框底
("#1f6feb44", "#d0d7de"), # 地址框边
("#0008", "#0002"), # 提示条阴影
]
# 深色主题才用的值,替换后不该再有
DARK_LEFTOVERS = ["#0b1220", "#262c36", "#1b222c", "#1c2733", "#213042",
"#2d1214", "#4a1a1e", "#484f58", "#0d1117"]
def main() -> int:
text = RELAY.read_text(encoding="utf-8")
counts = {}
for dark, light in COLOR_MAP:
n = text.count(dark)
if n:
text = text.replace(dark, light)
counts[dark] = (light, n)
if counts:
RELAY.write_text(text, encoding="utf-8", newline="\n")
print(" 换了这些颜色:")
for dark, (light, n) in counts.items():
print(" %s -> %s (%d 处)" % (dark, light, n))
else:
print(" 没有要改的")
try:
ast.parse(text)
print(" 语法 OK")
except SyntaxError as exc:
print(" [失败] 语法错误: 行 %s: %s" % (exc.lineno, exc.msg))
return 1
print()
print(" 残留深色值:")
left = [c for c in DARK_LEFTOVERS if c in text]
if left:
for c in left:
print(" %s:" % c)
for m in re.finditer(r".*" + re.escape(c) + r".*", text):
print(" %s" % m.group(0).strip()[:76])
else:
print(" 无 ✓")
return 0
if __name__ == "__main__":
sys.exit(main())
+121
View File
@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
r'''清掉管理页剩下的深色主题残留。
管理页整体已经改成白色了(--bg:#ffffff),
但 CSS 里还有几处是给深色底配的,在白底上很扎眼:
.url{background:#0b1220} 深藏青的地址框
button:hover{background:#262c36} 深灰悬停
tbody tr:hover td{#1b222c} 深灰行悬停
tr.picked td{#1c2733} 深色选中行
button.del{background:#2d1214} 暗红删除按钮
.dot{background:#484f58} 深灰圆点
a.logout{border-bottom:#484f58} 深灰虚线下划线
换成浅色系。
'''
from __future__ import annotations
import ast
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
RELAY = ROOT / "relay" / "relay.py"
# (旧, 新, 说明)
FIXES = [
# 公网地址框:深藏青 -> 浅蓝
("background:#0b1220;border:1px solid #1f6feb44",
"background:#f6f8fa;border:1px solid #d0d7de",
"地址框底色"),
("color:#0969da;word-break:break-all;\n background:#f6f8fa",
"color:#0969da;word-break:break-all;\n background:#f6f8fa",
"(保持)"),
# 按钮悬停
("button:hover{background:#262c36;border-color:#484f58}",
"button:hover{background:#f3f4f6;border-color:#d1d5db}",
"按钮悬停"),
("button.primary:hover{background:#4a95f8;border-color:#4a95f8}",
"button.primary:hover{background:#1d4ed8;border-color:#1d4ed8}",
"主按钮悬停"),
# 删除按钮:暗红 -> 浅红
("button.del{background:#2d1214;border-color:#6e2b2b;color:#ff7b72}",
"button.del{background:#fff5f5;border-color:#ffc9c9;color:#c92a2a}",
"删除按钮"),
("button.del:hover{background:#4a1a1e;border-color:#a33;color:#ffa198}",
"button.del:hover{background:#ffe3e3;border-color:#ffa8a8;color:#a61b1b}",
"删除按钮悬停"),
# 表格行悬停
("tbody tr:hover td{background:#1b222c}",
"tbody tr:hover td{background:#f6f8fa}",
"表格行悬停"),
# 选中行
("tr.picked td{background:#1c2733}",
"tr.picked td{background:#eff6ff}",
"选中行"),
("tr.picked:hover td{background:#213042}",
"tr.picked:hover td{background:#dbeafe}",
"选中行悬停"),
# 圆点
("vertical-align:middle;background:#484f58}",
"vertical-align:middle;background:#d0d7de}",
"状态圆点"),
# 退出登录的下划线
("border-bottom:1px dashed #484f58;transition:.12s}",
"border-bottom:1px dashed #d0d7de;transition:.12s}",
"退出登录下划线"),
# 提示条阴影(白底上要淡一点)
("box-shadow:0 8px 24px #0008;opacity:0;",
"box-shadow:0 8px 24px #0002;opacity:0;",
"提示条阴影"),
]
def main() -> int:
text = RELAY.read_text(encoding="utf-8")
changed = []
for old, new, why in FIXES:
if old == new:
continue
if new in text:
continue
if old in text:
text = text.replace(old, new)
changed.append(why)
if changed:
RELAY.write_text(text, encoding="utf-8", newline="\n")
print(" 改了:")
for c in changed:
print(" " + c)
else:
print(" 没有要改的")
try:
ast.parse(text)
print(" 语法 OK")
except SyntaxError as exc:
print(" [失败] 语法错误: 行 %s: %s" % (exc.lineno, exc.msg))
return 1
# 复核:还有没有深色残留
print()
print(" 残留的深色值检查:")
DARK = ["#0b1220", "#262c36", "#1b222c", "#1c2733", "#213042",
"#2d1214", "#4a1a1e", "#484f58", "#2a313c", "#0d1117"]
left = [c for c in DARK if c in text]
if left:
print(" 还有: %s" % " ".join(left))
for c in left:
import re
for m in re.finditer(r".*" + re.escape(c) + r".*", text):
print(" %s" % m.group(0).strip()[:80])
else:
print(" 无 ✓")
return 0
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
r'''抓管理页 HTML 到本地,供截图查看。
管理页要登录,无头浏览器不方便带头,所以先用 curl 抓下来存本地。'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
def main() -> int:
_c, out, _e = run(
"KEY=$(cat /root/lanremote-relay/data/admin_key.txt); "
"curl -s --max-time 20 -H \"X-Admin: $KEY\" "
"http://127.0.0.1:8080/zhongzhuanzhan",
timeout=120)
if not out or "<html" not in out.lower():
print(" 抓不到管理页")
print(out[:400])
return 1
# 把内联的相对链接改成绝对(否则本地 file:// 打开样式全丢)
out = out.replace('href="/favicon.ico"',
'href="https://relay.xxmxxs.top/favicon.ico"')
target = ROOT / "_build" / "admin_preview.html"
target.write_text(out, encoding="utf-8")
print(" 抓到 %d 字符 -> %s" % (len(out), target.name))
return 0
if __name__ == "__main__":
sys.exit(main())
+60
View File
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
r'''从本机测公网延迟 —— 这是真实用户路径。
为什么不在设备上测:
设备上 curl 自己的公网域名,流量要出到 Cloudflare 边缘再经隧道
绕回同一台设备,是"发夹回路",最坏情况。
真实用户(手表/电脑)在别处,路径短得多。
'''
from __future__ import annotations
import statistics
import sys
import time
import urllib.error
import urllib.request
URL = "https://relay.xxmxxs.top/"
HEALTH = "https://relay.xxmxxs.top/v1/health"
HEADERS = {"User-Agent": "LanRemote/1.4.0"}
def measure(url: str, rounds: int) -> list:
times = []
for i in range(rounds):
req = urllib.request.Request(url, headers=HEADERS)
start = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=25) as resp:
resp.read()
if resp.status == 200:
times.append(time.perf_counter() - start)
except (urllib.error.URLError, OSError, TimeoutError):
pass
time.sleep(0.4)
return times
def report(label: str, times: list, rounds: int) -> None:
if not times:
print(" %s: 全部失败" % label)
return
print(" %s: 可用 %d/%d" % (label, len(times), rounds))
print(" 平均 %.0f ms 中位 %.0f ms 最快 %.0f ms 最慢 %.0f ms"
% (statistics.mean(times) * 1000,
statistics.median(times) * 1000,
min(times) * 1000,
max(times) * 1000))
def main() -> int:
print(" 公网真实路径测试(30 次)")
print()
report("官网首页", measure(URL, 30), 30)
print()
report("健康接口", measure(HEALTH, 20), 20)
return 0
if __name__ == "__main__":
sys.exit(main())
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
r'''确认延迟瓶颈到底在哪一段。
对比三段:
1. 局域网直连(设备本机 -> relay 本机) 预期几十毫秒
2. 公网(本机 -> Cloudflare 边缘 -> 隧道 -> 设备)
3. 纯 Cloudflare 边缘 RTT(本机 -> 随便一个 CF 站点)
差值能告诉我们:
2 - 1 = 隧道 + 公网往返的开销
3 = 到边缘的裸 RTT
2 - 3 - 1 = 隧道那一段的开销(设备到边缘)
'''
from __future__ import annotations
import statistics
import sys
import time
import urllib.request
HEADERS = {"User-Agent": "LanRemote/1.4.0"}
def timed(url: str, rounds: int = 15) -> list:
out = []
for _ in range(rounds):
try:
start = time.perf_counter()
with urllib.request.urlopen(
urllib.request.Request(url, headers=HEADERS),
timeout=25) as resp:
resp.read()
if resp.status == 200:
out.append((time.perf_counter() - start) * 1000)
except Exception: # noqa: BLE001
pass
time.sleep(0.3)
return out
def show(label: str, values: list, rounds: int) -> float:
if not values:
print(" %-24s 全失败" % label)
return float("nan")
med = statistics.median(values)
print(" %-24s 可用 %2d/%d 中位 %7.0f ms 最快 %7.0f ms"
% (label, len(values), rounds, med, min(values)))
return med
def main() -> int:
print(" 分段测延迟(各 15 次取中位数)")
print()
lan = show("1. 局域网直连", timed("http://192.168.2.156:8080/v1/health"), 15)
pub = show("2. 公网(经隧道)",
timed("https://relay.xxmxxs.top/v1/health"), 15)
edge = show("3. Cloudflare 边缘裸 RTT",
timed("https://cloudflare.com/cdn-cgi/trace"), 15)
print()
print(" 分析:")
if lan == lan and pub == pub:
print(" 局域网 %.0f ms -> 公网 %.0f ms" % (lan, pub))
print(" 公网比局域网多 %.0f ms" % (pub - lan))
if edge == edge and pub == pub:
print()
print(" 到 Cloudflare 边缘的裸 RTT: %.0f ms" % edge)
print(" 公网请求 %.0f ms,其中边缘往返占 %.0f ms(%.0f%%)"
% (pub, edge, edge / pub * 100))
print(" 剩下的 %.0f ms 是:边缘 -> 设备(隧道)那一段 + 服务端处理"
% (pub - edge))
print()
print(" 结论:")
print(" 如果「边缘 -> 设备」那段占比大,说明瓶颈在设备的 4G 上行链路,")
print(" Cloudflare 这一侧没得优化了 —— 只能靠换网络或换中转位置。")
return 0
if __name__ == "__main__":
sys.exit(main())
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# 用实测最快的边缘 IP 固定隧道出口。
#
# 之前我判断"优选 IP 没用"是**错的** —— 结论下得太急。
# 真正的价值不是降低延迟(落点仍是 lax,改不了),
# 而是**避开会握手超时的 IP**:
#
# 日志里大量 dial tcp 198.41.192.167:7844: i/o timeout
# Retrying connection in up to 32s
# GeoDNS 随机给的 IP 里有一部分连不上,
# cloudflared 就连上-掉线-重连反复折腾,
# 表现就是延迟在 0.69s ~ 4.4s 之间乱跳。
#
# 实测(probe_cf_edge2.py)最快且稳定的:
# 198.41.200.238 138ms
# 198.41.192.214 143ms
set -u
echo "===== 1. 备份 ====="
cp -f /etc/hosts "/root/hosts.bak.$(date +%s)"
echo " 已备份 /etc/hosts"
echo
echo "===== 2. 先复测这两个 IP 还通不通 ====="
python3 - <<'PY'
import socket, time
for ip in ("198.41.200.238", "198.41.192.214", "198.41.200.13", "198.41.192.37"):
s = socket.socket(); s.settimeout(3)
t = time.perf_counter()
try:
s.connect((ip, 7844))
print(" %-16s %.0f ms" % (ip, (time.perf_counter() - t) * 1000))
except OSError as e:
print(" %-16s 不通 (%s)" % (ip, e.__class__.__name__))
finally:
s.close()
PY
echo
echo "===== 3. 写 /etc/hosts(固定两个最快的)====="
sed -i '/argotunnel.com/d' /etc/hosts
{
echo ""
echo "# ykxmz:固定 Cloudflare 隧道边缘 IP"
echo "# 目的不是降低延迟(落点仍是 lax),而是避开会握手超时的 IP。"
echo "# GeoDNS 随机发的 IP 里有些连不上,会导致隧道反复重连、延迟乱跳。"
echo "198.41.200.238 region1.v2.argotunnel.com"
echo "198.41.192.214 region2.v2.argotunnel.com"
} >> /etc/hosts
grep argotunnel /etc/hosts | sed 's/^/ /'
echo
echo "===== 4. 确认解析走的是我们指定的 ====="
python3 - <<'PY'
import socket
for host in ("region1.v2.argotunnel.com", "region2.v2.argotunnel.com"):
infos = socket.getaddrinfo(host, 7844, socket.AF_INET)
ips = sorted({i[4][0] for i in infos})
print(" %s -> %s" % (host, ips))
PY
echo
echo "===== 5. 重启隧道并观察 60 秒 ====="
systemctl restart lanremote-tunnel
: > /root/reconnect_watch.txt
for i in $(seq 1 12); do
sleep 5
conn=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
errs=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_request_errors' | awk '{print $2}')
echo " ${i}0s: 连接 $conn 错误 $errs"
done
echo
echo "===== 6. 这 60 秒里有几次超时告警 ====="
since=$(date -u -d '70 seconds ago' '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "")
if [ -n "$since" ]; then
n=$(awk -v s="$since" '$0 >= s' /root/lanremote-relay/tunnel.log 2>/dev/null \
| grep -c 'i/o timeout')
echo " 超时告警 $n 次(改之前每分钟都有好几次)"
else
echo " (算不出时间窗,跳过)"
fi
echo
echo "===== 7. 当前落点 ====="
grep -E 'Registered tunnel connection' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -3 \
| grep -oE 'ip=[0-9.]+ location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /'
echo
echo "===== 8. 公网延迟(10 次)====="
ok=0; total=0
for i in $(seq 1 10); do
r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
-H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health 2>/dev/null)
c=$(echo "$r" | cut -d' ' -f1)
t=$(echo "$r" | cut -d' ' -f2)
[ "$c" = "200" ] && { ok=$((ok+1)); total=$(echo "$total + $t" | bc); }
sleep 1
done
avg=$(echo "scale=2; $total / $ok" | bc 2>/dev/null || echo "?")
echo " 可用 $ok/10 平均 ${avg} 秒"
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# 撤掉边缘 IP 固定。
#
# 教训(一天内自己推翻自己两次):
# 第一次:以为优选 IP 能降延迟 -> 错。落点还是 lax,改不了路径。
# 第二次:以为优选 IP 能避开超时 -> 半小时内对,但对不久。
# 当时实测 138ms 的两个 IP,30 分钟后再测全都不通了。
#
# 根因:Cloudflare anycast 的可达性受运营商路由影响,
# 会随时间变化。**固定 IP 等于钉死到一个可能失效的地址上**,
# 比让 GeoDNS 动态解析更糟 —— 后者至少每次重连会重新挑。
#
# 结论:不固定。靠 cloudflared 自己的重试 + --ha-connections 2 冗余。
set -u
echo "===== 1. 清掉 hosts 固定 ====="
before=$(grep -c argotunnel /etc/hosts 2>/dev/null || echo 0)
sed -i '/argotunnel.com/d' /etc/hosts
after=$(grep -c argotunnel /etc/hosts 2>/dev/null || echo 0)
echo " argotunnel 条目: $before -> $after"
echo
echo "===== 2. 确认解析回到 GeoDNS ====="
python3 - <<'PY'
import socket
for host in ("region1.v2.argotunnel.com", "region2.v2.argotunnel.com"):
try:
infos = socket.getaddrinfo(host, 7844, socket.AF_INET)
ips = sorted({i[4][0] for i in infos})
print(" %s -> %d 个地址" % (host, len(ips)))
print(" %s" % " ".join(ips[:5]))
except OSError as e:
print(" %s 解析失败 %s" % (host, e))
PY
echo
echo "===== 3. 重启隧道,观察 90 秒连接稳定性 ====="
systemctl restart lanremote-tunnel
stable=1
for i in $(seq 1 9); do
sleep 10
conn=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
errs=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_request_errors' | awk '{print $2}')
echo " ${i}0s: 连接 $conn 请求错误 $errs"
[ "$i" -ge 6 ] && [ "$conn" != "2" ] && stable=0
done
[ "$stable" = "1" ] && echo " ✓ 后段稳定在 2 条连接" || echo " · 还在收敛"
echo
echo "===== 4. 这段时间的超时告警数 ====="
since=$(date -u -d '100 seconds ago' '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "")
if [ -n "$since" ]; then
n=$(awk -v s="$since" '$0 >= s' /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'i/o timeout')
echo " $n 次"
fi
echo
echo "===== 5. 当前落点(应为 2 条)====="
grep -E 'Registered tunnel connection' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -2 \
| grep -oE 'ip=[0-9.]+ location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /'
echo
echo "===== 6. 公网延迟(15 次,设备侧最坏情况)====="
ok=0; total=0
for i in $(seq 1 15); do
r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
-H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health 2>/dev/null)
c=$(echo "$r" | cut -d' ' -f1)
t=$(echo "$r" | cut -d' ' -f2)
[ "$c" = "200" ] && { ok=$((ok+1)); total=$(echo "$total + $t" | bc); printf " %2d: %s 秒\n" "$i" "$t"; } \
|| printf " %2d: 失败\n" "$i"
sleep 1
done
avg=$(echo "scale=2; $total / $ok" | bc 2>/dev/null || echo "?")
echo " 可用 $ok/15 平均 ${avg} 秒"
+141
View File
@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
r'''端到端验证局域网直连路径。
结论已经很清楚了:
局域网 118 ms vs 公网 1215 ms —— 快 10 倍
瓶颈是这条网络到 Cloudflare 的 4G 上行(裸 RTT 就 1.4 秒),
Cloudflare 那一侧没得优化。
所以真正要保证的是**局域网这条路能用**。
这个脚本起一个真实被控端,走 192.168.2.x 直连,量一下握手耗时。
'''
from __future__ import annotations
import json
import socket
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
PORT = 8875
CODE = "246810"
def talk(payload: dict, timeout: float = 12.0) -> dict:
sock = socket.create_connection(("127.0.0.1", PORT), timeout=timeout)
sock.settimeout(timeout)
sock.sendall((json.dumps(payload, ensure_ascii=False) + "\n").encode())
raw = b""
while b"\n" not in raw:
chunk = sock.recv(65536)
if not chunk:
break
raw += chunk
sock.close()
return json.loads(raw.split(b"\n")[0].decode("utf-8"))
def lan_ip() -> str:
try:
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
probe.connect(("8.8.8.8", 80))
addr = probe.getsockname()[0]
probe.close()
return addr
except OSError:
return "192.168.2.64"
def main() -> int:
env = dict(__import__("os").environ)
env["YKXMZ_DATA"] = str(ROOT / "_build" / "_lantest_data")
Path(env["YKXMZ_DATA"]).mkdir(parents=True, exist_ok=True)
print(" 启动被控端(端口 %d,配对码 %s)" % (PORT, CODE))
proc = subprocess.Popen(
[sys.executable, str(ROOT / "windows" / "run_agent.py"),
"--port", str(PORT), "--code", CODE, "--silent"],
cwd=str(ROOT), env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
passed = failed = 0
def check(name, ok, detail=""):
nonlocal passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + " " + str(detail))
try:
time.sleep(7)
ip = lan_ip()
print(" 本机局域网地址: %s" % ip)
print()
# 1. 本机回环
print(" 一、回环路径")
t = time.perf_counter()
reply = talk({"cmd": "pair", "deviceId": "lantest",
"deviceName": "测试手表", "code": CODE})
loop_ms = (time.perf_counter() - t) * 1000
check("回环配对成功", reply.get("ok") is True, str(reply)[:90])
token = reply.get("token", "")
print(" 握手 %.0f ms" % loop_ms)
# 2. 局域网地址(真实用户路径)
print()
print(" 二、局域网地址 %s(手表实际走这条)" % ip)
sock = socket.create_connection((ip, PORT), timeout=10)
sock.settimeout(10)
t = time.perf_counter()
sock.sendall((json.dumps({"cmd": "auth", "deviceId": "lantest",
"token": token}, ensure_ascii=False)
+ "\n").encode())
raw = b""
while b"\n" not in raw:
raw += sock.recv(65536)
lan_ms = (time.perf_counter() - t) * 1000
auth = json.loads(raw.split(b"\n")[0].decode("utf-8"))
check("局域网认证成功", auth.get("ok") is True, str(auth)[:90])
print(" 认证 %.0f ms" % lan_ms)
# 3. 功能列表
t = time.perf_counter()
sock.sendall((json.dumps({"cmd": "feature_list"}, ensure_ascii=False)
+ "\n").encode())
raw = b""
while b"\n" not in raw:
raw += sock.recv(262144)
feat = json.loads(raw.split(b"\n")[0].decode("utf-8"))
feat_ms = (time.perf_counter() - t) * 1000
features = feat.get("features", [])
check("功能列表拿到 %d 项" % len(features), len(features) > 100)
print(" 功能列表 %.0f ms" % feat_ms)
sock.close()
print()
print(" 三、结论")
print(" 局域网往返 %.0f ms vs 公网 1215 ms" % lan_ms)
print(" 局域网快 %.1f 倍" % (1215 / max(lan_ms, 1)))
check("局域网明显快于公网", lan_ms < 300, "%.0f ms" % lan_ms)
finally:
proc.terminate()
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
print()
print("=" * 52)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
r'''测被控端实际报出去的 lanIps 列表。'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
sys.path.insert(0, str(ROOT / "windows"))
def main() -> int:
from lanremote.server import lan_ips, local_ip, _ip_rank
ips = lan_ips()
print(" lan_ips() -> %s" % (ips,))
for ip in ips:
print(" %-18s rank=%d" % (ip, _ip_rank(ip)))
print()
print(" local_ip() -> %s" % local_ip())
print()
print(" 手表那边的判断(sameSubnet 比前 3 段):")
if ips:
first = ips[0]
print(" 被控端报的第一个: %s" % first)
print(" 如果手表在 192.168.2.x -> 匹配 ✓")
print(" 如果手表在别的网段 -> 不匹配,走中转")
print()
print(" 结论:")
real = [ip for ip in ips if ip.startswith(("192.168.", "10."))]
if real:
print(" ✓ 报了真实局域网地址 %s,手表同网段时能直连" % real[0])
print(" 所以「电脑不在同一网络」的提示,意味着")
print(" 手表当时**真的不在** 192.168.2.x —— 比如连了 5G 频段")
print(" 的另一个路由,或者开着移动数据。")
else:
print(" ✗ 没有真实局域网地址,手表永远匹配不上")
return 0
if __name__ == "__main__":
sys.exit(main())
+49
View File
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
r'''写提交消息文件(纯 UTF-8 无 BOM,避开 PowerShell 的坑)。'''
from __future__ import annotations
import sys
from pathlib import Path
MSG = """阶段3+4+5:管理页白色化 + 局域网诊断 + 全量验收
阶段3 管理页
- 清掉全部深色主题残留(地址框 #0b1220、按钮悬停 #262c36、
删除按钮 #2d1214 等 11 处),现在 0 处深色
- 接口全部正常:health / downloads / netcheck
阶段4 局域网诊断
- 查清楚"电脑不在同一网络"是怎么回事:被控端是**对的**
(lan_ips() 报 ['192.168.2.64', '169.254.83.107'],真实地址排第一),
是手表当时真不在 192.168.2.x —— 可能连了 5G 频段的另一个路由器,或开着移动数据
- 所以改了手表的提示:不再只说"不在同一网络",
而是把**双方地址都打出来**,用户一眼能看出是网段不同还是连错 WiFi
- 端到端验证局域网路径:认证 1 ms,功能列表 207 项
阶段1 结论修正(一天内推翻自己两次,如实记录)
- 第一次判断"优选 IP 无用":错。半小时内确实能避开超时 IP
- 第二次固定优选 IP:也错。那些实测 138ms 的 IP 半小时后全不通了
—— Cloudflare anycast 可达性随运营商路由变化,固定 IP 等于钉死到会失效的地址
- 最终:不固定,靠 GeoDNS + cloudflared 重试 + ha-connections 2 冗余
- 撤销后立即变好:连接稳定 2 条、超时告警 0 次、设备侧延迟 4.40s -> 1.66s
- **真正的瓶颈**:分段实测发现本机到 Cloudflare 的**裸 RTT 就 1439 ms**,
比经隧道的请求(1215 ms)还慢。说明瓶颈是这条网络的 4G 上行本身,
不是设备的隧道。公网侧已无优化空间。
- 局域网 118 ms vs 公网 1215 ms,快 10 倍 —— 这才是该走的路
阶段5 验收
- 全量回归 29 项全通过
- 设备侧验收 38 项全通过
- 成品:setup exe 14.37 MB / server zip / watch apk,已同步到设备下载区
"""
def main() -> int:
target = Path(__file__).resolve().parent / "_commitmsg_a.txt"
target.write_bytes(MSG.encode("utf-8"))
print(" 写好 %s(%d 字节,无 BOM)" % (target.name, target.stat().st_size))
return 0
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 608 KiB

@@ -2552,7 +2552,19 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
}
}
} else {
appendLog("电脑不在同一网络,继续用中转");
// 不在同一网段 -> 继续走中转。
// 光说"不在同一网络"用户没法自查,所以把
// 双方地址都打出来:一眼就能看出是网段不同、
// 还是手表连错了 WiFi。
java.util.List<String> theirs = client.relayLanIps();
java.util.List<String> mine = localIpv4List();
appendLog("电脑在 " + (theirs == null || theirs.isEmpty()
? "(没报地址)" : theirs.get(0))
+ ",手表在 " + (mine.isEmpty()
? "(读不到网卡)" : mine.get(0))
+ " —— 不在同一网段,走中转");
appendLog("想让手表直连:确认两边连的是同一个"
+ " WiFi(注意别连成 5G 频段的另一个名字)");
}
}
} else {
+12 -12
View File
@@ -1511,7 +1511,7 @@ class Handler(BaseHTTPRequestHandler):
line-height:1.5}}
.meta b{{color:var(--dim);font-weight:500}}
.meta a.logout{{color:var(--dim);text-decoration:none;
border-bottom:1px dashed #484f58;transition:.12s}}
border-bottom:1px dashed #d0d7de;transition:.12s}}
.meta a.logout:hover{{color:var(--accent);border-bottom-color:var(--accent)}}
/* ---------- 通用卡片 ---------- */
@@ -1524,7 +1524,7 @@ class Handler(BaseHTTPRequestHandler):
.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:#0969da;word-break:break-all;
background:#0b1220;border:1px solid #1f6feb44;border-radius:8px;
background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;
padding:10px 12px}}
.url.empty{{color:var(--muted);font-weight:400;font-style:normal}}
@@ -1540,7 +1540,7 @@ class Handler(BaseHTTPRequestHandler):
background:var(--accent);opacity:.65}}
.stat.ok::after{{background:var(--ok)}}
.stat.warn::after{{background:var(--warn)}}
.stat.mute::after{{background:#484f58}}
.stat.mute::after{{background:#d0d7de}}
/* ---------- 工具条 ---------- */
.toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;
@@ -1552,14 +1552,14 @@ class Handler(BaseHTTPRequestHandler):
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:hover{{background:#f3f4f6;border-color:#d0d7de}}
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.primary:hover{{background:#1d4ed8;border-color:#1d4ed8}}
button.del{{background:#fff5f5;border-color:#ffc9c9;color:#c92a2a}}
button.del:hover{{background:#ffe3e3;border-color:#a33;color:#a61b1b}}
button.del:disabled{{opacity:.4}}
button.small{{padding:5px 11px;font-size:12px}}
@@ -1574,7 +1574,7 @@ class Handler(BaseHTTPRequestHandler):
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:hover td{{background:#f6f8fa}}
tbody tr:last-child td{{border-bottom:none}}
td.name{{font-weight:500}}
td.num{{font-family:ui-monospace,Consolas,monospace;color:var(--dim);
@@ -1589,7 +1589,7 @@ class Handler(BaseHTTPRequestHandler):
letter-spacing:1.5px;font-family:ui-monospace,Consolas,monospace;
font-size:13px;color:#0969da}}
.dot{{display:inline-block;width:7px;height:7px;border-radius:50%;
margin-right:7px;vertical-align:middle;background:#484f58}}
margin-right:7px;vertical-align:middle;background:#d0d7de}}
.dot.on{{background:var(--ok);box-shadow:0 0 0 3px #1a7f3722}}
.badge{{font-size:11px;padding:2px 8px;border-radius:20px;
border:1px solid transparent;white-space:nowrap}}
@@ -1598,13 +1598,13 @@ class Handler(BaseHTTPRequestHandler):
.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}}
tr.picked td{{background:#eff6ff}}
tr.picked:hover td{{background:#dbeafe}}
/* ---------- 提示条 ---------- */
#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;
font-size:13px;box-shadow:0 8px 24px #0002;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}}