隧道"假活"真因修复 + 中转管理后台重做

用户反馈两件事:
  1. 早上打开 relay.xxmxxs.top 看到 Cloudflare Error 1033 —— "这就是你说的优化好了?"
  2. "中转管理那边 ui 界面大改,现在难看的要死"

============ 一、隧道 1033/502/530 的真因 ============

之前我只测 15~25 次就说"可用率 100%",太草率 ——
实际是**间歇性假活**:隧道显示 ha_connections=2 连着,
公网却 502/530/000。watchdog 日志把过程记了下来。

根因:**4G 链路静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着)。
Cloudflare 把请求发到这条僵尸连接上就回错误。
Linux 默认 tcp_keepalive_time = **7200 秒(2 小时)**才发现,
中间这段时间服务一直"看着在线、实际打不开"。

修法(都有实测数据支撑):
  1. tcp_keepalive 30 秒 + 每 10 秒探测 3 次
     => 最多约 60 秒发现僵尸连接并重连
  2. 隧道连接数 2 -> 4
     (对照实验:http2+2 只有 1 条连上时公网 0/12;
       http2+4 稳定 12/12。连接数太少会出现"只有 1 条"的危险窗口)
  3. 协议定为 http2(cloudflared 自检说 TCP 7844 被墙,
     但实测 http2 反而比 quic 稳)

实测:http2+4 之后公网 40/40 = 100%,最慢 1.3 秒,0 次重连。

============ 二、中转管理后台重做 ============

原来是几百行内联在 relay.py 的 f-string 里(HTML+CSS+JS 全挤一起),
改一处样式要在一堆转义花括号里翻。拆成独立模块 relay/admin.py。

新版设计(对齐官网那套白色简洁):
  * 吸顶顶栏:品牌 + 运行状态 + 版本/内存/请求数
  * 公网地址给最大权重(这是用户最需要复制的东西)
  * 四个数字卡(在线/会话数带颜色区分)
  * 设备表卡片:状态圆点、等宽配对码、行悬停
  * 工具卡:网络体检 + 下载区并排
  * 空状态改成图标+引导文案,不再是干巴巴一句
  * 窄屏时表格自动变卡片

relay.py 从 2089 行缩到 1550 行。

============ 三、顺手修的两个真 bug ============

1. 设备记录只增不减
   self.devices 从不清理,被控端每次换端口/重装就多一条,
   实测一天攒了 75 条僵尸记录,/v1/health 从几 KB 涨到 20KB。
   加 DEVICE_TTL(6 小时)在 snapshot() 里清。

2. 配对码映射也没清
   RELAY.codes 和 devices 是一对孪生 dict,
   只清 devices 会留下指向已删设备的孤儿条目 ——
   管理页显示"配对码占用 131",实际一台设备都没有。
   现在一起清。

============ 四、我自己造的两次事故(如实记录) ============

1. 删文件删过头
   用"从 _admin_page 截到文件末尾"的方式替换方法,
   但 _admin_page 后面**还有 _esc/_kb/_uptime/class Server/main()**。
   结果 relay 起不来,systemd 重启 24 次,所有接口 000。
   教训:**"删到文件末尾"之前必须先确认末尾有什么。**

2. ADMIN_PATH 引用了不存在的常量
   路由里写的是字面量,我却在新代码里用了 ADMIN_PATH,
   管理页一访问就 NameError。现在统一定义了常量。

二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
This commit is contained in:
xmz7891
2026-10-07 09:34:53 +08:00
parent 8c4ee6df08
commit 1f05084f51
33 changed files with 3684 additions and 938 deletions
+108
View File
@@ -115,6 +115,68 @@ footer{padding:52px 0 64px;border-top:1px solid #e6e8eb;
.foot-links{display:flex;gap:22px}
.foot-links a:hover{color:#1a1d21}
.copy{color:#9ca3af;font-size:13px;margin-top:26px;text-align:center}
/* ---------- 产品展示 ---------- */
.showcase{display:grid;gap:34px;align-items:start;
justify-items:center}
@media(min-width:820px){.showcase{grid-template-columns:1fr 1fr}}
/* 手表 mockup。尺寸按真实手表屏幕比例 */
.watch{width:300px;padding:10px;border-radius:34px;
background:linear-gradient(160deg,#2b3038,#14171c);
box-shadow:0 18px 40px rgba(16,24,40,.18);
position:relative}
.watch::after{content:'';position:absolute;right:-3px;top:110px;
width:5px;height:44px;border-radius:3px;background:#3a4048}
.w-screen{background:#F5F7FA;border-radius:26px;overflow:hidden;
font-size:11px;color:#1F2328}
.w-status{display:flex;justify-content:space-between;align-items:center;
padding:11px 13px 7px;font-size:10px;font-weight:600}
.w-conn{color:#16a34a;font-weight:600}
.w-tabs{display:flex;gap:4px;overflow:hidden;padding:0 11px 8px;
border-bottom:1px solid #E3E7ED}
.w-tab{flex:none;padding:3px 8px;border-radius:999px;color:#656D76;
font-size:10px;white-space:nowrap}
.w-tab.on{background:#185CE8;color:#fff;font-weight:600}
.w-body{padding:12px 13px 14px}
.w-head{font-size:12px;font-weight:700;margin-bottom:10px}
.w-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}
.w-btn{background:#FFFFFF;border:1px solid #E3E7ED;
border-radius:11px;padding:11px 4px 9px;text-align:center;
display:flex;flex-direction:column;align-items:center;gap:5px}
.w-ic{font-size:15px;line-height:1}
.w-lb{font-size:9px;color:#1F2328;white-space:nowrap}
.w-foot{padding:9px 13px 12px;color:#656D76;font-size:9px;
border-top:1px solid #E3E7ED}
/* 电脑端截图 */
.pcshot{margin:0;text-align:center}
.pcshot img{width:100%;max-width:360px;height:auto;
border:1px solid #E3E7ED;border-radius:12px;
box-shadow:0 18px 40px rgba(16,24,40,.14)}
.pcshot figcaption{margin-top:14px;color:#656D76;font-size:13px}
/* 连接方式示意 */
.flow{display:grid;gap:18px}
@media(min-width:760px){.flow{grid-template-columns:1fr 1fr}}
.flow-col{border:1px solid #e6e8eb;border-radius:14px;padding:24px 20px;
background:#fafbfc}
.flow-tag{display:inline-block;padding:3px 11px;border-radius:999px;
font-size:12px;font-weight:600;margin-bottom:20px}
.flow-tag.ok{background:#dcfce7;color:#15803d}
.flow-tag.warn{background:#fef3c7;color:#a16207}
.flow-row{display:flex;align-items:center;justify-content:center;
gap:2px;margin-bottom:16px;flex-wrap:nowrap}
.node{display:flex;flex-direction:column;align-items:center;gap:4px;
font-size:22px;flex:none}
.node span{font-size:11px;color:#6b7280}
.node.relay{font-size:19px}
.link{flex:1;min-width:16px;position:relative;display:flex;
align-items:center;justify-content:center}
.link .line{width:100%;height:2px;background:#d1d5db;border-radius:2px}
.link .ms{position:absolute;top:-15px;font-size:10px;color:#15803d;
font-weight:600;white-space:nowrap}
.flow-col p{color:#6b7280;font-size:13px;line-height:1.7;margin:0}
</style>
</head>
<body>
@@ -159,6 +221,52 @@ footer{padding:52px 0 64px;border-top:1px solid #e6e8eb;
<div class="steps"><div class="step"><div class="step-num">1</div><div class="step-title">电脑端</div><div class="step-desc">在教室那台 Windows 电脑上运行安装包,填一个 6 位配对码,装完自动后台运行</div></div><div class="step"><div class="step-num">2</div><div class="step-title">手表端</div><div class="step-desc">在安卓手表上装 APK,首页点「局域网连接」或「输入配对码连接」</div></div><div class="step"><div class="step-num">3</div><div class="step-title">连上就用</div><div class="step-desc">配对一次之后,以后打开就自动连,不用再输数字</div></div></div>
</div></section>
<section id="shots"><div class="wrap">
<div class="sec-head">
<h2>长什么样</h2>
<p>手表上十个分页,常用操作都在第一屏</p>
</div>
<div class="showcase">
<div class="watch"><div class="w-screen"><div class="w-status"><span>ykxmz</span><span class="w-conn">● 已连接 · 局域网 3 ms</span></div><div class="w-tabs"><span class="w-tab on">媒体</span><span class="w-tab">屏幕</span><span class="w-tab">文件</span><span class="w-tab">语音</span><span class="w-tab">电脑</span><span class="w-tab">网络</span><span class="w-tab">系统</span><span class="w-tab">教室</span><span class="w-tab">效率</span><span class="w-tab">娱乐</span></div><div class="w-body"><div class="w-head">媒体</div><div class="w-grid"><div class="w-btn"><span class="w-ic">🔊</span><span class="w-lb">放音频</span></div><div class="w-btn"><span class="w-ic">▶</span><span class="w-lb">放视频</span></div><div class="w-btn"><span class="w-ic">⏸</span><span class="w-lb">暂停</span></div><div class="w-btn"><span class="w-ic">⏵</span><span class="w-lb">继续</span></div><div class="w-btn"><span class="w-ic">+</span><span class="w-lb">音量 +</span></div><div class="w-btn"><span class="w-ic">−</span><span class="w-lb">音量 −</span></div><div class="w-btn"><span class="w-ic">🔇</span><span class="w-lb">静音</span></div><div class="w-btn"><span class="w-ic">⏭</span><span class="w-lb">下一首</span></div><div class="w-btn"><span class="w-ic">🖼</span><span class="w-lb">投图到屏幕</span></div></div></div><div class="w-foot">被控端:教室电脑 · 192.168.2.64</div></div></div>
<figure class="pcshot"><img src="/shot/pc-main.png" alt="ykxmz 电脑端首次设置界面" loading="lazy"><figcaption>电脑端:装完填个 6 位配对码就行</figcaption></figure>
</div>
</div></section>
<section id="how-connect"><div class="wrap">
<div class="sec-head">
<h2>怎么连上的</h2>
<p>同一个 WiFi 就走局域网直连,不同网络自动切中转</p>
</div>
<div class="flow">
<div class="flow-col">
<div class="flow-tag ok">同一个 WiFi</div>
<div class="flow-row">
<div class="node">⌚<span>手表</span></div>
<div class="link"><span class="ms">3 ms</span>
<div class="line"></div></div>
<div class="node">🖥<span>教室电脑</span></div>
</div>
<p>直连,不经过任何服务器。几十毫秒,画面基本跟手。</p>
</div>
<div class="flow-col">
<div class="flow-tag warn">不在同一个网络</div>
<div class="flow-row">
<div class="node">⌚<span>手表</span></div>
<div class="link"><div class="line"></div></div>
<div class="node relay">☁<span>中转</span></div>
<div class="link"><div class="line"></div></div>
<div class="node">🖥<span>电脑</span></div>
</div>
<p>两边都主动连到中转,绕过 NAT。办公室也能管教室。</p>
</div>
</div>
</div></section>
<section id="download"><div class="wrap">
<div class="sec-head">
<h2>下载</h2>