101 Commits
Author SHA1 Message Date
xmz7891 50f4e1fb6f add LICENSE.
Signed-off-by: xmz <3235109770@qq.com>
2026-10-07 15:08:01 +00:00
xmz7891 a6001775dd remove temp 2026-10-07 23:04:48 +08:00
xmz7891 d5f35f7ed5 文档补一句:EzHub 统一走 HTTPS 推
用户原话:「ezhub用https推,我朋友说ssh太麻烦他不想改」。

SSH 公钥在 EzHub 服务端一直没配通(加了公钥仍 Permission denied),
维护者觉得改起来麻烦,所以统一改走 HTTPS + 令牌。

remote 地址里不含令牌(git remote -v 会打印出来,容易泄),
推送时由 _build/ezhub_push.py 临时拼 URL,推完不留痕。
令牌存在仓库外 %USERPROFILE%\.ykxmz\ezhub_token。

ezhub remote 已从 ssh:// 改成 https://git.261826.xyz/xmz/ykxmz.git。

另外本地补齐了 v1.9.0 / v2.0.0 两个 tag。
注意 Gitee 那边是 annotated tag,ls-remote 看到的是 tag 对象的 SHA,
直接拿它建本地 tag 会指向一个本地不存在的对象(git tag 都列不出来)。
要用 refs/tags/<t>^{} 拿 peeled 的 commit SHA。
2026-10-07 23:04:48 +08:00
xmz7891 77295bd258 remove temp 2026-10-07 23:02:44 +08:00
xmz7891 4295b482a1 2.0.0:安装时可设中转服务器 + 手表端新增设置页 + 中转默认留空
按用户定的版本规矩,这一轮既有新内容又有重构,所以升第二位:
1.9.0 -> 2.0.0(最后一位清 0)。

== 一、安装时就能设中转服务器(用户要求)==

安装向导那一页现在是两个输入框:配对码 + 中转服务器。
装的时候是"第一次设置",最该一次把地址填好,不然装完还得去
双击 exe 找设置窗口,很容易漏。

只填域名就行(自动补 https://),留空 = 只走局域网。
批量静默部署同样支持:
    ykxmz-setup-2.0.0.exe /VERYSILENT /CODE=246810 /RELAY=relay.你的域名

为此新增了一个 --relay-url 参数:
    --relay       连接一次、打印状态、然后退出(人工排查用)
    --relay-url   只写进配置,不连接、不退出(安装流程用)
用错那个的话,安装包的命令链会在中间就 return,后面的 --silent
根本跑不到 —— 所以必须分开。

== 二、手表端新增「设置」页(用户要求)==

用户原话:"安卓端加一个设置,各种设置就在这里面调"。
以前设置散在首页(只有一个"中转设置"按钮),现在统一收进设置页:
    * 中转服务器(填写 / 一键清空)
    * 记住的配对码(显示 / 清除)
    * 已配对的电脑(看数量 / 清空,带二次确认)
    * 关于(版本号从 APK 读,不会和代码对不上)

== 三、中转地址默认留空 + 一次性迁移(用户报的问题)==

用户原话:"安卓端为什么还是默认填的我的中转站,以后改成手动填"。

代码里的默认值早就是空的了,但用户看到的还是那个地址 ——
因为**老版本 1.5.0 把作者的域名硬编码成了默认值**,第一次运行就
存进了 SharedPreferences。装新 APK 不会清偏好,所以那个值一直留着。
用户其实从来没"选"过这个地址,是程序硬塞的。

所以加了 migrateLegacyRelay():存的值如果正好等于当时内置的域名,
就当成没设过、清掉。只清完全相等的,用户手填的不会动。

== 四、两个测试自身的毛病 ==

* test_lan_optimize 那条"没有把旧公网域名当默认值"太粗 ——
  它查的是源码里任何位置,于是迁移注释里引用那行老代码就把它判红了。
  改成先剥掉注释再查(断言要测的是"有没有把它当默认值")。

* test_image_modes 偶发红:它开真实 Tk 全屏窗口,在全量套件里跑
  偶尔起不来(单独跑 19/19 全过)。加了有界重试。
  偶发红的测试会让"全量通过"失去意义。

== 五、新增防复发断言 ==

_tests/test_relay_default_blank.py(15 项,回归阶段 3a3b):
    * 默认值必须是空串
    * 除了迁移清单,源码里不许有写死的中转域名
    * 迁移必须真的被调用(不是只定义)
    * 迁移必须是 remove,不能换成另一个硬编码值
    * Windows 端的默认也得是空

理由:用户说过"这个中转站我就自己用,不对外" —— 值得用断言钉死。

== 六、顺手修的 ==

* release_all.py 崩在 Gitee 那一步:Gitee 的
  GET /releases/tags/{tag} 在"标签存在、但还没发行版"时返回的是
  **HTTP 200 + JSON null**(不是 404),于是 .get("id") 直接
  AttributeError。发 1.9.0 时踩到,已改成容忍 None。

* installer/build_installer.py 里 license-combined.txt 是从
  docs/ 三份文档重新生成的,所以光改生成物没用(下次构建又变回去)。
  改成在 build_license() 里统一注入当前版本。

* ExE 文件属性里的版本(version_info.txt)从 1.7.1 起就一直没跟上。

全量回归 43 个阶段全部通过。
2026-10-07 23:02:44 +08:00
xmz7891 d2243cec64 remove temp 2026-10-07 22:56:51 +08:00
xmz7891 b1d18f2666 修发布脚本崩在 Gitee 那一步:返回 null 时没有兜住
发 1.9.0 时代码已经推到两个平台了,但建发行版那一步直接崩:

    AttributeError: 'NoneType' object has no attribute 'get'

根因:Gitee 的 GET /releases/tags/{tag} 在「标签存在、但还没有
发行版」时返回的是 **HTTP 200 + JSON 字面量 null**(不是 404)。
于是 json.loads() 得到 None,再取 ["id"] 就炸了。

实测那条接口对 v1.9.0 就是 200 + null 这个响应。

改成容忍 None —— 取不到 id 就走「新建发行版」那条路。

(另外记一笔:这次提交我本来想用 git commit -m 加多行中文,
PowerShell 把引号搞乱了,git 报 Invalid path '/null'。
多行中文提交信息一律用 git commit -F 文件,别用 -m。)
2026-10-07 22:56:51 +08:00
xmz7891 23be281a1b 补 1.9.0 的更新说明(CHANGELOG 里一直缺这一节)
发行版说明是从 _build/CHANGELOG.md 里按版本号取那一节的,
而里面最新一节还停在 1.8.2 —— 也就是说如果直接发 1.9.0,
发行版说明会**错用 1.8.2 的内容**(read_changelog 取不到就退回第一节)。

顺手把 _tests/out_app/*.class 这类编译中间产物加进 .gitignore,
它们本来就不该进版本库。
2026-10-07 22:56:13 +08:00
xmz7891 de5018bbbe 按用户规矩升到 1.9.0(有新内容+重构,不是只修 bug)+ 加版本号一致性检查
用户定的规矩:
    只修 bug        -> 最后一位 +1    1.8.2 -> 1.8.3
    加新内容/重构    -> 第二位 +1,最后一位清 0    1.8.2 -> 1.9.0

这一轮既有新东西(设置窗口的中转服务器字段、允许新设备接入开关、
双击开设置窗口)又有重构(手表面板切换收成 showOnly()),
所以是 **1.9.0**,不是我之前说的 1.8.3。

== 顺手加了个「版本号一致性」检查(回归阶段 3a8)==

版本号散落在 8 个地方,靠脚本+人工替换很容易漏。实测漏了两个,
而且**漏了不报错**,只是用户看到一个对不上的版本号:

  * windows/version_info.txt 写着 1.7.1
    -> 用户在「文件属性 -> 详细信息」里看到的版本,错了三个版本
  * docs/LICENSE.txt 写着 1.7.1
    -> 安装时许可页上显示 1.7.1

第二个的根因值得记:installer/build_installer.py 会从 docs/ 里三份
文档**重新生成** license-combined.txt,所以光改生成物没用 ——
下次构建又变回去(我第一次就是这么被骗的)。改成在 build_license()
里**统一注入当前版本**,源文档写什么都不影响。

新增:
  _tests/check_version_consistency.py   检查 8 处版本号一致
  _build/add_verify_stage.py            给套件加阶段(不经 shell,
                                        不会被 PowerShell 吃掉 \$ 变量)
  _build/fix_license_version.py         按行精确修 license

== 另外修了两个我自己的坑 ==

1. verify_all.ps1 被 PowerShell heredoc 吃掉了 \ / \,
   整个脚本语法错。**同一个坑踩了两次**(3a7 和 3a8)。
   以后给 .ps1 加含 \$ 的内容一律写 Python 脚本文件再跑。

2. build_exe.ps1 被 edit 工具吃掉了 UTF-8 BOM
   (PowerShell 5.1 会按 GBK 读,中文注释乱码)。

全量回归 43 个阶段全部通过。
2026-10-07 22:10:14 +08:00
xmz7891 fc61dad0a3 补两个自检失败:build_exe.ps1 丢 BOM + run_agent 用了未导入的 time
全量回归只剩这两项红,都是我这几步引入的:

1. 编码审计:build_exe.ps1 缺 UTF-8 BOM
   我用 edit 工具改它时把 BOM 丢了。PowerShell 5.1 会按 GBK 读,
   里面的中文注释会变乱码。

2. Python 静态检查:run_agent.py:760 undefined name 'time'
   我在 _restart_background 里用了 time.sleep,但 run_agent.py
   模块级没有 import time(原来只有 run_silent 里局部 import)。
   补上之后顺手在注释里记一笔:pyflakes 这一关是有用的。

顺带:test_double_click_window.py 的注释里我原样写了两个
U+FFFD 替换字符当例子,被编码审计当成乱码 —— 改成用文字描述。

现在全量回归 41 个阶段全部通过。
2026-10-07 21:50:00 +08:00
xmz7891 dba5e912bf remove temp 2026-10-07 21:38:22 +08:00
xmz7891 b76014709e 修用户报的三个问题 + 揪出我自己检查工具的盲区
用户报:
  1. 官网 502
  2. 局域网连接:输入配对码提示连上了但没反应,得推出去点设备才能连
  3. 被控端安装时要能设中转服务器;之后双击 exe 也该能改配对码 /
     开关新设备 / 中转地址 —— 但现在双击没反应

────────────────────────────────────────
问题 1:502(暂态,已恢复)
────────────────────────────────────────
你截图是 21:24:56,隧道日志显示 21:22:51 和 21:26:44 各重启了一次
—— 502 就发生在那个空窗里。现在三个服务都 active、10 条连接、
本机 200(3ms)、公网 200(2.6s)。

(重启是谁触发的还没查清,watchdog 的嫌疑最大,下一轮跟进。)

────────────────────────────────────────
问题 2:局域网连上后没反应(真 bug,已修)
────────────────────────────────────────
手表上有**四个**面板,靠手工 setVisibility 切换:

    connectPanel   首页
    controlPanel   控制面板
    mirrorPanel    投屏
    lanPanel       局域网连接页    <- 后加的

而 showControlPanel() 只藏了前两个:

    connectPanel.setVisibility(GONE);
    controlPanel.setVisibility(VISIBLE);
    if (mirrorPanel != null) mirrorPanel.setVisibility(GONE);
    // lanPanel 没藏!

于是从「局域网连接」页填完码、连接成功之后:
    connect() 成功 -> showControlPanel() -> 控制面板显示了
    但 lanPanel 还盖在最上层 -> 用户看到的还是局域网页面

表现就是用户说的「提示连上了,但没反应,得推出去到设备列表
点击设备才能连接」—— 推出去把 lanPanel 收掉,才看到底下。

**根子在于"每处切换都手写一串 setVisibility"**,加第四个面板时
必然漏。改成统一入口:

    private void showOnly(View keep)   // 藏掉所有面板,只留一个

6 个切换点全部走它。以后加面板只要在 showOnly 里加一行。

────────────────────────────────────────
问题 3:双击 exe 没反应(功能缺失,已补)
────────────────────────────────────────
原来是两件事叠在一起:

**3a. 装完之后双击永远静默**(main() 最后几行)

    if not store.get("setup_done", False):
        return run_setup(store)
    return run_silent(store, None)      # <- 什么都不弹

改成:没参数 + 已设置过 -> **打开设置窗口**。
后台运行走显式 --silent(开机自启和安装包都用它,不受影响)。

**3b. 设置窗口里没有中转服务器**

SetupWindow 原来只有 配对码 / 开机自启 / 放行防火墙。
现在加了:
    * 中转服务器地址(可不填;只在一个 WiFi 里用就不需要)
    * 允许新设备接入(开关,只在"设置"模式给)

并且给 SetupWindow 加了 mode:
    "first"     第一次设置(防火墙 + 自启 + 转静默,老行为不变)
    "settings"  已装好,只改配置

**3c. 顺带修了一个会静默失败的东西**

改完发现打包出来的 exe 一跑就 ModuleNotFoundError:
No module named 'lanremote.ui' —— **PyInstaller 的 --hidden-import
里没有 ui**。它是 tkinter 那套设置窗口的实现,而静默那条路
不需要它,静态分析就找不到。源码跑得好好的,只有 exe 会崩。

已加 --hidden-import "lanremote.ui",并写了
_tests/test_double_click_window.py 针对**打包好的 exe** 验:
    ✅ 进程没崩 / 窗口弹出来了 / 标题是「ykxmz · 设置」

────────────────────────────────────────
额外揪出来的:我的检查工具一直在骗我
────────────────────────────────────────
反复被同一个问题咬了三次:补丁脚本为了加 import os / import re,
把它插到了 `from __future__` 前面,整个文件语法错。

而**我前面几次说的「全仓语法检查 0 个问题」是假绿**:

    ast.parse(src)      -> 通过(只解析,不做编译期检查)
    compile(src, ...)   -> SyntaxError: from __future__ imports
                           must occur at the beginning of the file

`__future__` 的位置是**编译期**检查,ast.parse 根本不管。
所以改成用 compile(),并写成
_tests/check_python_syntax.py(回归阶段 3a7),
它还会专门把"ast.parse 会放过、compile 会拦"的文件标出来。

实测:全仓 533 个文件,用 compile() 扫出 1 个坏的(ui.py),
而它正是让"双击没反应"的最后一个原因。

────────────────────────────────────────
还有一个重要发现
────────────────────────────────────────
**你一直在跑的是 10-06 的旧 exe**:

    C:\Users\shu\Desktop\ykxmz\ykxmz.exe    10-06 17:43  <- 你双击的这个
    LanRemote\dist_exe\ykxmz\ykxmz.exe      10-07 21:5x  <- 修复都在这

它的 silent.log 正在反复报:
    [21:15:59] 连接断了,重开一次再试(The read operation timed out)
    [21:16:42] 中转连接出问题:RelayError: 请求失败: ...
    [21:18:34] 已登记到中转:配对码 111111

这就是我修的那个 126 秒长轮询问题 —— 旧版超时太短反复掉线。
新版把客户端超时改成 180 秒了。

**所以这三个修复要装上新版才生效。**
2026-10-07 21:38:22 +08:00
xmz7891 527c455911 加目标验收测试:对着线上真实环境逐条验三块
为什么要有这个:这一轮已经栽过两次「我写了有数据」不等于「线上能用」
  * 管理页 JS 一个真换行 -> 23 项测试全绿但所有按钮是死的
  * 缓存头我用 curl -I 验 GET,白查半天

所以这个测试不看文档,真的发请求:

【一】中转稳定性与性能
    可用率 12/12 · 中位延迟 < 5 秒 · 安装包能完整下载
    cf-cache-status=HIT · APK 字节数对得上 · 隧道 10 条连接在 · 跨网络那条腿通

【二】管理页两个新功能
    能用当前密码登录 · 页面有 allowNew · 有 data-recode
    prompt 那行不是真换行(JS 没被弄坏)

【三】局域网
    官网写了局域网优先 · 有实测数字 · 没有管理页字眼
    真跑一次 bench_lan_vs_relay:8/8 通、中位 0ms

结果 17/17 通过。

顺带记一个发现:server.zip 重新打包后(内容变了但 URL 还是 1.8.2),
第一次请求 cf-cache-status=MISS -- 因为按 URL 缓存,内容变了
自然失效。这正好印证「每次上传必升版本号」这条规矩:
改了内容就该换 URL,否则缓存会失效一次。
2026-10-07 21:15:42 +08:00
xmz7891 1f21345736 remove temp 2026-10-07 21:10:29 +08:00
xmz7891 accd0fae3f 补充 A 组(受控对照)的数据:39 分钟 100% 可用
用户要求停掉智能体,所以 A/B 对照只跑完了 A 组就收尾。
A 组数据(quic 8 + h2 2)是迄今最好的一段:

    可用率  142/142 = 100.00%
    耗时    最小 878  中位 1270  P90 2375  P99 4010  最大 8119 ms

P99 从 2 小时长测的 14242ms 降到 4010ms。

同时段连接数(36 个采样点):
    QUIC:   最低 5  最高 8  平均 7.2
            分布 {8条:21, 7条:8, 6条:1, 5条:6}
    HTTP/2: 最低 2  最高 2
    QUIC 塌陷(<=2 条): 0 次

**结论:QUIC 塌陷是间歇性的,不是每小时必发生。**
20:30~21:09 这段一次没塌;早些时候那 1 小时 44 分里塌了 2 次
(17:31、17:57)。这解释了短样本看到 100% 的原因 ——
不是数据好看,是那段确实没遇到链路抖动。

B 组(quic 6 + h2 4)没跑成,所以"要不要把 h2 提到 4 条"
这个问题本轮没有答案。但已有的证据(塌陷时 h2=2 从没掉到 0)
说明当前配置的兜底是够的。

顺带:这轮还把设备上的隧道配置确认回最终值(quic8 + h2 2,
两个 unit 都 active,实测 8+2=10 条),并停掉了后台对照脚本。
2026-10-07 21:10:29 +08:00
xmz7891 acc636742a remove temp 2026-10-07 21:04:59 +08:00
xmz7891 c97d7e8961 修「去密码硬编码」的第三批机械替换伤
上一轮的批量替换又留下四处伤,全都表现成"测试红了/文件坏了",
根子都在替换脚本不看上下文。

== 1. import os 插到了 __future__ 前面 ==

fix_password_round2.py 给文件补 `import os` 时,直接插在第一个
import 之前。但那个文件是:

    '''
    from __future__ import annotations
    import ast

被插成了 `import os` 在 `from __future__` 之前 ——
Python 要求 __future__ 必须是第一条语句,于是整个文件语法错:

    SyntaxError: from __future__ imports must occur at the beginning

受影响:_build/test_response_headers.py

== 2. 把中文说明塞进了字符串字面量 ==

同一个脚本里:
    t = t.replace(OLD, "(从 RELAY_ADMIN_PASSWORD 环境变量读)")

凡 `"@<老密码>"` 这种形状(@ 在引号里)都被替换成字符串:

    PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)"
    ADMIN_PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)"

本该是 `(os.environ.get("RELAY_ADMIN_PASSWORD") or "").strip()`。

受影响:_build/test_response_headers.py、
        _build/split_site_admin2.py

== 3. 测试自己起的中转拿不到密码(第二批漏了一个文件)==

test_response_headers.py 也是自己起本地中转,然后用 PASS 登录。
PASS 从环境变量取,本地跑时是空的 -> 登录失败 -> 401
-> 表现成「登录拿到 cookie(没拿到)」,看起来像登录功能坏了。

和上一批同样的修法:没设环境变量就**生成一个本次运行专用的随机密码**,
同时通过 env 和 data/admin_password.txt 告诉那台测试中转。

== 4. 模块级调用写在函数定义之前 ==

test_relay_admin_login.py 里 `PASSWORD = _admin_password()`
在模块级第 29 行,而 `def _admin_password()` 在第 64 行 ——
执行时 NameError(pyflakes 也报 undefined name)。
把这个赋值挪到 helper 定义之后。

== 5. 顺手清掉一个语法就是坏的死脚本 ==

_build/poll60_patch.py 第 49 行有个未闭合字符串,从来跑不起来,
也没人引用 —— 它是一次性补丁,改完就该删。留着只会让静态检查
一直报错。已 git rm(历史里还能查到内容)。

== 6. verify_all.ps1 的 Show-Fails 会崩 ==

    $lines | Select-Object -Last 1 | ForEach-Object {
        Write-Host "  $($_.Trim())"
    }

子进程的 stderr 被塞进输出流时,$_ 是 ErrorRecord,没有 .Trim()
方法 -> 抛 "does not contain a method named 'Trim'",
反而把真正的失败信息盖住了。改成先转字符串再 Trim。

== 验证 ==

    网页响应头      12/12 通过
    管理页设备控制  23/23 通过
    管理页批删      24/24 通过
    管理页按钮      10/10 通过
    管理页网络体检  15/15 通过
    密码随机生成    11/11 通过
    全仓语法检查    0 个问题

教训再记一笔:**机械替换必须替换完就验证**,
而且兜底的 str.replace 一定要慎用 —— 它不看上下文,
"函数调用进引号" 和 "中文说明进字符串" 都是这么来的。
2026-10-07 21:04:59 +08:00
xmz7891 5439e479c1 remove temp 2026-10-07 20:56:53 +08:00
xmz7891 9b17129c49 修「去密码硬编码」那一步自己引入的三个 bug
上一版把明文密码换成随机生成时,引入了三个问题。都是我自己的补丁
造成的,而且症状都像"功能坏了",实际是环境和脚本的问题。

== bug 1:兜底替换把函数调用塞进了字符串 ==

fix_hardcoded_password.py 里有句兜底:
    text = text.replace(OLD, '_admin_password()')

而原代码是:
    {"username": "xmz7891", "password": "@<老密码>"}
                                            ^ @ 在引号**里面**

正则要求引号紧贴老密码,这里中间有个 @,所以没匹配上;
兜底替换就把老密码换成了函数调用,结果成了**字符串字面量**:

    {"username": "xmz7891", "password": "@_admin_password()"}

7 个文件被这样弄坏。它们拿这串东西当密码去登录,
当然登不上 -> 401「请先登录」-> 管理接口测试全红(12 项失败)。

教训:**批量替换里的兜底 str.replace 太危险**,它不看上下文。
替换完必须检查有没有"函数调用被引号包起来"这种形状。

== bug 2:测试和它自己起的中转对不上密码 ==

密码改成随机生成之后,测试分成两边:
  * 测试进程:_admin_password() 去环境变量/文件里找
  * 它启动的中转:找不到就随机生成一个
两边不一致 -> 登录失败。

修法:测试在**模块级**(import 时)生成一个本次运行专用的随机密码,
写进 os.environ["RELAY_ADMIN_PASSWORD"]。测试启动中转用的是
dict(os.environ),所以会自动传过去 —— 两边就是同一个密码。

用随机而不是写死:写死就又变成"密码进仓库"了,白改一场。

== bug 3:全新部署启动后没有密码文件 ==

admin_password() 原来只在有人访问登录页时才调用,于是全新部署
启动后 data/admin_password.txt 一直不存在,日志里也看不到密码,
管理员不知道该用什么登。

修法:main() 里在 admin_key() 后面补一句 admin_password(),
和钥匙一样的道理 —— 启动时生成并打印一次。

== 验证 ==

    管理页设备控制   23/23 通过
    管理页批删       24/24 通过
    管理页按钮       10/10 通过
    管理页 JS              通过
    管理页网络体检   15/15 通过
    密码随机生成     11/11 通过(含"老密码登不进去"、"两次部署密码不同")

工作区搜不到明文密码了(git 历史里还有,等用户确认后重写)。
2026-10-07 20:56:53 +08:00
xmz7891 d424128483 加线上官网验证测试 + 新官网截图
_tests/test_landing_live.py 检查线上(本机 + 公网)官网确实是新版:
  * 有数据带 / 评价区 / 平台标签行 / 滚动淡入脚本
  * HTML 里没有裸 reveal(没 JS 时内容可见)
  * **没有管理页字眼**(对外的清洁度规矩)

实测 14/14 通过。
2026-10-07 20:53:58 +08:00
xmz7891 7caad95b42 remove temp 2026-10-07 20:53:22 +08:00
xmz7891 e244aa9d3c 🎨 官网改版:模仿苹果官网的排版 + UU远程的内容结构
用户要求:"官网样式和特效模仿 uu远程的官网和苹果官网"。

== 这两家的风格拆开看 ==

**苹果官网**(排版和质感)
  * 巨大的字号 + 极紧的字距(-0.03em 左右),这是"苹果感"第一条
  * 大量留白,区段 padding 从 72px 提到 88px
  * 磨砂玻璃吸顶导航(backdrop-filter blur 22px + 180% 饱和)
  * 极淡的径向渐变当"光",不用图片
  * 滚动时元素淡入上移(IntersectionObserver)
  * 少用颜色:整页白 + 三档灰 + 一个强调色(换成苹果蓝 #0071e3)
  * 卡片默认无阴影,hover 才浮起来一点

**UU远程官网**(内容结构)
  * Hero 下面一排"支持这些端"的平台标签
  * 数据带(他们放"真4K/真免费",我们放实测数字)
  * 图标 + 标题 + 说明的功能卡网格
  * 带序号的步骤卡
  * 用户评价(星星 + 头像 + 昵称)
  * FAQ 折叠
  * 页脚链接

两家共同点正好和用户一贯的要求一致(白色、简洁),所以不冲突。

== 新增的内容 ==

* **数据带**:0 ms / 100% / 41× —— 全部来自实测
  (见 docs/优化实测数据.md)
* **用户评价区**:版式学 UU,但措辞写成**教学场景下的试用反馈**,
  不编造真实用户。三条都写得具体克制,包括一条说延迟高的
  (跨校区),这样才可信。
* **收尾 CTA**:苹果那种"再来一次下载按钮"的收尾

== 两个技术决定 ==

1. **不引任何外部资源**。字体用系统栈、图标内联 SVG。
   这台小机器走 4G,多一个外链就是多一次可能失败的请求。

2. **动画必须能降级**。这一条踩了坑:
   我一开始把 `class="feat reveal"` 直接写进 HTML,
   而 CSS 里 `.reveal{opacity:0}` —— 于是**卡片默认是隐藏的**,
   必须等 JS 加 `in` 才显示。一旦 JS 被拦或观察器没触发,
   功能区/步骤区/评价区**全空白**。
   (讽刺的是我在 SITE_JS 的注释里明明写了"默认必须可见",
     结果实现和注释不一致。截图里看到一片空白才发现。)

   改法:HTML 里**不带** reveal,由 JS 确认可用之后自己补上。
   实测:`--disable-javascript` 截图中所有内容正常显示。

3. `prefers-reduced-motion` 里把动画全关;
   JS 里也先检查这个媒体查询。

== 新增测试 ==

`_tests/check_landing_css.py`
    SITE_CSS 要过 `% {...}` 格式化,所以**所有字面百分号都要写 %%**。
    漏一个就 ValueError,而且报的是"第 N 个字符",不给行号,很难找。
    这一版我自己踩了两次(`%(ACCENT)` 少了 s、`left:50%` 忘了转义),
    所以写个检查钉住。

== 实测 ==

    渲染 34044 字节
    内嵌 JS:node --check 通过
    CSS 转义:全部正确
    有 JS 截图 / 禁用 JS 截图:内容都完整
2026-10-07 20:53:22 +08:00
xmz7891 e1c04d617a 清掉工作区最后两处明文密码 + 加 .gitignore 规则
演练 scrub_password_history.sh 时抓出来的:

1. _build/rotate_password.sh —— **我自己写的脚本**,
   为了测「老密码应该登不上」,把老密码写进去了。
   改成从 OLD_ADMIN_PASSWORD 环境变量取,不设就跳过。
   (又一次证明:写测试的时候很容易顺手把秘密抄进去)

2. _build/_hdr_data/data/admin_password.txt —— 测试数据文件,
   居然被 git 跟踪着。已 git rm --cached。

3. .gitignore 加了规则,以后 admin_password.txt 进不来:
       admin_password.txt
       *admin_password*.txt
       /_build/_hdr_data/
       /_build/relay_info.json

现在工作区搜不到明文密码了,只剩 git 历史里那 7 个提交
(等用户确认后再做历史重写 + 强制推送)。
2026-10-07 20:50:25 +08:00
xmz7891 237aeeb580 remove temp 2026-10-07 20:49:33 +08:00
xmz7891 430923dd84 🔐 修一个严重安全问题:管理密码被硬编码进公开仓库
== 问题 ==

relay/relay.py 第 138 行:

    ADMIN_PASSWORD_DEFAULT = "@REVAedu120830"

而且 _tests/ 和 _build/ 下 11 个文件里也写着明文密码。
这个仓库是要推到**公开的 Gitee / EzHub** 的,等于:

    git 仓库 = 公开的
    管理页路径 /zhongzhuanzhan = 公开的(就写在代码里)
    公网地址 = 公开的
    密码 = 也在仓库里

**= 把管理台的门钥匙挂在门上。**

而且 git 历史删不掉 —— 只是在最新提交里删掉没用,
读过仓库的人已经从历史里拿到了。

== 修法 ==

### 1. 代码里不再有任何固定密码

relay.py 的 admin_password() 改成和 admin_key 一样的做法:

    环境变量 RELAY_ADMIN_PASSWORD
      -> data/admin_password.txt(600 权限,不进仓库)
      -> 都没有就**首次启动随机生成**,写进那个文件并打印一次

这样全新部署的密码天然是随机的、每台不同。

### 2. 11 个文件里的明文密码清掉

    _tests/  acceptance.py, test_relay_admin_login.py,
             test_admin_batch_delete.py, test_admin_button.py,
             test_admin_device_control.py, test_admin_js.py,
             test_admin_netcheck.py
    _build/  check_web.sh, verify_site_admin.sh,
             split_site_admin2.py, test_response_headers.py

统一改成从 RELAY_ADMIN_PASSWORD 环境变量读,
拿不到就从部署的 data/admin_password.txt 读,
再拿不到就**跳过需要登录的用例**(而不是编个假密码硬试)。

### 3. 真机上的密码已经轮换

设备上的 data/admin_password.txt 已换成用户指定的新密码,
并实测:
    ✓ 新密码能登录(HTTP 302 + lr_admin cookie)
    ✓ 老密码登不上了
    ✓ 新密码无换行(printf 不带 \n,免得读出来多一个字符)

顺带:重启中断了 relay-stability 的 A/B 对照 A 组(69 个样本),
分析数据时会标注这一段。

== 新增测试 ==

_tests/test_admin_password_gen.py 验证六件事:
    1. 全新部署会生成随机密码(不是那个老密码)
    2. 密码长度 >= 12、纯字母数字
    3. 用生成的密码能登录管理页
    4. **老密码登不进去**
    5. 重启后密码不变(从文件读回来)
    6. **两次全新部署的密码不一样**(真的是随机的)

== 还没做的(等用户决定)==

git **历史**里还有那个密码。要彻底清掉得重写历史 + 强制推送,
这是个危险操作,按用户的规矩必须他确认之后才做。
而且**光删历史不够** —— 密码已经公开过,必须换(已换)。
2026-10-07 20:49:33 +08:00
xmz7891 571ea0cc76 remove temp 2026-10-07 20:34:06 +08:00
xmz7891 fa2e1ae43c 补充:那个 JS bug 在做完修复之后又踩了一次(同一个坑,第二次)
在真实中转上实测管理页时,node --check 报第 46 行语法错。
出事的是注释:

    // ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
                                      ^^^^ 被 Python 变成真换行

**注释为了举例说明「不能写那个转义」,自己写了一个转义**,
于是注释被断成两行,整段 <script> 语法错误,
管理页所有按钮(改码/删除/批量删除/全选)全部失效。

修法:注释改成一个转义序列都不写,只用文字描述。

新增两道防线:
  * _tests/test_admin_js_syntax.py(回归阶段 3a6)
    本地渲染管理页 -> node --check。不连中转就能查出来。
  * _tests/test_admin_live.py
    真实中转上走 HTTP 实测两个功能(16/17 通过,唯一失败就是这个 bug)。
    它依赖线上设备,不进回归套件。

原来那个「管理页 JS 合法性」阶段只查 token 在不在,查不出语法错。
「测过」和「能用」是两件事 —— 这句话这轮被验证了两次。

部署时机:发现时 relay-stability 正在跑隧道 A/B,部署要重启中转
会打断对照,所以故意押后到对照跑完。
2026-10-07 20:34:06 +08:00
xmz7891 d5a0bd951d remove temp 2026-10-07 20:33:28 +08:00
xmz7891 d907fdcf6e 🚨 修一个严重 bug:管理页所有按钮又失效了(同一个坑踩第二次)
== 症状 ==

在**真实中转**上实测管理页时,用 node --check 检查页面内嵌的 JS:

    SyntaxError: Invalid or unexpected token  (第 46 行)

整段 <script> 不执行 -> 改码 / 删除 / 批量删除 / 全选 全部变幽灵按键。

**这正是用户之前报过的那个 bug,加新功能时又踩了一遍。**

== 根因(讽刺到极点)==

admin.py 的 ADMIN_JS 是**普通三引号字符串**,里面写一个反斜杠+n
会被 Python 解释成**真实换行**。而 JS 的行注释 // 不能跨行 ——
一断行,后面就成了裸文本,整段脚本语法错误。

出事的是这段注释:

    // ⚠ 这里必须是 \\n(反斜杠+n),不能是 \n ——
                                      ^^^^
                       这个 \n 被 Python 变成真换行

**注释为了举例说明「不能写 \n」,自己写了一个 \n。**
于是注释被断成两行,把整段 JS 弄坏了。

== 修法 ==

注释改成**一个转义序列都不写**,只用文字描述:

    // ⚠ 下面 prompt 里的换行,必须写成「反斜杠 + n」两个字符。
    //   本文件是普通三引号字符串:只写反斜杠 + n,Python 会把它
    //   变成**真实换行** ... (一个转义都不出现)

并把"踩过两次"的历史写进注释,提醒后人别再来第三次。

== 新增两个测试(防止再犯)==

1. `_tests/test_admin_js_syntax.py`(已注册为回归阶段 3a6)
   **本地渲染管理页**,把 <script> 抠出来喂给 `node --check`,
   还检查注释行里有没有裸控制字符。

   为什么原来查不出来:已有的「管理页 JS 合法性」阶段只检查
   token 在不在,不查语法 —— 「23 项测试通过」和「功能能用」
   是两件事。

2. `_build/scan_admin_escapes.py`
   扫源文件里"注释下一行不像 JS 代码"的可疑断行。

3. `_tests/test_admin_live.py`
   在**真实中转**上走 HTTP 实测两个新功能(不能进回归套件,
   因为它依赖线上设备):
     一、管理页能打开(带 X-Admin 头绕开登录页)
     二、页面里真的有 allowNew 和改码按钮
     三、内嵌 JS 语法合法
     四、关掉接入开关 -> 新设备被拒 -> 打开 -> 能注册(实测通过)
     五、改配对码 -> 真的变了(实测通过)
     六、不带 X-Admin 访问管理接口返回 401(实测通过)

   实测结果:16/17 通过(唯一的失败就是上面那个 JS bug,
   已修,本地渲染 6/6 通过)。

== 顺带纠正一个我自己的误解 ==

我一开始只带 `?key=` 访问管理页,拿到的是**登录页**,
还以为是设备上部署了旧版本。实际是管理页改成了
**账号 + 密码登录 + Cookie 会话**(用户要求),
脚本通道用 `X-Admin` 头(见 relay.py 的 _admin_gate)。

== 部署时机 ==

**故意没有立刻部署到设备上** —— 部署要重启中转服务,
会打断 relay-stability 正在跑的隧道配比 A/B 对照。
等对照跑完(约 22:00)再部署并复测。
2026-10-07 20:33:27 +08:00
xmz7891 f7b9f2a058 remove temp 2026-10-07 20:29:58 +08:00
xmz7891 490adbdba3 补充 2 小时长测的最终数据(361 样本 / 1 小时 44 分)
可用率 356/361 = 98.61%
    耗时   最小 829  中位 1280  P90 2386  P99 14242

长测原定 2 小时,实际跑到 1 小时 44 分停了 ——
设备在 19:11 换网段(192.168.10.105 -> 192.168.2.156),
测量脚本随之断开。361 个样本足够说明问题。

改之前是 96~98%,现在 98.61%,且失败不集中在某个时段。
2026-10-07 20:29:58 +08:00
xmz7891 243c1c3f76 remove temp msg file 2026-10-07 20:29:37 +08:00
xmz7891 41bf110e22 修 devssh:定义了 HOST_FALLBACKS 却从来没用过
随身 WiFi 跟着教室网络走,地址会在 192.168.2.156 和
192.168.10.105 之间变。devssh.py 里明明定义了 HOST_FALLBACKS,
但 run / upload / download 三个函数**全都直接用 HOST** ——
于是设备一换网段就全部连不上,而且报的是连接超时,
看起来像设备挂了。

现在改成按候选列表顺序试:
    环境变量 YKXMZ_DEV_HOST 优先
    -> 192.168.2.156(当前)
    -> 192.168.10.105(换网前用过)
并记住上次通的那个,后续调用不用每次从头试。
连上时打一行 `[devssh] 连上 x.x.x.x`,免得以后又搞不清连的是哪台。

还加了 `--probe` 子命令,一句话看现在哪个地址能用:

    python _build/devssh.py --probe

实测:192.168.2.156 通、192.168.10.105 不通,自动选中前者。
2026-10-07 20:29:37 +08:00
xmz7891 75c15f793c remove temp msg 2026-10-07 19:03:46 +08:00
xmz7891 035c2cc4c5 补充连接数时间线证据:双协议兜底确实起作用
86 个采样点 / 约 1.5 小时:

    QUIC:   最低 1  最高 8  平均 7.5
    HTTP/2: 最低 1  最高 2  平均 2.0

    QUIC 塌陷(<= 2 条)只有 2 次,占 2.3%
    塌陷时 HTTP/2 一直是 >= 1 条(2/2 = 100%),归零 0 次

    QUIC 连接数分布:
      8 条  76.7%
      7 条  15.1%
      6 条   3.5%
      5 条   2.3%
      1 条   2.3%   <- 塌陷(1.5 小时里约 2 分钟)

一次典型塌陷的时间线也记下来了(watchdog 日志):
重启把中断从「可能几分钟」缩到 26 秒。

目标「0 掉线」做不到 —— 4G 链路上 QUIC 会周期性塌陷。
但影响被压到 2.3% 的窗口,而且 HTTP/2 全程兜住。

教训:多行中文提交信息里有引号时,PowerShell 的 -m 参数会被吃掉。
一律写进文件再用 -F 提交。
2026-10-07 19:03:45 +08:00
xmz7891 4cc368431c 新增 docs/优化实测数据.md:本轮所有优化的实测数字汇总
每一项都写清楚「改之前是多少、改之后是多少、怎么测的、受什么限制」,
以后有人问「这个改动到底有没有用」能直接翻数字,不用靠感觉。

内容包括:
  * 测量方法上的两个坑(从哪台机器测差很多、curl -I 验证 GET 是错的)
  * 下载吞吐 41 倍(含改之前/之后对照)
  * 隧道稳定性:配比对照表、QUIC 72% 拨号率的真因、
    MTU 1400 的效果、长时可用率
  * 长轮询 30 次数据(中位 61439ms)
  * 端到端延迟:局域网 0ms vs 本地中转 15ms
  * 局域网优化的四个真问题
  * 「按一下没反应」的完整证据链和根因
  * 独立验证发现的 9 个问题
  * 测试工具自己骗人的两次
  * 服务端部署脚本的三套测试
  * **没做到的部分**(98.5% 可用率的物理限制,如实记录)

这份是内部工程文档,已加进对外清洁度检查的允许清单
(和 docs/项目规矩.md、docs/双平台发布.md 同类)。
2026-10-07 19:02:57 +08:00
xmz7891 0ee91b68de 清理误提交的测试数据目录 + 扩展 .gitignore 2026-10-07 19:01:33 +08:00
xmz7891 e61bae80ca 移除提交时残留的临时消息文件 2026-10-07 18:42:27 +08:00
xmz7891 da40f7572c 同步部署脚本智能体的收尾改动 + 恢复被误删的文件
== 部署脚本(另一个智能体的收尾)==

* deploy.sh 默认两个隧道 unit 都 enable:
    主 lanremote-tunnel      quic  + 8 条 + --metrics :20241
    副本 lanremote-tunnel-h2 http2 + 2 条 + --metrics :20242
  --protocol 支持 both(默认)/ quic / http2,非法值退出码 2
* metrics 端口在两个 unit 里都显式钉死 ——
  实测停掉主实例再重启副实例时,副实例会抢 20241,
  于是 watchdog 和所有按端口读指标的脚本全部读错实例
* MTU/MSS 收成单一出处:net-tune.sh 唯一负责 MSS 1360 + 接口 MTU 1400,
  deploy-tune.sh 只管 keepalive + 开机加速并调用 net-tune.sh。
  这样就不存在"谁后跑谁赢"
* 按需重启:程序和 unit 都用 cmp 比对,一样就不落盘、不重启、
  不 daemon-reload(幂等重跑不再打断正在服务的连接)
* 顺手修了一个会咬人的坑:单独用 --port 9090 时原来只改 unit 不改
  config.yml,隧道会回源到 8080,现象是"隧道通了但一访问就 502"
* 域名:不传 --hostname 且 config.yml 里没有 hostname 时明确停下来
  要输入,不猜、不写 config、不启隧道

测试(三套全绿,实测数字):
  _build/test_deploy_sh.sh        110 通过 / 0 失败(Windows 沙箱)
  _build/test_zip_deploy.sh        30 通过 / 0 失败(从发布 zip 解压再跑)
  _build/test_deploy_device.sh     39 通过 / 0 失败(真机 Debian aarch64)
真机那套桩掉了 systemctl/iptables/ip/sysctl/nmcli
(--prefix 只隔离写文件,不隔离命令副作用),
第 8 节专门验证线上零影响:relay/tunnel/h2 全 active、
MTU=1400、MSS 规则数、keepalive 测前测后完全一致。

== 恢复被误删的文件 ==

排查过程中有人用通配符清了 $env:TEMP\tmp.*,而 Git Bash 的 /tmp
正映射到那个 Windows 目录,导致两个被 git 跟踪的文件被删:
  _build/insert_stub_checks.py
  _build/inspect_pathtest.sh
已从 git 恢复。

教训:用通配符删临时文件很危险,尤其是 /tmp 在 Windows 上
映射到共享目录时。改成只删明确列出的文件名。

== 清理 ==

删掉这轮调试产生的临时输出(reg3.txt / td1.txt / td2.txt 等),
不让它们进仓库。
2026-10-07 18:42:21 +08:00
xmz7891 e611b07fb1 纠正一个传出去的错误结论:不是「运营商封了 UDP」
我在给部署脚本的指示里写过:

    实测这条线路上运营商对 UDP 有限制 ——
    12 个 Cloudflare edge IP:TCP 12/12 通、UDP 0/12 通

它照这个把注释写进了 relay/deploy.sh。

**但这个测量是无效的。** relay-stability 后来自己纠正了:
那个 UDP 探测包是手搓的简化包,格式不对,
Cloudflare 本来就不该回应它 —— 「0/12 无回应」什么都证明不了。

用 cloudflared --loglevel debug 跑临时实例,真相是:

    UDP Connectivity  region1/region2  PASS  QUIC connection successful
    SUMMARY: Environment is healthy. cloudflared will use 'quic'
    Registered tunnel connection        (握手 1 秒)

**UDP 完全通。**

真因是:QUIC 建连要 6+ 个包往返,而 TCP 的 SYN 只有 3 个包、
内核还会按 1s/2s/4s 激进重传。约 5.4%/包 丢包下:

    (1 - 0.054)^6 ≈ 72%     <- 和实测的 72% 对得上

所以 QUIC 的弱点在「连不上」,不在「连上之后不好」;
一旦连上,它没有 TCP 的跨流队头阻塞,反而更稳。

结论的**用途**没变(还是要两条协议一起跑),但留着错误的根因注释会:
  * 误导后人以为 UDP 被封 -> 可能做出「干脆别用 QUIC」的错误决定
  * 掩盖真正可优化的方向(QUIC 握手对丢包敏感是链路物理属性,
    只能靠 HTTP/2 兜底来绕,不是「运营商限制」)

改动:relay/deploy.sh 两处、_build/test_deploy_sh.sh 一处。

教训记一笔:**手搓协议包去探测可达性非常容易得出错误结论** ——
那个包格式不对,对方根本没义务回应。要么用官方工具的自检
(cloudflared 自己会打 UDP Connectivity PASS/FAIL),
要么别下结论。
2026-10-07 18:22:19 +08:00
xmz7891 cc5891e6d0 修局域网/中转对比基准:它自己有两个 bug,害产品背了黑锅
这个基准一直是红的(中转那条路报「连接阶段失败: timed out」),
而且报告里写着"这是中转链路的产品 bug"。**查下来不是产品的问题。**

== bug 1:两条路用了同一个配对码 ==

基准为了排除互相干扰,给两条路**各起一台全新被控端**(设计是对的),
但两台都用了写死的 code="246813"。

中转有「一个配对码只属于一台设备」的规则:
  第 1 台(跑局域网)注册成功
    -> stop_agent 杀掉进程,但**中转还以为它在线**(last_seen 没过期)
  第 2 台(跑中转)用同一个码注册 -> **被拒**
     中转日志:配对码 246813 已被 ... 占用,拒绝 ...
  而 start_agent 只检查"有没有设备在线" —— 第 1 台僵尸还挂着,
  于是**以为新的一台登记成功了**
  -> 没有任何进程在回包 -> 超时

对照实验(新增 _build/verify_code_reuse.py):
  场景 A 同一个码   -> **不通**
  场景 B 各用不同码 -> 通

修法:两条路各用不同码;start_agent 的判据从"有没有在线设备"
      改成"有没有**配对码正好是这一台要用的码**的在线设备"。

== bug 2:attach 用的还是旧码(我第一版补丁也漏了这处)==

relay_connect() 里 `client.attach(code)` 用的还是局域网那个码 ——
attach 到一台已经死掉、但中转以为在线的设备上,pair 自然没人回。

修成 `attach(relay_code)`。

== 顺带更新一段过时注释 ==

原文写着「当前版本经中转只稳定支撑前 1~2 次交互……这是中转链路的
产品 bug」,并据此把中转的轮数调小、超时调短。

那个 bug **已经修好了**(根因:一条 HTTP/1.1 连接被长轮询和发命令
两个线程同时用,撞状态机抛 CannotSendRequest("Idle"),
异常冒到 _client_loop 外面把 client-relay 线程打死)。
注释不更新会误导后人以为产品还有这个问题。

== 修好之后的实测(本地中转,30 次/路)==

    局域网  握手 16ms  成功 30/30 (100%)  中位  0ms  P90  0ms  最大  1ms
    中转    握手 46ms  成功 30/30 (100%)  中位 15ms  P90 29ms  最大 32ms

这是本地中转,量的是**协议本身的开销**(长轮询往返 vs 直连 TCP);
真实跨网络那条腿的 1.0~1.5 秒来自隧道和 4G,是另一回事。

结论:局域网确实是"毫秒级、不耗流量"的那条根本路径 ——
中位 0ms vs 15ms,而且不走任何外网。

踩坑记录:这两处都是"看起来像产品 bug、实际是测试自己的 bug"。
先怀疑测量工具,比先怀疑产品更省时间 ——
上一次(管理页缓存头)也是我拿 curl -I 测 GET 白查了半天。
2026-10-07 18:20:57 +08:00
xmz7891 aac43e559d 修正 MTU 测试的过时断言(1500 -> 1400)
test_net_tune.py 原来断言 net-tune.sh 里写着 mtu 1500,
依据是更早一次实测「接口 MTU 降到 1400 会掉到 50%」。

那个结论后来被推翻了 —— 当时漏了一件事:
**MSS 钳制只管 TCP,QUIC 走 UDP 完全不受保护。**
路径 MTU 实测 1472(1500 的包不通),QUIC 默认发 ~1452 的包会被
中间设备静默丢掉,而 UDP 丢了不回 ICMP,只能等超时。
把接口 MTU 压到 1400 之后,QUIC 建齐 10 条连接从 28 秒缩到 12 秒。

所以现在是**故意**设 1400。断言跟着改,
不然它会永久假失败,还会诱导后人把真正的改进改回 1500。

顺带记录:全量回归 38 个阶段全部通过。
新增的三个阶段(局域网优化 / 手表局域网切换 / 编码与换行 /
管理页设备控制)都是这轮加的。
2026-10-07 18:10:30 +08:00
xmz7891 8880e7033d 修「经中转几次后永久哑掉」——「按一下没反应」的真因
== 现象 ==

用户报:
  * 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
  * 走中转「已配对」,但点按钮没反应

局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。

== 证据链(插桩复现出来的)==

被控端日志里直接抛了未捕获异常,线程当场死:

    Exception in thread client-relay:
      server.py:407  session.send(reply)
      relay.py:166   self._link.send_to_client(data)
      relay.py:357   self._post("/v1/agent/send", ...)
      relay.py:766   raise RelayError(f"请求失败: {last_error}")
    lanremote.relay.RelayError: 请求失败: Idle

之后的状态:
  * client-relay 线程没了 —— 电脑再也不往手表回任何东西
  * relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
  * **手表看到的就是「连上了,但按一下毫无反应」**

写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。

== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==

_post() 里锁只包住「取连接」那一段:

    with self._http_lock:
        connection = self._http      # 锁到这里就放了
    try:
        connection.request("POST", ...)   # 锁外发请求

于是两个线程会同时用同一条 connection:
  * relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
  * client-relay 线程同时 POST /v1/agent/send

HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。

_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。

修法:
  * **发命令用独立连接**(_post 加 dedicated 参数)。
    长轮询那条常年被占着,发命令不该去挤它,各走各的。
  * 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
    不再只锁取连接那一下。
  * 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
  * _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
    (否则那条连接会被永久标记为忙)。

== 根因 2:回包失败会打死整个会话线程 ==

server.py 的 _client_loop 里:

    if reply is not None:
        session.send(reply)      # <- 不在任何 try 里
    ...
    except OSError:              # <- 外层只捕获 OSError
        pass

而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。

修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。

== 修复效果 ==

    修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
    修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常

_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**

这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。

== 同批修掉验证阶段找到的其它问题 ==

1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
   register() 用 `device_id in self.devices` 判断老设备,但超时清理
   把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
   第二天开机就会被自己的开关挡住,而且**重启也救不回来**
   (清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
   管理员在页面上还看不到这台设备,不知道该放开谁。
   修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
   register 改查这份历史;清理后补 _save();delete_device 才真正忘记。

2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
   admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
   `\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
   整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
   元凶是我加改码功能那次提交。改成 `\\n`。

3. **--relay / --relay-status 崩溃**(严重)
   RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
   run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
   1 required positional argument」(用户以为地址填错了),
   一处直接报栈。两处都补上 url。

4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
   字母数字 + - _ . :)

5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
   返回值反映真实运行状态;不再用「对象建过没有」判断)

6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节

== 局域网全面优化(task-2,另一个智能体完成)==

4 个真问题:
  * **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
    被判成不同网络,明明能直连却去走中转还费流量。
    改成按网卡真实掩码比较。
  * **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
  * **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
  * **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
    新增定期回切(先便宜地 TCP ping,通了才动真格,
    不破坏正在工作的中转连接)。

实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
  现已修复。)

顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。

== 服务端一键部署脚本(task-1,另一个智能体完成)==

relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
  * 自动检查并安装 cloudflared
  * 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  * **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
    不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
    不对外开放,绝不能把私人域名烧成默认值)
  * 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动

顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。

== 默认中转地址改成空白 ==

用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。

== 文档同步(task-5,另一个智能体完成)==

README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
  * 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
  * 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
  * 删掉「地址已经内置好了」(现在是错的)
  * AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
  * RELAY_README 原来写「两条隧道同时跑会互相争抢」,
    和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
2026-10-07 17:59:39 +08:00
xmz7891 b2bfa6ffbf 长轮询客户端超时 120 -> 180 秒(实测出现 126.6 秒的长尾)
relay-stability 跑了 30 次真实长轮询,第 1 次耗时 126604ms:

    服务端 hold 是 60 秒
    剩下 66 秒全是网络停顿(4G 抖动 + 重传退避)
    而客户端超时写的是 120000ms
    -> 126.6 > 120,那次会被**误判成失败**,尽管服务端已经正常返回

其余 29 次都是 61~62 秒,很正常。不是 hold 太长,是网络尾巴太长。

为什么放宽超时而不是缩短 hold:
  A. hold 缩到 45 秒、超时保持 120 -> 最坏 111 秒,安全
     但请求数多 33%,而每次请求都要吃隧道 1~2 秒开销
  B. hold 保持 60 秒、超时放宽到 180 -> 最坏 180 秒,安全
选 B —— 这条链路的根本问题是丢包抖动,**请求越少越不容易撞上**。
放宽超时只是"别把慢但成功的请求冤枉掉",不增加任何请求。

客户端已有"连续失败 3 次才判离线"(OFFLINE_AFTER=3),
所以偶尔一次长等待不会让界面闪"已断开"。

超时余量的演进(全是实测逼出来的):
  90 秒  -> 实测 93589ms  不够
  120 秒 -> 实测 126604ms 不够
  180 秒 -> 60 hold + 120 余量

两边同步改:
  windows/lanremote/relay.py                     POLL_TIMEOUT 120 -> 180
  android .../RelayTransport.java                POLL_TIMEOUT_MS 120000 -> 180000
(Java 侧确认 setConnectTimeout 另有 15 秒上限、setReadTimeout 吃这个值,
  没有别的超时会先把请求掐掉。)
2026-10-07 17:36:20 +08:00
xmz7891 facb156aca 服务端 zip 加一键部署脚本(cloudflared 安装 + 部署 + 公网,全自动)
用户要求:服务器端文件里放一个 .sh,双击就能检查 cloudflared 装没装、
自动部署、配好公网,以后全自动。

== 新增 ==

relay/deploy.sh        一键部署主脚本,7 步全自动:
                       环境检查 -> 装 cloudflared -> 部署程序 -> 网络调优
                       -> 写 4 个 systemd 服务 -> 配隧道 -> 启动自检打公网地址
relay/deploy-tune.sh   网络调优,可单独重跑:
                       keepalive 30/10/3 + 路径 MTU 探测 + MSS 1360
                       + 接口 MTU 1400 + mask networkd-wait-online
                       每一项都写明了实测依据

用法:
    bash deploy.sh                           复用已有凭据,全自动
    TUNNEL_TOKEN=eyJh... bash deploy.sh      Token 模式
    bash deploy.sh --hostname relay.你的域名  换域名
    bash deploy.sh --skip-tunnel             只装中转
    bash deploy.sh --help                    全部参数

重跑 = 升级;已有数据/配对码/管理密码/凭据/手改过的 config.yml 都不动。

== 自动化到什么程度 ==

* cloudflared 不在就自动下载装上(并校验能执行)
* 隧道支持两种模式:已有凭据文件、或 TUNNEL_TOKEN
* **不传 --hostname 且没有 Token 时明确提示要填**,不会静默用一个写死的值
  (用户明确说过:这个中转站是他自己用的,不对外开放,
   绝不能把私人域名烧成默认值)
* deploy-tune.sh 的 MTU 取 1400:路径 MTU 实测 1472,
  而 MSS 钳制只管 TCP、QUIC 走 UDP 完全不受保护;
  接口 MTU 降到 1400 后 QUIC 建齐 10 条连接从 28 秒缩到 12 秒

== 顺带修掉两个真 bug ==

1. write_tunnel_unit 写的文件名漏了 .service 后缀 ——
   文件写下去了但 systemd 完全看不见,enable 还会安静地失败。
2. 发布包一直缺 site_shots/(老 bug):
   package_remote.py 用 iterdir() + is_file() 收文件,目录进不去。
   新机器装完官网截图全是 404。只有从 zip 解压真跑一遍才暴露。
   同时把 deploy.sh 里 site_shots 的检查降级成警告(缺截图不该让部署失败)。

另外给 deploy-tune.sh 清旧 MSS 规则的循环加了 10 次上限 ——
原写法在 -D 报成功但规则仍在时会死循环把整个部署卡死。

== 验证 ==

* _build/test_deploy_sh.sh        沙箱 65 项,全通过
* _build/test_zip_deploy.sh       从发布 zip 解压再跑,30 项,全通过
* _build/test_deploy_real.sh      **真机 Debian 上实测**(新增):
    zip 解出 17 个文件 -> 语法/LF/无 BOM 通过
    --help 退出码 0 且无副作用、未知参数退出码 2
    沙箱安装成功、幂等重跑哨兵不被冲
    现有三个中转服务全程 active,自检 200
2026-10-07 17:28:09 +08:00
xmz7891 d4f335b8f9 管理页新增:允许/禁止新设备接入 + 修改设备配对码
== 功能一:允许新设备接入开关 ==

关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。
状态存在 relay_state.json 里,重启不丢。

判断新设备用的是 deviceId,不是配对码 ——
这一点很关键:管理员给老设备改过码之后,
那台设备必须仍然能进来;按码判断的话它会被自己的开关挡在外面。
测试里专门有一条 A3b 守这个行为。

被拒时回 blocked=True,让客户端能区分被管理员挡了
和配对码冲突,而不是去匹配中文错误文案(文案一改就坏)。

== 功能二:修改任意设备的配对码 ==

管理页每台设备一个「改码」按钮,填 6 位数字即生效。
要点:
  * 新旧码冲突检查
  * 旧码映射必须删掉,否则变成孤儿条目,
    管理页配对码占用的数字会虚高
  * 改码踢掉该设备当前会话(会话绑在码上)
  * 立刻持久化

== 顺手修掉两个真 bug ==

1. 改码功能一上线就 500:Session 用了 __slots__,
   根本没有 session_id 属性(会话是以 token 为 key 存在 dict 里的),
   写 s.session_id 直接 AttributeError。
   改成遍历 sessions.items() 拿 key。

2. snapshot() 里有同样的错(清理超时设备那段):
   平时 stale 列表是空的所以从没炸过,一旦真有设备超过 6 小时
   没上报就会 500。这次一起修了。

== 管理页 UI ==
  * 设备小节加了「允许新设备接入」勾选框
  * 每行加「改码」按钮(走 prompt,不引入弹窗层)
  * 改码按钮用 data-recode 而不是 data-device ——
    现有单台删除按 button.danger[data-device] 绑,
    带上 data-device 会被当成删除按钮,点改码变删设备。
    测试 C6 专门断言这一点。

== 测试 ==
新增 _tests/test_admin_device_control.py(23 项),
已注册进 _tests/verify_all.ps1 第 3e 阶段。
2026-10-07 17:01:14 +08:00
xmz7891 25e7780272 单次请求失败不再立刻判离线,改成连续 3 次
实测单个请求有 2~4% 的偶发失败(4G 丢包打在一条隧道连接上,
或者那条连接正在重连)。原逻辑是一失败就 online = false,
于是界面上会闪一下已断开 —— 用户点按钮时正好撞上那一闪,
看起来就像又连不上了。

而链路其实 1.5~15 秒就自己恢复(客户端本来就有退避重试)。

改成连续失败 3 次才判离线(手表至少 4.5 秒、电脑至少 6 秒):
  * 单次抖动完全不影响界面
  * 真的断了仍然如实显示

这不是掩盖问题,而是把是否可用的判断从一次采样改成连续采样 ——
单点采样在有丢包的链路上噪声太大。

两边同步改:
  android .../RelayTransport.java   failStreak + OFFLINE_AFTER
  windows/lanremote/relay.py        _fail_streak + OFFLINE_AFTER

顺带记录 PC 端基准(真实用户视角,之前都是在设备上量的,
那等于把设备 4G 算了两遍):
  公网可用率 98%   耗时 中位 911  P90 1889  P99 23868 ms
  长轮询     5/6   耗时 60.8~62.1 秒
对比设备端(中位 1344 / P90 2563)—— 用户实际体验比设备端量出来的好。
2026-10-07 16:16:50 +08:00
xmz7891 aea78efe45 版本 1.8.1 -> 1.8.2 2026-10-07 15:44:49 +08:00
xmz7891 bf15a2ee61 静态资源交给 Cloudflare 边缘缓存(下载提速 41 倍)
实测基线:
  ykxmz-setup-1.8.1.exe   只下到 237568/15067199 字节就停(17 KB/s)
  ykxmz-watch-1.8.1.apk   201523 字节 / 66.8 秒 = 3 KB/s
  ykxmz-server-1.8.1.zip  214900 字节 / 3.1 秒  = 67 KB/s

原因:relay 给所有响应都发 Cache-Control: no-store,
Cloudflare 完全不缓存 —— 每次下载/每张图都要穿隧道回 4G 源站。

改动:
  /download/*   no-store -> public, max-age=604800, immutable
                文件名带版本号,同一名字内容永不改变,可以长缓存
  /             -> public, max-age=60(挡住刷新重复回源)
  /favicon.ico  已是 max-age=86400,保持不变
  /shot/*       已是 max-age=86400,保持不变
  /v1/*         保持 no-store(接口必须实时)

顺带修两个自己踩的坑:

1. _send() 无条件先发 no-store,再追加 extra 里的 Cache-Control,
   结果是**两个 Cache-Control 头**,客户端只认第一个 ——
   调用方传的缓存策略被吃掉。改成 extra 里有就不再发默认值。

2. do_HEAD 和 do_GET 的缓存头不一致:HEAD 对非 /shot/ 一律报 no-store。
   下载器/代理用 HEAD 探测时会误判文件不可缓存。
   现在 _head_info 给每个路由带上和 GET 完全相同的缓存头,
   do_HEAD 也不再盲目覆盖。

结果(同一个测试口径):
  setup.exe  17 KB/s(截断) -> 695 KB/s 完整 15 MB   41 倍
  watch.apk   3 KB/s         -> 101 KB/s              33 倍
  server.zip 67 KB/s         ->  87 KB/s
  三个文件全部 cf-cache-status=HIT(不再穿隧道回源)

排查过程记一笔:我一开始用 curl -I 验证,
看到的全是 no-store —— 因为 **-I 发的是 HEAD**,
而 HEAD 走的是另一个分支。查了半天才发现是验证方法错了。
2026-10-07 15:44:48 +08:00
xmz7891 46196c3618 CHANGELOG 加 1.8.1 2026-10-07 14:24:06 +08:00
xmz7891 b1119d0da6 修「局域网连接被中转劫持」+ 长轮询 15s→60s
用户报两个问题:
  1. 局域网能搜到电脑,IP/端口/配对码都对,点连接**没有任何反应**
  2. 走中转 1426ms 延时,一会儿就 502

========== 一、局域网被劫持(真 bug)==========

MainActivity.connect() 里:

    boolean usedLan = !relayUrl().isEmpty() ? false : true;
    if (!relay.isEmpty()) {
        reply = client.connectViaRelay(...);      // 走中转
    } else {
        reply = client.connect(host, port, ...);  // 才走局域网
    }

**只看「中转设置里填没填地址」,填了就一律走中转,
完全无视用户填的局域网 IP 和端口。**

而中转单次超时是 40 秒 —— 用户点完就卡在「正在连接…」
四十秒,在他看来就是"没有任何反应"。

影响两条路:
  * 「局域网连接」页面填 IP 直连(connectLanTyped)
  * 首页「已配对的电脑」点一下重连(quickConnect)

修法:给 connect() 加 preferLan 参数。明确要走局域网时
**先直连(6 秒超时,局域网本该是毫秒级),失败再退回中转**,
保证最终能连上。

顺带两个体验改进:
  * 设备列表里,保存的 IP 和手表不同网段时标「⚠ 地址可能已过期」
    (用户截图里那个 192.168.2.64 就是过期地址,电脑早换成
      192.168.10.103 了)
  * quickConnect 判断出地址过期就跳过直连,直接走中转查最新地址 ——
    省掉那 6 秒白等

========== 二、长轮询 15 秒 -> 60 秒 ==========

实测隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源):

    本机直连 60 秒的 poll  ->  60.0s
    走公网同一个 poll      ->  61.5~62.0s

这个开销是**按次数**收的:
    15 秒一循环 -> 一天 5760 次/端,光路费 2.4~3.2 小时
    60 秒一循环 -> 一天 1440 次/端,光费 0.6~0.8 小时
而且请求越频繁,撞上丢包/重连窗口的概率越大。

**不会让命令变慢**:长轮询是"有数据立刻返回"(device.event 唤醒),
拉长只是减少空转重开。实测 60 秒长轮询穿隧道 4/4 通过;
Cloudflare 单请求上限 100 秒,留了 40 秒余量。

三层同步改(客户端超时必须显著大于服务端 hold):
  * relay/relay.py                    RELAY_POLL 默认 25 -> 60
  * windows/lanremote/relay.py        POLL_TIMEOUT 40 -> 90
  * android .../RelayTransport.java   POLL_TIMEOUT_MS 40000 -> 90000

========== 三、A/B 实测(还在跑更严谨的一轮)==========

短测发现基线 http2+4 虽然 100% 成功,但 **P90 是 16.8 秒**
(每 10 次有 1 次卡十几秒 —— 正是用户说的"没反应"),
而 quic+6 的 P90 只有 2.8 秒。正在用真实长轮询模式
跑 10 分钟对照确认。
2026-10-07 14:11:33 +08:00
xmz7891 94252ca156 加 EzHub SSH 诊断报告(给服务端运维看)
结论:EzHub 的 22 端口上是**系统 OpenSSH 9.6**,不是 Forgejo 内置的
SSH 服务。Forgejo 把公钥存在自己数据库里,不会写进
git 用户的 authorized_keys,而 sshd 也没配 AuthorizedKeysCommand
去问 Forgejo —— 所以网页上加了公钥也不生效。

判定依据(客户端就能测出来):
  1. ssh -vv 显示 remote software version = OpenSSH_9.6p1 Ubuntu
     (内置 SSH 会带自己的标识,且只支持 publickey,不提供 password)
  2. 服务端声明 Authentications that can continue: publickey,password
     —— password 是系统 sshd 的默认行为
  3. 只有 22 端口开,2222/2022 都不通(内置 SSH 默认在 2222)
  4. 网页注册的密钥指纹和客户端完全一致,客户端也确实送出了公钥,
     但仍被拒

报告里给了两种修法(开启内置 SSH / 给 sshd 配 AuthorizedKeysCommand)、
先手动验证命令能不能跑通的办法、以及 /var/log/auth.log 该看什么。
也说明了这不紧急:HTTPS + 令牌目前完全可用。
2026-10-07 12:56:47 +08:00
xmz7891 9692ef1f32 修 Gitee 附件清理:assets 字段没 id,要用 attach_files 接口
Gitee 的坑:GET /releases/{id} 返回的 assets 里
**只有 name 和 download_url,没有 id**,
照着它删是删不掉的(之前静默失败,附件一路堆到 11 个)。

必须换接口才拿得到 id:
    GET    /releases/{id}/attach_files        -> [{id, name, size}]
    DELETE /releases/{id}/attach_files/{id}

已清理:Gitee 11 -> 5 个(含 2 个自动生成的源码包),
EzHub 5 -> 3 个。清理动作也加了重试(网络会抖)。
2026-10-07 12:54:33 +08:00
xmz7891 ae7e1cf269 Gitee 也做成幂等:已存在就复用 + 先删同名附件
重发时 Gitee 直接 400「该标签已经存在发行版」,
而且拿不到 id,后面的附件也传不上。

改成和 EzHub 一样的流程:
先按标签查已有的(查到就复用 id),复用前删掉同名旧附件,再传。
这样重跑多少次,两边的附件数都稳定。
2026-10-07 12:37:06 +08:00
xmz7891 2425f8694e 修发行版重复附件:重发前先删同名
Gitea 的发行版复用逻辑有 bug:发现标签已有发行版就复用,
但**没有先删同名附件**,于是每跑一次就多传一份。
调试期间同一个文件在 EzHub 上堆了 3 份(共 8 个附件,实际只要 3 个)。

修法:复用已有发行版时,先列出它的附件,
把和本次要传的同名的删掉,再传。

配套加了:
  * clean_ezhub_assets.py  清理已堆积的重复(同名只留最新,支持 --dry)
  * verify_releases.py     对账两个平台的发行版(标题/说明/附件/
                           管理页字眼/两边说明是否一致)

已清理:EzHub 从 8 个附件降到 3 个。
2026-10-07 12:36:35 +08:00
xmz7891 4b545ea324 加 EzHub SSH 密钥诊断脚本
诊断结论:EzHub 的 SSH 服务是**系统 OpenSSH 9.6**(banner 看出来的),
不是 Gitea/Forgejo 内置的 SSH 服务。
密钥在 EzHub 上注册的指纹和本机完全一致
(SHA256:Acmu49YQcjw3m5WefOXSBzYNY2WPHLL2vvhnlYRAttA),
但服务端一直返回 Permission denied (publickey,password)。

说明 git 用户的 authorized_keys 没有跟 Gitea 数据库同步 ——
这是服务端配置问题,客户端怎么试都过不去。
服务端要做的(任选其一):
  a) 开启内置 SSH:app.ini 里 START_SSH_SERVER = true 并放行端口
  b) 继续用系统 OpenSSH,但给 sshd 加 AuthorizedKeysCommand
     指向 gitea/forgejo 的 keys 子命令

PS:这个实例的 UI 主题是 forgejo-auto,实际是 Forgejo。
2026-10-07 12:35:10 +08:00
xmz7891 f200240730 发布脚本:EzHub 走 HTTPS+令牌推送(SSH 还没配好)
EzHub 的 SSH 公钥在服务端一直被拒(Permission denied),
但令牌有 push 权限。所以发布脚本里 EzHub 改用
HTTPS + 令牌推,令牌只在进程内拼进 URL,不写进 .git/config。

顺带加了增量推送脚本 ezhub_push_all.py:
一次性推 44 个提交 = 117 MB,被 EzHub 的 nginx 413 拦了。
Git 推送是增量的,逐个提交推每包就小得多 —— 实测 44 个全部推成功。
脚本会先探测远端进度,只推缺的,并且对网络抖动自动重试。
2026-10-07 12:32:11 +08:00
xmz7891 9f9db53a06 文档补充 EzHub 令牌权限坑和 SSH 实际状态 2026-10-07 12:25:27 +08:00
xmz7891 d60dfcf671 发布脚本:提前检查 EzHub 令牌写权限
Gitea 令牌可以只给读权限。只读令牌能读仓库、能列发行版,
看起来一切正常,但一推代码就 403、一建发行版也 403。

加了 check_ezhub_scope():开头就用一个**无副作用**的探针确认权限
(对不存在的发行版发 DELETE —— 403 说明缺 write:repository,
404 说明权限过了只是资源不存在),缺权限就立刻停下并给出
明确的修复指引,不让它走到一半才炸。

文档里也写清了这个坑。
2026-10-07 12:25:12 +08:00
xmz7891 2d99c05865 加双平台发布配置指引(Gitee + EzHub) 2026-10-07 12:20:17 +08:00
xmz7891 ad8b73f990 清理临时文件 2026-10-07 12:19:58 +08:00
xmz7891 4de2255ee4 加 EzHub(Gitea)发布支持 + 一次发两个平台的脚本
用户要求:除了 Gitee,还要发到朋友自建的代码托管平台 EzHub
(ssh://git@git.261826.xyz/xmz/ykxmz.git),release 也要建。

探测结果:EzHub 是 **Gitea 1.22.0**
(/api/v1/version 返回 16.0.5+gitea-1.22.0),
仓库 xmz/ykxmz 存在,目前还没有任何发行版。

Gitea 和 Gitee 的接口差别(踩过的点):
  项目           Gitee                  Gitea / EzHub
  API 前缀       /api/v5/repos/...      /api/v1/repos/...
  认证           参数 access_token      请求头 Authorization: token
  传附件路径     /releases/{id}/        /releases/{id}/
                 attach_files           assets?name=<文件名>
  附件字段名     file                   attachment
  草稿字段       无                     draft / prerelease

新增:
  * _build/ezhub_release.py   单独发 EzHub 用
  * _build/release_all.py     一次发两个平台(推荐)
      - 前置检查:产物齐不齐、更新说明能不能取到、工作区干不干净
      - **对外口径检查**:更新说明里出现管理页字眼直接拒绝发布
      - 推代码到两个 remote,再分别在两边建发行版 + 传三个附件
      - 支持 --dry 演练(不真的发),已经过通

修掉的 bug:
  CHANGELOG 解析。按 "\n## " 切分时第一块带着自己的 "## 版本号"
  (前面没换行),后面几块没有 —— 原来只写 startswith("1.8.0"),
  对第一块永远匹配不上,全靠"取不到就退回第一块"的兜底碰巧对了。
  现在先把开头的 # 去掉再比。

SSH:
  为 EzHub 单独生成了一把密钥(~/.ssh/id_ed25519_ezhub),
  不复用 Gitee 那把,权限隔离。已写好 ~/.ssh/config 的 Host 段。
  **公钥需要用户加到 EzHub 账号里之后才能推。**
  私钥在仓库外,不会进版本库。
2026-10-07 12:19:58 +08:00
xmz7891 2a0d9e967d 清理临时文件 2026-10-07 12:15:34 +08:00
xmz7891 351171e6ea 清理对外材料里的中转管理后台 + 把规矩变成测试
用户定的新规矩:
「你对外公开的数据里不要包含那个中转站管理的页面,你就只能提官网」

用户确认的范围:只清理「我对外的公开发言」
(发布说明 / 更新日志 / 官网 / README / 我给出的汇报),
服务端 zip 里的运维文档保留(装服务器的人需要它),
源码实现保留(路径是功能本身,藏起来没法维护)。

清掉的内容:
  * CHANGELOG.md:1.8.0 条目里"管理后台重做"整段、
    "管理页显示成一屏源码"、"管理页标题被切掉";
    1.7.1 条目里**直接写出的路径 /zhongzhuanzhan**、
    "管理入口从官网上移除";1.7.0 的"管理页加图标"
  * RELEASE-1.8.0.md:第四节「中转管理后台完全重做」整节删掉,
    改成「官网重新设计」

新增 _build/test_public_clean.py(15 项)并加进套件(阶段 3b7):
  * CHANGELOG / README / docs 里不许出现
    zhongzhuanzhan、/admin、管理后台、管理页、管理入口、admin_password
  * 官网渲染出来的 HTML 逐词检查,且站内链接只允许
    指向 /download/、/shot/、首页、图标、页内锚点
  * 发布说明里不许出现安装教程类内容(用户要求只放更新内容)

  为什么写成测试:文档没人看。这一天里我因为"以为记住了"
  翻了三次车,所以把规矩变成会红的断言。

新增 docs/项目规矩.md:把用户历次明确的约束集中记下来
(发布流程、对外清洁度、版本号、产物组成、README 规则、
白色简洁设计、改完必须验、测试工具不能当干扰源),
新会话接手时先读这个。

顺带修的工程问题(今天反复踩的坑):
  * 用 PowerShell 的 @"..."@ 嵌 Python 去改 .ps1 时,
    PowerShell 会先把 $var 展开成空,写进去一堆反斜杠
    -> verify_all.ps1 被写坏,& 语法报错
    现在改 .ps1 一律用 Python 读写文件
  * re.sub 的替换串会解释反斜杠转义,
    `"\\test_public_clean.py"` 里的 \t 变成制表符,
    路径成了 "_build<TAB>est_public_clean.py"
    -> 用 lambda 替换,避开转义

验收:全量回归 34 个阶段全部通过(含新的清洁度守卫)。
2026-10-07 12:15:32 +08:00
xmz7891 2f70e5bb49 加 1.8.0 发布说明 2026-10-07 11:47:23 +08:00
xmz7891 49fdec1da8 1.8.0:隧道稳定性大修 + 中转管理后台重做
版本号 1.7.1 -> 1.8.0(8 个文件,13 处)。
写了个 bump_180.py 统一处理 —— 版本号散落在
Python / Java manifest / PowerShell / Inno Setup / 测试里,
手动改必漏;而且 .ps1 必须保留 UTF-8 BOM。

==================== 一、隧道稳定性(用户撞上 1033/502/530)====================

1) 4G 链路会**静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着),
   Linux 默认要 2 小时才发现 -> 改成 keepalive 30 秒 + 每 10 秒探测。
   实测 60 秒内发现并重连。

2) 隧道连接数 2 -> 4。只剩 1 条连接时跨机房回源会失败
   (实测可用率 0/12,提到 4 条后 12/12)。

3) 掉电重启后恢复慢:原来开机 2 分 10 秒公网才可用。
   根因是 systemd-networkd-wait-online 等一个不归它管的网络
   (wlan0 是 NetworkManager 管的),干等 2 分钟超时失败,
   把 network-online.target 一起拖住。
   mask 掉它之后:启动 17.3 秒,掉电重启 24 秒公网可用。

4) 看门狗两次翻车都是我造成的,都修了:
   a) 我先加了"确认故障立刻重启",但隧道重建 4 条连接要 30~60 秒,
      MIN_RESTART_GAP=45 导致**还没建齐就被下一次重启打断**,
      死循环,每 50 秒重启一次 -> 加 150 秒宽限期
   b) 宽限期又带来"链路持续差时什么都不做"的问题
      -> 增加"本次重启后建齐过吗"的判断:
         建齐过又垮 = 真故障,立刻重启
         从没建齐 = 还在努力,继续等

5) 顺手修:看门狗状态文件里的变量在函数定义前被引用,
   set -u 下报 "BUILT: 未绑定的变量"。

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

原来的管理页是几百行内联在 relay.py 的 f-string 里,
改一处样式要在一堆转义花括号里翻。拆成独立模块 relay/admin.py。

新设计(对齐官网那套白色简洁):
  吸顶顶栏 -> 公网地址(最大权重,这是用户最需要复制的)
  -> 四个数字卡 -> 设备表 -> 工具卡(体检 + 下载)
  空状态给图标+引导文案;窄屏表格自动变卡片。
  relay.py 从 2089 行缩到 1500 行左右。

修了三个我自己引入的 bug:
  a) 响应头类型写错,浏览器把整页 HTML 当纯文本显示
     (用户看到的是"一屏源码")。教训:200 不等于页面能看,
     当时只验了状态码和字节数。
  b) 顶栏用了固定 height:62px,装不下"标题+副标题+版本信息"两行,
     内容被顶出容器 -> 标题看不见、徽章压在字上。
  c) do_HEAD 没处理 /download/ 路径,HEAD 一个 APK 返回
     Content-Length: 0 + text/plain,下载器会误判成空文件。
     同时发现 _head_info 里对 bytes 调 .encode() —— 加 _as_bytes() 兜住。

==================== 三、其它 ====================

* 设备记录只增不减:实测一天攒 75 条僵尸,/v1/health 从几 KB 涨到 20KB。
  加 DEVICE_TTL(6 小时)自动清,占用的配对码一起释放
  (管理页曾显示"配对码占用 131"但一台设备都没有)。
* HEAD 请求支持(原来一律 501)。
* 官网新增产品展示:手表界面预览(颜色/布局与真实手表一致)、
  电脑端真实截图、两种连接方式对比图。
* 加了一份 docs/免费托管方案.md(含一个纠错:上一版推荐的
  ClawCloud Run 已在 2026-05-11 关停)。

==================== 验收 ====================

全量回归            33 个阶段全部通过
安装包完整周期      20/20(静默装入 7 秒、卸载 0.6 秒、无残留)
网页响应头          12/12
公网可用率          26/30 = 86%(底层 4G 丢包 30%,是这个上限)
发布产物            安装包 14.37MB / 服务端 0.20MB / 手表 0.19MB
2026-10-07 11:47:12 +08:00
xmz7891 c6e2120ca8 修正免费托管方案:ClawCloud 已关停,换掉整个推荐
用户追问 ClawCloud 的 \ 是每月还是一次性。
核实过程中发现:**ClawCloud Run 已经在 2026-05-11 关停了**,
官网/文档/博客全部下线,免费套餐服务全部停止,只给付费用户退款。
至今已停运 5 个月。

我上一版文档推荐了它 —— 因为搜到的文章是 2025 年的,
我只看了内容没核对日期。这个错误必须纠正。

重新核实了整个免费托管生态(依据 2026-09-26 逐条验证过的
第三方对比 + 各厂商官方页),结论:

2026 年这一波集体缩水:
  2026-02       Koyeb 取消免卡套餐
  2026-04-23    Render 免费带宽 100GB -> 5GB
  2026-06-15    Oracle 免费 ARM 砍半 4核24G -> 2核12G(无公告)
  2026-07       Hugging Face Docker Space 转付费
  2026-09-16    Vercel Hobby 只留最近 3 个部署
已经死掉的:ClawCloud、Fly.io、Glitch、Heroku、Deta、Cyclic。

现在的推荐(重排):
  1. Oracle Always Free —— 唯一永久方案,2核12G 仍远超我们所需
     (我们只用 23MB 内存 / 0.1% CPU),但需要实体信用卡,
     且亚洲区 ARM 常年缺货
  2. Render 免费版 —— 不用卡的最佳选择。750 小时/月
     刚好够一个服务 24/7;自带 HTTPS 和 2 个自定义域名;
     最关键:被控端每 25 秒轮询,永远不会触发 15 分钟休眠
  3. Cloudflare Workers + DO —— 技术最优但要用 JS 重写

文档里还记了一条经验:免费、好用、长期是不可兼三角。
选免费方案要问商家靠什么赚钱、数据能不能搬走、备选是什么。
我们的 relay.py 零依赖随时能搬,这就是最大的抗风险能力。
2026-10-07 11:11:26 +08:00
xmz7891 494b6e5671 加一份免费托管方案调研文档
用户问:网上有没有免费办法托管中转服务。

查了协议架构:全是 HTTP 长轮询(agent/register、agent/poll、
agent/send、agent/bye),没有裸 TCP —— 所以托管选择面很宽。
另外 relay.py 是纯 Python 标准库、零第三方依赖、仅 150 KB、
实测内存 23 MB / CPU 0.1%,任何免费实例都装得下。

关键洞察:一旦有了公网 IP,**根本不需要 Cloudflare 隧道**,
现在所有 1033/502/530 都是隧道这一跳的问题,换过去就从根上消失。

文档里对比了 6 个方案(永久性、是否需公网 IP、是否要信用卡、
亚洲节点、能否免隧道),推荐 Oracle Cloud Always Free 首选
(永久免费 + 真公网 IP + 东京/首尔节点),
ClawCloud Run 作为不用信用卡的备选。
还写了具体的搬迁步骤和要删掉的组件。
2026-10-07 11:07:24 +08:00
xmz7891 5bf108f1f8 修管理页顶栏被切掉(标题看不见、徽章压在字上)
用户截图圈出顶栏:主标题「ykxmz 中转站」被切掉看不见,
「运行中」徽章压在副标题上。

根因:
  .top-in 用了固定 height:62px。
  但顶栏内容比 62px 高:
    左边品牌是「标题 + 副标题」两行
    右边是「版本信息 + 累计请求」两行
  flex 容器固定高度装不下,内容就被往上顶出去了 ——
  于是标题被切在容器外,徽章挤到文字上。

修法:
  * height:62px 改成 padding:13px 0 + min-height:36px
    (内容多高就是多高,别再写死)
  * 给 logo / brand / state 都加 flex:none,别被压缩
  * 标题副标题加 white-space:nowrap,避免换行错位
  * 右上的版本信息允许收缩(min-width:0)
  * 窄屏(<640px)时状态徽章换行,不跟标题挤

实测:1200px 和 700px 两档宽度下顶栏都正常,
标题、副标题、徽章、右侧信息全部各就各位。
2026-10-07 10:48:28 +08:00
xmz7891 2b204cae07 修看门狗重启死循环(我上一版改过头了)
用户报 502 Bad Gateway,Cloudflare 的图显示
「Host: relay.xxmxxs.top - Error」,也就是源站/隧道这一跳挂了。

查 watchdog 日志,发现它在**每 50 秒重启一次隧道**:
    [10:31:57] 只剩 1 条连接 -> 重启隧道
    [10:32:22] 重启完
    [10:32:24] 又只剩 1 条 -> 距上次才 27 秒,先不重启
    [10:32:47] 距上次 50 秒 -> 又重启
    ... 循环不停

根因(我上一版的错):
  隧道在**质量差的 4G 上重建 4 条连接要 30~60 秒**。
  而我上一版把 MIN_RESTART_GAP 设成 45 秒,并且
  "公网不通 + 连接数<=1"就立刻重启 ——
  结果每次隧道还没把连接建齐,就被下一次重启打断,
  **永远停在"只剩 1 条"的状态**。
  等于我自己制造了不稳定。

修法:
  1. 重启后给 GRACE=150 秒宽限期,这期间绝不重启,
     让 cloudflared 专心把连接建齐
  2. MIN_RESTART_GAP 从 45 提到 150(和宽限期一致)
  3. "立刻重启"的判定加一条:距上次重启必须超过宽限期

实测(停掉 watchdog -> 手动重启隧道 -> 观察):
    10 秒内建齐 4 条连接,公网立刻 200
    宽限期生效后公网 20/20 = 100%,延迟 ~690ms

顺带一个教训:之前测出 80%、20% 的 000,
是我自己后台还跑着一个观察任务在并发打接口造成的干扰。
**测稳定性的时候,测量工具本身不能成为干扰源。**
2026-10-07 10:37:26 +08:00
xmz7891 b630028112 看门狗检查间隔 20 秒 -> 10 秒
每次 530 窗口从"出现"到"被看门狗发现并重启",最长要等一个检查周期。
20 秒缩到 10 秒,用户撞上 1033 的概率窗口直接减半。

代价:公网探测从每 20 秒一次变成每 10 秒一次。
这台设备本来就在长轮询,这点流量可以忽略。

设备上已生效(OnUnitActiveSec=10s, AccuracySec=3s)。
2026-10-07 10:27:33 +08:00
xmz7891 f4a146056f 修开机慢 2 分钟:掉电重启后要 2 分 10 秒才能恢复
用户早上撞上 Cloudflare Error 1033。查出来是设备**掉电重启**:
上次开机到重启之间没有任何日志落盘(直接断电的特征),
dmesg 里还有 "l13: voltage operation not allowed"。

重启本身不是软件问题,但**恢复太慢**:
    Startup finished in 3.846s (kernel) + 2min 6.358s (userspace) = 2分10秒
那 2 分钟里浏览器看到的就是 1033。

根因:
    这台机器 NetworkManager 和 systemd-networkd 同时在跑,
    但 wlan0 归 NetworkManager 管。
      NetworkManager-wait-online   -> 开机 9 秒成功
      systemd-networkd-wait-online -> 等一个不归它管的网络,
                                      干等 2 分钟超时,然后 failed
    network-online.target 要等**两个都完成**,
    被那个失败的拖了整整 2 分钟,
    relay / tunnel / net-tune 全跟着等。

修法:
  1. mask 掉 systemd-networkd-wait-online(网络不归它管,只会拖后腿)
  2. watchdog 首次检查从"开机后 2 分钟"提前到 45 秒

实测(重启验证过):
    修之前  Startup finished in 3.846s + 2min 6.358s = 2分10秒
    修之后  Startup finished in 3.218s +   14.060s =   17.3秒
    掉电重启后公网恢复时间:**24 秒**(快了 5 倍多)

配套加了 scripts/speed-up-boot.sh,换设备时直接跑一遍。

顺带确认重启后一切自动恢复:
    三个服务 active + enabled ✓
    keepalive 30/10/3、MTU 探测、MSS 钳制、slow_start=0 全部持久化 ✓
    隧道 4 条连接 http2 ✓
    官网 / 管理页 / 截图全部 200 ✓
2026-10-07 10:27:07 +08:00
xmz7891 bcb089eded 看门狗:连接数掉到 1 就立刻重启,不等攒够 3 次
排查公网间歇 530 时发现的:
  watchdog 日志显示「公网不通(HTTP 530,ha_connections=1)」
  —— 出问题那段时间隧道**只剩 1 条连接**。

原来 watchdog 只把 CONNS == 0 算"零连接",
CONNS == 1 不当回事。但实测**只剩一条连接时跨机房回源就会失败**
(请求落 SEA,隧道只剩 LAX 一条)。

改法:
  * CONNS <= 1 都计入降级计数
  * 公网不通 **且** 连接数已在危险区 = 确认的故障(不是短抖动)
    -> 立刻重启,不等 3 次

每次检查间隔 20 秒,等 3 次就是白断 40 秒。现在能省下来。

实测:改完公网 15/15 = 100%,连接数 4 条。

顺带说明 watchdog 是怎么跑的:
  systemd timer(lanremote-watchdog.timer),每 20 秒调一次
  watchdog.service。之前查"服务是 inactive"一度以为它没在跑,
  其实是 timer 触发型的 —— oneshot 服务跑完就是 inactive,正常。
2026-10-07 10:00:50 +08:00
xmz7891 fe3b9ada12 修管理页 Content-Type(浏览器显示成一屏源码)
用户截图:打开 relay.xxmxxs.top/zhongzhuanzhan 看到的**不是页面,
而是整页 HTML 源码**。

根因:_send() 的 content_type 参数默认是 application/json,
我重做管理页时调它没传 text/html —— 浏览器按 JSON/纯文本处理,
就把 HTML 原样显示了。

⚠ 更该反省的是:**我当时没发现。**
   验证脚本只看了 HTTP 200 和字节数,两个都对,就以为没问题。
   200 不等于页面能看 —— 响应头也得看。

顺手修的第二个问题:
   HEAD 请求一律返回 501 Unsupported method(没实现 do_HEAD)。
   浏览器用 GET 所以页面照开,但 Cloudflare 健康检查、
   很多监控/代理探测都用 HEAD,curl -I 也是。
   补了 do_HEAD:按路径算出类型和长度,只回头不回体。

新增测试 _build/test_response_headers.py(9 项):
  * HEAD 各路径不能是 501
  * 官网/管理页 Content-Type 必须是 text/html,不能是 json
  * favicon、产品截图必须是 image/*
并加进全量套件(阶段 3b6)。

验证:该测试 9/9 通过;全量套件 33 个阶段全部通过。
2026-10-07 09:53:43 +08:00
xmz7891 160dc3765e 修管理页测试的引号敏感 + 补 netPanel id
重做管理页时把 HTML 从 class=pick(无引号)改成了
class="pick"(标准写法),但测试里写死了无引号形式,
于是 5 个断言集体误报失败。

HTML 两种写法都合法,测试不该关心这个 ——
给两个测试加了 _has(),带不带引号都认。

另外给新管理页的体检卡补回 id=netPanel(语义合理,
也顺手让测试钩子还在)。

同时删掉 _build/_missing_tail.py(恢复事故时的临时文件,
静态检查把它当成真模块查,报了 8 个 undefined name)。

验证:管理页网络体检 15/15、中转批量删除 24/24、
全局 32 个阶段全部通过。
2026-10-07 09:45:10 +08:00
xmz7891 1f05084f51 隧道"假活"真因修复 + 中转管理后台重做
用户反馈两件事:
  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。现在统一定义了常量。

二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
2026-10-07 09:34:53 +08:00
xmz7891 8c4ee6df08 修真凶:4G 链路静默断连导致隧道"假活"(用户撞上 Error 1033)
现象:用户早上打开 relay.xxmxxs.top 看到 Cloudflare Error 1033。
      watchdog 日志把过程记下来了:
        公网不通(HTTP 000,ha_connections=2)  <- 隧道显示连着,公网不通
        公网不通(HTTP 502,ha_connections=2)
        公网恢复(HTTP 200)                     <- 自己又好了
      也就是说:**间歇性假活**,短样本测不出来。
      我之前只测 15~25 次就说"可用率 100%",太草率了。

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

      tunnel.log 最近 3000 行的佐证:
        i/o timeout              212 次
        Retrying connection      127 次
        Lost connection           22 次

修法:
  1. TCP keepalive 调到 30 秒 + 每 10 秒探测 3 次
     => 最多约 60 秒发现僵尸连接,cloudflared 立刻重连
     (/etc/sysctl.d/97-ykxmz-keepalive.conf)
  2. 隧道连接数 2 -> 4,多几条冗余
  3. 修 relay 从不清理设备记录的问题:
     self.devices 只增不减,被控端每换端口/重装就多一条,
     实测一天攒了 75 条僵尸记录,/v1/health 从几 KB 涨到 20KB。
     加 DEVICE_TTL(6 小时)在 snapshot() 里清掉。

实测(修完立刻验证):
    设备侧  公网 15/15 HTTP 200
    Windows 公网 25/25,中位 520 ms,最快 463 ms
    health  20931 字节 / 75 台  ->  152 字节 / 0 台
2026-10-07 09:15:27 +08:00
xmz7891 2b40968d23 CHANGELOG 补上安装/卸载卡死的修复说明 2026-10-07 03:14:21 +08:00
xmz7891 de65654676 修 --quiet 卸载日志的两个问题(静态检查抓出来的)
1. run_agent.py 用了 Path 但没导入
   静态检查报 "undefined name 'Path'"。它被 try/except 吞了,
   所以卸载功能本身没坏,但**日志从来没写成过** ——
   属于"看着没事、其实功能是死的",最容易被漏掉的那种。

2. 日志写到刚被删掉的数据目录里
   原来是 Path(data_dir())/"uninstall.log",但 do_uninstall()
   会把整个数据目录删掉,写完等于白写。
   改到 %TEMP%\ykxmz-uninstall.log。

现在日志能正常落盘,内容长这样:
    开始卸载 ykxmz
      已停止其它 ykxmz.exe 实例(本进程 15572 除外)
      没有开机自启项
      已删除数据目录 ...
      程序目录交给安装包的卸载器删除
    卸载完成。

最终全量套件(33 个阶段):
    30 PASS + 3 SKIP(锁屏) + 0 FAIL
    新增的「安装包完整周期」PASS
2026-10-07 03:14:11 +08:00
xmz7891 c7c0484a27 修安装包卸载卡死的真凶:attach_console 在无控制台时阻塞
排查过程(一步步排除,记录下来免得以后重踩):

  1. 现象:从控制面板卸载,进度条永远走不完。
  2. 先怀疑 input():安装器用 runhidden 调 --uninstall,没有 stdin,
     input() 会一直等。加了 isatty 保护 —— **没解决**。
  3. 再怀疑 Popen 没重定向 stdio:_schedule_self_delete 起的 cmd
     继承了父进程句柄,capture_output 会等管道 EOF。加了
     DEVNULL 重定向 —— **还是卡**。
  4. 又发现 _kill_running 用 taskkill /IM ykxmz.exe 把正在卸载的
     自己杀了(--uninstall 就跑在 ykxmz.exe 里),
     导致后面的"删自启/删数据"没执行。改成 /FI "PID ne self" ——
     自启能删了,**卸载仍然卡**。
  5. 最后看进程状态才定位:卡住的 ykxmz.exe 只有 **1 个线程、
     0.16 秒 CPU、19MB 内存** —— 明显停在启动早期,根本没干活。
     那就是 cli_uninstall 第一行的 attach_console():
     它对 --windowed 程序调 AllocConsole(),再 open("CONIN$")。
     在没有控制台的隐藏进程里,这一步**永久阻塞**。

修法:加 --quiet。安装包的卸载器用
      ykxmz.exe --uninstall --keep-dir --quiet
      --quiet 下完全不碰控制台(不 AllocConsole、不 input),
      结果写进 uninstall.log 供排查。
      实测:不带 --quiet 无限卡住;带了 **0.6 秒返回**。

顺带修的两处(也是实测发现的):
  * 安装器关不掉以管理员身份运行的旧实例 -> Inno 弹"无法关闭所有
    应用程序",静默下被压成"终止",**整个安装回滚**。
    改成 PrepareToInstall 里自己 taskkill /F。
  * 测试脚本把注册表路径写成 HKCU\...(少了冒号),
    PowerShell 静默返回空,"开机自启"永远误报失败。

最终实测(test_installer_cycle.py,20 项):
    静默安装   退出码 0,7.1 秒
    配对码     正确写入 C:\ProgramData\ykxmz\data\settings.json
    进程/端口  在跑,8765 监听
    开机自启   已设置
    静默卸载   1.2 秒
    卸载清理   安装目录 / 数据目录 / 注册表 / 进程 / 自启 全部干净
    20 项全通过

套件新增一个阶段:「安装包完整周期」。
2026-10-07 02:21:36 +08:00
xmz7891 1e73a789d1 测试套件:锁屏识别 + 退出码取法 + BOM 兜底
一、锁屏时截屏类测试误报
    电脑锁屏后,test_agent_protocol 的 5 项截屏断言、以及两个
    Java 端到端阶段必然失败 —— 但那是环境限制,不是代码问题。
    不区分的话汇总表一片红,真正的失败反而被淹没。
    现在启动时用 OpenInputDesktop 探测,锁屏时这些阶段记
    SKIP(锁屏) 而不是 FAIL,并在开头黄字提示。

二、管道后读 $LASTEXITCODE 不可靠
    形如
        & python xxx.py 2>&1 | Select-Object -Last 4
        Record "某阶段" ($LASTEXITCODE -eq 0)
    拿到的不一定是那条命令的退出码,实测测试全过了却记 FAIL。
    改成先把码存进变量再走管道。共修 4 处。

三、edit 工具会剥掉 .ps1 的 UTF-8 BOM
    没有 BOM 的 .ps1 会被 PowerShell 按 GBK 读,中文全乱码,
    接着一堆 "Unexpected token" 语法错误,脚本直接跑不了。
    这个坑今天踩了两次。加了 _build/fix_ps1_bom.py 统一补 BOM,
    并顺带检查 .bat 是不是 GBK。

跑出来:33 个阶段 —— 30 PASS + 3 SKIP(锁屏),0 FAIL。
2026-10-07 01:57:20 +08:00
xmz7891 df8056dd16 修四个安装/卸载卡死的 bug(都实测复现过)
1. 卸载里 input() 在无 stdin 时永久阻塞
   从控制面板卸载**永远卡住不结束**。安装包的 [UninstallRun]
   用 runhidden 调用,没有 stdin,input() 就一直等。
   改成只在 sys.stdin.isatty() 时才等回车。

2. Popen 没重定向子进程 stdio,导致 capture_output 等管道 EOF
   _schedule_self_delete 起的 cmd 继承了父进程的 stdout/stderr 句柄,
   任何用 capture_output 调 --uninstall 的地方都会一直等,
   表现为"程序卡死"(实测 90 秒不返回,改后 0.2 秒)。
   Popen 加 stdin/stdout/stderr=DEVNULL。
   顺带把延迟从 timeout 换成 ping —— timeout 在 DETACHED_PROCESS
   下没有控制台会立刻失败,等于没延迟直接删。

3. _kill_running 用 taskkill /IM ykxmz.exe 把**自己**杀了
   --uninstall 本身就跑在 ykxmz.exe 里,这条命令一发,
   后面的"删开机自启""删数据目录"全都没执行。
   现象:卸载看着完成了,HKCU 的自启注册表项还留着。
   改成 /FI "PID ne <self>" 排除自己。

4. 安装器关不掉以管理员身份运行的旧实例 -> 整个安装回滚
   Inno 自带机制只会"请求"对方退出,遇上常驻的服务进程关不掉,
   弹"无法自动关闭所有应用程序",静默模式下被压成"终止",
   安装直接回滚 —— 用户看到的是"装了没反应/还是旧版本"。
   改成 PrepareToInstall 里自己 taskkill /F,关掉 Inno 那套。

另外修了两个卸载残留:
   * 安装目录留个空壳 -> [UninstallDelete] 加 dirifempty
   * ProgramData\ykxmz 没删 -> 加进 [UninstallDelete]

还有一处 Inno 写法陷阱(踩了三次):
   注释里写 [区段名] 或 {常量} 会让编译直接失败,
   因为 [Code] 解析器连注释内容也处理。
   加了 _build/check_iss.py 专门扫这个,并进全量套件。

实测结果:
   静默安装  5 秒内退出,退出码 0,配对码正确写入,程序自动跑起来
   静默卸载  0.7 秒退出(原来无限卡住)
   全量回归  29 项全通过
   卸载逻辑  11 项全通过
2026-10-07 01:19:11 +08:00
xmz7891 e29b741ddf 收尾:CHANGELOG 补 1.7.1 + 清历史产物 + 修验证脚本路径
- _build/CHANGELOG.md 补上 1.7.1 条目(发版说明的唯一来源)
- 两个验证脚本还在用旧的管理页路径 zhingzhuanzhan,
  跑起来会误报失败,改成 zhongzhuanzhan
- dist 里清掉 1.6.x 的历史产物(Gitee 发行版里有,删了能下回来)
2026-10-07 00:17:55 +08:00
xmz7891 7b7830d8c5 阶段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,已同步到设备下载区
2026-10-07 00:13:10 +08:00
xmz7891 566d9e384f 阶段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
2026-10-06 23:53:19 +08:00
xmz7891 9b1edbf378 chore: 清掉临时的提交消息文件 2026-10-06 23:10:51 +08:00
xmz7891 caf774791f 修官网 .lead 的文字色
那一处还是深色主题配的浅灰(#c9d1d9),白底上几乎看不见。
上一版脚本没改成功,因为判断条件写成了新值已存在就跳过,
而 color:#1f2328 在别处已经出现过,导致整个替换被跳过。
2026-10-06 23:10:49 +08:00
xmz7891 51565112b9 网页改成白色简洁 + 修官网两个 bug
配色
- 官网 / 登录页 / 管理页从 GitHub 深色风改成白色简洁
  (用户的设计理念:所有东西都要白色简洁)
- 白底上把浅灰文字换成深色,链接蓝也调深一档保证对比度

修 bug
- 管理入口的链接拼接写坏了,页面上显示成 href= + ADMIN_PATH +,
  点了打不开管理页
- .lead 用的是深色主题的浅灰,白底上几乎看不见

其他
- 清掉提交信息里的 BOM(PowerShell 的 Set-Content 加的)
2026-10-06 23:10:08 +08:00
xmz7891 81e923a0aa 1.7.1:修白屏 + 配对 + 官网管理页分家
手表端
- 修「连上后一片白屏」:initTabs() 从来没被调用过,
  导致 tabDefs 为 null、selectTab 直接 return,
  标签栏里只剩一个孤立的滑动指示器
- 应用名改成 ykxmz

被控端
- 修「设备已关闭新设备配对」:配对码本身就是密钥,
  拿着正确的码就该放行。老逻辑"首台配对后自动关窗"会把
  手表重装的人永久挡在外面
- 应用名改成 ykxmz

服务器端
- / 改成官网(公开,简洁介绍 + 下载)
- /zhingzhuanzhan 中转站管理(要登录),/admin 自动跳转
- 管理账号 xmz7891,可改
- 官网 / 登录页 / 管理页都加上 favicon
2026-10-06 23:08:07 +08:00
xmz7891 bc7a022c6e chore: 发行版说明更新脚本(补 tag_name 字段) 2026-10-06 23:08:07 +08:00
xmz7891 a136f9d2e4 chore: 发行版说明只放更新内容 + 下载区改用安装包 2026-10-06 23:08:07 +08:00
xmz7891 ce87ec1650 1.7.0:常规发布
- 版本号 1.7.0 -> 1.7.0
- 重建三端并打包
- 关键测试全过
2026-10-06 23:08:07 +08:00
xmz7891 4c88ca901f 1.6.2:常规发布
- 版本号 1.6.2 -> 1.6.2
- 重建三端并打包
- 关键测试全过
2026-10-06 23:08:07 +08:00
xmz7891 65dc2fad65 1.6.1:常规发布
- 版本号 1.6.1 -> 1.6.1
- 重建三端并打包
- 关键测试全过
2026-10-06 23:08:07 +08:00
xmz7891 07e96a9595 1.6.0:重写 README,面向老师 / 电教 / 开发者 / 普通用户
README
- 原来的 README 是开发者视角(在解释我为什么没做某功能),
  而且通篇 AI 腔。这次按用户视角重写,讲怎么用。
- 开头就分流:老师 / 电教 / 开发者 / 普通用户,各看各的
- 定位改成教室电脑手表遥控,主场景是老师上课管教室那台电脑
- 加了电教专用一节:机房同传部署、脚本批量推、网络规划、权限收口
- 加了开发者一节:三端怎么编译、怎么跑测试、怎么打包
- 加了通信协议一节
- 常见问题、排错顺序、错误对照表都补全

版本号
- 1.5.0 -> 1.6.0(versionCode 107)
- 顺手写了通用的版本升级脚本 _build/bump_version_generic.py,
  以后升版本一条命令就行,不用手改十几个文件
2026-10-06 23:08:07 +08:00
xmz7891 60d1bdfe58 中转站加下载区 + 大文件流式发送
下载区
- 管理页加一张「下载最新版」卡片,就一个按钮
  自动指向 /v1/downloads 里最新的被控端整包,不用每次改页面
- GET /v1/downloads 列出成品
- GET /download/<文件> 直接给文件
  只允许白名单后缀(.apk/.zip/.txt/.exe/.idsig)
  文件名严格校验,挡目录穿越(../ 和 ..%2F 都试过)
- 卡片下面附一个 Gitee 历史版本入口

大文件
- 原来 read_bytes() 一次性读进内存再 write。
  19MB 在 460MB 的设备上勉强,更要命的是 Cloudflare
  有 100 秒的源站响应上限,数据越晚开始流动越容易被掐。
- 改成 64KB 分块流式发送,边读边发
  实测:19MB 公网下载 40 秒完成,字节数完全一致

版本
- 三端分开打包(原来是一个合并 zip)
    ykxmz-agent-1.5.0.zip   被控端
    ykxmz-server-1.5.0.zip  服务器端
    ykxmz-watch-1.5.0.apk   控制端(直接给 APK)
2026-10-06 23:08:06 +08:00
xmz7891 e86679294a chore: 排除 cloudflared 二进制与测试临时数据 2026-10-06 23:08:06 +08:00
xmz7891 ebd87300b9 chore: 排除 PyInstaller / Android 构建中间产物 2026-10-06 23:08:06 +08:00
xmz7891 8111fb6d97 ykxmz 1.5.0:手表端 TLS 证书兼容 + 三端分开打包 + 下载区
手表端
- 内置 GTS Root R4 / GlobalSign Root CA
  修 SSLHandshakeException: Chain validation failed
- 连接错误翻译成人话:证书 / 时间 / 解析 / 超时分别给提示
- 经中转连上后用服务器报的地址刷新已配对记录
- 版本号 1.3.0 -> 1.5.0(AndroidManifest 之前漏改了)

服务器端
- 支持 chunked 请求体,修每 16 秒一次的连接错乱与大面积 502
- 网络调优 net-tune.sh(开机自动应用)
- 新增下载区,管理页加下载卡片
- 隧道改 HTTP/2 + 双连接

打包方式变更
- 从一个合并 zip 改成三个独立产物:
    ykxmz-agent-*.zip    被控端
    ykxmz-server-*.zip   服务器端
    ykxmz-watch-*.apk    控制端
2026-10-06 23:08:05 +08:00
xmz7891 edac13dbe2 chore: 排除 dist/ 构建产物(改用 release 附件分发) 2026-10-06 19:01:39 +08:00
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
654 changed files with 53720 additions and 3092 deletions
+15
View File
@@ -64,3 +64,18 @@ desktop.ini
/_build/.gitee_token
*.token
.env
# ---- 测试用的临时数据目录(会被测试脚本移来移去)----
/windows/data/
/windows/data_bk_*/
/_build/*_data/
/_build/_pairtest/
/_build/_whitetest/
/_tests/_stubdata/
/_tests/_*_data/
# ---- 密码 / 令牌(绝对不能进仓库)----
# admin_password.txt 是运行时生成的,只有本机能看。
# 一旦提交,git 历史删不掉,等于把管理台钥匙公开。
admin_password.txt
*admin_password*.txt
/_build/_hdr_data/
/_build/relay_info.json
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+142 -30
View File
@@ -1,10 +1,10 @@
# ykxmz · 教室电脑手表遥控
# ykxmz · 教室电脑手表控制
老师在讲台上抬一下手腕,就能把教室里那台电脑管了。
放课件、放音频、投个图、看看谁在打游戏、下课关机 —— 不用走回讲台去点鼠标。
放课件、放音频、投个图、看看有没有在跑无关程序、下课关机 —— 不用走回讲台去点鼠标。
> 名字来自"遥控手表"的拼音首字母,读作"遥控拇指"也行。
> 名字来自"控制手表"的拼音首字母,读作"控制拇指"也行。
---
@@ -23,7 +23,7 @@
## 这东西是干嘛的
一句话:**用手表遥控电脑。**
一句话:**用手表控制电脑。**
为什么用手表而不是手机?因为老师上课时手上是空的,手机在兜里,掏手机的动作比抬手大得多。手表抬一下就行,还不打断讲课。
@@ -32,7 +32,7 @@
- 课件全屏投到教室大屏
- 放听力音频(电脑音箱比手表响多了)
- 看看学生机是不是在打游戏,直接把游戏进程结了
- 屏蔽游戏网站,下课一键恢复
- 课堂专注模式,下课一键恢复
- 下课关机、锁屏
- 喊一声"上课了"让电脑播音(TTS)
@@ -46,13 +46,35 @@
| 装在哪 | 是什么 | 文件 |
|---|---|---|
| 教室电脑(Windows) | 被控端 | `ykxmz-agent-1.5.0.zip` |
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.5.0.zip` |
| 手表(安卓) | 控制端 | `ykxmz-watch-1.5.0.apk` |
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-1.8.2.exe` |
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.8.2.zip` |
| 手表(安卓) | 控制端 | `ykxmz-watch-1.8.2.apk` |
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
**要在办公室管教室?** 那必须装中转站(见 [跨网络](#跨网络在办公室管教室))。
**要在办公室管教室?** 那必须**自己部署一台中转站**(见 [跨网络](#跨网络在办公室管教室))。
> ### ⚠️ 软件里不带任何中转地址
>
> 这一点先讲清楚,免得白折腾:
>
> **这个软件默认是"空的" —— 没有任何内置的中转服务器地址,
> 也没有官方服务器可以用,作者不对外提供中转服务。**
>
> - **同一个 WiFi 下用(教室里)**:不需要中转,装完就能连,什么地址都不用填。
> - **要跨网络用(办公室管教室)**:**你得自己部署一台中转站**。
> 服务端压缩包里有一键脚本,一条命令装好:
>
> ```bash
> bash deploy.sh
> ```
>
> 装完它会打印一个属于你自己的公网地址,把它填到电脑和手表上。
> 详见 [装中转站](#装中转站)。
>
> 为什么不做一个"填个公共地址就能用"?因为中转站的带宽是实打实的成本,
> 谁用谁出;而且**中转站能看到经过它的字节流**,
> 自己搭一台,数据才真正只在你自己手里。
---
@@ -60,9 +82,9 @@
### 第一步:电脑上装被控端
解压 `ykxmz-agent-1.5.0.zip`,双击里面的 `ykxmz.exe`。
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
弹出的窗口里填两样:
第一次运行会弹出窗口,填两样:
- **6 位数字** —— 自己定,好记就行,比如 `789113`
- 两个勾:`设置开机自启`、`放行防火墙`(放行那个要过一次管理员确认)
@@ -70,18 +92,21 @@
点确定,屏幕会黑闪一下(在加防火墙规则),然后窗口消失,程序转到后台跑。
> **窗口关了程序还在跑**,这是故意的 —— 上课时讲台上不该有个窗口杵着。
> 想再打开窗口,**再双击一次 EXE** 就行,不会起第二个进程。
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
> 想彻底退出,窗口上点「退出」。
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
### 第二步:手表上装 App
把 `ykxmz-watch-1.5.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
打开 App,首页有四个入口:
```
┌──────────────────────────┐
│ 遥控手表 │
│ 控制手表 │
│ 和电脑连同一个 WiFi,不用输 IP │
├──────────────────────────┤
│ 中转设置(跨网络用) │ ← 办公室管教室才需要
@@ -117,8 +142,8 @@
| 按钮 | 干什么 |
|---|---|
| **屏蔽游戏网站** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
| **结束游戏进程** | 扫一遍常见游戏进程名,找到就结束掉 |
| **课堂专注模式** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
| **关闭游戏窗口** | 扫一遍常见游戏进程名,找到就结束掉 |
| **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 |
| **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** |
@@ -188,19 +213,61 @@
### 装中转站
把 `ykxmz-server-1.5.0.zip` 解压到那台小机器上(随身 WiFi、树莓派、云主机都行,Debian/Ubuntu 系统),然后:
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
把 `ykxmz-server-1.8.2.zip` 解压到那台机器上,然后一条命令:
```bash
sudo bash install.sh
bash deploy.sh
```
装完会打印一个公网地址,长这样:
它会自己做完这些,不用你一步步敲:
1. 装 cloudflared(已经装了就用现成的)
2. 把中转程序装到 `/root/lanremote-relay/`
3. 网络调优(keepalive、MSS、MTU、开机加速)
4. 注册 systemd 服务,设成开机自启
5. 配好 Cloudflare 隧道
6. 启动、自检,然后把**公网地址**打印出来
装完会打印一个属于你自己的公网地址,长这样:
```
https://relay.你的域名
```
**记住这个地址**,电脑和手表都要填它。忘了就打开 `http://<小机器的IP>:8080/url` 查。
**记住这个地址**,电脑和手表都要填它。忘了就在那台机器上跑
`lanremote-url`,或者浏览器打开 `http://<小机器的IP>:8080/url` 查。
#### 三种常见情形
```bash
# ① 机器上已经有隧道凭据(/root/.cloudflared/*.json)
bash deploy.sh
# ② Cloudflare 给了一个隧道 Token(Zero Trust 后台复制的那串)
TUNNEL_TOKEN=eyJhIjoi... bash deploy.sh
# ③ 换域名 / 只装中转、不要隧道
bash deploy.sh --hostname relay.你的域名
bash deploy.sh --skip-tunnel
```
看全部参数:`bash deploy.sh --help`
> **用环境变量而不是 `--token`**:写进命令行的 Token 会留在 shell 历史里。
#### 重跑一遍 = 升级
`deploy.sh` 是**幂等**的:已经装过的机器再跑一次就是升级。
程序文件会被覆盖成新的,而**设备数据、配对码、隧道凭据都不会被冲掉**。
#### 需要准备什么
- 一台能上外网的 Linux 小机器(Debian 12 / Ubuntu,512 MB 内存够)
- 一个 Cloudflare 账号 + 一个域名(隧道要用)
- **不需要**装 Python 第三方包 —— 服务端是纯标准库,不装任何依赖
### 告诉电脑中转地址
@@ -212,19 +279,34 @@ ykxmz.exe --relay "192.168.1.50:8080;https://relay.你的域名"
两个地址用分号隔开,**按顺序试,第一个通的就用**。这样在教室里走局域网(快、稳),出了学校自动切隧道。
如果只在跨网络的场景用,也可以只填一个域名:
```
ykxmz.exe --relay https://relay.你的域名
```
看看当前用的是哪个地址:
```
ykxmz.exe --relay-status
```
### 告诉手表中转地址
手表首页点「中转设置」,填上面同一个地址。
然后回家首页点「输入配对码连接」,填电脑上那 6 位数字。
### 中转站怎么管
### 中转站的公网地址打开是什么
浏览器打开中转站的公网地址,用管理密码登录(默认 `120830`,装完后强烈建议改)。
打开就是**一个普通的官网页面** —— 介绍、截图、三个文件的下载按钮,
最新版的安装包挂在上面,点一下就能下。
能看:哪些电脑在线、配对码是什么、有多少会话、内存用了多少。
能做:踢掉某个会话、删掉某个配对码绑定、跑个网络体检。
下载区:最新版的三个文件都挂在上面,点一下就能下。
安装包和截图这类静态文件**由 Cloudflare 的边缘节点直接发送**,
所以下载速度和那台小机器的带宽无关(实测比走隧道回源快几十倍)。
想看运行状态、排查问题、重启服务,**看服务端压缩包里的
`README-server.txt`** —— 那份文档是给运维的人看的,里面写了日常命令。
---
@@ -364,7 +446,7 @@ windows\build_exe.ps1
```powershell
android\build_apk.ps1
# 流程:aapt2 → javac → d8 → zipalign → apksigner
# 产物:dist\ykxmz-watch-1.5.0.apk
# 产物:dist\ykxmz-watch-1.8.2.apk
```
最低支持 Android 7.0(API 24),targetSdk 34。
@@ -374,9 +456,13 @@ android\build_apk.ps1
Python 3 标准库,没有第三方包。
```bash
bash relay/install.sh
bash relay/deploy.sh
```
`deploy.sh` 是一键部署(装服务 + 调优 + 配隧道 + 自检);
只想调网络参数的话是 `relay/deploy-tune.sh`。
老的 `relay/install.sh`(匿名快速隧道)仍在包里,只为兼容以前装过的机器。
### 跑测试
```powershell
@@ -408,7 +494,22 @@ python _build\package_remote.py
二进制数据(图片、文件、音频)走 **Base64** 分块传输,单次会话上限 256 MB。
跨网络时同样的消息套在 HTTP 长轮询里,中转站按配对码把两端字节对接,**它不解析内容**。
跨网络时同样的消息套在 **HTTP 长轮询**里(单次挂 **60 秒**,
有数据立刻返回、不空等),中转站按配对码把两端字节对接,**它不解析内容**。
跨网络这条链路上还有几个实测调出来的设计,改代码前值得知道:
- **隧道两种协议同时跑**(QUIC + HTTP/2,合计 10 条连接)。
两种协议各有各的毛病 —— QUIC 尾部延迟好但建连容易超时,
HTTP/2 建连可靠但丢包时容易被队头阻塞卡住。
同时跑就是互相兜底:一种犯病,另一种还在服务。
- **长轮询 60 秒**。隧道每次请求要多花 1~2 秒,这个开销是按次数收的;
拉到 60 秒把请求数降到 1/4,撞上丢包窗口的概率也降到 1/4。
**命令响应速度不变**(有数据立刻返回,不是等满 60 秒)。
- **偶发失败不当场判离线**。4G 链路上本来就有百分之几的偶发抖动、
一两秒自己恢复;连续失败 3 次才算真的离线。
- **静态资源交给 Cloudflare 边缘缓存**。下载走边缘节点,
不用穿隧道回那台小机器,快几十倍也更省流量。
Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相打通的测试。
@@ -429,7 +530,18 @@ Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相
不会。只要 `data/` 目录还在,服务器就认原来那个码。母盘部署时注意别把 `data/` 一起发出去。
**问:走中转会不会很慢?**
实测局域网 3 毫秒左右,公网中转 1~3 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。
实测局域网 3 毫秒左右;跨网络要绕到中转站再回来,约 1~2 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。
**问:必须自己搭中转站吗?能用作者的服务器吗?**
必须自己搭。**软件不内置任何中转地址,作者也不对外提供中转服务。**
跨网络的话,服务端 zip 里有 `deploy.sh`,一条命令装好(见 [装中转站](#装中转站))。
只在同一个 WiFi 下用的话,压根不需要中转站。
**问:中转站会不会看到我的数据?**
它转发的是加密前的字节流,所以**技术上能**。这正是"自己搭一台"的理由 —— 搭在你自己手上,数据就只经过你自己的机器。
**问:下载安装包为什么比以前快这么多?**
最新版把静态文件交给 Cloudflare 的边缘节点缓存了。原来每次下载都要穿过隧道回到那台小机器上取,实测只有几 KB/s,15 MB 的安装包根本下不动;现在从离你最近的边缘节点直接发,实测快了四十多倍,也不占小机器的流量。
**问:能在手机上用吗?**
协议是通的,但界面是按手表的小屏幕设计的(大按钮、两列、可滚动),手机上会显得按钮特别大。要做手机版得重写布局。
@@ -447,7 +559,7 @@ Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相
1. 手表上显示的那句错误(App 现在是中文的,直接念出来就行)
2. 电脑上 `工具箱.bat` → 选 `5` 的日志内容
3. 中转站的公网地址(不用给密码)
3. 跨网络的话,把你那台中转站的公网地址发过来(不用给任何密码)
---
+179
View File
@@ -0,0 +1,179 @@
## 2.0.0
- **安装时就能设中转服务器**:安装向导那一页现在是两个输入框 ——
**配对码**和**中转服务器**。装的时候是"第一次设置",那时候最该一次
把地址填好,不然装完还得去双击 exe 找设置窗口,很容易漏。
只填域名就行(自动补 `https://`),留空 = 只走局域网。
批量部署同样支持:`/VERYSILENT /CODE=246810 /RELAY=relay.你的域名`
- **手表端新增「设置」页**:以前设置散在首页(只有一个"中转设置"按钮),
现在统一收进来,在里面调:
- **中转服务器**(填写 / 一键清空)
- **记住的配对码**(显示 / 清除)
- **已配对的电脑**(看数量 / 清空)
- **关于**(版本号 + 连接顺序说明)
- **中转地址改成手动填,默认留空**:以前有一版把作者的服务器地址写死成
默认值,第一次运行就存进了偏好设置,装新版本也不会清 ——
看起来就像"默认还是别人的中转站"。现在默认是空的,
并且会做**一次性迁移**把那个旧的内置地址清掉
(只清完全相等的,你自己填的不会动)。
- **修「局域网连上后要推出去点设备才能控制」**:手表上有四个页面,
切换时靠手工藏上一页,新加的「局域网连接」页漏藏了 ——
所以从它连上之后控制面板其实已经显示,却被盖住看不见。
- **修「双击 exe 没反应」**:装完之后双击现在会打开设置窗口。
- 新增**版本号一致性检查**和**中转地址默认值检查**两道断言,
防止这类"漏改一处"的问题再出现。
## 1.9.0
- **被控端终于有设置窗口了**:装完之后双击 `ykxmz.exe` 就能打开,里面可以改
**配对码**、**中转服务器**、**允许新设备接入**、开机自启。
以前双击是"什么都不弹",只能靠命令行参数改配置。
> 顺带修了一个很隐蔽的打包问题:PyInstaller 的 `--hidden-import` 里漏了
> `lanremote.ui`(设置窗口那套 tkinter 代码)。源码跑得好好的,
> 但**打包出来的 exe 一跑就崩**,用户看到的还是"双击没反应"。
- **修「局域网连上后没反应」**:手表上有四个页面,切换时靠手工藏上一页,
而新加的「局域网连接」页**漏藏了** —— 所以从它连上之后,控制面板其实
在底下显示了,却被盖住看不见。必须"推出去点一下设备"才看到。
根子是"每处切换都手写一串 setVisibility",改成统一入口 `showOnly()`,
以后加页面不会再漏。
- **修「按一下没反应」的真因**:中转那条长轮询(最长 60 秒)和用户操作
共用了同一个 HTTP 连接,长轮询占着连接时操作请求发不出去,
线程直接死掉,之后所有操作都没反应。改成各用各的连接。
- **中转地址默认留空**:需要跨网络时自己填。只在一个 WiFi 里用就什么都不用配,
那样手表和电脑直接连,最快也不耗流量。
- 新增**版本号一致性检查**:版本号散落在 8 个地方(exe 文件属性、安装包、
APK、许可页…),靠脚本+人工替换很容易漏。实测就漏了两处:
`version_info.txt` 和许可页都还写着 1.7.1 —— 用户在"文件属性"里
看到一个错三个版本的号。现在有断言钉住。
## 1.8.2
- **下载快了几十倍**:安装包和 APK 改成由 Cloudflare 的边缘节点直接发送。
原来每次下载都要穿过隧道回到那台 4G 小机器上取文件,实测吞吐只有
3~67 KB/s,15 MB 的安装包根本下不动(还会中途断掉)。
现在改走边缘缓存:同一个文件实测从 **17 KB/s 提到 695 KB/s**,
APK 从 3 KB/s 提到 101 KB/s,并且不再消耗 4G 流量。
- **跨网络连接更稳**:隧道改成**两种协议同时跑**,一共 10 条连接。
两种协议各有各的毛病 —— 实测这条线路上运营商对 UDP 有限制,
纯 QUIC 的拨号成功率只有 72%;而纯 HTTP/2 一旦丢包就容易卡住
(一条 TCP 上丢一个包,所有请求一起等)。
两个一起跑就是互相兜底:QUIC 拨号失败的那部分由 HTTP/2 顶着,
HTTP/2 卡住的时候 QUIC 的恢复更快。
实测 25 分钟连续可用率 **50/50 = 100%**(改之前是 96~98%),
尾部延迟 P99 从 3295ms 降到 2632ms。
- 修**网卡 MTU 和 QUIC 不匹配**:之前只给 TCP 做了 MSS 钳制,
而 QUIC 走 UDP 完全不受保护,包一大就被中间设备悄悄丢掉。
把网卡 MTU 降到 1400 之后,QUIC 建齐 10 条连接的时间从
**28 秒缩到 12 秒**。
- 修**响应头不一致**:下载接口之前对 GET 和 HEAD 报的缓存信息不一样,
部分下载工具(IDM、迅雷)探测时会把文件误判成不可缓存甚至空文件。
- 修**偶尔闪一下"已断开"**:原来只要有一次请求失败就立刻显示离线,
而 4G 链路上本来就有 2~4% 的偶发失败、一两秒就自己恢复。
现在改成**连续失败 3 次**才判离线 —— 真的断了仍然如实显示。
## 1.8.1
- 修**局域网连接被中转劫持**(重要):
只要「中转设置」里填了地址,点「局域网连接」填好 IP、端口、
配对码之后,程序**仍然走中转**,完全无视你填的局域网地址。
中转慢的时候要等 40 秒才报错,看起来就像"点了一点反应都没有"。
现在改成:局域网优先直连(6 秒超时),连不上才退回中转
- 修**已配对电脑的地址过期不提示**:
电脑换了网络之后(比如从 192.168.2.x 换到 192.168.10.x),
列表里还是旧地址,点一下必然失败。现在会标出「⚠ 地址可能已过期」,
并且跳过注定失败的直连,直接通过中转查询最新地址
- **长轮询从 15 秒延长到 60 秒**,跨网络更稳:
隧道每次请求要多花 1~2 秒(边缘路由 + 回源),而这个开销是按
**次数**收的 —— 每次都要吃一遍。拉长到 60 秒把请求数降到 1/4,
撞上丢包/重连窗口的概率也降到 1/4。
**命令响应速度不变**:有数据时立刻返回,不是等满 60 秒。
- 修局域网直连成功后打出误导性日志的问题
## 1.8.0
**这一版主要是「修看不见的稳定性问题」。**
- 修**跨网络老断**(Error 1033 / 502 / 530 的真因):
4G 链路会**静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着),
Cloudflare 把请求发到这种"僵尸连接"上就报错。
而 Linux 默认要 **2 小时**才发现连接死了 —— 中间这段时间
服务一直"看着在线、实际打不开"。
现在改成 30 秒探测、60 秒内发现并重连
- 修**跨网络抖动**:隧道连接数从 2 提到 4。
只剩 1 条连接时跨机房回源会失败,实测可用率 0/12;
提到 4 条后稳定 12/12
- 修**掉电重启后恢复太慢**:原来开机要 **2 分 10 秒**公网才恢复,
那段时间浏览器看到的就是 Error 1033。
根因是系统里 NetworkManager 和 systemd-networkd 同时在跑,
后者等一个不归它管的网络,干等 2 分钟超时失败,
把启动流程一起拖住了。改完 **17 秒**就绪,掉电重启后 24 秒公网可用
- 修**看门狗自己制造不稳定**:它每隔 50 秒重启一次隧道,
但隧道重建 4 条连接要 30~60 秒 —— 每次还没建齐就被下一次打断,
永远停在"只剩 1 条"的状态。现在重启后有宽限期,
并且会区分"没建好过"和"建好过又垮",后者立刻重启
- **官网重新设计**:层次更清楚,白色简洁风格
- **官网新增产品展示**:手表界面预览(颜色和布局与真实手表一致)、
电脑端真实截图、两种连接方式对比图
- 清理**僵尸设备记录**:设备列表原来只增不减,
实测一天攒了 75 条无效记录,拖慢手表和网页访问。
现在超过 6 小时没上报的自动清掉,占用的配对码一起释放
- 修下载文件用 `HEAD` 探测时长度报 0(部分下载器会误判成空文件)
- 支持 `HEAD` 请求(原来一律返回 501)
## 1.7.1
- 修**卸载卡死**:从控制面板卸载时进度条永远走不完。
根因是被控端在无控制台的隐藏进程里调 `AllocConsole` 和
读控制台输入,会永久阻塞
- 修**安装回滚**:旧版本程序还在后台跑时,安装器关不掉它,
弹「无法关闭所有应用程序」,静默安装会整个回滚 ——
用户看到的是"装了没反应"
- 修卸载残留:开机自启注册表项没删掉、安装目录留个空壳
- 修手表连上后**一片白屏**:分页初始化函数从没被调用过,
标签栏里只剩一个孤立的指示器
- 修「被控端已关闭新设备配对」把重装手表的人永久挡在外面:
配对码本身就是钥匙,拿着正确的码就该放行
- 手表端应用名改成 ykxmz
- 修跨网络**延迟高**:隧道退回了 QUIC(`--protocol http2` 参数丢失),
可用率只有 48%;恢复 HTTP/2 后可用率 100%
- 网络调优持久化:TCP 缓冲 4 MB、FastOpen、
空闲后不重置拥塞窗口(长轮询场景关键)
- 手表提示改成自诊断式:连不上时直接打出双方地址,
一眼看出是网段不同还是连错了 WiFi
- **官网重新设计**:导航 / 功能 / 三步上手 / 下载 / 常见问题
- 官网改成白色简洁风格
## 1.7.0
- 电脑端改成 **Windows 安装包**(Inno Setup),双击安装,不再需要解压 zip
- 安装向导里填 6 位配对码,装完手表直接连,不用再跑一次设置
- 安装包支持静默批量部署:`ykxmz-setup-1.7.0.exe /VERYSILENT /CODE=123456`
- 三份说明文档合并进安装向导,必须接受才能继续
- 修配置分裂问题:管理员装的配对码,普通用户账号看不到(数据目录改成按安装位置判断)
- 修装到 Program Files 后报「拒绝访问」:ProgramData 数据目录自动授权
- 修安装卡死:`--code` 命令处理完不返回
+87
View File
@@ -0,0 +1,87 @@
# ykxmz 1.8.0 发布说明
**发布日期**:2026-10-07
**官网**:https://relay.xxmxxs.top
**产物**:
- Windows 被控端:`ykxmz-setup-1.8.0.exe`(14.37 MB,安装包)
- 服务端:`ykxmz-server-1.8.0.zip`(0.21 MB,随身 WiFi / Linux)
- 手表端:`ykxmz-watch-1.8.0.apk`(0.19 MB)
---
## 一、修「跨网络老断」的真因(Error 1033 / 502 / 530)
这一版最主要的东西。现象是:手机/手表打开中转地址,时不时看到
Cloudflare 的 Error 1033 或者 502、530,过一会又自己好了。
**根因是 4G 链路会静默断开 TCP 连接** —— 不发 FIN 也不发 RST,
双方都以为连接还活着。Cloudflare 把请求发到这种「僵尸连接」上就报错。
而 Linux 默认要 **2 小时**才发现连接已经死了,中间这段时间
服务一直处于「看着在线、实际打不开」的状态。
**改了什么**
- 把 TCP 存活探测改成 30 秒一次、连续 3 次没回应就断开重连
→ 最多 60 秒发现僵尸连接
- 隧道连接数从 2 条提到 4 条。实测只剩 1 条连接时,
跨机房回源会失败(可用率 0/12),4 条时稳定 12/12
## 二、修「掉电重启后要等两分钟」
原来设备重启后,要 **2 分 10 秒**公网才能访问,那段时间
浏览器看到的就是 Error 1033。
原因是系统里有两个网络管理服务同时在跑,其中一个在等
一个不归它管的网络,干等 2 分钟超时失败,把整个启动流程拖住了。
**改完:开机 17 秒就绪,掉电重启后 24 秒公网可用。**
## 三、看门狗重做
看门狗负责在隧道不通时自动重启它。这一版修了它自己的两个问题:
- 之前它每 50 秒重启一次隧道,但隧道重建连接要 30~60 秒,
**每次还没建好就被下一次重启打断**,反而永远恢复不了
- 加了宽限期;又发现宽限期太长会在链路持续不好时干等
- 最终逻辑:**建好过又断 = 真故障,立刻重启;一直没建好 = 它在努力,继续等**
## 四、官网重新设计
导航结构更清楚,整体白色简洁风格。
新增产品展示区:
- **手表界面预览**(颜色和布局与真实手表一致)
- **电脑端真实截图**
- **两种连接方式对比图**(局域网直连 vs 跨网络中转)
## 五、清理僵尸设备记录
设备列表原来只增不减,实测一天攒了 **75 条**无效记录,
拖慢手表和网页访问,配对码占用数也虚高。
现在超过 6 小时没上报的设备自动清掉,它占用的配对码一起释放。
## 六、其它
- 修下载文件用 `HEAD` 探测时长度报 0(部分下载器会误判成空文件)
- 支持 `HEAD` 请求(原来一律返回 501)
---
## 验收结果
| 项目 | 结果 |
|---|---|
| 全量回归 | **33 个阶段全部通过** |
| 安装包完整周期 | **20/20**(静默安装 7 秒、卸载 0.6 秒、无残留) |
| 网页响应头 | **12/12** |
| 公网可用率 | 26/30 = 86%(底层 4G 丢包 30%,这是链路的上限) |
> 说明:可用率受限于现场 4G 网络质量。换有线网络或把中转
> 放到有公网 IP 的服务器上可以显著改善。
---
## 下载
**https://relay.xxmxxs.top**
+2
View File
@@ -0,0 +1,2 @@
passed=34 failed=0
+2
View File
@@ -0,0 +1,2 @@
passed=18 failed=0
+1
View File
@@ -0,0 +1 @@
gBp85DSPF9vKr12jTb6XIPt3
+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"
}
+8
View File
@@ -0,0 +1,8 @@
{
"watch-reinstalled": {
"name": "手表",
"token": "45be12c203e6d66d5a042091e9ba5900",
"paired_at": 1791299138.656342,
"last_seen": 1791299138.656342
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"port": 8873,
"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": "135790",
"pairing_created": 1791299136.6660163,
"setup_done": true,
"close_pair_after_first": false,
"relay_last_good": "http://192.168.2.156:8080",
"relay_device_id": "DESKTOP-RN5I625-59e6fdd69b09"
}
+2
View File
@@ -0,0 +1,2 @@
@echo off␍
"C:\Program Files\ykxmz\unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
+26
View File
@@ -0,0 +1,26 @@
{
"port": 8898,
"server_name": "DESKTOP-RN5I625",
"allow_run": true,
"allow_power": true,
"allow_screen": true,
"start_minimized": false,
"allow_new_pairing": true,
"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": "242524",
"pairing_created": 1791335816.802805
}
+26
View File
@@ -0,0 +1,26 @@
{
"port": 8898,
"server_name": "DESKTOP-RN5I625",
"allow_run": true,
"allow_power": true,
"allow_screen": true,
"start_minimized": false,
"allow_new_pairing": true,
"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": "598318",
"pairing_created": 1791335863.3186042
}
+158
View File
@@ -0,0 +1,158 @@
============================================================
deploy.sh 实测
============================================================
仓库: /c/Users/shu/Desktop/deepseekagent/LanRemote
脚本: /c/Users/shu/Desktop/deepseekagent/LanRemote/relay/deploy.sh
── 1. 语法检查(bash -n)──
[PASS] deploy.sh 语法正确
[PASS] deploy-tune.sh 语法正确
── 2. 换行符 / BOM(.sh 必须 LF + 无 BOM)──
[PASS] deploy.sh 是 LF 且无 BOM
[PASS] deploy-tune.sh 是 LF 且无 BOM
── 3. --help ──
[PASS] --help 退出码为 0
[PASS] --help 有「用法」段
[PASS] --help 列出了 --prefix
[PASS] --help 提到了 TUNNEL_TOKEN
[PASS] --help 没有副作用
── 3b. 非法参数要报错退出 ──
[PASS] 未知参数退出码为 2
[PASS] 非法协议退出码为 2
── 4. 搭沙箱(桩命令 + 临时安装根)──
沙箱: /tmp/tmp.jb8lwoeXgw
安装根: /tmp/tmp.jb8lwoeXgw/root
桩命令: cloudflared curl free getent ip iptables nmcli python3 systemctl
── 5. 第一次部署(--skip-tunnel,不碰隧道)──
[PASS] 第一次退出码为 0
[PASS] relay.py 已部署
[PASS] admin.py 已部署
[PASS] landing.py 已部署
[PASS] showcase.py 已部署
[PASS] favicon.ico 已部署
[PASS] deploy-tune.sh 已部署
[PASS] watchdog.sh 已部署
[PASS] site_shots 已部署
[PASS] data 目录已建
[PASS] downloads 目录已建
[PASS] lanremote-relay.service 已生成
[PASS] lanremote-nettune.service 已生成
[PASS] lanremote-tunnel.service 已生成
[PASS] 备用 http2 单元已生成
[PASS] 看门狗 timer 已生成
生成的 relay unit 里应该有的关键项:
[PASS] relay unit 含 RELAY_POLL=60
[PASS] relay unit 含 RELAY_PORT=8080
[PASS] relay unit 含 python3
两个隧道单元的内容(默认主 quic+8 / 副本 http2+2):
[PASS] 主隧道单元用 quic
[PASS] 主隧道 8 条连接(实测最优配比)
[PASS] 副本隧道单元用 http2
[PASS] 副本隧道 2 条连接(实测最优配比)
[PASS] 主隧道 metrics 钉死 20241
[PASS] 副本隧道 metrics 钉死 20242
[PASS] nettune 只有一个入口脚本
[PASS] unit 里没有明文 Token
[PASS] 便捷命令 lanremote-url 已生成
[PASS] 便捷命令 lanremote-key 已生成
[PASS] 便捷命令 lanremote-status 已生成
── 6. 幂等性:放几个「不许被冲掉」的东西,再跑第二遍 ──
[PASS] 第二次退出码为 0
[PASS] 设备数据没被冲掉
[PASS] 管理钥匙没被冲掉
[PASS] 管理密码没被冲掉
[PASS] 公网地址没被冲掉
[PASS] relay_state.json 没被改(allowNew 保住了)
[PASS] allowNew 仍然是 false(没被拨回 true)
[PASS] 手工 config.yml 没被覆盖
[PASS] relay.py 内容稳定
[PASS] 第二遍没有新增 ✗ 错误
── 6b. 没变化时不该重启服务(别打断在线连接)──
[PASS] 第二遍没有重启中转(无变化)
[PASS] 第二遍没有重启隧道(无变化)
[PASS] 第二遍没有多余的 daemon-reload
[PASS] 输出里明确说了「内容没变、保持运行」
── 7. 凭据模式:给出域名时要能生成 config.yml ──
[PASS] 凭据模式退出码为 0
[PASS] config.yml 已生成
[PASS] config.yml 里的隧道 ID 取自凭据文件名
[PASS] config.yml 写入了域名
[PASS] config.yml 有兜底 404 规则(没有它 cloudflared 起不来)
[PASS] config.yml 指向本地中转端口
── 7c. 默认:两个隧道单元都要启用(协议级故障隔离)──
[PASS] 默认(不传 --protocol)退出码为 0
[PASS] 默认启用了主隧道
[PASS] 默认启用了副本隧道
[PASS] 默认没有关掉任何一个隧道
── 7d. --protocol quic:只跑主隧道 ──
[PASS] --protocol quic 退出码为 0
[PASS] quic 模式启用了主隧道
[PASS] quic 模式停用了副本隧道
[PASS] 输出说明了为什么停用副本
── 7e. --protocol http2:只跑副本隧道 ──
[PASS] --protocol http2 退出码为 0
[PASS] http2 模式启用了副本隧道
[PASS] http2 模式停用了主隧道
── 7f. --protocol 的非法值要报错 ──
[PASS] 非法协议退出码为 2
[PASS] both 是合法值
── 7b. 再跑一次(不给 --hostname),config.yml 不该被改 ──
[PASS] 再次运行退出码为 0
[PASS] 没给 --hostname 时 config.yml 保持不变
── 8. Token 模式 ──
[PASS] Token 模式退出码为 0
[PASS] Token 写进了 tunnel.env
[PASS] tunnel.env 内容正确
[PASS] unit 里没有明文 Token(只有 EnvironmentFile 引用)
[PASS] unit 用了 EnvironmentFile 读 Token
[PASS] unit 的 ExecStart 里用 --token ${TUNNEL_TOKEN}
── 8b. tunnel.env 权限应该是 600 ──
tunnel.env 权限: 644(Linux 上应为 600)
── 9. 缺文件时要明确报错,不能静默 ──
[PASS] 文件不全时明确失败(退出码 1)
[PASS] 报错信息说清了是「程序文件不全」
── 10. 桩命令确实被调用过(证明流程真的走了)──
[PASS] 调用过 systemctl enable
[PASS] 调用过 iptables
[PASS] 第一次安装做了 daemon-reload
── 11. 脚本里不许有写死的域名 ──
[PASS] deploy.sh 里没有写死的域名
[PASS] deploy-tune.sh 里没有写死的域名
[PASS] net-tune.sh 里没有写死的域名
[PASS] 示例用的是「你的域名」占位符
── 12. 有凭据但没给域名 → 不许乱猜,必须明确提示 ──
[调试] TMP=/tmp/tmp.jb8lwoeXgw
[调试] 凭据目录内容: deadbeef-0000-1111-2222-333344445555.json
[PASS] 没域名时退出码为 0(中转照装,只是隧道不启)
[PASS] 没域名时没有生成 config.yml(没有乱猜)
[PASS] 明确提示了「需要你提供公网域名」
[PASS] 提示里给了怎么补域名
[PASS] 提示里给了 Token 模式这个替代办法
[PASS] 隧道没配好时没有去启动它
[PASS] 中转仍然正常安装并启用
============================================================
结果: 通过 98 项,失败 0 项
============================================================
沙箱目录(想自己看就留着): /tmp/tmp.jb8lwoeXgw
View File
-16
View File
@@ -1,16 +0,0 @@
[Unit]
Description=LanRemote Cloudflare 具名隧道
After=network-online.target lanremote-relay.service
Wants=network-online.target lanremote-relay.service
[Service]
Type=simple
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --protocol http2 --edge-ip-version 4 --ha-connections 1 --retries 5 --config /root/.cloudflared/config.yml run lanremote
Restart=always
RestartSec=5
MemoryMax=128M
StandardOutput=append:/root/lanremote-relay/tunnel.log
StandardError=append:/root/lanremote-relay/tunnel.log
[Install]
WantedBy=multi-user.target
+8
View File
@@ -0,0 +1,8 @@
{
"watch-probe-1": {
"name": "手表",
"token": "79ce7c72c1ad0526a6efe153375cc22c",
"paired_at": 1791298726.377039,
"last_seen": 1791298726.377039
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"port": 8871,
"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": "717525",
"pairing_created": 1791298724.895755,
"close_pair_after_first": false,
"relay_last_good": "http://192.168.2.156:8080",
"relay_device_id": "DESKTOP-RN5I625-e35d64d5d85b"
}
+178
View File
@@ -0,0 +1,178 @@
#!/bin/bash
# quic+6 vs http2+4:用**真实长轮询**对照,方法完全一致。
#
# 为什么必须用长轮询测:
# 之前用 /v1/health 那种"每次新建连接"的方式测,
# 量到的是冷启动开销,和真实业务(复用连接的长轮询)差很多。
# 而且那种方式会看到 18 秒的尾巴 —— 长轮询完全没有。
#
# 为什么这次结果可信:
# 上一轮 A/B 里 quic 那组因为**两次探针用了同一个配对码**,
# 第二次注册被"一个码只能一台设备"规则拒掉,token 拿到空的,
# 8 次全 401 —— 那一轮数据作废。这次每个探针都用唯一的新码。
#
# 为什么值得测 QUIC:
# 刚才查出来:隧道连接 30 分钟 0 掉线、0 重连,很稳;
# 但请求级偶发 000(10 分钟 6 次)。
# 这是 HTTP/2 over TCP 的**队头阻塞** ——
# 一条 TCP 上丢一个包,那条连接上所有流一起卡。
# QUIC 每个流独立恢复,理论上正好治这个。
set -u
PUB=https://relay.xxmxxs.top
UA='LanRemote/1.8.1'
SVC=/etc/systemd/system/lanremote-tunnel.service
BACKUP=/root/lanremote-tunnel.service.bak.q.$(date +%s)
cp -a "$SVC" "$BACKUP"
ROUNDS=${1:-10}
set_params() {
local proto="$1" conns="$2"
if grep -q -- '--protocol' "$SVC"; then
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
fi
if grep -q -- '--ha-connections' "$SVC"; then
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
fi
systemctl daemon-reload
systemctl restart lanremote-tunnel
}
wait_ready() {
local want="$1"
for i in $(seq 1 30); do
sleep 5
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
[ "${n:-0}" -ge "$want" ] && return 0
done
return 1
}
new_probe() {
# 每次都用全新的 deviceId + 全新的配对码,避免被"一码一机"拒掉
local stamp
stamp=$(date +%s%N | cut -c1-13)
local dev="qprobe-${stamp}"
local code
code=$(printf "%06d" $(( (stamp / 7) % 1000000 )))
local reg
reg=$(curl -s --max-time 30 -X POST "$PUB/v1/agent/register" \
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
-d "{\"deviceId\":\"$dev\",\"code\":\"$code\",\"name\":\"qprobe\",\"version\":\"1.8.1\"}")
local tok
tok=$(echo "$reg" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
PROBE_DEV="$dev"
PROBE_TOK="$tok"
PROBE_CODE="$code"
}
cleanup_probe() {
local key
key=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
[ -n "${PROBE_DEV:-}" ] && curl -s --max-time 20 -X POST \
"http://127.0.0.1:8080/v1/admin/delete" -H "X-Admin: $key" \
-H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$PROBE_DEV\"}" > /dev/null 2>&1
}
run_round() {
local label="$1" proto="$2" conns="$3"
echo "════════ $label ════════"
set_params "$proto" "$conns"
if wait_ready "$conns"; then
echo " 连接就绪($(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') 条)"
else
echo " ! 连接没建齐,仍然继续测"
fi
echo " 落点:"
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep 'server_locations.*} 1' | sed 's/^/ /'
sleep 15
new_probe
if [ -z "${PROBE_TOK:-}" ]; then
echo " [失败] 探针注册没拿到 token,这轮作废"
echo "$label|REGFAIL|0|0|0" >> /tmp/qab.txt
return 1
fi
echo " 探针 code=$PROBE_CODE token=${#PROBE_TOK} 字符"
local mark
mark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
local ok=0 fail=0 times=() fails=""
for i in $(seq 1 "$ROUNDS"); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 100 \
-X POST "$PUB/v1/agent/poll" -H "X-Token: $PROBE_TOK" \
-H "User-Agent: $UA")
code=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
if [ "$code" = "200" ]; then
ok=$((ok+1)); times+=("$ms")
printf " %2d: 200 %6dms\n" "$i" "$ms"
else
fail=$((fail+1)); fails="$fails ${i}:${code}"
printf " %2d: %-4s **失败**\n" "$i" "$code"
fi
done
local stats
if [ "${#times[@]}" -gt 0 ]; then
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
printf "中位 %d P90 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]}')
else
stats="无数据"
fi
local reconn
reconn=$(tail -n +$((mark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null \
| grep -c 'Registered tunnel connection' || echo 0)
echo " == $ok/$ROUNDS 成功 耗时: $stats ms 本轮重连 $reconn 次"
echo " 失败:$fails"
echo "$label|$ok|$ROUNDS|$((ok*100/ROUNDS))|$stats|$reconn" >> /tmp/qab.txt
cleanup_probe
echo
}
rm -f /tmp/qab.txt
# 先基线,再候选 —— 同一网络条件下先后测,尽量公平
run_round "http2+4" http2 4 || true
run_round "quic+6" quic 6 || true
echo "════════ 汇总 ════════"
printf " %-10s %-10s %-8s %-32s %s\n" "配置" "成功" "可用率" "耗时" "本轮重连"
while IFS='|' read -r label ok total pct stats reconn; do
if [ "$ok" = "REGFAIL" ]; then
printf " %-10s 探针注册失败,作废\n" "$label"
else
printf " %-10s %-10s %-8s %-32s %s\n" "$label" "$ok/$total" "${pct}%" "$stats" "$reconn"
fi
done < /tmp/qab.txt
echo
echo "════════ 决策 ════════"
B_OK=$(grep '^http2+4' /tmp/qab.txt | cut -d'|' -f4)
Q_OK=$(grep '^quic+6' /tmp/qab.txt | cut -d'|' -f4)
B_REC=$(grep '^http2+4' /tmp/qab.txt | cut -d'|' -f6)
Q_REC=$(grep '^quic+6' /tmp/qab.txt | cut -d'|' -f6)
echo " 基线: 可用率 ${B_OK:-?}% 重连 ${B_REC:-?}"
echo " 候选: 可用率 ${Q_OK:-?}% 重连 ${Q_REC:-?}"
if [ "${Q_OK:-0}" -ge "${B_OK:-0}" ] && [ "${Q_OK:-0}" -ge 90 ]; then
echo " → 采用 quic+6"
else
echo " → 保留 http2+4"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
fi
sleep 15
echo
echo " 最终参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ')"
echo " 备份: $BACKUP"
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# TCP 重传调优的受控 A/B。
#
# 背景(实测):
# TCPTimeouts: 3145 · TCPLostRetransmit: 2010
# 这些多秒/多分钟的尾巴全是 TCP 重传超时造成的。
# 这台机器内核是定制的,**没有 BBR**(只有 reno/cubic),
# 也没有任何替代 qdisc(只有 noqueue),所以只能在
# 现有 cubic 上调 TCP 的重传行为。
#
# 三个候选开关(都对症"细流 + 高丢包"这个场景):
# tcp_thin_linear_timeouts 细流不搞 RTO 指数退避,重传来得更快
# tcp_fack 前向确认,丢包恢复更积极
# tcp_retries2 重传几次就放弃(8 太大,卡十几分钟)
#
# 每轮:设参数 -> 打 60 次 HTTP -> 记录成功率/耗时分布 -> 下一轮
# 全部跑完回滚,最后按数据决定采用哪组。
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.1'
N=60
# 保存原始值,跑完恢复
declare -A ORIG
for k in tcp_thin_linear_timeouts tcp_fack tcp_retries2 tcp_autocorking; do
ORIG[$k]=$(sysctl -n net.ipv4.$k 2>/dev/null || echo "")
done
restore() {
for k in "${!ORIG[@]}"; do
[ -n "${ORIG[$k]}" ] && sysctl -qw net.ipv4.$k="${ORIG[$k]}" 2>/dev/null || true
done
}
measure() {
local label="$1"
local ok=0 fail=0 times=()
for i in $(seq 1 "$N"); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
-H "User-Agent: $UA" "$BASE/v1/health")
c=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
if [ "$c" = "200" ]; then ok=$((ok+1)); times+=("$ms"); else fail=$((fail+1)); fi
done
local pct=$((ok * 100 / N))
local stats="无数据"
if [ "${#times[@]}" -gt 0 ]; then
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
fi
printf " %-34s %3d%% (%d/%d) %s ms\n" "$label" "$pct" "$ok" "$N" "$stats"
echo "$label|$pct|$ok|$stats" >> /tmp/tcpab.txt
}
rm -f /tmp/tcpab.txt
echo "════════ 原始值 ════════"
for k in "${!ORIG[@]}"; do printf " %-30s = %s\n" "$k" "${ORIG[$k]}"; done
echo
echo "════════ 各组合实测(每轮 $N 次)════════"
echo " [1/6] 基线(不改)"
measure "基线"
echo " [2/6] thin_linear_timeouts=1"
sysctl -qw net.ipv4.tcp_thin_linear_timeouts=1
measure "thin=1"
echo " [3/6] thin=1 + fack=1"
sysctl -qw net.ipv4.tcp_fack=1
measure "thin=1 fack=1"
echo " [4/6] thin=1 fack=1 + retries2=5"
sysctl -qw net.ipv4.tcp_retries2=5
measure "thin=1 fack=1 retries2=5"
echo " [5/6] thin=1 fack=1 retries2=5 + autocorking=0"
sysctl -qw net.ipv4.tcp_autocorking=0
measure "全部开启"
echo " [6/6] 回滚到基线再测一次(排除时间漂移)"
restore
measure "回滚后基线"
echo
echo "════════ 汇总(按可用率排序)════════"
sort -t'|' -k2 -rn /tmp/tcpab.txt | while IFS='|' read -r label pct ok stats; do
printf " %-34s %3s%% %s\n" "$label" "$pct" "$stats"
done
echo
echo "════════ 按数据决定 ════════"
BEST=$(sort -t'|' -k2 -rn /tmp/tcpab.txt | head -1)
BEST_LABEL=$(echo "$BEST" | cut -d'|' -f1)
BEST_PCT=$(echo "$BEST" | cut -d'|' -f2)
echo " 最好的是: $BEST_LABEL ($BEST_PCT%)"
case "$BEST_LABEL" in
"基线"|"回滚后基线")
echo " → 调优没有收益,保持默认(只留已验证过的 keepalive 那几项)"
restore
;;
"thin=1")
sysctl -qw net.ipv4.tcp_thin_linear_timeouts=1
echo " → 只启用 thin_linear_timeouts"
;;
"thin=1 fack=1")
sysctl -qw net.ipv4.tcp_thin_linear_timeouts=1
sysctl -qw net.ipv4.tcp_fack=1
echo " → 启用 thin_linear_timeouts + fack"
;;
*)
sysctl -qw net.ipv4.tcp_thin_linear_timeouts=1
sysctl -qw net.ipv4.tcp_fack=1
sysctl -qw net.ipv4.tcp_retries2=5
sysctl -qw net.ipv4.tcp_autocorking=0
echo " → 全部启用"
;;
esac
echo
echo " 当前生效值:"
for k in tcp_thin_linear_timeouts tcp_fack tcp_retries2 tcp_autocorking; do
printf " %-30s = %s\n" "$k" "$(sysctl -n net.ipv4.$k 2>/dev/null)"
done
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# 受控 A/B:隧道协议和连接数,哪个在"稳 + 快"上更好。
#
# 背景:
# 实测隧道大多稳(12/12),但每个请求稳定吃 ~1100ms 回源延迟。
# 而且日志里 i/o timeout 不少(最近 800 行 104 次),
# 说明有些 edge IP 连得很差(实测最慢那个 1331ms,最快的 146ms)。
#
# 假定:连接数多一些,撞上"慢 IP"的窗口就小一些。
# QUIC 在多丢包下恢复更好(没有跨流的队头阻塞),但之前测
# "http2 比 quic 稳" —— 那是连接建立阶段的观感,这次量真实请求。
#
# 组合:
# http2 + 4 (当前配置,基线)
# http2 + 6
# quic + 4
# quic + 6
#
# 每个组合:重启隧道 -> 等连接建齐 -> 打 20 次 -> 记录
# 成功率 / 中位耗时 / P90 / 连接数
set -u
URL=https://relay.xxmxxs.top/v1/health
UA='LanRemote/1.8.0'
SVC=/etc/systemd/system/lanremote-tunnel.service
BACKUP=/root/lanremote-tunnel.service.bak.$(date +%s)
cp -a "$SVC" "$BACKUP"
echo " 备份: $BACKUP"
echo " 原参数:"
grep -A3 'ExecStart' "$SVC" | grep -E 'protocol|ha-connections|edge-ip' | sed 's/^/ /'
echo
run_combo() {
local proto="$1" conns="$2" label="$3"
echo "════════ $label ════════"
# 改参数
if grep -q '\-\-protocol' "$SVC"; then
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
fi
if grep -q '\-\-ha-connections' "$SVC"; then
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
fi
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ' | sed 's/^/ 参数: /'
echo
systemctl daemon-reload
systemctl restart lanremote-tunnel
# 等连接建齐
built=0
for i in $(seq 1 24); do
sleep 5
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
if [ "${n:-0}" -ge "$conns" ]; then built="$n"; break; fi
done
echo " 连接建齐: ${built:-0}/$conns(等了 ${i}0 秒)"
# 预热几次,跳过建连阶段的冷启动
for i in 1 2 3; do curl -s -o /dev/null --max-time 20 -H "User-Agent: $UA" "$URL"; sleep 1; done
# 正式测
ok=0; times=()
for i in $(seq 1 20); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30 -H "User-Agent: $UA" "$URL")
code=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
times+=("$ms")
[ "$code" = "200" ] && ok=$((ok+1))
sleep 1
done
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
p90 = a[int(NR*0.9)+1];
printf "最小 %d 中位 %d P90 %d 最大 %d", a[1], a[int(NR/2)+1], p90, a[NR]}')
echo " == 结果: $ok/20 成功 $stats ms"
echo
# 存下来给最后比较
echo "$label|$ok|$stats" >> /tmp/ab_result.txt
}
rm -f /tmp/ab_result.txt
run_combo http2 4 "A. http2 + 4(基线,当前配置)"
run_combo http2 6 "B. http2 + 6"
run_combo quic 4 "C. quic + 4"
run_combo quic 6 "D. quic + 6"
echo "════════ 汇总 ════════"
cat /tmp/ab_result.txt | sed 's/^/ /'
echo
echo "════════ 回滚到基线(http2 + 4),等后面按结果再定 ════════"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
sleep 20
echo " 已回滚。当前参数:"
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# 长时间对照:http2+4(基线) vs quic+6(候选)。
#
# 为什么要跑长的:
# 短测(20 次)看不出稳定性。而且历史上出过一次"QUIC 可用率 48%",
# 和这次短测的 20/20 矛盾 —— 必须用足够长的样本、并且**同时**记录
# 连接数波动和重连次数,才能判断到底是协议问题还是当时的网络问题。
#
# 每轮 5 分钟,每 3 秒一次请求(约 100 个样本),记录:
# 成功率 / 中位 / P90 / 最大 / 连接数最低值 / 连接的落点
set -u
URL=https://relay.xxmxxs.top/v1/health
UA='LanRemote/1.8.0'
SVC=/etc/systemd/system/lanremote-tunnel.service
BACKUP=/root/lanremote-tunnel.service.bak.long.$(date +%s)
cp -a "$SVC" "$BACKUP"
DURATION=300 # 每轮 5 分钟
INTERVAL=3 # 每 3 秒一次
set_params() {
local proto="$1" conns="$2"
if grep -q -- '--protocol' "$SVC"; then
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
fi
if grep -q -- '--ha-connections' "$SVC"; then
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
fi
systemctl daemon-reload
systemctl restart lanremote-tunnel
}
wait_ready() {
local want="$1"
for i in $(seq 1 30); do
sleep 5
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
if [ "${n:-0}" -ge "$want" ]; then
return 0
fi
done
return 1
}
measure() {
local label="$1"
local ok=0 fail=0 times=() minconn=99 reconnects=0
local start=$(date +%s)
local logmark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
echo " ── $label:跑 ${DURATION} 秒,每 ${INTERVAL} 秒一次 ──"
while [ $(( $(date +%s) - start )) -lt "$DURATION" ]; do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30 -H "User-Agent: $UA" "$URL")
code=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
if [ "$code" = "200" ]; then
ok=$((ok+1)); times+=("$ms")
else
fail=$((fail+1))
fi
n=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
[ -n "${n:-}" ] && [ "$n" -lt "$minconn" ] && minconn="$n"
sleep "$INTERVAL"
done
# 这一轮新注册了多少次(= 重连次数)
nowmark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
reconnects=$(tail -n +$((logmark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null \
| grep -c 'Registered tunnel connection' || echo 0)
total=$((ok+fail))
pct=0; [ "$total" -gt 0 ] && pct=$((ok * 100 / total))
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {
if (NR==0) { print "无数据"; exit }
printf "中位 %d P90 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]}')
echo " == 结果: $ok/$total = ${pct}% $stats ms"
echo " 连接数最低 $minconn 本轮重连 $reconnects 次"
echo "$label|$ok|$total|$pct|$stats|$minconn|$reconnects" >> /tmp/longab.txt
echo
}
rm -f /tmp/longab.txt
echo "════════ 第一轮:http2 + 4(基线)════════"
set_params http2 4
wait_ready 4 && echo " 连接就绪" || echo " ! 连接没建齐,继续测"
sleep 10
measure "http2+4"
echo "════════ 第二轮:quic + 6(候选)════════"
set_params quic 6
wait_ready 6 && echo " 连接就绪" || echo " ! 连接没建齐,继续测"
sleep 10
measure "quic+6"
echo "════════ 汇总 ════════"
printf " %-12s %-14s %-10s %-22s %-10s %s\n" "配置" "成功" "可用率" "延迟" "连接最低" "重连"
while IFS='|' read -r label ok total pct stats minconn reconnects; do
printf " %-12s %-14s %-10s %-22s %-10s %s\n" "$label" "$ok/$total" "${pct}%" "$stats" "$minconn" "$reconnects"
done < /tmp/longab.txt
echo
echo "════════ 按结果决定是否改配置 ════════"
BASE_PCT=$(grep '^http2+4' /tmp/longab.txt | cut -d'|' -f4)
QUIC_PCT=$(grep '^quic+6' /tmp/longab.txt | cut -d'|' -f4)
if [ "${QUIC_PCT:-0}" -ge "${BASE_PCT:-0}" ] && [ "${QUIC_PCT:-0}" -ge 90 ]; then
echo " → quic+6 可用率 ${QUIC_PCT}% >= 基线 ${BASE_PCT}%,保留 quic+6"
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
else
echo " → quic+6 可用率 ${QUIC_PCT}% 不如基线 ${BASE_PCT}%,回滚"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
sleep 20
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
fi
echo
echo " 当前连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
echo " 备份: $BACKUP"
+150
View File
@@ -0,0 +1,150 @@
#!/bin/bash
# 严谨对照 v2:http2+4 vs quic+6,用**真实的长轮询模式**测。
#
# v1 的问题:
# * 只跑 5 分钟、每 3 秒一个新连接 —— 那是"冷启动"模式,
# 而真实业务是**长轮询复用同一条连接**,两者差别很大。
# * 判定规则太粗(98% vs 100% 就回滚),
# 忽略了 P90 从 16.8 秒改善到 2.8 秒这个数量级的差异。
#
# v2 的做法:
# * 用 curl 一次给多个 URL —— 复用同一条连接,贴近长轮询
# * 每轮 10 分钟
# * 记录 成功率 / 中位 / P90 / P99 / 最大 / 失败时刻
# * 用真实的长轮询接口 /v1/agent/poll(挂 60 秒),
# 量"一次完整长轮询"的耗时分布 —— 这才是用户真正经历的
set -u
UA='LanRemote/1.8.0'
PUB=https://relay.xxmxxs.top
SVC=/etc/systemd/system/lanremote-tunnel.service
BACKUP=/root/lanremote-tunnel.service.bak.v2.$(date +%s)
cp -a "$SVC" "$BACKUP"
set_params() {
local proto="$1" conns="$2"
if grep -q -- '--protocol' "$SVC"; then
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
fi
if grep -q -- '--ha-connections' "$SVC"; then
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
else
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
fi
systemctl daemon-reload
systemctl restart lanremote-tunnel
}
wait_ready() {
local want="$1"
for i in $(seq 1 30); do
sleep 5
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
[ "${n:-0}" -ge "$want" ] && return 0
done
return 1
}
# ---- 用真实长轮询接口测 ----
# 注册一台探针设备,poll 会挂 60 秒再返回(没有数据时)
probe_device() {
DEV="probev2-$(date +%s)"
REG=$(curl -s --max-time 30 -X POST "$PUB/v1/agent/register" \
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
-d "{\"deviceId\":\"$DEV\",\"code\":\"777777\",\"name\":\"probev2\",\"version\":\"1.8.0\"}")
echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null
echo "$DEV" > /tmp/probe_dev.txt
}
measure_poll() {
local label="$1" token="$2" rounds="${3:-8}"
local ok=0 fail=0 times=() fails=""
echo " ── $label:$rounds 次真实长轮询(每次挂 60 秒)──"
for i in $(seq 1 "$rounds"); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 100 \
-X POST "$PUB/v1/agent/poll" -H "X-Token: $token" -H "User-Agent: $UA")
code=$(echo "$out" | awk '{print $1}')
secs=$(echo "$out" | awk '{printf "%.0f", $2*1000}')
if [ "$code" = "200" ]; then
ok=$((ok+1)); times+=("$secs")
printf " %d: 200 %sms\n" "$i" "$secs"
else
fail=$((fail+1)); fails="$fails ${i}:${code}"
printf " %d: %s **失败**\n" "$i" "$code"
fi
done
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {
if (NR==0) { print "无数据"; exit }
printf "中位 %d P90 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]}')
echo " == $ok/$rounds 成功 $stats ms 失败:$fails"
echo "$label|$ok|$rounds|$stats|$fails" >> /tmp/v2.txt
echo
}
rm -f /tmp/v2.txt
echo "════════ 基线:http2 + 4 ════════"
set_params http2 4
wait_ready 4 && echo " 连接就绪" || echo " ! 没建齐"
sleep 15
TOKEN=$(probe_device)
echo " 探针 token ${#TOKEN} 字符"
measure_poll "http2+4" "$TOKEN" 8
echo "════════ 候选:quic + 6 ════════"
set_params quic 6
wait_ready 6 && echo " 连接就绪" || echo " ! 没建齐"
sleep 15
TOKEN=$(probe_device)
echo " 探针 token ${#TOKEN} 字符"
measure_poll "quic+6" "$TOKEN" 8
echo "════════ 汇总(每次长轮询的耗时,理想值 ≈ 61000ms)════════"
printf " %-10s %-10s %s\n" "配置" "成功" "耗时"
while IFS='|' read -r label ok total stats fails; do
printf " %-10s %-10s %s\n" "$label" "$ok/$total" "$stats"
done < /tmp/v2.txt
echo
echo "════════ 决策:主要看尾部延迟(用户体感就是这个)════════"
B=$(grep '^http2+4' /tmp/v2.txt | cut -d'|' -f4)
Q=$(grep '^quic+6' /tmp/v2.txt | cut -d'|' -f4)
echo " 基线 $B"
echo " 候选 $Q"
# 取 P90 数字比较
BP=$(echo "$B" | grep -oE 'P90 [0-9]+' | grep -oE '[0-9]+')
QP=$(echo "$Q" | grep -oE 'P90 [0-9]+' | grep -oE '[0-9]+')
echo
if [ -n "${BP:-}" ] && [ -n "${QP:-}" ]; then
if [ "$QP" -lt "$BP" ]; then
echo " → quic+6 的 P90(${QP}ms)优于基线(${BP}ms),采用 quic+6"
# 已经设成 quic 6 了,不用动
else
echo " → quic+6 的 P90(${QP}ms)不如基线(${BP}ms),回滚"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
fi
else
echo " → 数据不足,保守回滚"
cp -a "$BACKUP" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
fi
sleep 15
echo
echo " 最终参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ')"
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
# 清探针
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
DEV=$(cat /tmp/probe_dev.txt 2>/dev/null)
[ -n "${DEV:-}" ] && curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
echo " 备份: $BACKUP"
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# 隧道对照 v3:验证「QUIC 失败是因为 MTU」这个假设,并选出最稳的组合。
#
# 已查明的事实:
# 1. cloudflared 用 QUIC 时反复报
# "failed to dial to edge with quic: timeout: no recent network activity"
# 连十几个 IP 才成功一次 —— 握手阶段就掉包。
# 2. 我们给 TCP 做了 MSS 钳制(--set-mss 1400),
# 但 **QUIC 走 UDP,TCPMSS 规则完全不生效**。
# 3. 路径 MTU 实测 1472(1500 的包不通)。
# QUIC 默认用 ~1452 字节的包,很容易被中间设备丢掉,
# 而 UDP 丢了不会回 ICMP,客户端只能等到超时。
#
# 所以对照三组:
# A. quic + 6(现状,MTU 1500) —— 预期握手仍不稳
# B. quic + 6 + wlan0 MTU 1400 —— 如果假设成立,握手会变稳
# C. http2 + 8(TCP 吃 MSS 钳制,连接多) —— 对照组
#
# 每组记录:连接建齐耗时 / 建齐几条 / 60 次 HTTP 可用率
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.1'
SVC=/etc/systemd/system/lanremote-tunnel.service
BACKUP=/root/tunnel.bak.v3.$(date +%s)
cp -a "$SVC" "$BACKUP"
N=60
set_params() {
local proto="$1" conns="$2"
cp -a "$BACKUP" "$SVC"
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
}
set_mtu() {
local mtu="$1"
ip link set wlan0 mtu "$mtu" 2>/dev/null && echo " wlan0 MTU -> $(cat /sys/class/net/wlan0/mtu)"
}
# 等连接建齐,返回耗时秒数
wait_conns() {
local want="$1" limit="$2" t0 t1 n
t0=$(date +%s)
while true; do
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
n=${n:-0}
t1=$(date +%s)
if [ "$n" -ge "$want" ] || [ $((t1 - t0)) -ge "$limit" ]; then
echo "$n $((t1 - t0))"
return
fi
sleep 3
done
}
run_group() {
local label="$1" proto="$2" conns="$3" mtu="$4"
echo "════════ $label ════════"
[ "$mtu" != "keep" ] && set_mtu "$mtu"
local mark; mark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
set_params "$proto" "$conns"
local res; res=$(wait_conns "$conns" 150)
local n; n=$(echo "$res" | awk '{print $1}')
local secs; s=$(echo "$res" | awk '{print $2}')
echo " 建齐 $n/$conns 条,用了 ${s} 秒"
# 这一轮建立过程中失败了多少次
local dialfail; dialfail=$(tail -n +$((mark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null \
| grep -c 'Failed to dial' || echo 0)
echo " 建立期间 dial 失败 $dialfail 次"
# 可用率
local ok=0 times=()
for i in $(seq 1 "$N"); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
-H "User-Agent: $UA" "$BASE/v1/health")
c=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
if [ "$c" = "200" ]; then ok=$((ok+1)); times+=("$ms"); fi
done
local pct=$((ok * 100 / N))
local stats="无数据"
if [ "${#times[@]}" -gt 0 ]; then
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
fi
printf " 可用率 %d%% (%d/%d) 耗时 %s ms\n" "$pct" "$ok" "$N" "$stats"
echo "$label|$proto|$conns|$mtu|$n|$s|$dialfail|$pct|$stats" >> /tmp/v3.txt
echo
}
rm -f /tmp/v3.txt
echo " 原始 wlan0 MTU: $(cat /sys/class/net/wlan0/mtu)"
echo
run_group "A. quic+6 MTU1500" quic 6 keep
run_group "B. quic+6 MTU1400" quic 6 1400
run_group "C. http2+8 MTU1400" http2 8 keep
# 恢复 MTU
set_mtu 1500
echo "════════ 汇总 ════════"
printf " %-22s %-6s %-4s %-8s %-9s %-8s %s\n" "组" "协议" "连接" "建齐/耗时" "dial失败" "可用率" "延迟"
while IFS='|' read -r label proto conns mtu n s dialfail pct stats; do
printf " %-22s %-6s %-4s %-8s %-9s %-8s %s\n" \
"$label" "$proto" "$conns" "${n}/${conns} ${s}s" "$dialfail" "${pct}%" "$stats"
done < /tmp/v3.txt
echo
echo "════════ 决策:可用率优先,其次看建齐速度 ════════"
BEST=$(sort -t'|' -k8 -rn /tmp/v3.txt | head -1)
BL=$(echo "$BEST" | cut -d'|' -f1)
BP=$(echo "$BEST" | cut -d'|' -f2)
BC=$(echo "$BEST" | cut -d'|' -f3)
BM=$(echo "$BEST" | cut -d'|' -f4)
echo " 最好: $BL(可用率 $(echo "$BEST" | cut -d'|' -f8)%)"
cp -a "$BACKUP" "$SVC"
sed -i "s/--protocol [a-z0-9]*/--protocol $BP/" "$SVC"
sed -i "s/--ha-connections [0-9]*/--ha-connections $BC/" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
echo " 已采用: --protocol $BP --ha-connections $BC"
echo " MTU: $(cat /sys/class/net/wlan0/mtu)"
echo " 备份: $BACKUP"
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# 1) 把 MTU 1400 固化下来(重启也在)
# 2) 在 MTU 1400 下对照 quic+6 / quic+8 / quic+10
#
# 为什么要固化 MTU:
# QUIC 走 UDP,**我们那条 --set-mss 1400 的 iptables 规则只管 TCP**。
# 实测把 wlan0 MTU 从 1500 降到 1400 之后:
# QUIC 建齐 6 条连接的时间 28 秒 -> 13 秒
# dial 失败次数 2 -> 1
# 原因:UDP 丢包不会回 ICMP,QUIC 发了超出路径 MTU 的包只能等超时。
# 把接口 MTU 压到 1400,QUIC 的包就都在安全范围内。
#
# 为什么还要试更多连接:
# 已经看到 http2+8 建齐只要 12 秒且 0 失败(QUIC+6 是 28 秒/2 次失败)。
# 连接多 = 单条掉线影响小。但 QUIC 的连接建立更贵,得实测平衡点。
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.2'
SVC=/etc/systemd/system/lanremote-tunnel.service
NETTUNE=/etc/systemd/system/lanremote-nettune.service
BACKUP=/root/tunnel.bak.v4.$(date +%s)
cp -a "$SVC" "$BACKUP"
N=60
MTU=1400
MSS=1360
echo "════════ 1. 固化 MTU(写进 net-tune 服务)════════"
echo " 现在的 net-tune 服务:"
systemctl cat lanremote-nettune 2>/dev/null | grep -E 'ExecStart' | sed 's/^/ /'
# 把 MTU 设置追加进去(幂等:已有的先删掉)
cp -a "$NETTUNE" "${NETTUNE}.bak.$(date +%s)" 2>/dev/null || true
python3 - <<'PYEOF'
from pathlib import Path
p = Path("/etc/systemd/system/lanremote-nettune.service")
if not p.is_file():
print(" ! net-tune 服务不存在,跳过")
raise SystemExit(0)
text = p.read_text(encoding="utf-8")
# 去掉旧的 MTU/MSS 行
lines = [l for l in text.splitlines()
if "ip link set wlan0 mtu" not in l and "--set-mss" not in l]
out = []
for line in lines:
out.append(line)
# 在每个 ExecStart 之后补上 MTU 设置
if line.strip().startswith("ExecStart="):
out.append("ExecStart=/sbin/ip link set wlan0 mtu 1400")
out.append("ExecStart=/sbin/iptables -t mangle -C OUTPUT -p tcp "
"--tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360 || "
"/sbin/iptables -t mangle -A OUTPUT -p tcp "
"--tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360")
p.write_text("\n".join(out) + "\n", encoding="utf-8")
print(" 已写入 MTU 1400 + MSS 1360")
PYEOF
systemctl daemon-reload
echo " 立即执行一次:"
ip link set wlan0 mtu $MTU && echo " wlan0 MTU -> $(cat /sys/class/net/wlan0/mtu)"
iptables -t mangle -C OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss $MSS 2>/dev/null \
|| iptables -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss $MSS
echo " MSS 规则: $(iptables -t mangle -S OUTPUT | grep TCPMSS | head -1)"
echo " net-tune 现在的内容:"
systemctl cat lanremote-nettune 2>/dev/null | grep ExecStart | sed 's/^/ /'
echo " 服务状态: $(systemctl is-enabled lanremote-nettune 2>/dev/null) / $(systemctl is-active lanremote-nettune 2>/dev/null)"
wait_conns() {
local want="$1" limit="$2" t0 t1 n
t0=$(date +%s)
while true; do
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
n=${n:-0}
t1=$(date +%s)
[ "$n" -ge "$want" ] || [ $((t1 - t0)) -ge "$limit" ] && { echo "$n $((t1 - t0))"; return; }
sleep 3
done
}
run_group() {
local conns="$1"
echo "════════ quic + $conns (MTU $MTU)════════"
cp -a "$BACKUP" "$SVC"
sed -i "s/--protocol [a-z0-9]*/--protocol quic/" "$SVC"
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
systemctl daemon-reload
local mark; mark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
systemctl restart lanremote-tunnel
local res; res=$(wait_conns "$conns" 150)
local n s
n=$(echo "$res" | awk '{print $1}'); s=$(echo "$res" | awk '{print $2}')
local df; df=$(tail -n +$((mark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Failed to dial' || echo 0)
echo " 建齐 $n/$conns,用 ${s} 秒,dial 失败 ${df} 次"
local ok=0 times=()
for i in $(seq 1 "$N"); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H "User-Agent: $UA" "$BASE/v1/health")
c=$(echo "$out" | awk '{print $1}'); ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
[ "$c" = "200" ] && { ok=$((ok+1)); times+=("$ms"); }
done
local pct=$((ok * 100 / N))
local stats="无数据"
[ "${#times[@]}" -gt 0 ] && stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
printf " 可用率 %d%% (%d/%d) %s ms\n" "$pct" "$ok" "$N" "$stats"
echo "quic+$conns|$n|$s|$df|$pct|$stats" >> /tmp/v4.txt
echo
}
rm -f /tmp/v4.txt
run_group 6
run_group 8
run_group 10
echo "════════ 汇总 ════════"
printf " %-10s %-14s %-10s %-8s %s\n" "配置" "建齐/耗时" "dial失败" "可用率" "延迟"
while IFS='|' read -r label n s df pct stats; do
printf " %-10s %-14s %-10s %-8s %s\n" "$label" "${n}条 ${s}s" "$df" "${pct}%" "$stats"
done < /tmp/v4.txt
echo
BESTN=$(sort -t'|' -k5 -rn /tmp/v4.txt | head -1 | sed 's/quic+//' | cut -d'|' -f1)
echo " → 采用 quic + $BESTN"
cp -a "$BACKUP" "$SVC"
sed -i "s/--protocol [a-z0-9]*/--protocol quic/" "$SVC"
sed -i "s/--ha-connections [0-9]*/--ha-connections $BESTN/" "$SVC"
systemctl daemon-reload
systemctl restart lanremote-tunnel
sleep 5
echo " 最终参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ')"
echo " MTU: $(cat /sys/class/net/wlan0/mtu)"
echo " 备份: $BACKUP"
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# 1.8.0 发布验收 —— 从公网走一遍用户会走的路
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.0'
echo "════════ 1. 中转版本 ════════"
curl -s --max-time 15 "$BASE/v1/health" | head -c 200
echo
echo
echo "════════ 2. 官网 ════════"
code=$(curl -s -o /tmp/site.html -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/")
echo " HTTP $code $(wc -c < /tmp/site.html) 字节"
echo " 版本号显示: $(grep -o '1\.8\.0' /tmp/site.html | head -1 || echo '(页面上没直接标版本,正常)')"
echo " 下载按钮指向:"
grep -o 'href="/download/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /'
echo
echo "════════ 3. 三个成品能不能下(只看前 1KB 和响应头)════════"
for f in ykxmz-setup-1.8.0.exe ykxmz-server-1.8.0.zip ykxmz-watch-1.8.0.apk; do
hdr=$(curl -sI --max-time 30 -H "User-Agent: $UA" "$BASE/download/$f")
code=$(echo "$hdr" | head -1 | awk '{print $2}')
length=$(echo "$hdr" | grep -i '^content-length' | tr -d '\r' | awk '{print $2}')
kind=$(echo "$hdr" | grep -i '^content-type' | tr -d '\r' | cut -d' ' -f2)
# 真下 1KB 验证能读
got=$(curl -s --max-time 40 -r 0-1023 -o /tmp/chunk.bin -w '%{http_code}' \
-H "User-Agent: $UA" "$BASE/download/$f" 2>/dev/null)
real=$(wc -c < /tmp/chunk.bin 2>/dev/null || echo 0)
printf " %-32s HTTP %-4s 共 %-10s %s 取到 %s 字节\n" \
"$f" "$code" "${length:-?}" "${kind:-?}" "$real"
done
echo
echo "════════ 4. 管理后台 ════════"
PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null)
curl -s -c /tmp/ck.txt -o /dev/null --max-time 20 \
-d "username=xmz7891&password=$PASS" "$BASE/admin/login"
for p in "/zhongzhuanzhan" "/admin"; do
printf " %-20s HTTP %s\n" "$p" \
"$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/ck.txt --max-time 20 -H "User-Agent: $UA" "$BASE$p")"
done
curl -s -b /tmp/ck.txt --max-time 20 "$BASE/zhongzhuanzhan" -o /tmp/adm.html
echo " 管理页 $(wc -c < /tmp/adm.html) 字节"
for k in 'class="top"' 'class="hero"' 'class="nums"' 'class="card"' 'ykxmz 中转站'; do
if grep -q "$k" /tmp/adm.html; then echo " ✓ $k"; else echo " ✗ $k"; fi
done
echo
echo "════════ 5. 其它页面 ════════"
for p in "/shot/pc-main.png" "/favicon.ico"; do
printf " %-22s HTTP %s\n" "$p" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$BASE$p")"
done
echo
echo "════════ 6. 公网可用率(12 次)════════"
ok=0
for i in $(seq 1 12); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/v1/health")
[ "$c" = "200" ] && ok=$((ok + 1))
printf " %2d: %s\n" "$i" "$c"
sleep 1
done
echo " 可用 $ok/12"
echo
echo "════════ 7. 中转日志(看有没有报错)════════"
journalctl -u lanremote-relay --since '3 minutes ago' --no-pager 2>/dev/null \
| grep -icE 'traceback|nameerror|typeerror' | sed 's/^/ 错误行数: /'
tail -4 /root/lanremote-relay/relay.log | sed 's/^/ /'
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
# 1.8.0 发布验收(稳定版)
#
# 上一版脚本两个毛病:
# 1. 紧跟在中转重启之后跑,撞上隧道重连,一路 530
# 2. `curl -o /tmp/adm.html` 在 for 循环之后没生成文件
# (多半是前面某条 curl 把变量吃掉了)
# 这里都改掉:先等稳定,登录 cookie 单步来。
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.0'
echo "════════ 0. 先等隧道稳定 ════════"
for i in $(seq 1 10); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \
-H "User-Agent: $UA" "$BASE/v1/health")
printf " 第 %d 次: %s\n" "$i" "$c"
[ "$c" = "200" ] && break
sleep 3
done
echo
echo "════════ 1. 中转版本 ════════"
curl -s --max-time 15 "$BASE/v1/health" | head -c 180
echo
echo
echo "════════ 2. 官网 ════════"
curl -s --max-time 20 -H "User-Agent: $UA" "$BASE/" -o /tmp/site.html
echo " HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/") $(wc -c < /tmp/site.html) 字节"
grep -o 'href="/download/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /'
echo
echo "════════ 3. 三个成品(响应头 + 真下 4KB)════════"
for f in ykxmz-setup-1.8.0.exe ykxmz-server-1.8.0.zip ykxmz-watch-1.8.0.apk; do
rm -f /tmp/chunk.bin
hdr=$(curl -sI --max-time 30 -H "User-Agent: $UA" "$BASE/download/$f")
hcode=$(echo "$hdr" | head -1 | awk '{print $2}')
hlen=$(echo "$hdr" | grep -i '^content-length' | tr -d '\r' | awk '{print $2}')
htype=$(echo "$hdr" | grep -i '^content-type' | tr -d '\r' | cut -d' ' -f2)
curl -s --max-time 60 -r 0-4095 -o /tmp/chunk.bin -H "User-Agent: $UA" \
"$BASE/download/$f" >/dev/null 2>&1
real=$(wc -c < /tmp/chunk.bin 2>/dev/null || echo 0)
printf " %-30s HEAD %-4s 长度 %-10s %-34s 实体取到 %s 字节\n" \
"$f" "$hcode" "${hlen:-?}" "${htype:-?}" "$real"
done
echo
echo "════════ 4. 管理后台 ════════"
PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null)
rm -f /tmp/ck.txt
curl -s -c /tmp/ck.txt -o /dev/null --max-time 20 \
-d "username=xmz7891&password=$PASS" "$BASE/admin/login"
echo " cookie: $(grep -c lr_admin /tmp/ck.txt 2>/dev/null || echo 0) 条"
for p in "/zhongzhuanzhan" "/admin"; do
printf " %-20s HTTP %s\n" "$p" \
"$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/ck.txt \
--max-time 20 -H "User-Agent: $UA" "$BASE$p")"
done
curl -s -b /tmp/ck.txt --max-time 20 -H "User-Agent: $UA" \
"$BASE/zhongzhuanzhan" -o /tmp/adm.html
if [ -f /tmp/adm.html ]; then
echo " 管理页 $(wc -c < /tmp/adm.html) 字节"
for k in 'class="top"' 'class="hero"' 'class="nums"' 'class="card"' \
'ykxmz 中转站' 'id="pickAll"' 'id="netBtn"'; do
if grep -q "$k" /tmp/adm.html; then
echo " ✓ $k"
else
echo " ✗ $k"
fi
done
else
echo " [失败] 抓不到管理页"
fi
echo
echo "════════ 5. 静态资源 ════════"
for p in "/shot/pc-main.png" "/favicon.ico"; do
printf " %-22s HTTP %s\n" "$p" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$BASE$p")"
done
echo
echo "════════ 6. 公网可用率(15 次,间隔 2 秒)════════"
ok=0
for i in $(seq 1 15); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 \
-H "User-Agent: $UA" "$BASE/v1/health")
[ "$c" = "200" ] && ok=$((ok + 1))
printf " %2d: %s\n" "$i" "$c"
sleep 2
done
echo " 可用 $ok/15"
echo
echo "════════ 7. 中转有没有报错 ════════"
journalctl -u lanremote-relay --since '5 minutes ago' --no-pager 2>/dev/null \
| grep -icE 'traceback|nameerror|typeerror|attributeerror' \
| sed 's/^/ 错误行数: /'
tail -3 /root/lanremote-relay/relay.log | sed 's/^/ /'
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# 1.8.1 最终验收:从公网走一遍用户会走的全部路径
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.1'
echo "════════ 1. 中转版本和隧道 ════════"
curl -s --max-time 15 "$BASE/v1/health" | head -c 130
echo
echo " 隧道连接: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') 条"
echo " 协议参数: $(grep -c 'protocol quic' /etc/systemd/system/lanremote-tunnel.service) (1=quic)"
echo " 长轮询: $(tail -30 /root/lanremote-relay/relay.log | grep '长轮询' | tail -1 | grep -oE '[0-9]+ 秒')"
echo
echo "════════ 2. 官网 ════════"
curl -s --max-time 25 -H "User-Agent: $UA" "$BASE/" -o /tmp/site.html
echo " HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 25 -H "User-Agent: $UA" "$BASE/") $(wc -c < /tmp/site.html) 字节"
echo " 下载按钮:"
grep -o 'href="/download/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /'
echo " 管理页字眼: $(grep -cE 'zhongzhuanzhan|管理后台|管理页' /tmp/site.html || echo 0) 处(应为 0)"
echo
echo "════════ 3. 三个成品能下吗 ════════"
for f in ykxmz-setup-1.8.1.exe ykxmz-server-1.8.1.zip ykxmz-watch-1.8.1.apk; do
hdr=$(curl -sI --max-time 40 -H "User-Agent: $UA" "$BASE/download/$f")
code=$(echo "$hdr" | head -1 | awk '{print $2}')
len=$(echo "$hdr" | grep -i '^content-length' | tr -d '\r' | awk '{print $2}')
printf " %-30s HTTP %-4s %s 字节\n" "$f" "$code" "${len:-?}"
done
echo
echo "════════ 4. 长轮询还能跑通吗(2 次真实验证)════════"
STAMP=$(date +%s)
DEV="final-${STAMP}"
CODE=$(printf "%06d" $(( STAMP % 1000000 )))
REG=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
-d "{\"deviceId\":\"$DEV\",\"code\":\"$CODE\",\"name\":\"final\",\"version\":\"1.8.1\"}")
TOKEN=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
echo " 探针注册: $([ -n "$TOKEN" ] && echo 成功 || echo 失败)"
for i in 1 2; do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 130 \
-X POST "$BASE/v1/agent/poll" -H "X-Token: $TOKEN" -H "User-Agent: $UA")
printf " 第 %d 次: 码 %s 耗时 %.1fs(应约 61 秒)\n" \
"$i" "$(echo "$out" | awk '{print $1}')" "$(echo "$out" | awk '{print $2}')"
done
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
echo " 探针已清理"
echo
echo "════════ 5. 公网可用率(10 次)════════"
ok=0
for i in $(seq 1 10); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/v1/health")
[ "$c" = "200" ] && ok=$((ok+1))
printf " %2d: %s\n" "$i" "$c"
sleep 1
done
echo " 可用 $ok/10"
echo
echo "════════ 6. 中转有没有报错 ════════"
journalctl -u lanremote-relay --since '5 minutes ago' --no-pager 2>/dev/null \
| grep -icE 'traceback|nameerror|typeerror' | sed 's/^/ 错误行数: /'
tail -3 /root/lanremote-relay/relay.log | sed 's/^/ /'
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# 1.8.2 最终验收(在设备上跑)
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.2'
echo "════════ 1. 官网和三个成品(走公网,看缓存)════════"
for p in / /download/ykxmz-setup-1.8.2.exe /download/ykxmz-server-1.8.2.zip /download/ykxmz-watch-1.8.2.apk; do
hdr=$(curl -s -D - -o /dev/null --max-time 45 -H "User-Agent: $UA" "$BASE$p" 2>/dev/null | tr -d '\r')
code=$(echo "$hdr" | head -1 | awk '{print $2}')
len=$(echo "$hdr" | grep -i '^content-length' | awk '{print $2}')
cf=$(echo "$hdr" | grep -i '^cf-cache-status' | awk '{print $2}')
cc=$(echo "$hdr" | grep -i '^cache-control' | cut -d' ' -f2-)
printf " %-38s HTTP %-4s %10s cf=%-6s %s\n" "$p" "$code" "$len" "${cf:-无}" "${cc:-无}"
done
echo
echo "════════ 2. 对外清洁度(官网不能有管理页字眼)════════"
curl -s --max-time 30 -H "User-Agent: $UA" "$BASE/" -o /tmp/site.html
n=$(grep -cE 'zhongzhuanzhan|管理后台|管理页|管理入口|管理面板' /tmp/site.html || true)
echo " 官网里管理页字眼: $n 处(应为 0)"
echo " 官网大小: $(wc -c < /tmp/site.html) 字节"
echo " 下载按钮:"
grep -o 'href="/download/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /'
echo
echo "════════ 3. 管理页两个新功能 ════════"
if [ -f /root/taf.py ]; then
python3 /root/taf.py 2>&1 | tail -16 | sed 's/^/ /'
else
echo " (测试脚本不在设备上)"
fi
echo
echo "════════ 4. 中转核心功能(长轮询 + 注册)════════"
STAMP=$(date +%s)
DEV="acc-$STAMP"
CODE=$(printf "%06d" $(( STAMP % 1000000 )))
REG=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
-d "{\"deviceId\":\"$DEV\",\"code\":\"$CODE\",\"name\":\"验收\",\"version\":\"1.8.2\"}")
TOKEN=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
echo " 注册: $([ -n "$TOKEN" ] && echo 成功 || echo 失败)"
if [ -n "$TOKEN" ]; then
for i in 1 2; do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 200 \
-X POST "$BASE/v1/agent/poll" -H "X-Token: $TOKEN" -H "User-Agent: $UA")
printf " 长轮询 %d: 码 %s 耗时 %.1fs(应约 61 秒)\n" \
"$i" "$(echo "$out" | awk '{print $1}')" "$(echo "$out" | awk '{print $2}')"
done
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
echo " 探针已清理"
fi
echo
echo "════════ 5. 公网可用率(20 次)════════"
ok=0
for i in $(seq 1 20); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/v1/health")
[ "$c" = "200" ] && ok=$((ok + 1))
sleep 1
done
echo " 可用 $ok/20 = $((ok * 5))%"
echo
echo "════════ 6. 隧道和错误 ════════"
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2; do
printf " %-24s %s\n" "$s" "$(systemctl is-active $s)"
done
echo " 中转错误行数(近 10 分钟): $(journalctl -u lanremote-relay --since '10 minutes ago' --no-pager 2>/dev/null | grep -icE 'traceback|nameerror|typeerror|AttributeError' || echo 0)"
echo " 连接数: 20241=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') 20242=$(curl -s --max-time 4 http://127.0.0.1:20242/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
+200
View File
@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
r'''中转管理页新增两个功能(中转端逻辑)。
== 功能一:允许/禁止新设备接入 ==
一个开关,关掉之后**只有已经登记过的设备能继续上线**,
没见过的新设备一律拒绝。
用途:不想让陌生人拿配对码乱试、或者临时把中转"关起来"。
实现要点:
* 标志位存在 relay_state.json 里,重启不丢
* 判断"新设备"以 deviceId 为准(它装机时生成、重装也不变),
不是看配对码 —— 老设备换码也必须能进来
* 被拒绝时回一个明确的标记 blocked=True,
让客户端能区分"被管理员挡了"和"配对码冲突",
不要用中文错误文案去匹配(文案一改就坏)
== 功能二:修改任意设备的配对码 ==
管理页上每台设备一个"改配对码"按钮,填新码就生效。
实现要点:
* 新码必须是 6 位数字
* 新码不能已经被别的设备占用
* 改的时候要同时更新 codes 映射和 device.code,
并清掉该设备**旧码**的映射 —— 否则旧码会变成孤儿条目,
仪表盘上"配对码占用"会虚高
* 改完立刻 _save(),重启后仍然是新码
* 该设备当前的手表会话要踢掉(会话是绑在旧码上的),
否则手表还拿着旧会话以为连着,实际电脑已经换了码
'''
from __future__ import annotations
import sys
from pathlib import Path
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\relay.py")
def patch(text: str, old: str, new: str, label: str) -> tuple:
if old not in text:
print(" ✗ 没找到:%s" % label)
return text, False
if text.count(old) != 1:
print(" ! 出现 %d 次:%s" % (text.count(old), label))
return text, False
print(" ✓ %s" % label)
return text.replace(old, new, 1), True
def main() -> int:
text = SRC.read_text(encoding="utf-8")
before = text.count("\n") + 1
ok_all = True
# ---------------------------------------------------------- 1. 字段
old = """ self.retired: dict = {}
self.started = time.time()"""
new = """ self.retired: dict = {}
# 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
self.allow_new = True
self.started = time.time()"""
text, ok = patch(text, old, new, "加 allow_new 字段")
ok_all = ok_all and ok
# ---------------------------------------------------------- 2. _load
old = """ for item in raw.get("codes", []):
device_id = str(item.get("deviceId", ""))
code = str(item.get("code", ""))
if device_id and CODE_RE.match(code):
self.codes[code] = device_id
log(f"恢复配对码绑定:设备 {device_id[:12]}… -> {code}")"""
new = """ # 是否允许新设备接入(重启不丢)
self.allow_new = bool(raw.get("allowNew", True))
for item in raw.get("codes", []):
device_id = str(item.get("deviceId", ""))
code = str(item.get("code", ""))
if device_id and CODE_RE.match(code):
self.codes[code] = device_id
log(f"恢复配对码绑定:设备 {device_id[:12]}… -> {code}")"""
text, ok = patch(text, old, new, "_load 恢复 allow_new")
ok_all = ok_all and ok
# ---------------------------------------------------------- 3. _save
old = """ payload = {"codes": [
{"deviceId": did, "code": code}
for code, did in sorted(self.codes.items())
]}"""
new = """ payload = {
"allowNew": bool(self.allow_new),
"codes": [
{"deviceId": did, "code": code}
for code, did in sorted(self.codes.items())
],
}"""
text, ok = patch(text, old, new, "_save 持久化 allow_new")
ok_all = ok_all and ok
# ---------------------------------------------------------- 4. 拒绝新设备
old = """ with self.lock:
known = self.codes.get(code)
if known and known != device_id:"""
new = """ with self.lock:
# 管理员关掉了"允许新设备接入"时,
# 只放行**已经登记过**的设备。
#
# 注意判断依据是 deviceId(装机时生成、重装也不变),
# **不是配对码** —— 否则管理员给老设备改了码,
# 那台设备就被自己的开关挡在外面了。
known_device = device_id in self.devices
if not known_device and not self.allow_new:
log(f"新设备接入已被关闭,拒绝 {name} ({device_id[:12]}…)")
return {"ok": False,
"blocked": True,
"error": "中转站当前不接受新设备接入"}
known = self.codes.get(code)
if known and known != device_id:"""
text, ok = patch(text, old, new, "register 拒绝新设备")
ok_all = ok_all and ok
# ---------------------------------------------------------- 5. 管理方法
old = """ def device_by_token(self, token: str) -> Device | None:"""
new = """ # ---------------- 管理操作 ----------------
def set_allow_new(self, allow: bool) -> dict:
\"\"\"开关"允许新设备接入"。\"\"\"
with self.lock:
self.allow_new = bool(allow)
self._save()
log(f"管理员把「允许新设备接入」设为 {self.allow_new}")
return {"ok": True, "allowNew": self.allow_new}
def set_device_code(self, device_id: str, new_code: str) -> dict:
\"\"\"给一台设备换配对码。
要点:
* 新码必须是 6 位数字
* 不能已经被**别的**设备占用
* 旧码的映射必须删掉,否则会变成孤儿条目,
管理页上"配对码占用"的数字会虚高
* 该设备挂着的会话要踢掉 —— 会话是绑在码上的,
不踢的话手表还拿着旧会话以为连着
\"\"\"
device_id = str(device_id or "").strip()
new_code = str(new_code or "").strip()
if not CODE_RE.match(new_code):
return {"ok": False, "error": "配对码必须是 6 位数字"}
with self.lock:
device = self.devices.get(device_id)
if device is None:
return {"ok": False, "error": "没有这台设备(可能已下线)"}
owner = self.codes.get(new_code)
if owner and owner != device_id:
return {"ok": False,
"error": "这个配对码已经被别的设备占用了"}
old_code = device.code
if old_code == new_code:
return {"ok": True, "code": new_code, "unchanged": True}
# 删掉旧码映射(连同可能存在的别名)
for code, owner_id in list(self.codes.items()):
if owner_id == device_id:
self.codes.pop(code, None)
self.codes[new_code] = device_id
device.code = new_code
# 会话绑在码上,旧会话作废,让手表重新配对
kicked = 0
for sid in [s.session_id for s in self.sessions.values()
if s.device_id == device_id]:
self.sessions.pop(sid, None)
kicked += 1
self._save()
log(f"管理员把设备 {device.name} 的配对码 "
f"{old_code} 改成 {new_code}(踢掉 {kicked} 个会话)")
return {"ok": True, "code": new_code,
"oldCode": old_code, "kicked": kicked}
def device_by_token(self, token: str) -> Device | None:"""
text, ok = patch(text, old, new, "加 set_allow_new / set_device_code")
ok_all = ok_all and ok
if not ok_all:
print()
print(" 有锚点没匹配,没有写回")
return 1
SRC.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 已写回:%d -> %d 行" % (before, text.count("\n") + 1))
return 0
if __name__ == "__main__":
sys.exit(main())
+183
View File
@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
r'''管理页 UI:加「允许新设备接入」开关 + 每台设备的「改码」按钮。
设计上遵守用户的"所有东西都要白色简洁":
* 开关用系统原生 checkbox + 极简样式,不引入花哨组件
* 改码走 prompt(),不加弹窗层 —— 管理页是自用的,
少一层 DOM 就少一处会坏的地方
* 按钮沿用现有的 .ghost 样式(不抢 .danger 的视觉重量)
⚠ 一个容易踩的坑:
现有 JS 用 `button.danger[data-device]` 绑定"单台删除"。
新加的"改码"按钮**故意不带 data-device 而用 data-recode**,
否则会被删除逻辑一起选中,点改码变成删设备。
'''
from __future__ import annotations
import sys
from pathlib import Path
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\admin.py")
def patch(text: str, old: str, new: str, label: str) -> tuple:
if old not in text:
print(" ✗ 没找到:%s" % label)
return text, False
if text.count(old) != 1:
print(" ! 出现 %d 次:%s" % (text.count(old), label))
return text, False
print(" ✓ %s" % label)
return text.replace(old, new, 1), True
def main() -> int:
text = SRC.read_text(encoding="utf-8")
ok_all = True
# ------------------------------------------------ 1. 行里加"改码"按钮
old = """ '<td class="ops"><button class="danger sm" '
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
'</tr>' % (
did, name,
"on" if online else "off", name, did,
code,
"on" if online else "off", "在线" if online else "离线",
device["idleSeconds"],
device["queuedToClient"], device["queuedToAgent"],
_kb(device["bytesIn"]), _kb(device["bytesOut"]),
did, name, code)
)"""
new = """ '<td class="ops">'
# ⚠ 这个按钮用 data-recode 而不是 data-device ——
# 现有"单台删除"是按 button.danger[data-device] 绑的,
# 这里要是也带 data-device 就会被当成删除按钮。
'<button class="ghost sm" data-recode="%s" '
'data-name="%s" data-code="%s">改码</button>'
'<button class="danger sm" '
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
'</tr>' % (
did, name,
"on" if online else "off", name, did,
code,
"on" if online else "off", "在线" if online else "离线",
device["idleSeconds"],
device["queuedToClient"], device["queuedToAgent"],
_kb(device["bytesIn"]), _kb(device["bytesOut"]),
did, name, code,
did, name, code)
)"""
text, ok = patch(text, old, new, "设备行加「改码」按钮")
ok_all = ok_all and ok
# ------------------------------------------------ 2. 开关放进"设备"小节
old = """ <div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>"""
new = """ <div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" id="allowWrap"
style="cursor:pointer;display:flex;align-items:center;gap:7px;margin:0 18px 0 0">
<input type="checkbox" id="allowNew"%(allownew)s>
<span>允许新设备接入</span>
</label>
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>"""
text, ok = patch(text, old, new, "设备小节加开关")
ok_all = ok_all and ok
# ------------------------------------------------ 3. 传入开关状态
old = """ sessions = snap.get("sessions", 0)"""
new = """ sessions = snap.get("sessions", 0)
# 开关状态:允许新设备接入时勾上
allow_new = bool(snap.get("allowNew", True))"""
text, ok = patch(text, old, new, "读 allowNew 状态")
ok_all = ok_all and ok
old = """ "rows": _device_rows(devices),
"dl": dl_rows,"""
new = """ "rows": _device_rows(devices),
"allownew": " checked" if allow_new else "",
"dl": dl_rows,"""
text, ok = patch(text, old, new, "把状态传进模板")
ok_all = ok_all and ok
# ------------------------------------------------ 4. JS
old = """// ---------------- 单台删除 ----------------"""
new = """// ---------------- 允许/禁止新设备接入 ----------------
// 关掉之后,只有**已经登记过**的电脑能继续上线,
// 没见过的新设备一律拒绝。
const allowNew = document.getElementById('allowNew');
if (allowNew) allowNew.onchange = async () => {
const want = allowNew.checked;
allowNew.disabled = true;
try {
const j = await post('/v1/admin/allow_new', {allow: want});
if (j.ok) {
toast(j.allowNew ? '已允许新设备接入' : '已禁止新设备接入', 'ok');
} else {
toast('设置失败:' + (j.error || '未知错误'), 'err');
allowNew.checked = !want;
}
} catch (e) {
toast('请求出错:' + e, 'err');
allowNew.checked = !want;
}
allowNew.disabled = false;
};
// ---------------- 改配对码 ----------------
document.querySelectorAll('button[data-recode]').forEach(b => {
b.onclick = async () => {
const name = b.dataset.name, old = b.dataset.code;
const typed = prompt('把「' + name + '」的配对码 ' + old
+ ' 改成:\\n\\n(必须是 6 位数字)', '');
if (typed === null) return;
const code = (typed || '').trim();
if (!/^[0-9]{6}$/.test(code)) {
toast('配对码必须是 6 位数字', 'err');
return;
}
b.disabled = true;
try {
const j = await post('/v1/admin/set_code',
{deviceId: b.dataset.recode, code: code});
if (j.ok) {
toast(j.unchanged ? '配对码没有变化'
: ('配对码已改成 ' + j.code
+ (j.kicked ? ',踢掉 ' + j.kicked + ' 个会话' : '')),
'ok');
setTimeout(() => location.reload(), 900);
} else {
toast('改码失败:' + (j.error || '未知错误'), 'err');
b.disabled = false;
}
} catch (e) {
toast('请求出错:' + e, 'err');
b.disabled = false;
}
};
});
// ---------------- 单台删除 ----------------"""
text, ok = patch(text, old, new, "加开关和改码的 JS")
ok_all = ok_all and ok
if not ok_all:
print()
print(" 有锚点没匹配,没有写回")
return 1
SRC.write_text(text, encoding="utf-8", newline="\n")
print()
print(" admin.py 已写回")
return 0
if __name__ == "__main__":
sys.exit(main())
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
r'''给静态资源加缓存头,让 Cloudflare 在边缘直接发。
== 为什么这是最大的一招 ==
基线实测(下载 15 MB 安装包走隧道回 4G 源站):
ykxmz-watch-1.8.1.apk 201523 字节 / 66.8 秒 = 3 KB/s
ykxmz-server-1.8.1.zip 214900 字节 / 3.1 秒 = 67 KB/s
ykxmz-setup-1.8.1.exe 只下到 237568/15067199 字节就停了
原因:relay 给所有响应都发 `Cache-Control: no-store`,
**Cloudflare 完全不缓存** —— 每一次下载、每一张图、每个 favicon
都要穿隧道回 4G 源站。而回源吞吐只有几 KB/s。
改成可缓存后,Cloudflare 会在用户附近的边缘节点直接发,
速度是"边缘到用户",跟 4G 那条腿无关。
== 各路径的策略 ==
/download/* public, max-age=604800, immutable
文件名带版本号(ykxmz-setup-1.8.1.exe),
同一名字的内容永不改变,所以可以放心长缓存。
/shot/* 已经是 public, max-age=86400(保持不变)
/favicon.ico public, max-age=604800(147 KB,之前每次 3.2 秒)
/ public, max-age=60
首页会列出当前版本,缓存久了下新版本会看不到按钮。
60 秒足够挡住刷新的重复回源,又不会让版本信息过期太久。
/v1/* no-store(接口,必须实时)
注意:Cloudflare 默认只缓存一部分扩展名,.exe/.zip/.apk 可能不在
默认列表里。所以改完必须**实测 cf-cache-status 是不是 HIT**,
不 HIT 就要在 Cloudflare 后台加一条 Cache Rule(我会告诉用户怎么加)。
'''
from __future__ import annotations
import sys
from pathlib import Path
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\relay.py")
OLD_DL = """ self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")"""
NEW_DL = """ self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
# ★ 让 Cloudflare 在边缘缓存。
# 原来这里是 no-store,于是每次下载都要穿隧道回 4G 源站,
# 实测吞吐只有 3~67 KB/s(15 MB 的安装包根本下不动)。
# 文件名带版本号,同一名字内容永不改变,可以长缓存。
self.send_header(
"Cache-Control",
"public, max-age=604800, immutable")"""
# favicon 那块
OLD_ICON = """ self._send(200, icon, "image/x-icon")"""
NEW_ICON = """ self._send(200, icon, "image/x-icon",
{"Cache-Control": "public, max-age=604800"})"""
# 首页那块
OLD_SITE = """ self._send(200, _site_page().encode("utf-8"),
"text/html; charset=utf-8")
return"""
NEW_SITE = """ # 首页缓存 60 秒:挡住刷新的重复回源,
# 又不会让"当前版本/下载按钮"过期太久。
self._send(200, _site_page().encode("utf-8"),
"text/html; charset=utf-8",
{"Cache-Control": "public, max-age=60"})
return"""
def patch(text: str, old: str, new: str, label: str) -> tuple:
if old not in text:
print(" ✗ 没找到:%s" % label)
return text, False
if text.count(old) != 1:
print(" ! 出现 %d 次:%s" % (text.count(old), label))
return text, False
print(" ✓ %s" % label)
return text.replace(old, new, 1), True
def main() -> int:
text = SRC.read_text(encoding="utf-8")
before = text.count("\n") + 1
ok_all = True
print(" 加缓存头:")
for old, new, label in (
(OLD_DL, NEW_DL, "下载:no-store -> public,max-age=604800,immutable"),
(OLD_ICON, NEW_ICON, "favicon:加 7 天缓存"),
(OLD_SITE, NEW_SITE, "首页:加 60 秒缓存"),
):
text, ok = patch(text, old, new, label)
ok_all = ok_all and ok
if not ok_all:
print()
print(" 有锚点没匹配,没有写回")
return 1
SRC.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 已写回:%d -> %d 行" % (before, text.count("\n") + 1))
return 0
if __name__ == "__main__":
sys.exit(main())
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
r"""给被控端加 --code / --name,供安装包调用。
为什么需要:
安装向导里用户填了 6 位配对码,安装程序必须把它传给被控端。
但命令行原来只有 --new-code(随机换一个),**没有"设成指定值"** ——
所以用户填的码会被忽略,装完还得再跑一次设置窗口。
加了之后安装程序可以这样一条命令搞定:
ykxmz.exe --code 789113 --name 三年二班 --autostart on --silent
--code 同时会标记 setup_done,这样就不会再弹设置窗口。
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
RUN = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
r"\run_agent.py"))
# ---------- 1. 参数声明 ----------
OLD_ARG = ''' parser.add_argument("--new-code", action="store_true", help="换一组配对码")'''
NEW_ARG = ''' parser.add_argument("--new-code", action="store_true", help="换一组配对码")
parser.add_argument("--code", default=None, metavar="6位数字",
help="把配对码设成指定值(必须是 6 位数字)。"
"安装包用这个把用户在安装向导里填的码写进去。")
parser.add_argument("--name", default=None, metavar="名字",
help="设置这台电脑在手表上显示的名字"
"(默认用 Windows 的计算机名)")'''
# ---------- 2. 处理逻辑 ----------
OLD_USE = ''' if args.reset_devices:
store.devices = {}
store.save_devices()
if args.new_code or not store.pairing_code():
store.new_pairing_code()'''
NEW_USE = ''' if args.reset_devices:
store.devices = {}
store.save_devices()
# --code:把配对码设成安装向导里填的那个。
# 严格要 6 位数字 —— 和设置窗口里的规则一致,
# 免得用户装了之后发现手表上少输一位连不上。
if args.code is not None:
digits = "".join(ch for ch in str(args.code) if ch.isdigit())
if len(digits) != 6:
print(f"配对码要 6 位数字,收到的是 {args.code!r}")
return 2
try:
store.set_pairing_code(digits)
except ValueError as exc:
print(f"设置配对码失败:{exc}")
return 2
# 装完就是"设置好了",不要再弹设置窗口
store.set("setup_done", True)
store.set("allow_new_pairing", True)
print(f"配对码已设为 {digits}")
if args.name:
store.set("name", str(args.name).strip()[:32])
if args.new_code or not store.pairing_code():
store.new_pairing_code()'''
# ---------- 3. 帮助文本 ----------
OLD_HELP = ''' --new-code 换一组配对码'''
NEW_HELP = ''' --new-code 换一组配对码
--code 123456 把配对码设成指定值(安装包用)
--name "三年二班" 设置电脑在手表上显示的名字'''
def main() -> int:
text = RUN.read_text(encoding="utf-8")
ok = True
if '"--code"' in text:
print(" 已经改过")
return 0
for old, new, label in ((OLD_ARG, NEW_ARG, "参数声明"),
(OLD_USE, NEW_USE, "处理逻辑"),
(OLD_HELP, NEW_HELP, "帮助文本")):
if old not in text:
print(f" [失败] {label} 锚点没找到")
ok = False
continue
text = text.replace(old, new, 1)
print(f" {label}")
if not ok:
return 1
RUN.write_text(text, encoding="utf-8", newline="\n")
try:
ast.parse(text)
print(" run_agent.py 语法 OK")
except SyntaxError as exc:
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+126
View File
@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
r'''把安装包完整周期测试加进套件,并提交这一轮的关键修复。'''
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERIFY = ROOT / "_tests" / "verify_all.ps1"
GIT = r"C:\Program Files\Git\bin\git.exe"
BOM = b"\xef\xbb\xbf"
STAGE = '''
# ---------------------------------------------------------------- 3b5
Banner "3b5/7 安装包完整周期(装 -> 验 -> 卸 -> 再验)"
# 这一步真的装一遍、再卸一遍。踩过的四个卡死 bug 全靠它才发现:
# input() 无 stdin 阻塞 / Popen 不重定向 stdio /
# taskkill 把自己杀了 / 关不掉管理员权限的旧实例导致安装回滚。
# 注意:需要管理员权限,且会真的往 Program Files 装东西。
$stageOut = & python (Join-Path $root "_build\\test_installer_cycle.py") 2>&1
Show-Fails $stageOut
Record "安装包完整周期" ($LASTEXITCODE -eq 0)
'''
MSG = """修安装包卸载卡死的真凶:attach_console 在无控制台时阻塞
排查过程(一步步排除,记录下来免得以后重踩):
1. 现象:从控制面板卸载,进度条永远走不完。
2. 先怀疑 input():安装器用 runhidden 调 --uninstall,没有 stdin,
input() 会一直等。加了 isatty 保护 —— **没解决**。
3. 再怀疑 Popen 没重定向 stdio:_schedule_self_delete 起的 cmd
继承了父进程句柄,capture_output 会等管道 EOF。加了
DEVNULL 重定向 —— **还是卡**。
4. 又发现 _kill_running 用 taskkill /IM ykxmz.exe 把正在卸载的
自己杀了(--uninstall 就跑在 ykxmz.exe 里),
导致后面的"删自启/删数据"没执行。改成 /FI "PID ne self" ——
自启能删了,**卸载仍然卡**。
5. 最后看进程状态才定位:卡住的 ykxmz.exe 只有 **1 个线程、
0.16 秒 CPU、19MB 内存** —— 明显停在启动早期,根本没干活。
那就是 cli_uninstall 第一行的 attach_console():
它对 --windowed 程序调 AllocConsole(),再 open("CONIN$")。
在没有控制台的隐藏进程里,这一步**永久阻塞**。
修法:加 --quiet。安装包的卸载器用
ykxmz.exe --uninstall --keep-dir --quiet
--quiet 下完全不碰控制台(不 AllocConsole、不 input),
结果写进 uninstall.log 供排查。
实测:不带 --quiet 无限卡住;带了 **0.6 秒返回**。
顺带修的两处(也是实测发现的):
* 安装器关不掉以管理员身份运行的旧实例 -> Inno 弹"无法关闭所有
应用程序",静默下被压成"终止",**整个安装回滚**。
改成 PrepareToInstall 里自己 taskkill /F。
* 测试脚本把注册表路径写成 HKCU\\...(少了冒号),
PowerShell 静默返回空,"开机自启"永远误报失败。
最终实测(test_installer_cycle.py,20 项):
静默安装 退出码 0,7.1 秒
配对码 正确写入 C:\\ProgramData\\ykxmz\\data\\settings.json
进程/端口 在跑,8765 监听
开机自启 已设置
静默卸载 1.2 秒
卸载清理 安装目录 / 数据目录 / 注册表 / 进程 / 自启 全部干净
20 项全通过
套件新增一个阶段:「安装包完整周期」。
"""
def add_stage() -> None:
raw = VERIFY.read_bytes()
text = raw.decode("utf-8-sig")
if "test_installer_cycle.py" in text:
print(" 套件里已有周期测试阶段")
return
anchor = "# ---------------------------------------------------------------- 3c"
if anchor not in text:
anchor = "# ---------------------------------------------------------------- 4"
if anchor not in text:
print(" [失败] 找不到锚点")
return
text = text.replace(anchor, STAGE.lstrip("\n") + anchor, 1)
VERIFY.write_bytes(BOM + text.replace("\r\n", "\n").encode("utf-8"))
print(" 已加「安装包完整周期」阶段")
def commit() -> None:
print()
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_cmsg4.txt"
msg_file.write_bytes(MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
status = subprocess.run([GIT, "status", "--short"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" 工作区: %s" % ("干净 ✓" if not (status.stdout or "").strip()
else (status.stdout or "").strip()[:160]))
def main() -> int:
add_stage()
commit()
return 0
if __name__ == "__main__":
sys.exit(main())
+206
View File
@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
r'''单次请求失败不再立刻判"离线",改成连续失败 3 次才算。
== 为什么 ==
实测单个请求会有 2~4% 的偶发失败(4G 丢包打在一条隧道连接上,
或者那条连接正在重连)。原来的逻辑是**一失败就 online = false**,
于是:
手表/电脑的界面上会闪一下"已断开"
用户点按钮时正好撞上那一闪 -> 看起来像"又连不上了"
而实际上链路 1.5~15 秒后就自己恢复了(客户端本来就有退避重试)。
改成"连续失败 3 次才判离线",效果是:
* 单次抖动完全不影响界面显示
* 真的断了(连续 3 次,至少 4.5 秒都没通)仍然会如实显示离线
这不是掩盖问题,而是把"是否可用"的判断从"一次采样"改成
"连续采样"—— 单点采样在有丢包的链路上噪声太大。
== 两边都要改 ==
1. android/src/com/lanremote/watch/RelayTransport.java (手表轮询)
2. windows/lanremote/relay.py (电脑轮询)
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
BOM = b"\xef\xbb\xbf"
# ---------------------------------------------------------------- 手表
WATCH = ROOT / "android/src/com/lanremote/watch/RelayTransport.java"
WATCH_CONST_OLD = " private static final int RETRY_MAX_MS = 15000;"
WATCH_CONST_NEW = """ private static final int RETRY_MAX_MS = 15000;
/**
* 连续失败几次才判定"离线"。
*
* <p>单次失败就报离线太敏感:4G 链路上随手就有 2~4% 的偶发失败,
* 而链路通常一两秒就自己恢复,界面却已经闪了一下"已断开"。
* 改成连续 3 次(至少 4.5 秒都不通)才判离线 ——
* 真的断了仍然会如实显示。
*/
private static final int OFFLINE_AFTER = 3;"""
WATCH_FIELD_OLD = " private Thread pollThread;"
WATCH_FIELD_NEW = """ private Thread pollThread;
/** 连续失败次数(成功一次就清零)。 */
private volatile int failStreak;"""
# 401/403 分支
W_401_OLD = """ if (result.code == 401 || result.code == 403) {
// 会话过期(中转重启等)—— 这种可以自动重连
online = false;
lastError = "会话已失效,正在重新接入";"""
W_401_NEW = """ if (result.code == 401 || result.code == 403) {
// 会话过期(中转重启等)—— 这种可以自动重连。
// 会话失效是明确的状态,直接标离线。
online = false;
lastError = "会话已失效,正在重新接入";"""
# 非 200 分支
W_OTHER_OLD = """ if (result.code != 200) {
online = false;
lastError = "轮询返回 " + result.code;
sleep(backoff);
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
continue;
}
online = true;
lastError = "";
backoff = RETRY_MIN_MS;"""
W_OTHER_NEW = """ if (result.code != 200) {
// 偶发的 502/504 不算断线,连续几次才算
failStreak++;
if (failStreak >= OFFLINE_AFTER) {
online = false;
}
lastError = "轮询返回 " + result.code;
sleep(backoff);
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
continue;
}
online = true;
failStreak = 0;
lastError = "";
backoff = RETRY_MIN_MS;"""
# 异常分支
W_EXC_OLD = """ } catch (Exception exc) {
if (closed) {
return;
}
online = false;
lastError = "轮询出错:" + exc.getClass().getSimpleName();
sleep(backoff);
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
}"""
W_EXC_NEW = """ } catch (Exception exc) {
if (closed) {
return;
}
// 连接超时/重置很常见(隧道在重连),连续几次才算真断
failStreak++;
if (failStreak >= OFFLINE_AFTER) {
online = false;
}
lastError = "轮询出错:" + exc.getClass().getSimpleName();
sleep(backoff);
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
}"""
# ---------------------------------------------------------------- 电脑
AGENT = ROOT / "windows/lanremote/relay.py"
A_CONST_OLD = "RETRY_MAX = 20.0"
A_CONST_NEW = """RETRY_MAX = 20.0
# 连续失败几次才判定"离线"。
# 单次失败就报离线太敏感:4G 链路上有 2~4% 的偶发失败,
# 而链路通常一两秒就自己恢复,界面却已经闪了一下"已断开"。
# 连续 3 次(至少 6 秒都不通)才判离线,真的断了仍然如实显示。
OFFLINE_AFTER = 3"""
A_FAIL_OLD = """ except Exception as exc: # noqa: BLE001
self.online = False
self.last_error = f"{type(exc).__name__}: {exc}"
self._log(f"中转连接出问题:{self.last_error}")"""
A_FAIL_NEW = """ except Exception as exc: # noqa: BLE001
# 偶发的连接超时/重置不算断线,连续几次才算
self._fail_streak = getattr(self, "_fail_streak", 0) + 1
if self._fail_streak >= OFFLINE_AFTER:
self.online = False
self.last_error = f"{type(exc).__name__}: {exc}"
self._log(f"中转连接出问题(第 {self._fail_streak} 次):"
f"{self.last_error}")"""
A_OK_OLD = """ self.online = True
self.last_ok = time.time()
self.last_error = \"\""""
A_OK_NEW = """ self.online = True
self._fail_streak = 0
self.last_ok = time.time()
self.last_error = \"\""""
def patch(text: str, old: str, new: str, label: str) -> tuple:
if old not in text:
if new.strip() and new.splitlines()[-1].strip() in text:
print(" • %s 已经是新值" % label)
return text, True
print(" ✗ 没找到:%s" % label)
return text, False
if text.count(old) != 1:
print(" ! 出现 %d 次:%s" % (text.count(old), label))
return text, False
print(" ✓ %s" % label)
return text.replace(old, new, 1), True
def main() -> int:
ok_all = True
print(" 手表 RelayTransport.java:")
raw = WATCH.read_bytes()
text = raw.decode("utf-8-sig")
for old, new, label in (
(WATCH_CONST_OLD, WATCH_CONST_NEW, "加 OFFLINE_AFTER 常量"),
(WATCH_FIELD_OLD, WATCH_FIELD_NEW, "加 failStreak 字段"),
(W_OTHER_OLD, W_OTHER_NEW, "非 200 用连续计数"),
(W_EXC_OLD, W_EXC_NEW, "异常用连续计数"),
):
text, ok = patch(text, old, new, label)
ok_all = ok_all and ok
if ok_all:
WATCH.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 电脑 relay.py:")
araw = AGENT.read_bytes()
atext = araw.decode("utf-8-sig")
for old, new, label in (
(A_CONST_OLD, A_CONST_NEW, "加 OFFLINE_AFTER 常量"),
(A_FAIL_OLD, A_FAIL_NEW, "异常用连续计数"),
(A_OK_OLD, A_OK_NEW, "成功时清零"),
):
atext, ok = patch(atext, old, new, label)
ok_all = ok_all and ok
if not ok_all:
print()
print(" 有失败项,没写回电脑端")
return 1
AGENT.write_text(atext, encoding="utf-8", newline="\n")
print()
print(" 两边都改好了")
return 0
if __name__ == "__main__":
sys.exit(main())
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
r"""给中转管理页加 favicon(用项目的 app.ico)。
用户提过:"那个中转管理端那个,网页图标没有啊,图标就是我之前给你那个图标"
做法:
1. 把 windows/app.ico 拷一份到 relay/favicon.ico(跟 relay.py 同目录)
2. relay.py 加一条 GET /favicon.ico 路由直接给这个文件
3. 管理页 <head> 里加 <link rel="icon" ...>
为什么不做成 base64 内嵌:
ico 有 147KB,内嵌到 relay.py 里会让源码从 78KB 涨到 270KB,
而且每次改图标都要动代码。放成独立文件干净得多。
"""
from __future__ import annotations
import ast
import shutil
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
RELAY = ROOT / "relay" / "relay.py"
ICON_SRC = ROOT / "windows" / "app.ico"
ICON_DST = ROOT / "relay" / "favicon.ico"
# ---------- 1. 路由 ----------
ANCHOR_ROUTE = ''' # ---- 成品下载(挂在管理页上给人下 APK / 整包)----'''
NEW_ROUTE = ''' # ---- 网站图标 ----
if path in ("/favicon.ico", "/apple-touch-icon.png"):
icon = Path(__file__).resolve().parent / "favicon.ico"
if icon.is_file():
try:
blob = icon.read_bytes()
except OSError:
blob = b""
if blob:
self.send_response(200)
self.send_header("Content-Type", "image/x-icon")
self.send_header("Content-Length", str(len(blob)))
# 图标不常变,让浏览器缓存一天
self.send_header("Cache-Control", "public, max-age=86400")
self.end_headers()
self.wfile.write(blob)
return
# 没有图标文件就 404,别让请求打到后面的逻辑
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
return
''' + ANCHOR_ROUTE
# ---------- 2. 页面里声明 ----------
ANCHOR_HEAD = '''<meta name=viewport content="width=device-width,initial-scale=1">'''
NEW_HEAD = ('''<meta name=viewport content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/favicon.ico">''')
def main() -> int:
ok = True
# ---- 图标文件 ----
if not ICON_SRC.is_file():
print(f" [失败] 找不到 {ICON_SRC}")
return 1
shutil.copy2(ICON_SRC, ICON_DST)
print(f" 拷贝图标 -> relay/favicon.ico({ICON_DST.stat().st_size} 字节)")
text = RELAY.read_text(encoding="utf-8")
if "favicon.ico" in text:
print(" relay.py 里已经有 favicon 路由")
else:
for old, new, label in ((ANCHOR_ROUTE, NEW_ROUTE, "favicon 路由"),
(ANCHOR_HEAD, NEW_HEAD, "页面里的 link 声明")):
if old not in text:
print(f" [失败] {label} —— 锚点没找到")
ok = False
continue
text = text.replace(old, new, 1)
print(f" {label}")
if ok:
RELAY.write_text(text, encoding="utf-8", newline="\n")
if not ok:
return 1
try:
ast.parse(RELAY.read_text(encoding="utf-8"))
print(" relay.py 语法 OK")
except SyntaxError as exc:
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
r'''把响应头检查加进套件,并提交这次的修复。'''
from __future__ import annotations
import ast
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERIFY = ROOT / "_tests" / "verify_all.ps1"
GIT = r"C:\Program Files\Git\bin\git.exe"
BOM = b"\xef\xbb\xbf"
STAGE = '''
# ---------------------------------------------------------------- 3b6
Banner "3b6/7 网页响应头(Content-Type / HEAD)"
# 踩过的坑:重做管理页时 _send() 没传 content_type,
# 默认成了 application/json —— 浏览器把整页 HTML 当纯文本显示,
# 用户看到的是一屏源码。
# 更糟的是当时没发现:只验了 HTTP 200 和字节数,两个都对。
# **200 不等于页面能看。**
$stageOut = & python (Join-Path $root "_build\\test_response_headers.py") 2>&1
Show-Fails $stageOut
Record "网页响应头" ($LASTEXITCODE -eq 0)
'''
MSG = """修管理页 Content-Type(浏览器显示成一屏源码)
用户截图:打开 relay.xxmxxs.top/zhongzhuanzhan 看到的**不是页面,
而是整页 HTML 源码**。
根因:_send() 的 content_type 参数默认是 application/json,
我重做管理页时调它没传 text/html —— 浏览器按 JSON/纯文本处理,
就把 HTML 原样显示了。
⚠ 更该反省的是:**我当时没发现。**
验证脚本只看了 HTTP 200 和字节数,两个都对,就以为没问题。
200 不等于页面能看 —— 响应头也得看。
顺手修的第二个问题:
HEAD 请求一律返回 501 Unsupported method(没实现 do_HEAD)。
浏览器用 GET 所以页面照开,但 Cloudflare 健康检查、
很多监控/代理探测都用 HEAD,curl -I 也是。
补了 do_HEAD:按路径算出类型和长度,只回头不回体。
新增测试 _build/test_response_headers.py(9 项):
* HEAD 各路径不能是 501
* 官网/管理页 Content-Type 必须是 text/html,不能是 json
* favicon、产品截图必须是 image/*
并加进全量套件(阶段 3b6)。
验证:该测试 9/9 通过;全量套件 33 个阶段全部通过。
"""
def add_stage() -> None:
raw = VERIFY.read_bytes()
text = raw.decode("utf-8-sig")
if "test_response_headers" in text:
print(" 套件里已有响应头阶段")
return
anchor = "# ---------------------------------------------------------------- 3c"
if anchor not in text:
anchor = "# ---------------------------------------------------------------- 4"
if anchor not in text:
print(" [失败] 找不到锚点")
return
text = text.replace(anchor, STAGE.lstrip("\n") + anchor, 1)
VERIFY.write_bytes(BOM + text.replace("\r\n", "\n").encode("utf-8"))
print(" 已加「网页响应头」阶段")
def commit() -> None:
print()
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_c9.txt"
msg_file.write_bytes(MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
def main() -> int:
add_stage()
commit()
return 0
if __name__ == "__main__":
sys.exit(main())
+143
View File
@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
r'''把安装包相关的检查加进全量套件,然后提交。
新增三个阶段(都是这轮踩坑后补的):
* .iss 语法陷阱检查 —— 注释里的 [区段] / {常量} 会让编译失败(踩了三次)
* 卸载逻辑检查 —— 自杀、stdio、input 阻塞、keep_dir
* 安装包周期检查 —— 装/卸完整走一遍(需要管理员,失败不算硬性)
'''
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERIFY = ROOT / "_tests" / "verify_all.ps1"
GIT = r"C:\Program Files\Git\bin\git.exe"
NEW_STAGES = '''
# ---------------------------------------------------------------- 3b3
Banner "3b3/7 安装包脚本语法(Inno 的注释陷阱)"
# 踩过的坑:Inno 的 [Code] 解析器**连注释内容也处理**。
# 注释里写 [区段名] 报"无效的区段标记",写 {常量} 报 "Syntax error",
# 都是直接编译失败。这个检查专门拦这类。
$stageOut = & python (Join-Path $root "_build\\check_iss.py") 2>&1
Show-Fails $stageOut
Record "安装包脚本语法" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3b4
Banner "3b4/7 卸载逻辑(不能杀自己 / 不能阻塞)"
# 踩过的坑:
# 1. taskkill /IM ykxmz.exe 把正在卸载的进程自己杀了 -> 删自启没执行
# 2. Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
# 3. input() 在无 stdin 时永久阻塞 -> 控制面板卸载卡死
# 4. keep_dir 缺失 -> 自删和 Inno 卸载器抢目录
$stageOut = & python (Join-Path $root "_build\\test_uninstall_logic.py") 2>&1
Show-Fails $stageOut
Record "卸载逻辑" ($LASTEXITCODE -eq 0)
'''
COMMIT_MSG = """修四个安装/卸载卡死的 bug(都实测复现过)
1. 卸载里 input() 在无 stdin 时永久阻塞
从控制面板卸载**永远卡住不结束**。安装包的 [UninstallRun]
用 runhidden 调用,没有 stdin,input() 就一直等。
改成只在 sys.stdin.isatty() 时才等回车。
2. Popen 没重定向子进程 stdio,导致 capture_output 等管道 EOF
_schedule_self_delete 起的 cmd 继承了父进程的 stdout/stderr 句柄,
任何用 capture_output 调 --uninstall 的地方都会一直等,
表现为"程序卡死"(实测 90 秒不返回,改后 0.2 秒)。
Popen 加 stdin/stdout/stderr=DEVNULL。
顺带把延迟从 timeout 换成 ping —— timeout 在 DETACHED_PROCESS
下没有控制台会立刻失败,等于没延迟直接删。
3. _kill_running 用 taskkill /IM ykxmz.exe 把**自己**杀了
--uninstall 本身就跑在 ykxmz.exe 里,这条命令一发,
后面的"删开机自启""删数据目录"全都没执行。
现象:卸载看着完成了,HKCU 的自启注册表项还留着。
改成 /FI "PID ne <self>" 排除自己。
4. 安装器关不掉以管理员身份运行的旧实例 -> 整个安装回滚
Inno 自带机制只会"请求"对方退出,遇上常驻的服务进程关不掉,
弹"无法自动关闭所有应用程序",静默模式下被压成"终止",
安装直接回滚 —— 用户看到的是"装了没反应/还是旧版本"。
改成 PrepareToInstall 里自己 taskkill /F,关掉 Inno 那套。
另外修了两个卸载残留:
* 安装目录留个空壳 -> [UninstallDelete] 加 dirifempty
* ProgramData\\ykxmz 没删 -> 加进 [UninstallDelete]
还有一处 Inno 写法陷阱(踩了三次):
注释里写 [区段名] 或 {常量} 会让编译直接失败,
因为 [Code] 解析器连注释内容也处理。
加了 _build/check_iss.py 专门扫这个,并进全量套件。
实测结果:
静默安装 5 秒内退出,退出码 0,配对码正确写入,程序自动跑起来
静默卸载 0.7 秒退出(原来无限卡住)
全量回归 29 项全通过
卸载逻辑 11 项全通过
"""
def add_stages() -> None:
raw = VERIFY.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "check_iss.py" in text:
print(" 套件里已有安装包相关阶段")
return
anchor = "# ---------------------------------------------------------------- 3b2"
if anchor not in text:
# 退而求其次:放在"3c"之前
anchor = "# ---------------------------------------------------------------- 3c"
if anchor not in text:
print(" [失败] 找不到插入锚点")
return
text = text.replace(anchor, NEW_STAGES.lstrip("\n") + anchor, 1)
VERIFY.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
+ text.replace("\r\n", "\n").encode("utf-8"))
print(" 已加入两个阶段(安装包脚本语法 / 卸载逻辑)")
def commit() -> None:
print()
print(" 提交")
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_cmsg2.txt"
msg_file.write_bytes(COMMIT_MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
def main() -> int:
add_stages()
commit()
return 0
if __name__ == "__main__":
sys.exit(main())
+55
View File
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
r'''把「未调用方法」检查加进全量套件,然后把版本升到 1.7.1。
为什么加这个阶段:
1.7.0 交付后是白屏 —— initTabs() 漏调,编译能过、别的测试也能过,
只有真机跑起来才看得见。加进套件就不靠运气了。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERIFY = ROOT / "_tests" / "verify_all.ps1"
ANCHOR = "# ---------------------------------------------------------------- 2a"
NEW = '''# ---------------------------------------------------------------- 1z
Banner "1z/7 手表分页完整性(防白屏:方法定义了必须被调用)"
& python (Join-Path $root "_tests\\test_no_orphan_methods.py") 2>&1 |
Select-Object -Last 6
Record "手表分页完整性" ($LASTEXITCODE -eq 0)
''' + ANCHOR
def main() -> int:
raw = VERIFY.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "test_no_orphan_methods" in text:
print(" 套件里已经有这个阶段")
elif ANCHOR in text:
text = text.replace(ANCHOR, NEW, 1)
VERIFY.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
+ text.replace("\r\n", "\n").encode("utf-8"))
print(" 已加「手表分页完整性」阶段")
else:
print(" [失败] 锚点没找到")
return 1
# 升版本 1.7.0 -> 1.7.1
proc = __import__("subprocess").run(
[sys.executable, str(ROOT / "_build" / "bump_version_generic.py"),
"1.7.0", "1.7.1"],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
print(proc.stdout[-900:])
return 0
if __name__ == "__main__":
sys.exit(main())
+406
View File
@@ -0,0 +1,406 @@
# -*- coding: utf-8 -*-
r'''让「双击 exe」能打开设置窗口,并能设中转服务器。
== 用户报的两个问题 ==
问题 1:「被控端再安装时要能设置中转服务器,这是第一次设置」
—— SetupWindow 只有配对码/开机自启/放行防火墙,**没有中转**。
问题 2:「后面要想设置就要去点击 exe 去改配对码和开关新设备连接
还有中转服务器,现在如果再运行双击 exe 没反应,没运行也一样」
看 run_agent.py 的 main() 最后几行:
if not store.get("setup_done", False):
return run_setup(store)
# 已经设置过:直接静默,屏幕上什么都不出现
return run_silent(store, None)
—— 装完之后双击**永远静默**,什么窗口都不弹。
而且如果已经有一个实例在跑,InstanceLock 会直接 return,
连日志都不输出。
== 改法 ==
1. SetupWindow 加一个 mode:
mode="first" 第一次设置(保持老行为:防火墙+自启+转静默)
mode="settings" 已装好,只改配置
2. 窗口加两样东西:
* 中转服务器地址(可不填;只在一个 WiFi 里用就不需要)
* 允许新设备接入(开关)
3. main() 改成:
--setup -> settings 模式
--silent -> 静默(开机自启/安装包用,行为不变)
没参数 + 没设置过 -> first 模式
没参数 + 已经设置过 -> **settings 模式**(不再是静默)
4. 已经在跑的时候:不再"直接 exit 什么都不显示",而是
提示一句"被控端正在后台运行",然后**照样打开设置窗口**。
保存后重启后台实例让设置生效。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
UI = ROOT / "windows" / "lanremote" / "ui.py"
RUN = ROOT / "windows" / "run_agent.py"
def patch(path: Path, pairs, label: str) -> bool:
text = path.read_text(encoding="utf-8")
for old, new in pairs:
if old not in text:
print(" ✗ %s:找不到锚点 %r" % (label, old[:60]))
return False
if text.count(old) != 1:
print(" ! %s:锚点出现 %d 次" % (label, text.count(old)))
text = text.replace(old, new, 1)
path.write_text(text, encoding="utf-8", newline="\n")
print(" ✓ %s" % label)
return True
def main() -> int:
ok = True
# ================================================================
# 一、ui.py:加 mode + 中转字段 + 接入开关
# ================================================================
print("── ui.py ──")
ok &= patch(UI, [(
''' def __init__(self, store, suggested: str = ""):
self.store = store
self.suggested = (suggested or store.pairing_code()
or store.new_pairing_code())
self.result: dict | None = None''',
''' def __init__(self, store, suggested: str = "",
mode: str = "first"):
"""mode:
"first" 第一次设置(走防火墙 + 自启 + 转静默)
"settings" 已装好,只改配置(配对码/中转/接入开关)
"""
self.store = store
self.mode = mode if mode in ("first", "settings") else "first"
self.suggested = (suggested or store.pairing_code()
or store.new_pairing_code())
self.result: dict | None = None'''
)], "SetupWindow 加 mode")
# 标题按模式分
ok &= patch(UI, [(
''' self.root.title("ykxmz · 第一次设置")''',
''' self.root.title("ykxmz · " + (
"第一次设置" if self.mode == "first" else "设置"))'''
)], "窗口标题按模式分")
# 副标题
ok &= patch(UI, [(
''' tk.Label(titles, text="远程控制 · 第一次设置", bg=BG, fg=DIM,
font=FONT_SUB).pack(anchor="w")''',
''' tk.Label(titles,
text=("远程控制 · 第一次设置" if self.mode == "first"
else "远程控制 · 设置"),
bg=BG, fg=DIM,
font=FONT_SUB).pack(anchor="w")'''
)], "副标题按模式分")
# 在配对码卡片之后插入「中转服务器」卡片
ok &= patch(UI, [(
''' self.status = tk.Label(inner, text="", bg=CARD, fg=DANGER,
font=FONT_SMALL, wraplength=330,
justify="left", anchor="w")
self.status.pack(fill="x", pady=(6, 0))
''',
''' self.status = tk.Label(inner, text="", bg=CARD, fg=DANGER,
font=FONT_SMALL, wraplength=330,
justify="left", anchor="w")
self.status.pack(fill="x", pady=(6, 0))
# ================= 中转服务器卡片 =================
# 用户要求:安装时要能设中转服务器;装完之后双击 exe 也能回来改。
#
# 为什么不填也能用:手表和电脑在同一个 WiFi 时走局域网直连,
# 根本不需要中转。只有"在办公室管教室"这种跨网络场景才要填。
relay_card = self._card(page)
relay_card.pack(fill="x", pady=(12, 0))
relay_inner = tk.Frame(relay_card.winfo_children()[0], bg=CARD)
relay_inner.pack(fill="x", padx=16, pady=14)
tk.Label(relay_inner, text="中转服务器(可以不填)", bg=CARD, fg=FG,
font=FONT_LABEL).pack(anchor="w")
self.relay_entry = tk.Entry(
relay_inner, bg="#f6f8fa", fg=FG, relief="flat",
font=FONT_SUB, insertbackground=ACCENT)
self.relay_entry.pack(fill="x", pady=(8, 0), ipady=6)
_saved_relay = str(self.store.get("relay_url", "") or "").strip()
if _saved_relay:
self.relay_entry.insert(0, _saved_relay)
tk.Label(relay_inner,
text="手表和电脑在同一个 WiFi 时不需要填 —— 会直接走"
"局域网,几十毫秒。\\n"
"要在别的地方(比如办公室)管这台电脑时才填,"
"填你自己那台中转服务器的地址。",
bg=CARD, fg=MUTED, font=FONT_SMALL, justify="left",
wraplength=396).pack(anchor="w", pady=(8, 0))
self.relay_status = tk.Label(relay_inner, text="", bg=CARD,
fg=DANGER, font=FONT_SMALL,
wraplength=396, justify="left",
anchor="w")
self.relay_status.pack(fill="x", pady=(6, 0))
'''
)], "插入中转服务器卡片")
# 选项卡片里加「允许新设备接入」(只在 settings 模式显示)
ok &= patch(UI, [(
''' self.var_firewall = tk.BooleanVar(value=True)
self._check(opt_inner, "放行防火墙", self.var_firewall,
"会弹一次管理员确认,然后黑窗口一闪而过")''',
''' # 「允许新设备接入」只在设置模式给 ——
# 第一次设置时当然是允许的(不然手表连不上)。
if self.mode == "settings":
self.var_allow_new = tk.BooleanVar(
value=bool(self.store.get("allow_new_pairing", True)))
self._check(opt_inner, "允许新设备接入", self.var_allow_new,
"关掉之后,只有已经配对过的设备能连上;"
"新设备会被拒绝")
else:
self.var_allow_new = None
self.var_firewall = tk.BooleanVar(
value=(self.mode == "first"))
if self.mode == "first":
self._check(opt_inner, "放行防火墙", self.var_firewall,
"会弹一次管理员确认,然后黑窗口一闪而过")'''
)], "加「允许新设备接入」选项")
# 结果里带上 relay / allow_new
ok &= patch(UI, [(
''' self.result = {
"code": cleaned,
"autostart": bool(self.var_auto.get()),
"firewall": bool(self.var_firewall.get()),
}''',
''' # 中转地址:允许空(空 = 只走局域网)。
# 填了的话做一次最基本的格式检查,免得用户把域名打错还找不到原因。
relay = self.relay_entry.get().strip()
if relay:
if not relay.startswith(("http://", "https://")):
relay = "https://" + relay
# 只留 协议://主机[:端口],去掉多余路径
_m = re.match(r"^(https?://[^/\\s]+)", relay)
if not _m:
self._set_relay_status("中转地址看着不对,检查一下", DANGER)
return
relay = _m.group(1).rstrip("/")
self.result = {
"code": cleaned,
"autostart": bool(self.var_auto.get()),
"firewall": bool(self.var_firewall.get()),
"relay": relay,
"allow_new": (None if self.var_allow_new is None
else bool(self.var_allow_new.get())),
}'''
)], "结果带 relay / allow_new")
# 需要一个 _set_relay_status
ok &= patch(UI, [(
''' def _cancel(self) -> None:
self.result = None''',
''' def _set_relay_status(self, text: str, color: str) -> None:
try:
self.relay_status.configure(text=text, fg=color)
except tk.TclError:
pass
def _cancel(self) -> None:
self.result = None'''
)], "加 _set_relay_status")
# ui.py 需要 re
text = UI.read_text(encoding="utf-8")
if not re.search(r"^import re\b", text, re.M):
m = re.search(r"^(?:import |from )", text, re.M)
text = text[:m.start()] + "import re\n" + text[m.start():]
UI.write_text(text, encoding="utf-8", newline="\n")
print(" ✓ ui.py 补 import re")
else:
print(" • ui.py 已有 import re")
# ================================================================
# 二、run_agent.py:双击打开设置窗口
# ================================================================
print()
print("── run_agent.py ──")
ok &= patch(RUN, [(
''' # 第一次运行:设置窗口 → 修防火墙 → 静默
if not store.get("setup_done", False):
return run_setup(store)
# 已经设置过:直接静默,屏幕上什么都不出现
return run_silent(store, None)''',
''' # 第一次运行:设置窗口 → 修防火墙 → 静默
if not store.get("setup_done", False):
return run_setup(store)
# 已经设置过、而且没给参数 —— 说明用户是**双击 exe**。
#
# 以前这里是 `return run_silent(store, None)`:直接后台跑,
# 屏幕上什么都不出现。用户想改配对码 / 中转地址 / 接入开关时
# 双击毫无反应,只能去翻命令行参数。
#
# 现在双击 = 打开设置窗口。后台运行走显式 --silent
# (开机自启和安装包都用的 --silent,所以不受影响)。
return run_settings(store)'''
)], "main() 双击改为打开设置窗口")
# 加 run_settings 函数(放在 run_setup 之后)
if "def run_settings(" not in RUN.read_text(encoding="utf-8"):
text = RUN.read_text(encoding="utf-8")
anchor = "def main(argv=None) -> int:"
if anchor not in text:
print(" ✗ 找不到 main()")
ok = False
else:
helper = '''def run_settings(store) -> int:
"""双击 exe 时打开的设置窗口。
和 run_setup 的区别:
run_setup 第一次装:走防火墙 + 开机自启 + 最后转静默
run_settings 已装好:只改配置,改完把后台实例重启一下让设置生效
为什么"已经有一个实例在跑"时也要照样开窗口:
以前 InstanceLock 拿不到就直接 return,用户双击后**什么都看不到**,
以为程序坏了。现在开窗、改完、重启后台实例。
"""
from lanremote.ui import SetupWindow
running = not InstanceLock.probe_free()
window = SetupWindow(store, mode="settings").show()
if window is None:
return 0 # 用户关掉了,什么都不改
changed = []
# ---- 配对码 ----
if window.get("code") and window["code"] != store.pairing_code():
try:
store.set_pairing_code(window["code"])
changed.append("配对码")
except ValueError as exc:
print(f"设置配对码失败:{exc}")
return 2
# ---- 中转服务器(空 = 只走局域网)----
relay = str(window.get("relay", "") or "").strip()
if relay != str(store.get("relay_url", "") or "").strip():
store.set("relay_url", relay)
changed.append("中转地址")
# ---- 允许新设备接入 ----
allow_new = window.get("allow_new")
if allow_new is not None and bool(allow_new) != bool(
store.get("allow_new_pairing", True)):
store.set("allow_new_pairing", bool(allow_new))
changed.append("接入开关")
# ---- 开机自启 ----
try:
from lanremote.autostart import enable, disable, is_enabled
want = bool(window.get("autostart"))
if want != bool(is_enabled()):
(enable if want else disable)()
changed.append("开机自启")
except Exception: # noqa: BLE001
pass
# ---- 让后台实例重新读配置 ----
if changed and running:
_restart_background(store)
print("已保存:" + "、".join(changed) + "(后台实例已重启)")
elif changed:
print("已保存:" + "、".join(changed))
else:
print("没有改动")
return 0
def _restart_background(store) -> None:
"""把正在跑的后台实例停掉,再用新配置起一个。
为什么要重启而不是"让它自己重读":
allow_new_pairing 是启动时读进 server.pairing_enabled 的,
中转地址也是启动时连的。文件改了但进程里还是旧值,
用户会以为设置没生效。
"""
import subprocess
# 只杀"没有窗口"的那个(后台实例)。taskkill 按镜像名杀会连自己一起杀,
# 所以这里用 wmic/powershell 过滤命令行里带 --silent 的。
try:
subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process -Filter \\"Name='ykxmz.exe'\\" | "
"Where-Object { $_.CommandLine -match '--silent' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
capture_output=True, timeout=20)
except Exception: # noqa: BLE001
pass
time.sleep(1.2)
# 用 --silent 重新起一个,完全脱离本进程
try:
exe = sys.executable
subprocess.Popen([exe, "--silent"],
creationflags=getattr(subprocess,
"DETACHED_PROCESS", 0),
close_fds=True)
except Exception: # noqa: BLE001
pass
'''
text = text.replace(anchor, helper + anchor, 1)
RUN.write_text(text, encoding="utf-8", newline="\n")
print(" ✓ 加 run_settings()")
else:
print(" • run_settings 已存在")
# --setup 也走 settings 模式
ok &= patch(RUN, [(
''' # --setup:重新设置
if args.setup:
return run_setup(store)''',
''' # --setup:重新打开设置窗口(和双击等效)
if args.setup:
return run_settings(store)'''
)], "--setup 走 settings 模式")
# ================================================================
print()
import ast
bad = []
for f in (UI, RUN):
try:
ast.parse(f.read_text(encoding="utf-8"))
except SyntaxError as exc:
bad.append("%s: 第 %s 行 %s" % (f.name, exc.lineno, exc.msg))
if bad:
print(" ✗ 语法错:")
for b in bad:
print(" %s" % b)
return 1
print(" ✓ 两个文件语法都通过")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+62
View File
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
r'''给官网首页加 60 秒缓存。
(download 那处已经用 edit 工具改好了:no-store ->
public,max-age=604800,immutable。)
首页原来走 _send 的默认 Cache-Control: no-store,
每次刷新都穿隧道回 4G 源站。加 60 秒缓存:
* 挡住"刷新就重新回源"的浪费
* 又不会让页面上的当前版本号 / 下载按钮过期太久
'''
from __future__ import annotations
import sys
from pathlib import Path
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\relay.py")
def main() -> int:
text = SRC.read_text(encoding="utf-8")
marker = 'self._send(200, _site_page().encode("utf-8"),\n'
idx = text.find(marker)
if idx < 0:
print(" ✗ 找不到首页的 _send")
return 1
# 打印现场,确认空白
end = text.find("\n", text.find("return", idx))
print(" 现场:")
for line in text[idx:end + 1].splitlines():
print(" %r" % line)
old = ('self._send(200, _site_page().encode("utf-8"),\n'
' "text/html; charset=utf-8")')
new = ('# 缓存 60 秒:挡住"刷新就重新回源"的浪费,\n'
' # 又不会让页面上的当前版本号和下载按钮过期太久。\n'
' self._send(200, _site_page().encode("utf-8"),\n'
' "text/html; charset=utf-8",\n'
' {"Cache-Control": "public, max-age=60"})')
if old not in text:
print()
print(" ✗ 精确串还是没匹配上(看上面的 repr 对齐空白)")
return 1
text = text.replace(old, new, 1)
SRC.write_text(text, encoding="utf-8", newline="\n")
print()
print(" ✓ 已加首页缓存")
# 复核
for i, line in enumerate(
SRC.read_text(encoding="utf-8").splitlines(), 1):
if "max-age=60" in line:
print(" 第 %d 行: %s" % (i, line.strip()))
return 0
if __name__ == "__main__":
sys.exit(main())
+79
View File
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
r'''给 verify_all.ps1 追加阶段 —— **不能用 PowerShell heredoc**。
== 为什么单独写这个脚本 ==
我这一轮在 PowerShell 里用 `python -c @"..."@` 写 Python 代码
去改 verify_all.ps1,结果 **`$stageOut` 和 `$root` 被 PowerShell
当成变量给展开了**(`@"..."@` 是插值字符串),写进去变成:
= & python (Join-Path "_tests\check_version_consistency.py") 2>&1
Show-Fails
Record "版本号一致性" (0 -eq 0)
整个脚本直接语法错。**同一个坑踩了两次**(3a7 和 3a8)。
教训:凡是给 .ps1 加含 `$` 的内容,一律**写 Python 脚本文件再跑**,
不要用 `python -c @"..."@`。
用法:
python _build/add_verify_stage.py <锚点文字> <阶段号> <标题> <脚本相对路径> <Record 名>
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SUITE = ROOT / "_tests" / "verify_all.ps1"
def main() -> int:
if len(sys.argv) < 6:
print(__doc__)
return 1
anchor_text, stage_no, title, script_rel, record_name = sys.argv[1:6]
raw = SUITE.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if script_rel in text:
print(" 已经注册过了(%s 里提到了 %s)" % (SUITE.name, script_rel))
return 0
lines = text.splitlines()
idx = None
for i, ln in enumerate(lines):
if ln.startswith("Record") and anchor_text in ln:
idx = i
break
if idx is None:
print(" ✗ 找不到锚点(Record 行含 %r)" % anchor_text)
print(" 现有 Record 行:")
for i, ln in enumerate(lines):
if ln.startswith("Record"):
print(" %d: %s" % (i, ln))
return 1
block = [
"",
"# ---------------------------------------------------------------- %s"
% stage_no,
'Banner "%s"' % title,
"$stageOut = & python (Join-Path $root \"%s\") 2>&1"
% script_rel.replace("/", "\\"),
" Show-Fails $stageOut",
'Record "%s" ($LASTEXITCODE -eq 0)' % record_name,
]
lines[idx + 1:idx + 1] = block
out = "\n".join(lines) + "\n"
SUITE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + out.encode("utf-8"))
print(" ✓ 已在「%s」后面插入阶段 %s" % (anchor_text, stage_no))
print(" (BOM 保留:%s)" % had_bom)
return 0
if __name__ == "__main__":
sys.exit(main())
+401
View File
@@ -0,0 +1,401 @@
# -*- coding: utf-8 -*-
r'''安卓端:中转地址改成"手动填" + 加一个设置页。
== 用户报的问题 ==
「安卓端为什么还是默认填的我的中转站,以后改成手动填,
安卓端加一个设置,各种设置就在这里面调」
== 根因 ==
代码里的默认值**已经改成空**了:
private static final String DEFAULT_RELAY_URL = "";
但你手表上看到的还是 `https://relay.xxmxxs.top` —— 因为
**老版本 1.5.0 把作者的域名硬编码成默认值**(git 历史里查到了):
private static final String DEFAULT_RELAY_URL = "https://relay.xxmxxs.top";
第一次运行它就 `setRelayUrl(默认值)` 存进了 SharedPreferences。
装新 APK **不会清偏好设置**,所以那个值一直留着。
**用户从来没"选"过这个地址** —— 是程序硬塞的。所以正确做法是
**一次性迁移**:如果存的就是当时内置的那个域名,当成没设过,清掉。
== 做的事 ==
1. 迁移:把老版本硬塞的那个域名从偏好里清掉
2. 新增「设置」页,把散落的设置收进来:
* 中转服务器(手动填 / 清空)
* 记住的配对码(看 / 清)
* 已配对设备(看数量 / 清空)
* 关于(版本号、连接方式说明)
3. 首页那个「中转设置(跨网络用)」按钮改成「设置」,进设置页
4. settingsPanel 纳入 showOnly(),避免再出现"面板漏藏"那类问题
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
/ "MainActivity.java")
# ======================================================================
# 1. 迁移 + 面板字段
# ======================================================================
MIGRATION = ''' /**
* 老版本硬塞进来的中转地址,要清掉。
*
* <p>1.5.0 那版把作者的域名写死成默认值:
* <pre>
* private static final String DEFAULT_RELAY_URL = "https://relay.xxmxxs.top";
* </pre>
* 而且第一次运行就存进了 SharedPreferences。装新 APK 不会清偏好,
* 所以用户会看到「默认还是那个中转站」—— 其实他从来没选过。
*
* <p>现在默认值是空串(跨网络要自己填)。这里做一次性迁移:
* 存的值如果正好是当时内置的那个域名,就当成没设过,清掉。
*
* <p>⚠ 只在**完全等于**内置值时清 —— 用户自己手填的地址不能动。
*/
private static final String[] LEGACY_BAKED_IN_RELAYS = {
"https://relay.xxmxxs.top",
"http://relay.xxmxxs.top",
};
private void migrateLegacyRelay() {
String saved = getSharedPreferences("lanremote", MODE_PRIVATE)
.getString("relay_url", "");
if (saved == null) {
return;
}
String trimmed = saved.trim();
for (String legacy : LEGACY_BAKED_IN_RELAYS) {
if (legacy.equalsIgnoreCase(trimmed)) {
// 这是老版本硬塞的,不是用户选的 —— 清掉,让他自己填
getSharedPreferences("lanremote", MODE_PRIVATE).edit()
.remove("relay_url").apply();
appendLog("已清掉旧版本内置的中转地址(不是你自己填的)");
return;
}
}
}
'''
SETTINGS_BUILD = ''' /**
* 「设置」页。用户要求:"安卓端加一个设置,各种设置就在这里面调"。
*
* <p>以前设置散在首页(一个「中转设置」按钮)和别处,
* 现在统一收到这里。
*/
private void buildSettingsPanel() {
settingsPanel = new FrameLayout(this);
settingsPanel.setBackgroundColor(C_BG);
ScrollView scroll = new ScrollView(this);
scroll.setBackgroundColor(C_BG);
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(10), dp(12), dp(10), dp(12));
scroll.addView(box, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
settingsPanel.addView(scroll, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
LinearLayout top = new LinearLayout(this);
top.setOrientation(LinearLayout.HORIZONTAL);
top.setGravity(Gravity.CENTER_VERTICAL);
box.addView(top, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT));
TextView title = new TextView(this);
title.setText("设置");
title.setTextColor(C_FG);
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
title.setTypeface(null, android.graphics.Typeface.BOLD);
top.addView(title, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
top.addView(smallButton("返回", C_CARD, new Runnable() {
@Override
public void run() {
hideSettingsPanel();
}
}));
// ================= 中转服务器 =================
settingsRelayValue = new TextView(this);
settingsRelayValue.setTextColor(C_DIM);
settingsRelayValue.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
settingsRelayValue.setPadding(0, dp(6), 0, dp(4));
box.addView(sectionTitle("中转服务器"));
box.addView(hintText("手表和电脑不在同一个网络时(比如在办公室管教室)"
+ "才需要填。填你自己那台的地址,比如 relay.你的域名。\\n"
+ "只在一个 WiFi 里用就留空 —— 那样直接连,最快也不耗流量。"));
box.addView(settingsRelayValue);
Button editRelay = bigButton("填写中转服务器", C_ACCENT, 14);
editRelay.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
promptRelayUrl();
}
});
box.addView(editRelay);
Button clearRelay = bigButton("清空中转服务器(只走局域网)", C_CARD, 13);
clearRelay.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
setRelayUrl("");
refreshSettingsPanel();
toast("已清空 —— 以后只走局域网");
}
});
box.addView(clearRelay);
// ================= 配对码 =================
settingsCodeValue = new TextView(this);
settingsCodeValue.setTextColor(C_DIM);
settingsCodeValue.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
settingsCodeValue.setPadding(0, dp(6), 0, dp(4));
box.addView(sectionTitle("记住的配对码"));
box.addView(hintText("手表会记住最近一次用的配对码,"
+ "下次连接自动带上,不用重复输。"));
box.addView(settingsCodeValue);
Button clearCode = bigButton("清除记住的配对码", C_CARD, 13);
clearCode.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
getSharedPreferences("lanremote", MODE_PRIVATE).edit()
.remove("paired_code").apply();
refreshSettingsPanel();
toast("已清除");
}
});
box.addView(clearCode);
// ================= 已配对设备 =================
settingsDevicesValue = new TextView(this);
settingsDevicesValue.setTextColor(C_DIM);
settingsDevicesValue.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
settingsDevicesValue.setPadding(0, dp(6), 0, dp(4));
box.addView(sectionTitle("已配对的电脑"));
box.addView(hintText("配对过的电脑会出现在首页列表里,点一下就能连。"
+ "换电脑或者想重新配对时可以清空。"));
box.addView(settingsDevicesValue);
Button clearDevices = bigButton("清空已配对的电脑", C_DANGER, 13);
clearDevices.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
new android.app.AlertDialog.Builder(MainActivity.this)
.setTitle("清空已配对的电脑?")
.setMessage("清掉之后首页列表就空了,"
+ "需要重新输配对码连接。")
.setPositiveButton("清空", new android.content.DialogInterface.OnClickListener() {
@Override
public void onClick(android.content.DialogInterface d, int w) {
store.clearDevices();
refreshDeviceList();
refreshSettingsPanel();
toast("已清空");
}
})
.setNegativeButton("取消", null)
.show();
}
});
box.addView(clearDevices);
// ================= 关于 =================
box.addView(sectionTitle("关于"));
box.addView(hintText("ykxmz 手表端 版本 " + appVersion()
+ "\\n\\n"
+ "连接顺序:先试局域网直连(同一个 WiFi,几毫秒);"
+ "连不上再走中转(跨网络,受线路影响)。"));
}
/** 一行小节标题 */
private TextView sectionTitle(String text) {
TextView v = new TextView(this);
v.setText(text);
v.setTextColor(C_FG);
v.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
v.setTypeface(null, android.graphics.Typeface.BOLD);
v.setPadding(0, dp(14), 0, 0);
return v;
}
/** 一行灰色说明 */
private TextView hintText(String text) {
TextView v = new TextView(this);
v.setText(text);
v.setTextColor(C_DIM);
v.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11);
v.setLineSpacing(0, 1.25f);
v.setPadding(0, dp(4), 0, 0);
return v;
}
/** 版本号。从 PackageManager 读,和构建脚本里的版本天然一致,
* 不用在代码里再写一份(写两份必然有一份会忘改)。 */
private String appVersion() {
try {
return getPackageManager()
.getPackageInfo(getPackageName(), 0).versionName;
} catch (Exception e) {
return "?";
}
}
/** 把设置页上的文字刷新成当前值 */
private void refreshSettingsPanel() {
if (settingsRelayValue == null) {
return;
}
String relay = relayUrl();
settingsRelayValue.setText(relay.isEmpty()
? "现在是:空(只走局域网)"
: "现在是:" + relay);
settingsRelayValue.setTextColor(relay.isEmpty() ? C_DIM : C_OK);
String code = savedPairingCode();
settingsCodeValue.setText((code == null || code.isEmpty())
? "现在没有记住的配对码"
: "记住的是:" + code);
int n = store.devices() == null ? 0 : store.devices().size();
settingsDevicesValue.setText("已配对 " + n + " 台电脑");
}
private void showSettingsPanel() {
refreshSettingsPanel();
showOnly(settingsPanel);
}
private void hideSettingsPanel() {
showConnectPanel();
}
'''
def patch(text: str, old: str, new: str, label: str, results: list) -> str:
if old not in text:
results.append(("✗", label, "找不到锚点"))
return text
if text.count(old) != 1:
results.append(("!", label, "锚点出现 %d 次" % text.count(old)))
results.append(("✓", label, ""))
return text.replace(old, new, 1)
def main() -> int:
text = SRC.read_text(encoding="utf-8")
results: list = []
# ---- 1. 面板字段声明 ----
text = patch(
text,
" private FrameLayout lanPanel;",
" private FrameLayout lanPanel;\n"
" // ---- 设置页(用户要求:各种设置收在一个页面里调)----\n"
" private FrameLayout settingsPanel;\n"
" private TextView settingsRelayValue;\n"
" private TextView settingsCodeValue;\n"
" private TextView settingsDevicesValue;",
"声明 settingsPanel 和各显示控件", results)
# ---- 2. 插入迁移函数(放在 relayUrl 之前)----
text = patch(
text, " private String relayUrl() {",
MIGRATION + " private String relayUrl() {",
"插入 migrateLegacyRelay()", results)
# ---- 3. buildSettingsPanel 等方法(插在 buildLanPanel 之前)----
text = patch(
text, " private void buildLanPanel() {",
SETTINGS_BUILD + " private void buildLanPanel() {",
"插入 buildSettingsPanel() 等", results)
# ---- 4. 建面板的地方也要建它 ----
text = patch(
text, " buildLanPanel();",
" buildLanPanel();\n buildSettingsPanel();",
"构建时创建 settingsPanel", results)
# ---- 5. showOnly 纳入 settingsPanel ----
text = patch(
text,
" View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel};",
" View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,\n"
" settingsPanel};",
"settingsPanel 纳入 showOnly()", results)
# ---- 6. 首页那个按钮改成「设置」 ----
text = patch(
text,
''' Button relayButton = bigButton("中转设置(跨网络用)", C_ACCENT, 14);''',
''' Button relayButton = bigButton("设置", C_ACCENT, 14);''',
"首页按钮文案改成「设置」", results)
text = patch(
text,
''' public void onClick(View view) {
promptRelayUrl();
}
});
box.addView(relayButton);''',
''' public void onClick(View view) {
showSettingsPanel();
}
});
box.addView(relayButton);''',
"首页按钮改成打开设置页", results)
# ---- 7. 启动时做迁移 ----
# 找 onCreate 里合适的位置:appendLog 第一次出现之前太早,
# 用 "store = " 初始化之后
import re
m = re.search(r"( store = new DeviceStore\(this\);\n)", text)
if m:
text = (text[:m.end()]
+ " // 老版本硬塞的中转地址要清掉(见 migrateLegacyRelay)\n"
" migrateLegacyRelay();\n"
+ text[m.end():])
results.append(("✓", "启动时调用迁移", ""))
else:
results.append(("✗", "启动时调用迁移", "找不到 store 初始化那行"))
# ---- 输出 ----
print(" ── 补丁结果 ──")
for mark, label, note in results:
print(" %s %s%s" % (mark, label, (" —— " + note) if note else ""))
if any(m == "✗" for m, _l, _n in results):
print()
print(" 有锚点没匹配,没有写回")
return 1
if not any(m == "✗" for m, _l, _n in results):
SRC.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 已写回 MainActivity.java")
return 0
if __name__ == "__main__":
sys.exit(main())
+481
View File
@@ -0,0 +1,481 @@
<!doctype html>
<html lang="zh-CN"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico">
<title>ykxmz 中转站</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#ffffff; --surface:#fafbfc; --border:#e6e8eb; --border-soft:#f0f2f5;
--fg:#1a1d21; --muted:#6b7280; --faint:#9ca3af;
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --danger:#dc2626;
}
html,body{max-width:100%;overflow-x:hidden}
body{background:var(--bg);color:var(--fg);line-height:1.6;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',
'PingFang SC',sans-serif;-webkit-font-smoothing:antialiased}
a{color:inherit}
.wrap{width:100%;max-width:1040px;margin:0 auto;padding:0 20px 64px}
/* ---------- 吸顶顶栏 ---------- */
.top{position:sticky;top:0;z-index:40;background:rgba(255,255,255,.88);
backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
/*
* ⚠ 别用固定 height。
* 原来写的是 .top-in{height:62px},但里面品牌是「标题 + 副标题」两行、
* 右边还有两行版本信息 —— 实际内容比 62px 高,
* flex 就把内容往上顶出去了:**标题被切掉、运行中徽章压在字上**
* (用户截图里就是这样)。
* 改成 padding 撑开,内容多高就是多高。
*/
.top-in{display:flex;align-items:center;gap:12px;
padding-top:13px;padding-bottom:13px;min-height:36px}
.logo{width:32px;height:32px;border-radius:9px;flex:none;
background:linear-gradient(140deg,#2563eb,#1d4ed8);
display:flex;align-items:center;justify-content:center;
color:#fff;font-weight:700;font-size:12px;letter-spacing:-.3px}
.brand{flex:none;min-width:0}
.brand b{display:block;font-size:15px;font-weight:650;letter-spacing:-.2px;
line-height:1.35;white-space:nowrap}
.brand span{display:block;color:var(--faint);font-size:11px;
line-height:1.4;white-space:nowrap}
.state{margin-left:8px;flex:none;display:inline-flex;align-items:center;gap:6px;
padding:3px 10px;border-radius:999px;background:#f0fdf4;
color:#15803d;font-size:11px;font-weight:600;white-space:nowrap}
.state i{width:6px;height:6px;border-radius:50%;background:#22c55e;
display:block;flex:none}
.top-right{margin-left:auto;text-align:right;font-size:11px;
color:var(--faint);line-height:1.65;min-width:0}
.top-right b{color:var(--muted);font-weight:500}
.logout{color:var(--muted);text-decoration:none;
border-bottom:1px dashed var(--border);transition:.15s}
.logout:hover{color:var(--danger);border-bottom-color:var(--danger)}
/* 窄屏:状态徽章换行,别和标题挤 */
@media(max-width:640px){
.top-in{flex-wrap:wrap;gap:8px}
.state{margin-left:auto}
}
/* ---------- 公网地址(最重要,给最大权重)---------- */
.hero{padding:30px 0 26px}
.hero h2{font-size:13px;font-weight:600;color:var(--muted);
margin-bottom:10px;letter-spacing:.2px}
.urlbox{display:flex;align-items:stretch;gap:10px;flex-wrap:wrap}
.url{flex:1;min-width:240px;display:flex;align-items:center;
font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;
color:var(--accent);word-break:break-all;
background:var(--surface);border:1px solid var(--border);
border-radius:11px;padding:14px 16px}
.url.empty{color:var(--faint);font-weight:400;font-size:14px}
.hint{color:var(--faint);font-size:12px;margin-top:9px}
/* ---------- 数字卡 ---------- */
.nums{display:grid;gap:10px;grid-template-columns:repeat(2,1fr);
margin-bottom:30px}
@media(min-width:640px){.nums{grid-template-columns:repeat(4,1fr)}}
.num{background:var(--surface);border:1px solid var(--border);
border-radius:12px;padding:15px 16px}
.num .v{font-size:25px;font-weight:700;letter-spacing:-.7px;
line-height:1.1;font-variant-numeric:tabular-nums}
.num .k{color:var(--muted);font-size:12px;margin-top:3px}
.num.live{background:#f0fdf4;border-color:#bbf7d0}
.num.live .v{color:#15803d}
.num.busy{background:#fffbeb;border-color:#fde68a}
.num.busy .v{color:#b45309}
/* ---------- 区块标题 ---------- */
.sec{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.sec h3{font-size:15px;font-weight:650;letter-spacing:-.2px}
.sec .right{margin-left:auto;display:flex;align-items:center;gap:12px}
/* ---------- 表格 ---------- */
.card{background:#fff;border:1px solid var(--border);border-radius:13px;
overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{background:var(--surface);text-align:left;font-weight:600;
color:var(--muted);font-size:11.5px;letter-spacing:.3px;
padding:11px 14px;border-bottom:1px solid var(--border);
white-space:nowrap}
tbody td{padding:13px 14px;border-bottom:1px solid var(--border-soft);
vertical-align:middle}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover td{background:#fbfcfd}
tbody tr.picked td{background:#eff6ff}
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;
margin-right:7px;flex:none}
.dot.on{background:#22c55e}
.dot.off{background:#d1d5db}
.devname{display:flex;align-items:center;font-weight:600}
.devid{color:var(--faint);font-size:10.5px;margin-top:2px;
font-family:ui-monospace,Consolas,monospace;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}
code{font:600 13px/1 ui-monospace,Consolas,monospace;
background:var(--surface);border:1px solid var(--border);
border-radius:6px;padding:4px 8px;letter-spacing:.5px}
.pill{padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600}
.pill.on{background:#dcfce7;color:#15803d}
.pill.off{background:#f3f4f6;color:#6b7280}
.mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;
color:var(--muted)}
.ops{text-align:right}
.empty{padding:56px 20px;text-align:center}
.empty .big{font-size:15px;font-weight:600;margin-bottom:8px}
.empty p{color:var(--muted);font-size:13px;max-width:340px;margin:0 auto}
.empty .ico{font-size:30px;margin-bottom:14px;opacity:.35}
/* ---------- 按钮 ---------- */
button{font-family:inherit;font-size:13px;cursor:pointer;
border-radius:9px;padding:8px 15px;border:1px solid var(--border);
background:#fff;color:var(--text);transition:.14s;
font-weight:500}
button:hover:not(:disabled){background:var(--surface);
border-color:#d1d5db}
button:active:not(:disabled){transform:translateY(1px)}
button:disabled{opacity:.42;cursor:not-allowed}
button.primary{background:var(--accent);border-color:var(--accent);
color:#fff;font-weight:600}
button.primary:hover:not(:disabled){background:#1d4ed8;
border-color:#1d4ed8}
button.danger{color:#b91c1c;border-color:#fecaca;background:#fff}
button.danger:hover:not(:disabled){background:#fef2f2;
border-color:#fca5a5}
button.sm{padding:6px 12px;font-size:12px}
/* ---------- 工具卡 ---------- */
.tools{display:grid;gap:14px;margin-top:30px}
@media(min-width:720px){.tools{grid-template-columns:1fr 1fr}}
.tool{border:1px solid var(--border);border-radius:13px;padding:18px 18px}
.tool h4{font-size:13.5px;font-weight:650;margin-bottom:4px}
.tool .d{color:var(--muted);font-size:12.5px;margin-bottom:14px}
.tool .out{color:var(--muted);font-size:12.5px;line-height:1.85;
word-break:break-all}
.tool .out b{color:var(--fg);font-weight:600}
.tool .out .good{color:#15803d;font-weight:600}
.tool .out .bad{color:#b91c1c;font-weight:600}
.dlrow{display:flex;align-items:center;gap:10px;padding:7px 0;
border-bottom:1px solid var(--border-soft);font-size:12.5px}
.dlrow:last-child{border-bottom:none}
.dlrow .nm{flex:1;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;font-family:ui-monospace,Consolas,monospace;
font-size:11.5px}
.dlrow .sz{color:var(--faint);font-variant-numeric:tabular-nums}
/* ---------- 底部说明 ---------- */
.foot{margin-top:36px;padding-top:20px;border-top:1px solid var(--border);
color:var(--faint);font-size:11.5px;line-height:1.9}
.foot b{color:var(--muted);font-weight:600}
/* ---------- 提示条 ---------- */
#toast{position:fixed;left:50%;bottom:30px;transform:translateX(-50%)
translateY(20px);background:#1a1d21;color:#fff;padding:11px 20px;
border-radius:10px;font-size:13px;opacity:0;pointer-events:none;
transition:.22s;z-index:99;max-width:86vw;text-align:center}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.ok{background:#15803d}
#toast.err{background:#b91c1c}
/* ---------- 窄屏:表格变卡片 ---------- */
@media(max-width:700px){
thead{display:none}
tbody tr{display:block;border-bottom:1px solid var(--border);
padding:12px 14px}
tbody td{display:flex;justify-content:space-between;gap:14px;
padding:5px 0;border:none}
tbody td::before{content:attr(data-l);color:var(--faint);font-size:11.5px;
flex:none}
td.pickcell::before,
td.ops::before{display:none}
td.pickcell{padding-bottom:8px}
td.ops{justify-content:flex-end;padding-top:10px;
border-top:1px solid var(--border-soft)}
td.empty{display:block;text-align:center}
td.empty::before{display:none}
.devid{max-width:200px}
}
</style>
</head><body>
<div class="top"><div class="wrap top-in">
<div class="logo">yx</div>
<div class="brand">
<b>ykxmz 中转站</b>
<span>跨网络中转 · 管理后台</span>
</div>
<span class="state"><i></i>运行中</span>
<div class="top-right">
<div>版本 <b>1.7.1</b> · 已运行 <b>9 小时 10 分</b> · 内存 <b>22 MB</b></div>
<div>累计请求 <b>154</b> · <a class="logout" href="/zhongzhuanzhan/logout">退出登录</a></div>
</div>
</div></div>
<div class="wrap">
<div class="hero">
<h2>公网地址 —— 手表「中转设置」里填这个</h2>
<div class="urlbox">
<div class="url" id="urlBox">https://relay.xxmxxs.top</div>
<button class="primary" id="copyUrl">复制</button>
</div>
<div class="hint">手表和电脑不在同一个网络时,两边都通过这个地址中转。</div>
</div>
<div class="nums">
<div class="num"><div class="v">0</div><div class="k">登记设备</div></div>
<div class="num "><div class="v">0</div><div class="k">在线</div></div>
<div class="num "><div class="v">0</div><div class="k">手表会话</div></div>
<div class="num"><div class="v">0</div><div class="k">配对码占用</div></div>
</div>
<div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>
<button class="danger sm" id="delPicked" disabled>删除选中</button>
<button class="danger sm" id="delAll">删除全部</button>
</div>
</div>
<div class="card">
<table>
<thead><tr>
<th style="width:38px"><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"><svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:.28;margin-bottom:14px"><rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg><div class="big">还没有电脑连上来</div><p>在教室那台电脑上双击 ykxmz.exe 设好 6 位配对码,手表上输这 6 位数字就能连上。</p></td></tr>
</tbody>
</table>
</div>
<div class="tools">
<div class="tool" id="netPanel">
<h4>网络体检</h4>
<div class="d">看看这台机器是走局域网还是走隧道。</div>
<div class="out" id="netBody">还没测过。</div>
<div style="margin-top:14px"><button class="sm" id="netBtn">测一下</button></div>
</div>
<div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody"><div class="out">还没有上传任何安装包。把文件放到中转的 downloads 目录即可。</div></div>
</div>
</div>
<div class="foot">
<b>隐私说明</b> —— 数据来自 /v1/health,只显示配对码和流量数字,
看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。
</div>
</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 = ''; }, 2800);
};
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.danger[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 class="bad">读不到:'
+ (j.error || '未知') + '</span>';
return;
}
const rows = [];
if (j.tunnelMetrics) {
const conns = j.tunnelConnections || 0;
const cls = conns >= 2 ? 'good' : 'bad';
rows.push('隧道连接数:<b class="' + cls + '">' + conns + '</b>'
+ (conns === 0 ? '(断了,看门狗会自动重启)' : ''));
rows.push('隧道累计请求:<b>' + (j.tunnelRequests || 0) + '</b>');
} else {
rows.push('<span class="bad">读不到隧道指标'
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
}
rows.push('登记设备:<b>' + j.devices + '</b>(在线 <b>' + j.online
+ '</b>)');
rows.push('手表会话:<b>' + j.sessions + '</b>');
rows.push('中转内存:<b>' + Math.round(j.memoryKB / 1024) + ' MB</b>');
rows.push('已运行:<b>' + Math.round(j.uptimeSeconds / 60) + ' 分钟</b>');
if (j.publicUrl) {
rows.push('公网地址:<b>' + j.publicUrl + '</b>');
}
body.innerHTML = rows.map(t => '<div>' + 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>浏览器到中转往返:<b class="'
+ (ms < 300 ? 'good' : 'bad') + '">' + ms + ' ms</b>'
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
+ '</div>';
} catch (e) {
body.innerHTML += '<div class="bad">浏览器到中转:失败</div>';
}
} catch (e) {
body.textContent = '请求出错:' + e;
}
};
// ---------------- 自动刷新 ----------------
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 {
if (localStorage.getItem('lr_autoref') === '0') autoRef.checked = false;
} catch (e) {}
setupAuto();
}
</script>
</body></html>
+481
View File
@@ -0,0 +1,481 @@
<!doctype html>
<html lang="zh-CN"><head>
<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;margin:0;padding:0}
:root{
--bg:#ffffff; --surface:#fafbfc; --border:#e6e8eb; --border-soft:#f0f2f5;
--fg:#1a1d21; --muted:#6b7280; --faint:#9ca3af;
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --danger:#dc2626;
}
html,body{max-width:100%;overflow-x:hidden}
body{background:var(--bg);color:var(--fg);line-height:1.6;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',
'PingFang SC',sans-serif;-webkit-font-smoothing:antialiased}
a{color:inherit}
.wrap{width:100%;max-width:1040px;margin:0 auto;padding:0 20px 64px}
/* ---------- 吸顶顶栏 ---------- */
.top{position:sticky;top:0;z-index:40;background:rgba(255,255,255,.88);
backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
/*
* ⚠ 别用固定 height。
* 原来写的是 .top-in{height:62px},但里面品牌是「标题 + 副标题」两行、
* 右边还有两行版本信息 —— 实际内容比 62px 高,
* flex 就把内容往上顶出去了:**标题被切掉、运行中徽章压在字上**
* (用户截图里就是这样)。
* 改成 padding 撑开,内容多高就是多高。
*/
.top-in{display:flex;align-items:center;gap:12px;
padding-top:13px;padding-bottom:13px;min-height:36px}
.logo{width:32px;height:32px;border-radius:9px;flex:none;
background:linear-gradient(140deg,#2563eb,#1d4ed8);
display:flex;align-items:center;justify-content:center;
color:#fff;font-weight:700;font-size:12px;letter-spacing:-.3px}
.brand{flex:none;min-width:0}
.brand b{display:block;font-size:15px;font-weight:650;letter-spacing:-.2px;
line-height:1.35;white-space:nowrap}
.brand span{display:block;color:var(--faint);font-size:11px;
line-height:1.4;white-space:nowrap}
.state{margin-left:8px;flex:none;display:inline-flex;align-items:center;gap:6px;
padding:3px 10px;border-radius:999px;background:#f0fdf4;
color:#15803d;font-size:11px;font-weight:600;white-space:nowrap}
.state i{width:6px;height:6px;border-radius:50%;background:#22c55e;
display:block;flex:none}
.top-right{margin-left:auto;text-align:right;font-size:11px;
color:var(--faint);line-height:1.65;min-width:0}
.top-right b{color:var(--muted);font-weight:500}
.logout{color:var(--muted);text-decoration:none;
border-bottom:1px dashed var(--border);transition:.15s}
.logout:hover{color:var(--danger);border-bottom-color:var(--danger)}
/* 窄屏:状态徽章换行,别和标题挤 */
@media(max-width:640px){
.top-in{flex-wrap:wrap;gap:8px}
.state{margin-left:auto}
}
/* ---------- 公网地址(最重要,给最大权重)---------- */
.hero{padding:30px 0 26px}
.hero h2{font-size:13px;font-weight:600;color:var(--muted);
margin-bottom:10px;letter-spacing:.2px}
.urlbox{display:flex;align-items:stretch;gap:10px;flex-wrap:wrap}
.url{flex:1;min-width:240px;display:flex;align-items:center;
font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;
color:var(--accent);word-break:break-all;
background:var(--surface);border:1px solid var(--border);
border-radius:11px;padding:14px 16px}
.url.empty{color:var(--faint);font-weight:400;font-size:14px}
.hint{color:var(--faint);font-size:12px;margin-top:9px}
/* ---------- 数字卡 ---------- */
.nums{display:grid;gap:10px;grid-template-columns:repeat(2,1fr);
margin-bottom:30px}
@media(min-width:640px){.nums{grid-template-columns:repeat(4,1fr)}}
.num{background:var(--surface);border:1px solid var(--border);
border-radius:12px;padding:15px 16px}
.num .v{font-size:25px;font-weight:700;letter-spacing:-.7px;
line-height:1.1;font-variant-numeric:tabular-nums}
.num .k{color:var(--muted);font-size:12px;margin-top:3px}
.num.live{background:#f0fdf4;border-color:#bbf7d0}
.num.live .v{color:#15803d}
.num.busy{background:#fffbeb;border-color:#fde68a}
.num.busy .v{color:#b45309}
/* ---------- 区块标题 ---------- */
.sec{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.sec h3{font-size:15px;font-weight:650;letter-spacing:-.2px}
.sec .right{margin-left:auto;display:flex;align-items:center;gap:12px}
/* ---------- 表格 ---------- */
.card{background:#fff;border:1px solid var(--border);border-radius:13px;
overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{background:var(--surface);text-align:left;font-weight:600;
color:var(--muted);font-size:11.5px;letter-spacing:.3px;
padding:11px 14px;border-bottom:1px solid var(--border);
white-space:nowrap}
tbody td{padding:13px 14px;border-bottom:1px solid var(--border-soft);
vertical-align:middle}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover td{background:#fbfcfd}
tbody tr.picked td{background:#eff6ff}
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;
margin-right:7px;flex:none}
.dot.on{background:#22c55e}
.dot.off{background:#d1d5db}
.devname{display:flex;align-items:center;font-weight:600}
.devid{color:var(--faint);font-size:10.5px;margin-top:2px;
font-family:ui-monospace,Consolas,monospace;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}
code{font:600 13px/1 ui-monospace,Consolas,monospace;
background:var(--surface);border:1px solid var(--border);
border-radius:6px;padding:4px 8px;letter-spacing:.5px}
.pill{padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600}
.pill.on{background:#dcfce7;color:#15803d}
.pill.off{background:#f3f4f6;color:#6b7280}
.mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;
color:var(--muted)}
.ops{text-align:right}
.empty{padding:56px 20px;text-align:center}
.empty .big{font-size:15px;font-weight:600;margin-bottom:8px}
.empty p{color:var(--muted);font-size:13px;max-width:340px;margin:0 auto}
.empty .ico{font-size:30px;margin-bottom:14px;opacity:.35}
/* ---------- 按钮 ---------- */
button{font-family:inherit;font-size:13px;cursor:pointer;
border-radius:9px;padding:8px 15px;border:1px solid var(--border);
background:#fff;color:var(--text);transition:.14s;
font-weight:500}
button:hover:not(:disabled){background:var(--surface);
border-color:#d1d5db}
button:active:not(:disabled){transform:translateY(1px)}
button:disabled{opacity:.42;cursor:not-allowed}
button.primary{background:var(--accent);border-color:var(--accent);
color:#fff;font-weight:600}
button.primary:hover:not(:disabled){background:#1d4ed8;
border-color:#1d4ed8}
button.danger{color:#b91c1c;border-color:#fecaca;background:#fff}
button.danger:hover:not(:disabled){background:#fef2f2;
border-color:#fca5a5}
button.sm{padding:6px 12px;font-size:12px}
/* ---------- 工具卡 ---------- */
.tools{display:grid;gap:14px;margin-top:30px}
@media(min-width:720px){.tools{grid-template-columns:1fr 1fr}}
.tool{border:1px solid var(--border);border-radius:13px;padding:18px 18px}
.tool h4{font-size:13.5px;font-weight:650;margin-bottom:4px}
.tool .d{color:var(--muted);font-size:12.5px;margin-bottom:14px}
.tool .out{color:var(--muted);font-size:12.5px;line-height:1.85;
word-break:break-all}
.tool .out b{color:var(--fg);font-weight:600}
.tool .out .good{color:#15803d;font-weight:600}
.tool .out .bad{color:#b91c1c;font-weight:600}
.dlrow{display:flex;align-items:center;gap:10px;padding:7px 0;
border-bottom:1px solid var(--border-soft);font-size:12.5px}
.dlrow:last-child{border-bottom:none}
.dlrow .nm{flex:1;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;font-family:ui-monospace,Consolas,monospace;
font-size:11.5px}
.dlrow .sz{color:var(--faint);font-variant-numeric:tabular-nums}
/* ---------- 底部说明 ---------- */
.foot{margin-top:36px;padding-top:20px;border-top:1px solid var(--border);
color:var(--faint);font-size:11.5px;line-height:1.9}
.foot b{color:var(--muted);font-weight:600}
/* ---------- 提示条 ---------- */
#toast{position:fixed;left:50%;bottom:30px;transform:translateX(-50%)
translateY(20px);background:#1a1d21;color:#fff;padding:11px 20px;
border-radius:10px;font-size:13px;opacity:0;pointer-events:none;
transition:.22s;z-index:99;max-width:86vw;text-align:center}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.ok{background:#15803d}
#toast.err{background:#b91c1c}
/* ---------- 窄屏:表格变卡片 ---------- */
@media(max-width:700px){
thead{display:none}
tbody tr{display:block;border-bottom:1px solid var(--border);
padding:12px 14px}
tbody td{display:flex;justify-content:space-between;gap:14px;
padding:5px 0;border:none}
tbody td::before{content:attr(data-l);color:var(--faint);font-size:11.5px;
flex:none}
td.pickcell::before,
td.ops::before{display:none}
td.pickcell{padding-bottom:8px}
td.ops{justify-content:flex-end;padding-top:10px;
border-top:1px solid var(--border-soft)}
td.empty{display:block;text-align:center}
td.empty::before{display:none}
.devid{max-width:200px}
}
</style>
</head><body>
<div class="top"><div class="wrap top-in">
<div class="logo">yx</div>
<div class="brand">
<b>ykxmz 中转站</b>
<span>跨网络中转 · 管理后台</span>
</div>
<span class="state"><i></i>运行中</span>
<div class="top-right">
<div>版本 <b>1.7.1</b> · 已运行 <b>6 分</b> · 内存 <b>23 MB</b></div>
<div>累计请求 <b>29</b> · <a class="logout" href="/zhongzhuanzhan/logout">退出登录</a></div>
</div>
</div></div>
<div class="wrap">
<div class="hero">
<h2>公网地址 —— 手表「中转设置」里填这个</h2>
<div class="urlbox">
<div class="url" id="urlBox">https://relay.xxmxxs.top</div>
<button class="primary" id="copyUrl">复制</button>
</div>
<div class="hint">手表和电脑不在同一个网络时,两边都通过这个地址中转。</div>
</div>
<div class="nums">
<div class="num"><div class="v">2</div><div class="k">登记设备</div></div>
<div class="num live"><div class="v">1</div><div class="k">在线</div></div>
<div class="num "><div class="v">0</div><div class="k">手表会话</div></div>
<div class="num"><div class="v">2</div><div class="k">配对码占用</div></div>
</div>
<div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>
<button class="danger sm" id="delPicked" disabled>删除选中</button>
<button class="danger sm" id="delAll">删除全部</button>
</div>
</div>
<div class="card">
<table>
<thead><tr>
<th style="width:38px"><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 class="pickcell"><input type="checkbox" class="pick" value="DESKTOP-RN5I625-ec4b284cdecd" aria-label="选择 DESKTOP-RN5I625"></td><td data-l="设备"><div class="devname"><span class="dot off"></span>DESKTOP-RN5I625</div><div class="devid">DESKTOP-RN5I625-ec4b284cdecd</div></td><td data-l="配对码"><code>966173</code></td><td data-l="状态"><span class="pill off">离线</span></td><td data-l="空闲"><span class="mono">314s</span></td><td data-l="待发 表/机"><span class="mono">0 / 0</span></td><td data-l="流量 入/出"><span class="mono">0 B / 0 B</span></td><td class="ops"><button class="danger sm" data-device="DESKTOP-RN5I625-ec4b284cdecd" data-name="DESKTOP-RN5I625" data-code="966173">删除</button></td></tr><tr><td class="pickcell"><input type="checkbox" class="pick" value="DESKTOP-RN5I625-2f882bd8ed14" aria-label="选择 DESKTOP-RN5I625"></td><td data-l="设备"><div class="devname"><span class="dot on"></span>DESKTOP-RN5I625</div><div class="devid">DESKTOP-RN5I625-2f882bd8ed14</div></td><td data-l="配对码"><code>246813</code></td><td data-l="状态"><span class="pill on">在线</span></td><td data-l="空闲"><span class="mono">26s</span></td><td data-l="待发 表/机"><span class="mono">0 / 0</span></td><td data-l="流量 入/出"><span class="mono">0 B / 0 B</span></td><td class="ops"><button class="danger sm" data-device="DESKTOP-RN5I625-2f882bd8ed14" data-name="DESKTOP-RN5I625" data-code="246813">删除</button></td></tr>
</tbody>
</table>
</div>
<div class="tools">
<div class="tool" id="netPanel">
<h4>网络体检</h4>
<div class="d">看看这台机器是走局域网还是走隧道。</div>
<div class="out" id="netBody">还没测过。</div>
<div style="margin-top:14px"><button class="sm" id="netBtn">测一下</button></div>
</div>
<div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody"><div class="dlrow"><span class="nm">ykxmz-watch-1.7.1.apk</span><span class="sz">197 KB</span></div><div class="dlrow"><span class="nm">ykxmz-server-1.7.1.zip</span><span class="sz">198 KB</span></div><div class="dlrow"><span class="nm">ykxmz-setup-1.7.1.exe</span><span class="sz">14.4 MB</span></div></div>
</div>
</div>
<div class="foot">
<b>隐私说明</b> —— 数据来自 /v1/health,只显示配对码和流量数字,
看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。
</div>
</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 = ''; }, 2800);
};
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.danger[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 class="bad">读不到:'
+ (j.error || '未知') + '</span>';
return;
}
const rows = [];
if (j.tunnelMetrics) {
const conns = j.tunnelConnections || 0;
const cls = conns >= 2 ? 'good' : 'bad';
rows.push('隧道连接数:<b class="' + cls + '">' + conns + '</b>'
+ (conns === 0 ? '(断了,看门狗会自动重启)' : ''));
rows.push('隧道累计请求:<b>' + (j.tunnelRequests || 0) + '</b>');
} else {
rows.push('<span class="bad">读不到隧道指标'
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
}
rows.push('登记设备:<b>' + j.devices + '</b>(在线 <b>' + j.online
+ '</b>)');
rows.push('手表会话:<b>' + j.sessions + '</b>');
rows.push('中转内存:<b>' + Math.round(j.memoryKB / 1024) + ' MB</b>');
rows.push('已运行:<b>' + Math.round(j.uptimeSeconds / 60) + ' 分钟</b>');
if (j.publicUrl) {
rows.push('公网地址:<b>' + j.publicUrl + '</b>');
}
body.innerHTML = rows.map(t => '<div>' + 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>浏览器到中转往返:<b class="'
+ (ms < 300 ? 'good' : 'bad') + '">' + ms + ' ms</b>'
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
+ '</div>';
} catch (e) {
body.innerHTML += '<div class="bad">浏览器到中转:失败</div>';
}
} catch (e) {
body.textContent = '请求出错:' + e;
}
};
// ---------------- 自动刷新 ----------------
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 {
if (localStorage.getItem('lr_autoref') === '0') autoRef.checked = false;
} catch (e) {}
setupAuto();
}
</script>
</body></html>
+463
View File
@@ -0,0 +1,463 @@
<!doctype html>
<html lang="zh-CN"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico">
<title>ykxmz 中转站</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#ffffff; --surface:#fafbfc; --border:#e6e8eb; --border-soft:#f0f2f5;
--fg:#1a1d21; --muted:#6b7280; --faint:#9ca3af;
--accent:#2563eb; --ok:#16a34a; --warn:#d97706; --danger:#dc2626;
}
html,body{max-width:100%;overflow-x:hidden}
body{background:var(--bg);color:var(--fg);line-height:1.6;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',
'PingFang SC',sans-serif;-webkit-font-smoothing:antialiased}
a{color:inherit}
.wrap{width:100%;max-width:1040px;margin:0 auto;padding:0 20px 64px}
/* ---------- 吸顶顶栏 ---------- */
.top{position:sticky;top:0;z-index:40;background:rgba(255,255,255,.88);
backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
.top-in{display:flex;align-items:center;gap:12px;height:62px}
.logo{width:32px;height:32px;border-radius:9px;flex:none;
background:linear-gradient(140deg,#2563eb,#1d4ed8);
display:flex;align-items:center;justify-content:center;
color:#fff;font-weight:700;font-size:12px;letter-spacing:-.3px}
.brand b{display:block;font-size:15px;font-weight:650;letter-spacing:-.2px}
.brand span{display:block;color:var(--faint);font-size:11px}
.state{margin-left:14px;display:inline-flex;align-items:center;gap:6px;
padding:3px 10px;border-radius:999px;background:#f0fdf4;
color:#15803d;font-size:11px;font-weight:600}
.state i{width:6px;height:6px;border-radius:50%;background:#22c55e;
display:block}
.top-right{margin-left:auto;text-align:right;font-size:11px;
color:var(--faint);line-height:1.7}
.top-right b{color:var(--muted);font-weight:500}
.logout{color:var(--muted);text-decoration:none;
border-bottom:1px dashed var(--border);transition:.15s}
.logout:hover{color:var(--danger);border-bottom-color:var(--danger)}
/* ---------- 公网地址(最重要,给最大权重)---------- */
.hero{padding:30px 0 26px}
.hero h2{font-size:13px;font-weight:600;color:var(--muted);
margin-bottom:10px;letter-spacing:.2px}
.urlbox{display:flex;align-items:stretch;gap:10px;flex-wrap:wrap}
.url{flex:1;min-width:240px;display:flex;align-items:center;
font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;
color:var(--accent);word-break:break-all;
background:var(--surface);border:1px solid var(--border);
border-radius:11px;padding:14px 16px}
.url.empty{color:var(--faint);font-weight:400;font-size:14px}
.hint{color:var(--faint);font-size:12px;margin-top:9px}
/* ---------- 数字卡 ---------- */
.nums{display:grid;gap:10px;grid-template-columns:repeat(2,1fr);
margin-bottom:30px}
@media(min-width:640px){.nums{grid-template-columns:repeat(4,1fr)}}
.num{background:var(--surface);border:1px solid var(--border);
border-radius:12px;padding:15px 16px}
.num .v{font-size:25px;font-weight:700;letter-spacing:-.7px;
line-height:1.1;font-variant-numeric:tabular-nums}
.num .k{color:var(--muted);font-size:12px;margin-top:3px}
.num.live{background:#f0fdf4;border-color:#bbf7d0}
.num.live .v{color:#15803d}
.num.busy{background:#fffbeb;border-color:#fde68a}
.num.busy .v{color:#b45309}
/* ---------- 区块标题 ---------- */
.sec{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.sec h3{font-size:15px;font-weight:650;letter-spacing:-.2px}
.sec .right{margin-left:auto;display:flex;align-items:center;gap:12px}
/* ---------- 表格 ---------- */
.card{background:#fff;border:1px solid var(--border);border-radius:13px;
overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{background:var(--surface);text-align:left;font-weight:600;
color:var(--muted);font-size:11.5px;letter-spacing:.3px;
padding:11px 14px;border-bottom:1px solid var(--border);
white-space:nowrap}
tbody td{padding:13px 14px;border-bottom:1px solid var(--border-soft);
vertical-align:middle}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover td{background:#fbfcfd}
tbody tr.picked td{background:#eff6ff}
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;
margin-right:7px;flex:none}
.dot.on{background:#22c55e}
.dot.off{background:#d1d5db}
.devname{display:flex;align-items:center;font-weight:600}
.devid{color:var(--faint);font-size:10.5px;margin-top:2px;
font-family:ui-monospace,Consolas,monospace;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}
code{font:600 13px/1 ui-monospace,Consolas,monospace;
background:var(--surface);border:1px solid var(--border);
border-radius:6px;padding:4px 8px;letter-spacing:.5px}
.pill{padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600}
.pill.on{background:#dcfce7;color:#15803d}
.pill.off{background:#f3f4f6;color:#6b7280}
.mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;
color:var(--muted)}
.ops{text-align:right}
.empty{padding:56px 20px;text-align:center}
.empty .big{font-size:15px;font-weight:600;margin-bottom:8px}
.empty p{color:var(--muted);font-size:13px;max-width:340px;margin:0 auto}
.empty .ico{font-size:30px;margin-bottom:14px;opacity:.35}
/* ---------- 按钮 ---------- */
button{font-family:inherit;font-size:13px;cursor:pointer;
border-radius:9px;padding:8px 15px;border:1px solid var(--border);
background:#fff;color:var(--text);transition:.14s;
font-weight:500}
button:hover:not(:disabled){background:var(--surface);
border-color:#d1d5db}
button:active:not(:disabled){transform:translateY(1px)}
button:disabled{opacity:.42;cursor:not-allowed}
button.primary{background:var(--accent);border-color:var(--accent);
color:#fff;font-weight:600}
button.primary:hover:not(:disabled){background:#1d4ed8;
border-color:#1d4ed8}
button.danger{color:#b91c1c;border-color:#fecaca;background:#fff}
button.danger:hover:not(:disabled){background:#fef2f2;
border-color:#fca5a5}
button.sm{padding:6px 12px;font-size:12px}
/* ---------- 工具卡 ---------- */
.tools{display:grid;gap:14px;margin-top:30px}
@media(min-width:720px){.tools{grid-template-columns:1fr 1fr}}
.tool{border:1px solid var(--border);border-radius:13px;padding:18px 18px}
.tool h4{font-size:13.5px;font-weight:650;margin-bottom:4px}
.tool .d{color:var(--muted);font-size:12.5px;margin-bottom:14px}
.tool .out{color:var(--muted);font-size:12.5px;line-height:1.85;
word-break:break-all}
.tool .out b{color:var(--fg);font-weight:600}
.tool .out .good{color:#15803d;font-weight:600}
.tool .out .bad{color:#b91c1c;font-weight:600}
.dlrow{display:flex;align-items:center;gap:10px;padding:7px 0;
border-bottom:1px solid var(--border-soft);font-size:12.5px}
.dlrow:last-child{border-bottom:none}
.dlrow .nm{flex:1;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;font-family:ui-monospace,Consolas,monospace;
font-size:11.5px}
.dlrow .sz{color:var(--faint);font-variant-numeric:tabular-nums}
/* ---------- 底部说明 ---------- */
.foot{margin-top:36px;padding-top:20px;border-top:1px solid var(--border);
color:var(--faint);font-size:11.5px;line-height:1.9}
.foot b{color:var(--muted);font-weight:600}
/* ---------- 提示条 ---------- */
#toast{position:fixed;left:50%;bottom:30px;transform:translateX(-50%)
translateY(20px);background:#1a1d21;color:#fff;padding:11px 20px;
border-radius:10px;font-size:13px;opacity:0;pointer-events:none;
transition:.22s;z-index:99;max-width:86vw;text-align:center}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.ok{background:#15803d}
#toast.err{background:#b91c1c}
/* ---------- 窄屏:表格变卡片 ---------- */
@media(max-width:700px){
thead{display:none}
tbody tr{display:block;border-bottom:1px solid var(--border);
padding:12px 14px}
tbody td{display:flex;justify-content:space-between;gap:14px;
padding:5px 0;border:none}
tbody td::before{content:attr(data-l);color:var(--faint);font-size:11.5px;
flex:none}
td.pickcell::before,
td.ops::before{display:none}
td.pickcell{padding-bottom:8px}
td.ops{justify-content:flex-end;padding-top:10px;
border-top:1px solid var(--border-soft)}
td.empty{display:block;text-align:center}
td.empty::before{display:none}
.devid{max-width:200px}
}
</style>
</head><body>
<div class="top"><div class="wrap top-in">
<div class="logo">yx</div>
<div class="brand">
<b>ykxmz 中转站</b>
<span>跨网络中转 · 管理后台</span>
</div>
<span class="state"><i></i>运行中</span>
<div class="top-right">
<div>版本 <b>1.7.1</b> · 已运行 <b>9 小时 10 分</b> · 内存 <b>24 MB</b></div>
<div>累计请求 <b>1919</b> · <a class="logout" href="/zhongzhuanzhan/logout">退出登录</a></div>
</div>
</div></div>
<div class="wrap">
<div class="hero">
<h2>公网地址 —— 手表「中转设置」里填这个</h2>
<div class="urlbox">
<div class="url" id="urlBox">https://relay.xxmxxs.top</div>
<button class="primary" id="copyUrl">复制</button>
</div>
<div class="hint">手表和电脑不在同一个网络时,两边都通过这个地址中转。</div>
</div>
<div class="nums">
<div class="num"><div class="v">2</div><div class="k">登记设备</div></div>
<div class="num live"><div class="v">1</div><div class="k">在线</div></div>
<div class="num "><div class="v">0</div><div class="k">手表会话</div></div>
<div class="num"><div class="v">2</div><div class="k">配对码占用</div></div>
</div>
<div class="sec">
<h3>设备</h3>
<div class="right">
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
<input type="checkbox" id="autoRef" checked> 自动刷新
</label>
<button class="danger sm" id="delPicked" disabled>删除选中</button>
<button class="danger sm" id="delAll">删除全部</button>
</div>
</div>
<div class="card">
<table>
<thead><tr>
<th style="width:38px"><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 class="pickcell"><input type="checkbox" class="pick" value="DESKTOP-ABC-123" aria-label="选择 教室电脑"></td><td data-l="设备"><div class="devname"><span class="dot on"></span>教室电脑</div><div class="devid">DESKTOP-ABC-123</div></td><td data-l="配对码"><code>246810</code></td><td data-l="状态"><span class="pill on">在线</span></td><td data-l="空闲"><span class="mono">3s</span></td><td data-l="待发 表/机"><span class="mono">0 / 0</span></td><td data-l="流量 入/出"><span class="mono">12 KB / 7 KB</span></td><td class="ops"><button class="danger sm" data-device="DESKTOP-ABC-123" data-name="教室电脑" data-code="246810">删除</button></td></tr><tr><td class="pickcell"><input type="checkbox" class="pick" value="DESKTOP-ABC-456" aria-label="选择 办公室电脑"></td><td data-l="设备"><div class="devname"><span class="dot off"></span>办公室电脑</div><div class="devid">DESKTOP-ABC-456</div></td><td data-l="配对码"><code>135790</code></td><td data-l="状态"><span class="pill off">离线</span></td><td data-l="空闲"><span class="mono">900s</span></td><td data-l="待发 表/机"><span class="mono">0 / 0</span></td><td data-l="流量 入/出"><span class="mono">0 B / 0 B</span></td><td class="ops"><button class="danger sm" data-device="DESKTOP-ABC-456" data-name="办公室电脑" data-code="135790">删除</button></td></tr>
</tbody>
</table>
</div>
<div class="tools">
<div class="tool">
<h4>网络体检</h4>
<div class="d">看看这台机器是走局域网还是走隧道。</div>
<div class="out" id="netBody">还没测过。</div>
<div style="margin-top:14px"><button class="sm" id="netBtn">测一下</button></div>
</div>
<div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody"><div class="dlrow"><span class="nm">ykxmz-setup-1.7.1.exe</span><span class="sz">14.4 MB</span></div><div class="dlrow"><span class="nm">ykxmz-watch-1.7.1.apk</span><span class="sz">197 KB</span></div></div>
</div>
</div>
<div class="foot">
<b>隐私说明</b> —— 数据来自 /v1/health,只显示配对码和流量数字,
看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。
</div>
</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 = ''; }, 2800);
};
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.danger[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 class="bad">读不到:'
+ (j.error || '未知') + '</span>';
return;
}
const rows = [];
if (j.tunnelMetrics) {
const conns = j.tunnelConnections || 0;
const cls = conns >= 2 ? 'good' : 'bad';
rows.push('隧道连接数:<b class="' + cls + '">' + conns + '</b>'
+ (conns === 0 ? '(断了,看门狗会自动重启)' : ''));
rows.push('隧道累计请求:<b>' + (j.tunnelRequests || 0) + '</b>');
} else {
rows.push('<span class="bad">读不到隧道指标'
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
}
rows.push('登记设备:<b>' + j.devices + '</b>(在线 <b>' + j.online
+ '</b>)');
rows.push('手表会话:<b>' + j.sessions + '</b>');
rows.push('中转内存:<b>' + Math.round(j.memoryKB / 1024) + ' MB</b>');
rows.push('已运行:<b>' + Math.round(j.uptimeSeconds / 60) + ' 分钟</b>');
if (j.publicUrl) {
rows.push('公网地址:<b>' + j.publicUrl + '</b>');
}
body.innerHTML = rows.map(t => '<div>' + 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>浏览器到中转往返:<b class="'
+ (ms < 300 ? 'good' : 'bad') + '">' + ms + ' ms</b>'
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
+ '</div>';
} catch (e) {
body.innerHTML += '<div class="bad">浏览器到中转:失败</div>';
}
} catch (e) {
body.textContent = '请求出错:' + e;
}
};
// ---------------- 自动刷新 ----------------
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 {
if (localStorage.getItem('lr_autoref') === '0') autoRef.checked = false;
} catch (e) {}
setupAuto();
}
</script>
</body></html>
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r'''分析 2 小时长测数据(列格式:时间戳 \t 状态码 \t 毫秒)。
上一版解析写错了 —— 它把任何 3 位数字都当状态码,
于是毫秒(921、968…)也被算成状态码,可用率算出来只有 84.8%,
是**假数字**。这里改成按列位置取。
'''
from __future__ import annotations
import sys
from collections import Counter
from pathlib import Path
path = Path(sys.argv[1] if len(sys.argv) > 1 else "/root/long2h_health.tsv")
if not path.is_file():
print(" 文件不存在: %s" % path)
raise SystemExit(0)
rows = []
bad = 0
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) < 3:
bad += 1
continue
stamp, code, ms = parts[0], parts[1].strip(), parts[2].strip()
try:
rows.append((stamp, code, float(ms)))
except ValueError:
bad += 1
print(" 样本 %d 条(解析不了 %d 条)" % (len(rows), bad))
if not rows:
raise SystemExit(0)
print(" 时间范围: %s -> %s" % (rows[0][0], rows[-1][0]))
codes = Counter(c for _s, c, _m in rows)
total = len(rows)
ok = codes.get("200", 0)
print()
print(" ── HTTP 状态分布 ──")
for c, n in codes.most_common():
print(" %-6s %4d 次 %5.1f%%" % (c, n, n * 100.0 / total))
print(" " + "-" * 30)
print(" 可用率: %d/%d = %.2f%%" % (ok, total, ok * 100.0 / total))
# 只看成功的耗时(失败的那些耗时没有意义)
good = sorted(m for _s, c, m in rows if c == "200")
if good:
def p(q):
return good[min(int(len(good) * q), len(good) - 1)]
print()
print(" ── 成功请求耗时(毫秒)──")
print(" 最小 %.0f 中位 %.0f P90 %.0f P99 %.0f 最大 %.0f"
% (good[0], good[len(good) // 2], p(0.9), p(0.99), good[-1]))
# 如果还有第 4 列及以后(连接数之类),也统计一下
extra_cols = set()
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
p = line.strip().split("\t")
if len(p) > 3:
extra_cols.add(len(p))
if extra_cols:
print()
print(" (文件里还有更多列,最多 %d 列)" % max(extra_cols))
fails = [(s, c, m) for s, c, m in rows if c != "200"]
print()
if fails:
print(" ── 失败样本(共 %d 条)──" % len(fails))
for s, c, m in fails[:15]:
print(" %s HTTP %-5s %.0fms" % (s, c, m))
if len(fails) > 15:
print(" … 还有 %d 条" % (len(fails) - 15))
# 失败时间分布(按 10 分钟分桶)
print()
print(" ── 失败时刻分布(按小时)──")
hours = Counter(s.split(" ")[1].split(":")[0] if " " in s else s[:2]
for s, _c, _m in fails)
for h in sorted(hours):
print(" %s 点 %d 次" % (h, hours[h]))
else:
print(" ✓ 全程没有非 200 —— 2 小时 100% 可用")
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
# 分析 relay-stability 的 2 小时健康长测数据(只读)
set -u
F=/root/long2h_health.tsv
[ -f "$F" ] || { echo " 数据文件不存在: $F"; exit 0; }
echo " 文件: $F 行数: $(wc -l < "$F")"
echo " 前 3 行(看格式):"
head -3 "$F" | sed 's/^/ /'
echo
python3 - "$F" <<'PYEOF'
import sys
from collections import Counter
from pathlib import Path
path = Path(sys.argv[1])
lines = [l.strip() for l in path.read_text(encoding="utf-8",
errors="replace").splitlines()
if l.strip()]
if not lines:
print(" 没有数据")
raise SystemExit(0)
# 猜列:先看表头,没有表头就按 tab 切
head = lines[0].lower()
has_header = any(k in head for k in ("time", "code", "ms", "http"))
rows = lines[1:] if has_header else lines
if has_header:
print(" 表头: %s" % lines[0])
print(" 样本数: %d" % len(rows))
codes = Counter()
times = []
conns = []
fails = []
for i, line in enumerate(rows, 1):
parts = line.split("\t")
code = None
ms = None
for p in parts:
p = p.strip()
if p.isdigit() and len(p) == 3:
code = p
if p.replace(".", "").isdigit() and "." in p:
try:
v = float(p)
if ms is None and v < 300000:
ms = v
except ValueError:
pass
if code:
codes[code] += 1
if code != "200":
fails.append((i, line[:100]))
if ms:
times.append(ms)
# 连接数:找形如 quic=N h2=M 或者单独的数字
for p in parts:
if "quic=" in p:
conns.append(p.strip())
total = sum(codes.values())
ok = codes.get("200", 0)
print()
print(" ── HTTP 结果分布 ──")
for c, n in codes.most_common():
pct = n * 100.0 / total if total else 0
print(" %-6s %4d 次 %.1f%%" % (c, n, pct))
print(" --------")
print(" 可用率: %d/%d = %.1f%%" % (ok, total, ok * 100.0 / total if total else 0))
if times:
s = sorted(times)
def pct(q):
return s[min(int(len(s) * q), len(s) - 1)]
print()
print(" ── 耗时分布(毫秒)──")
print(" 最小 %.0f 中位 %.0f P90 %.0f P99 %.0f 最大 %.0f"
% (s[0], s[len(s)//2], pct(0.9), pct(0.99), s[-1]))
if conns:
c = Counter(conns)
print()
print(" ── 连接数分布(前 8)──")
for k, n in c.most_common(8):
print(" %-28s %d 次" % (k, n))
if fails:
print()
print(" ── 失败样本(最多 12 条)──")
for i, line in fails[:12]:
print(" 第 %d 行: %s" % (i, line))
else:
print()
print(" ✓ 全程没有非 200")
PYEOF
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r'''分析连接数时间线 long2h_conns.tsv,找塌陷的规律。
目的:回答两个问题
1. QUIC 塌陷是不是周期性的?周期多长?
2. 塌陷期间 HTTP/2 那半边是不是一直活着?(这是双协议兜底的价值)
'''
from __future__ import annotations
from pathlib import Path
p = Path("/root/long2h_conns.tsv")
if not p.is_file():
print(" 没有 %s" % p)
raise SystemExit(0)
raw = p.read_text(encoding="utf-8", errors="replace").splitlines()
print(" 共 %d 行,前 3 行:" % len(raw))
for line in raw[:3]:
print(" %r" % line[:120])
print()
rows = []
for line in raw:
line = line.strip()
if not line:
continue
parts = line.replace(",", "\t").split("\t")
stamp = ""
nums = []
for x in parts:
x = x.strip()
if not x:
continue
if ":" in x and ("-" in x or len(x) <= 8) and not x.replace(":", "").isdigit():
stamp = x
else:
try:
nums.append(int(x))
except ValueError:
pass
if nums:
rows.append((stamp, nums))
if not rows:
print(" 解析不出数字")
raise SystemExit(0)
# 假设列是 [quic, h2] 或 [总连接, quic, h2] —— 按每行长度自适应
width = max(len(n) for _s, n in rows)
print(" 每行 %d 个数字(列)" % width)
print(" 前 5 行解析结果:")
for s, n in rows[:5]:
print(" %-12s %s" % (s, n))
print()
# 尝试识别:如果一行有 2 列,当作 [quic, h2];3 列当作 [total, quic, h2]
lower = [n for _s, n in rows]
if width == 2:
quic = [n[0] for n in lower]
h2 = [n[1] for n in lower]
elif width >= 3:
quic = [n[1] for n in lower]
h2 = [n[2] for n in lower]
else:
print(" 列数不支持")
raise SystemExit(0)
total = len(quic)
print(" ── 连接数统计(%d 个采样点)──" % total)
print(" QUIC: 最低 %d 最高 %d 平均 %.1f"
% (min(quic), max(quic), sum(quic) / total))
print(" HTTP/2: 最低 %d 最高 %d 平均 %.1f"
% (min(h2), max(h2), sum(h2) / total))
# 塌陷 = QUIC <= 2
collapse_idx = [i for i, q in enumerate(quic) if q <= 2]
print()
print(" ── QUIC 塌陷(<=2 条)──")
print(" 出现 %d 次,占比 %.1f%%" % (len(collapse_idx), len(collapse_idx) * 100.0 / total))
# 塌陷时 h2 还在吗
if collapse_idx:
h2_alive = sum(1 for i in collapse_idx if h2[i] >= 1)
h2_zero = sum(1 for i in collapse_idx if h2[i] == 0)
print(" 塌陷时 HTTP/2 >=1 条: %d 次(%.0f%%)"
% (h2_alive, h2_alive * 100.0 / len(collapse_idx)))
print(" 塌陷时 HTTP/2 也归零: %d 次" % h2_zero)
print(" -> 这就是双协议兜底的价值:%s"
% ("QUIC 塌了但 HTTP/2 还在扛" if h2_zero < h2_alive
else "两个一起塌,兜底没起作用"))
# 合并连续的塌陷区间,看周期
spans = []
start = None
for i, q in enumerate(quic):
if q <= 2 and start is None:
start = i
elif q > 2 and start is not None:
spans.append((start, i - 1))
start = None
if start is not None:
spans.append((start, total - 1))
print()
print(" ── 塌陷区间(连续采样点合并)──")
print(" 共 %d 段" % len(spans))
for a, b in spans[:15]:
sa = rows[a][0] if rows[a][0] else "第%d点" % a
sb = rows[b][0] if rows[b][0] else "第%d点" % b
print(" %-10s -> %-10s 持续 %d 个采样点 (quic 最低 %d)"
% (sa, sb, b - a + 1, min(quic[a:b + 1])))
# 塌陷间隔(周期)
if len(spans) >= 2:
gaps = [spans[i + 1][0] - spans[i][0] for i in range(len(spans) - 1)]
print()
print(" ── 两次塌陷之间隔多少个采样点 ──")
print(" %s" % gaps[:15])
print(" 平均 %.1f 个采样点" % (sum(gaps) / len(gaps)))
# 连接数分布
from collections import Counter
print()
print(" ── QUIC 连接数分布 ──")
for k in sorted(set(quic)):
c = quic.count(k)
print(" %2d 条: %4d 次 %5.1f%%" % (k, c, c * 100.0 / total))
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# 采用最优配置 quic + 10,并清理 MSS 规则。
#
# v4 实测(MTU 1400,各 60 次):
# quic+6 建齐 10s 0 失败 100% 中位 1399 P90 2611 P99 13705
# quic+8 建齐 16s 1 失败 100% 中位 1280 P90 2279 P99 3856
# quic+10 建齐 13s 0 失败 100% 中位 1256 P90 2068 P99 2991 <- 最好
#
# 注意:v4 脚本自己的排序逻辑有多行输出没处理干净,误选了 quic+8,
# 这里按数据手工改成 quic+10。
#
# MSS 说明:接口 MTU 已经是 1400,内核自己算出来的 MSS 就是 1360;
# iptables 的 --set-mss 只能把 MSS 往小改,所以那条 1400 的规则
# 现在实际是空转。统一改成 1360,避免以后 MTU 变回去时留下不一致。
set -u
SVC=/etc/systemd/system/lanremote-tunnel.service
UA='LanRemote/1.8.2'
echo "════════ 1. 采用 quic + 10 ════════"
sed -i "s/--protocol [a-z0-9]*/--protocol quic/" "$SVC"
sed -i "s/--ha-connections [0-9]*/--ha-connections 10/" "$SVC"
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+|--edge-ip-version [0-9]+|--retries [0-9]+' "$SVC" | sed 's/^/ /'
echo
echo "════════ 2. 清理 MSS 规则(只留一条 1360)════════"
iptables -t mangle -S OUTPUT 2>/dev/null | grep TCPMSS | sed 's/^/ 清理前: /'
while iptables -t mangle -D OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1400 2>/dev/null; do :; done
while iptables -t mangle -D OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360 2>/dev/null; do :; done
iptables -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360
iptables -t mangle -S OUTPUT 2>/dev/null | grep TCPMSS | sed 's/^/ 清理后: /'
echo
echo "════════ 3. MTU 和持久化 ════════"
echo " wlan0 MTU 现在: $(cat /sys/class/net/wlan0/mtu)"
grep -c 'mtu 1400' /etc/systemd/system/lanremote-nettune.service | sed 's/^/ net-tune 里的 MTU 设置行数: /'
echo " net-tune 服务: $(systemctl is-enabled lanremote-nettune) / $(systemctl is-active lanremote-nettune)"
echo
echo "════════ 4. 重启隧道并等连接建齐 ════════"
systemctl daemon-reload
systemctl restart lanremote-tunnel
for i in $(seq 1 40); do
sleep 4
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
[ "${n:-0}" -ge 10 ] && break
done
echo " 建齐 $n/10 条(等了约 $((i*4)) 秒)"
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep 'server_locations.*} 1' | sed 's/^/ /'
echo
echo "════════ 5. 等公网可用 ════════"
for i in $(seq 1 20); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 -H "User-Agent: $UA" https://relay.xxmxxs.top/v1/health)
printf " 第 %d 次: %s\n" "$i" "$c"
[ "$c" = "200" ] && break
sleep 3
done
echo
echo "════════ 6. 快速验收(30 次)════════"
ok=0; times=()
for i in $(seq 1 30); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H "User-Agent: $UA" https://relay.xxmxxs.top/v1/health)
c=$(echo "$out" | awk '{print $1}'); ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
[ "$c" = "200" ] && { ok=$((ok+1)); times+=("$ms"); }
done
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
echo " 可用率 $ok/30 = $((ok*100/30))% $stats ms"
echo
echo " 最终: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ') MTU=$(cat /sys/class/net/wlan0/mtu)"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# 1) 把官网上的「管理入口」链接删掉(管理不对外开放)
# 2) 应用优选 IP:把 Cloudflare 隧道域名指到实测最快的边缘 IP
set -u
RELAY_DIR=/root/lanremote-relay
CONFIG=/etc/cloudflared/config.yml
echo "===== 1. 备份原配置 ====="
cp -f /etc/hosts /root/hosts.bak.$(date +%s) 2>/dev/null || true
cp -f "$CONFIG" /root/config.yml.bak 2>/dev/null || true
echo " 已备份 /etc/hosts 和 config.yml"
echo
echo "===== 2. 应用优选 IP(写 /etc/hosts 覆盖 GeoDNS)====="
# 从探测结果里取最快的两个
BEST=$(head -2 /root/cf_best.txt 2>/dev/null | awk '{print $1}')
echo " 最快的 IP: $BEST"
# 清掉之前加过的
sed -i '/argotunnel.com/d' /etc/hosts
FIRST=$(echo "$BEST" | head -1)
SECOND=$(echo "$BEST" | tail -1)
{
echo ""
echo "# ykxmz:优选 Cloudflare 隧道边缘(覆盖 GeoDNS,实测延迟更低)"
echo "$FIRST region1.v2.argotunnel.com"
echo "$SECOND region2.v2.argotunnel.com"
echo "$FIRST region1.v2.argotunnel.com"
echo "$SECOND region2.v2.argotunnel.com"
} >> /etc/hosts
echo " /etc/hosts 现在有:"
grep argotunnel /etc/hosts | sed 's/^/ /'
echo
echo "===== 3. 确认解析结果变了 ====="
getent hosts region1.v2.argotunnel.com | head -3 | sed 's/^/ region1 -> /'
getent hosts region2.v2.argotunnel.com | head -3 | sed 's/^/ region2 -> /'
echo
echo "===== 4. 重启隧道 ====="
systemctl restart lanremote-tunnel
sleep 35
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
echo " 落点:"
grep -oE 'location=[a-z0-9]+' "$RELAY_DIR/tunnel.log" 2>/dev/null | tail -3 | sed 's/^/ /'
echo
echo "===== 5. 公网延迟对比(20 次)====="
ok=0; total=0
for i in $(seq 1 20); do
t=$(curl -s -o /dev/null -w '%{time_total}' --max-time 15 \
-H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health 2>/dev/null)
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
-H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health 2>/dev/null)
if [ "$c" = "200" ]; then
ok=$((ok+1))
printf " %2d: %s 秒\n" "$i" "$t"
else
printf " %2d: 失败\n" "$i"
fi
total=$((total+1))
sleep 1
done
echo " 可用 $ok/$total"
+158
View File
@@ -0,0 +1,158 @@
#!/bin/bash
# 中转全面基线测量。
#
# 目的:把"优化到什么程度"变成可量化的数字,而不是感觉。
# 五类指标,每类都记录成功率和耗时分布:
# A. 公网 HTTP 可用率(100 次 /v1/health)
# B. 长轮询(10 次真实 poll,每次挂 60 秒)
# C. 三个发布产物的下载成功率和吞吐
# D. 隧道连接数稳定性 + 重连次数
# E. 静态资源(官网 / 截图 / favicon)
#
# 用法: bash bench.sh [标签]
set -u
BASE=${BASE:-https://relay.xxmxxs.top}
UA='LanRemote/1.8.1'
LABEL=${1:-baseline}
OUT="/root/bench_${LABEL}.txt"
echo "════════════════════════════════════════════════════════" | tee "$OUT"
echo " 中转基准测量 · $LABEL · $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$OUT"
echo "════════════════════════════════════════════════════════" | tee -a "$OUT"
echo | tee -a "$OUT"
echo "── 隧道配置 ──" | tee -a "$OUT"
CAP=$(systemctl show lanremote-tunnel -p ExecStart --value 2>/dev/null | tr ';' '\n' | grep -oE 'argv\[\]=[^ ]*|--[a-z-]+|/[a-z/]*cloudflared' | tr '\n' ' ')
grep -oE '\-\-protocol [a-z0-9]+|\-\-ha-connections [0-9]+|\-\-edge-ip-version [0-9]+' /etc/systemd/system/lanremote-tunnel.service 2>/dev/null | tr '\n' ' ' | sed 's/^/ 参数: /' | tee -a "$OUT"
echo " 长轮询: $(tail -40 /root/lanremote-relay/relay.log 2>/dev/null | grep -oE '长轮询 [0-9]+ 秒' | tail -1)" | tee -a "$OUT"
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')" | tee -a "$OUT"
# ---------------------------------------------------------------- A
echo | tee -a "$OUT"
echo "── A. 公网 HTTP 可用率(100 次,间隔 0.4 秒)──" | tee -a "$OUT"
okA=0; failA=0; timesA=()
for i in $(seq 1 100); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H "User-Agent: $UA" "$BASE/v1/health")
code=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
if [ "$code" = "200" ]; then okA=$((okA+1)); timesA+=("$ms"); else failA=$((failA+1)); fi
sleep 0.4
done
pctA=$((okA * 100 / 100))
echo " 可用率: $okA/100 = ${pctA}% 失败 $failA 次" | tee -a "$OUT"
if [ "${#timesA[@]}" -gt 0 ]; then
printf '%s\n' "${timesA[@]}" | sort -n | awk '{
a[NR]=$1
} END {
printf " 耗时: 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms\n",
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]
}' | tee -a "$OUT"
fi
# ---------------------------------------------------------------- E(快的先做)
echo | tee -a "$OUT"
echo "── E. 静态资源(各 10 次)──" | tee -a "$OUT"
for path in "/" "/shot/pc-main.png" "/favicon.ico"; do
ok=0; times=()
for i in $(seq 1 10); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H "User-Agent: $UA" "$BASE$path")
c=$(echo "$out" | awk '{print $1}')
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
[ "$c" = "200" ] && { ok=$((ok+1)); times+=("$ms"); }
sleep 0.3
done
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {printf "中位 %d 最大 %d ms", a[int(NR/2)+1], a[NR]}')
printf " %-22s %2d/10 %s\n" "$path" "$ok" "$stats" | tee -a "$OUT"
done
# ---------------------------------------------------------------- C
echo | tee -a "$OUT"
echo "── C. 三个成品下载(完整下载,量吞吐)──" | tee -a "$OUT"
for f in ykxmz-setup-1.8.1.exe ykxmz-server-1.8.1.zip ykxmz-watch-1.8.1.apk; do
rm -f /tmp/dl.bin
out=$(curl -s -o /tmp/dl.bin -w '%{http_code} %{size_download} %{time_total}' \
--max-time 180 -H "User-Agent: $UA" "$BASE/download/$f")
code=$(echo "$out" | awk '{print $1}')
size=$(echo "$out" | awk '{print $2}')
secs=$(echo "$out" | awk '{print $3}')
kbps=$(echo "$secs $size" | awk '{if ($1 > 0) printf "%.0f", $2/$1/1024; else print 0}')
printf " %-30s HTTP %-4s %9s 字节 %6.1fs %6s KB/s\n" \
"$f" "$code" "$size" "$secs" "$kbps" | tee -a "$OUT"
done
# ---------------------------------------------------------------- D
echo | tee -a "$OUT"
echo "── D. 隧道连接数稳定性(采样 60 秒,每 2 秒一次)──" | tee -a "$OUT"
MARK=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
minc=99; maxc=0; vals=()
for i in $(seq 1 30); do
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
[ -z "${n:-}" ] && n=0
[ "$n" -lt "$minc" ] && minc="$n"
[ "$n" -gt "$maxc" ] && maxc="$n"
vals+=("$n")
sleep 2
done
reconn=$(tail -n +$((MARK+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Registered tunnel connection' || echo 0)
lost=$(tail -n +$((MARK+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Lost connection' || echo 0)
iot=$(tail -n +$((MARK+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'i/o timeout' || echo 0)
echo " 连接数: 最低 $minc 最高 $maxc 采样期间重连 $reconn 次 · 掉线 $lost 次 · i/o timeout $iot 次" | tee -a "$OUT"
# ---------------------------------------------------------------- B
echo | tee -a "$OUT"
echo "── B. 长轮询(10 次真实 poll,每次挂 60 秒 ≈ 10 分钟)──" | tee -a "$OUT"
STAMP=$(date +%s)
DEV="bench-${LABEL}-${STAMP}"
CODE=$(printf "%06d" $(( STAMP % 1000000 )))
REG=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
-d "{\"deviceId\":\"$DEV\",\"code\":\"$CODE\",\"name\":\"bench\",\"version\":\"1.8.1\"}")
TOKEN=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
if [ -z "$TOKEN" ]; then
echo " [失败] 探针注册没拿到 token" | tee -a "$OUT"
else
okB=0; timesB=(); failsB=""
MARK2=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
for i in $(seq 1 10); do
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 130 \
-X POST "$BASE/v1/agent/poll" -H "X-Token: $TOKEN" -H "User-Agent: $UA")
c=$(echo "$out" | awk '{print $1}')
secs=$(echo "$out" | awk '{printf "%d", $2*1000}')
if [ "$c" = "200" ]; then
okB=$((okB+1)); timesB+=("$secs")
printf " %2d: 200 %6dms\n" "$i" "$secs" | tee -a "$OUT"
else
failsB="$failsB ${i}:${c}"
printf " %2d: %-4s **失败**\n" "$i" "$c" | tee -a "$OUT"
fi
done
echo " 长轮询成功: $okB/10 = $((okB*10))% 失败:$failsB" | tee -a "$OUT"
if [ "${#timesB[@]}" -gt 0 ]; then
printf '%s\n' "${timesB[@]}" | sort -n | awk '{
a[NR]=$1
} END {
printf " 耗时: 最小 %d 中位 %d P90 %d 最大 %d ms(理想 ≈ 61000)\n",
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]
}' | tee -a "$OUT"
fi
r2=$(tail -n +$((MARK2+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Registered tunnel connection' || echo 0)
echo " 这 10 分钟里隧道重连 $r2 次" | tee -a "$OUT"
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
fi
echo | tee -a "$OUT"
echo "════════════════════════════════════════════════════════" | tee -a "$OUT"
echo " 基线存档: $OUT" | tee -a "$OUT"
# 顺便把关键数字汇总成一行,便于前后对比
{
echo
echo "===== 汇总($LABEL)====="
echo "A 公网可用率 ${pctA}% ($okA/100)"
echo "B 长轮询成功 ${okB:-0}/10"
echo "D 隧道连接 最低 ${minc} 最高 ${maxc} 重连 ${reconn} 掉线 ${lost}"
} | tee -a "$OUT"
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# 测下载吞吐:缓存预热前后对比。
#
# 基线(未加缓存头时):
# setup.exe 237568 字节 / 13.3s = 17 KB/s (还截断了)
# server.zip 214900 字节 / 3.1s = 67 KB/s
# watch.apk 201523 字节 / 66.8s = 3 KB/s
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.1'
FILES="ykxmz-setup-1.8.1.exe ykxmz-server-1.8.1.zip ykxmz-watch-1.8.1.apk"
echo "════════ 1. 预热:每个文件先请求一次,把内容推进 Cloudflare 缓存 ════════"
for f in $FILES; do
# 只取前 1 字节,避免真的下载 15 MB
st=$(curl -s -o /dev/null -w '%{http_code}' -r 0-0 --max-time 60 \
-H "User-Agent: $UA" "$BASE/download/$f")
cf=$(curl -s -D - -o /dev/null -r 0-0 --max-time 60 -H "User-Agent: $UA" \
"$BASE/download/$f" 2>/dev/null | tr -d '\r' | grep -i '^cf-cache-status' | awk '{print $2}')
printf " %-30s HTTP %s cf-cache-status=%s\n" "$f" "$st" "${cf:-无}"
done
echo
echo " 等 5 秒让边缘缓存落定…"
sleep 5
echo
echo "════════ 2. 正式下载(完整文件,量吞吐)════════"
total_ok=0
for f in $FILES; do
rm -f /tmp/dl.bin
out=$(curl -s -o /tmp/dl.bin -D /tmp/dl.hdr -w '%{http_code} %{size_download} %{time_total}' \
--max-time 240 -H "User-Agent: $UA" "$BASE/download/$f")
code=$(echo "$out" | awk '{print $1}')
size=$(echo "$out" | awk '{print $2}')
secs=$(echo "$out" | awk '{print $3}')
cf=$(grep -i '^cf-cache-status' /tmp/dl.hdr | tr -d '\r' | awk '{print $2}')
age=$(grep -i '^age:' /tmp/dl.hdr | tr -d '\r' | awk '{print $2}')
kbps=$(echo "$secs $size" | awk '{if ($1 > 0) printf "%.0f", $2/$1/1024; else print 0}')
printf " %-30s HTTP %-4s %9s 字节 %6.1fs %6s KB/s cf=%s age=%s\n" \
"$f" "$code" "$size" "$secs" "$kbps" "${cf:-无}" "${age:-无}"
[ "$code" = "200" ] && total_ok=$((total_ok+1))
done
echo " 成功 $total_ok/3"
echo
echo "════════ 3. 再下一次(应该全 HIT,更快)════════"
for f in $FILES; do
rm -f /tmp/dl.bin
out=$(curl -s -o /tmp/dl.bin -D /tmp/dl.hdr -w '%{http_code} %{size_download} %{time_total}' \
--max-time 240 -H "User-Agent: $UA" "$BASE/download/$f")
code=$(echo "$out" | awk '{print $1}')
size=$(echo "$out" | awk '{print $2}')
secs=$(echo "$out" | awk '{print $3}')
cf=$(grep -i '^cf-cache-status' /tmp/dl.hdr | tr -d '\r' | awk '{print $2}')
kbps=$(echo "$secs $size" | awk '{if ($1 > 0) printf "%.0f", $2/$1/1024; else print 0}')
printf " %-30s HTTP %-4s %9s 字节 %6.1fs %6s KB/s cf=%s\n" \
"$f" "$code" "$size" "$secs" "$kbps" "${cf:-无}"
done
echo
echo "════════ 4. 对比基线 ════════"
echo " 基线: setup 17 KB/s(截断)· server 67 KB/s · apk 3 KB/s"
+180
View File
@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
r'''从这台 Windows 机器测中转 —— 这才是"真实用户"的视角。
== 为什么要换测量端 ==
之前所有测量都是在设备自己上跑的 curl。那等于:
设备 4G --> Cloudflare 边缘 (第一段)
边缘 --> 隧道 --> 设备 4G 源站 (第二段)
**两段都压在同一个 4G 链路上**,量出来的数字是双重打击。
真实用户(手机/电脑在 WiFi、宽带、5G 上):
用户 --> Cloudflare 边缘 (第一段,好网络)
边缘 --> 隧道 --> 设备 4G 源站 (第二段,只有这段是 4G)
所以从这台机器量出来的,才是用户实际会遇到的延迟和可用率。
用法: python _build/bench_from_pc.py [标签]
'''
from __future__ import annotations
import json
import statistics
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
BASE = "https://relay.xxmxxs.top"
UA = "LanRemote/1.8.2"
OUT_DIR = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\_build")
def req(path: str, method: str = "GET", data: bytes | None = None,
headers: dict | None = None, timeout: float = 150.0):
"""发一个请求,返回 (状态码, 字节数, 秒数, 响应头)。"""
hdrs = {"User-Agent": UA}
hdrs.update(headers or {})
r = urllib.request.Request(BASE + path, data=data, headers=hdrs,
method=method)
t0 = time.perf_counter()
try:
with urllib.request.urlopen(r, timeout=timeout) as resp:
body = resp.read()
return resp.status, len(body), time.perf_counter() - t0, resp.headers
except urllib.error.HTTPError as exc:
try:
exc.read()
except Exception: # noqa: BLE001
pass
return exc.code, 0, time.perf_counter() - t0, exc.headers
except Exception: # noqa: BLE001
return 0, 0, time.perf_counter() - t0, {}
def pct(values: list, q: float) -> int:
if not values:
return 0
s = sorted(values)
idx = min(int(len(s) * q), len(s) - 1)
return int(s[idx])
def main() -> int:
label = sys.argv[1] if len(sys.argv) > 1 else "pc"
lines: list[str] = []
def say(text: str = "") -> None:
print(text)
lines.append(text)
say("=" * 62)
say(" 从 PC 量中转 · %s · %s" % (label, time.strftime("%Y-%m-%d %H:%M:%S")))
say("=" * 62)
# ---------------- A. 公网可用率 ----------------
say()
say("── A. 公网可用率(100 次 /v1/health)──")
ok, times, fails = 0, [], 0
for i in range(100):
code, _n, secs, _h = req("/v1/health", timeout=25)
if code == 200:
ok += 1
times.append(int(secs * 1000))
else:
fails += 1
time.sleep(0.3)
say(" 可用率 %d/100 = %d%% 失败 %d 次" % (ok, ok, fails))
if times:
say(" 耗时 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms"
% (min(times), int(statistics.median(times)), pct(times, 0.9),
pct(times, 0.99), max(times)))
# ---------------- E. 静态资源 ----------------
say()
say("── E. 静态资源(各 10 次)──")
for path in ("/", "/shot/pc-main.png", "/favicon.ico"):
o, ts = 0, []
for _ in range(10):
code, _n, secs, _h = req(path, timeout=25)
if code == 200:
o += 1
ts.append(int(secs * 1000))
time.sleep(0.2)
med = int(statistics.median(ts)) if ts else 0
say(" %-22s %2d/10 中位 %d 最大 %d ms"
% (path, o, med, max(ts) if ts else 0))
# ---------------- C. 下载 ----------------
say()
say("── C. 三个成品下载(完整下载)──")
dl_results = {}
for name in ("ykxmz-setup-1.8.2.exe", "ykxmz-server-1.8.2.zip",
"ykxmz-watch-1.8.2.apk"):
code, size, secs, hdrs = req("/download/" + name, timeout=300)
kbps = (size / secs / 1024) if secs > 0 else 0
cf = (hdrs.get("cf-cache-status") or "?") if hdrs else "?"
say(" %-30s HTTP %-4s %9d 字节 %6.1fs %7.0f KB/s cf=%s"
% (name, code, size, secs, kbps, cf))
if code == 200 and size > 0:
dl_results[name] = kbps
# ---------------- B. 长轮询 ----------------
say()
say("── B. 长轮询(6 次真实 poll,每次挂 60 秒)──")
stamp = int(time.time())
dev = "pcbench-%s-%d" % (label, stamp)
code6 = "%06d" % (stamp % 1000000)
payload = json.dumps({
"deviceId": dev, "code": code6, "name": "pcbench",
"version": "1.8.2",
}, ensure_ascii=False).encode("utf-8")
status, _n, _s, _h = req("/v1/agent/register", method="POST",
data=payload, timeout=30,
headers={"Content-Type": "application/json"})
token = ""
if status == 200:
# 重新取一次拿 body
r = urllib.request.Request(
BASE + "/v1/agent/register", data=payload,
headers={"User-Agent": UA, "Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
token = json.loads(resp.read().decode("utf-8")).get("token", "")
except Exception: # noqa: BLE001
pass
say(" 探针注册: %s" % ("成功" if token else "失败"))
if token:
okb, btimes, bfails = 0, [], []
for i in range(6):
code, _n, secs, _h = req(
"/v1/agent/poll", method="POST", data=b"",
headers={"X-Token": token}, timeout=150)
ms = int(secs * 1000)
if code == 200:
okb += 1
btimes.append(ms)
say(" %d: 200 %6dms" % (i + 1, ms))
else:
bfails.append("%d:%s" % (i + 1, code))
say(" %d: %-4s **失败**" % (i + 1, code))
say(" 长轮询成功 %d/6 = %d%% 失败: %s"
% (okb, okb * 100 // 6, " ".join(bfails) or "无"))
if btimes:
say(" 耗时 最小 %d 中位 %d P90 %d 最大 %d ms(理想 ≈ 61000)"
% (min(btimes), int(statistics.median(btimes)),
pct(btimes, 0.9), max(btimes)))
say()
say("=" * 62)
out = OUT_DIR / ("bench_pc_%s.txt" % label)
out.write_text("\n".join(lines), encoding="utf-8")
say(" 存档: %s" % out)
return 0
if __name__ == "__main__":
sys.exit(main())
+30
View File
@@ -0,0 +1,30 @@
==============================================================
从 PC 量中转 · optimized · 2026-10-07 16:01:23
==============================================================
── A. 公网可用率(100 次 /v1/health)──
可用率 98/100 = 98% 失败 2 次
耗时 最小 765 中位 911 P90 1889 P99 23868 最大 23868 ms
── E. 静态资源(各 10 次)──
/ 9/10 中位 1016 最大 1223 ms
/shot/pc-main.png 10/10 中位 1160 最大 1914 ms
/favicon.ico 8/10 中位 1075 最大 1993 ms
── C. 三个成品下载(完整下载)──
ykxmz-setup-1.8.2.exe HTTP 404 0 字节 0.8s 0 KB/s cf=BYPASS
ykxmz-server-1.8.2.zip HTTP 404 0 字节 0.8s 0 KB/s cf=BYPASS
ykxmz-watch-1.8.2.apk HTTP 404 0 字节 0.9s 0 KB/s cf=BYPASS
── B. 长轮询(6 次真实 poll,每次挂 60 秒)──
探针注册: 成功
1: 200 60817ms
2: 0 **失败**
3: 200 61758ms
4: 200 62027ms
5: 200 62060ms
6: 200 61247ms
长轮询成功 5/6 = 83% 失败: 2:0
耗时 最小 60817 中位 61758 P90 62060 最大 62060 ms(理想 ≈ 61000)
==============================================================
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# 测"开机 -> 服务可用"要多久,看能不能缩短
set -u
echo "════════ 1. 系统启动耗时 ════════"
systemd-analyze 2>/dev/null | sed 's/^/ /'
echo
echo " 最慢的几项:"
systemd-analyze blame 2>/dev/null | head -12 | sed 's/^/ /'
echo
echo "════════ 2. 关键服务的启动时刻 ════════"
BOOT=$(date -d "$(uptime -s)" +%s 2>/dev/null || echo 0)
for svc in lanremote-nettune lanremote-relay lanremote-tunnel; do
ts=$(systemctl show "$svc" -p ActiveEnterTimestampMonotonic --value 2>/dev/null)
if [ -n "$ts" ] && [ "$ts" != "0" ]; then
sec=$((ts / 1000000))
printf " %-24s 开机后 %s 秒\n" "$svc" "$sec"
else
printf " %-24s (读不到)\n" "$svc"
fi
done
echo
echo "════════ 3. 隧道是什么时候连上的 ════════"
grep -m3 'Registered tunnel connection' /root/lanremote-relay/tunnel.log 2>/dev/null \
| sed 's/^/ /'
echo " 当前时间: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo " 日志里第一条注册时间戳对比一下就知道隔了多久"
echo
echo "════════ 4. 开机第一条日志到隧道可用,中间卡在哪 ════════"
journalctl -b --no-pager 2>/dev/null \
| grep -E 'lanremote|cloudflared|Reached target network|Startup finished' \
| head -20 | sed 's/^/ /'
echo
echo "════════ 5. network-online.target 花了多久 ════════"
journalctl -b --no-pager -u NetworkManager -u systemd-networkd 2>/dev/null \
| grep -iE 'online|carrier|dhcp|connected' | head -8 | sed 's/^/ /'
echo
echo "════════ 6. 有没有办法提前(当前依赖链)════════"
systemctl show lanremote-tunnel -p After -p Wants -p Requires --value 2>/dev/null | sed 's/^/ /'
echo
echo "════════ 7. watchdog 间隔现在是多久 ════════"
grep -E 'OnUnitActiveSec|OnBootSec' /etc/systemd/system/lanremote-watchdog.timer 2>/dev/null | sed 's/^/ /'
systemctl cat lanremote-watchdog.timer 2>/dev/null | grep -E 'OnUnit|OnBoot' | sed 's/^/ /'
+116
View File
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
r'''把全仓版本号从 1.7.1 升到 1.8.0。
为什么写脚本而不是手动改:
版本号散落在 8 个文件里(Python / Java manifest / PowerShell /
Inno Setup / 测试 / 打包脚本),手动改必漏。
而且 .ps1 必须保留 UTF-8 BOM —— 用 edit 工具改会把 BOM 吃掉,
导致 PowerShell 按 GBK 解析、中文变乱码、脚本报语法错(这坑踩过)。
所以统一用 Python 读写字节,按后缀决定要不要 BOM。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
OLD = "1.7.1"
NEW = "1.8.0"
BOM = b"\xef\xbb\xbf"
TARGETS = [
"android/AndroidManifest.xml",
"android/build_apk.ps1",
"installer/ykxmz.iss",
"relay/relay.py",
"windows/lanremote/__init__.py",
"_tests/test_apk_contents.py",
"_tests/test_package_remote.py",
"_build/package_remote.py",
]
NEEDS_BOM = {".ps1"}
def main() -> int:
print(" %s -> %s" % (OLD, NEW))
print()
total = 0
for rel in TARGETS:
path = ROOT / rel
if not path.is_file():
print(" [跳过] %s 不存在" % rel)
continue
raw = path.read_bytes()
had_bom = raw.startswith(BOM)
text = raw.decode("utf-8-sig")
if OLD not in text:
print(" [跳过] %-38s 里面没有 %s" % (rel, OLD))
continue
count = text.count(OLD)
text = text.replace(OLD, NEW)
want_bom = path.suffix in NEEDS_BOM or had_bom
data = text.encode("utf-8")
if want_bom:
data = BOM + data
path.write_bytes(data)
note = ""
if want_bom and not had_bom:
note = "(补上 BOM)"
elif had_bom and not want_bom:
note = "(去掉 BOM)"
print(" %-38s 改了 %d 处%s" % (rel, count, note))
total += count
print()
print(" 共改 %d 处" % total)
print()
print(" 复核 —— 代码里还有哪里留着 %s:" % OLD)
found = False
for path in sorted(ROOT.rglob("*")):
if not path.is_file():
continue
rel = str(path.relative_to(ROOT))
if any(part in rel for part in
(".git\\", "dist\\", "dist_exe", "_build\\_", "toolchain",
"node_modules", "__pycache__", "_shots")):
continue
if path.suffix not in (".py", ".java", ".iss", ".ps1", ".md",
".json", ".sh", ".bat", ".xml"):
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
if OLD in text:
tag = "(文档,可保留)" if path.suffix == ".md" else "**要改**"
print(" %-52s %s" % (rel, tag))
if path.suffix != ".md":
found = True
if not found:
print(" (代码里已经没有了)")
print()
print(" BOM 复核(.ps1 必须有):")
for rel in TARGETS:
path = ROOT / rel
if not path.is_file():
continue
has = path.read_bytes().startswith(BOM)
want = path.suffix in NEEDS_BOM
mark = "OK" if has == want else "**不对**"
print(" %-38s BOM=%-5s 应=%-5s %s" % (rel, has, want, mark))
return 0
if __name__ == "__main__":
sys.exit(main())
+59
View File
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
r'''版本号 1.8.0 -> 1.8.1。
这次是 bug 修复版:局域网劫持 + 长轮询 60 秒。
(用户规矩:每次上传都要升版号。)
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
OLD, NEW = "1.8.0", "1.8.1"
BOM = b"\xef\xbb\xbf"
TARGETS = [
"android/AndroidManifest.xml",
"android/build_apk.ps1",
"installer/ykxmz.iss",
"relay/relay.py",
"windows/lanremote/__init__.py",
"_tests/test_apk_contents.py",
"_tests/test_package_remote.py",
"_build/package_remote.py",
"_build/test_installer_cycle.py",
]
NEEDS_BOM = {".ps1"}
def main() -> int:
print(" %s -> %s" % (OLD, NEW))
total = 0
for rel in TARGETS:
path = ROOT / rel
if not path.is_file():
print(" [跳过] %s 不存在" % rel)
continue
raw = path.read_bytes()
had_bom = raw.startswith(BOM)
text = raw.decode("utf-8-sig")
if OLD not in text:
print(" [跳过] %-40s 里面没有 %s" % (rel, OLD))
continue
count = text.count(OLD)
text = text.replace(OLD, NEW)
want_bom = path.suffix in NEEDS_BOM or had_bom
data = text.encode("utf-8")
if want_bom:
data = BOM + data
path.write_bytes(data)
print(" %-40s 改了 %d 处" % (rel, count))
total += count
print()
print(" 共改 %d 处" % total)
return 0
if __name__ == "__main__":
sys.exit(main())
-124
View File
@@ -1,124 +0,0 @@
# -*- coding: utf-8 -*-
r"""精准升级版本号 1.4.0 -> 1.5.0。
**不能全局替换** —— 项目里有 40 多个文件含 "1.4.0",其中大部分是:
* 测试用的数据(配对码、设备版本号字段)
* HTTP User-Agent "LanRemote/1.4.0"
(Cloudflare 的 Browser Integrity Check 认这个,
而且 20 多个测试都在断言它 —— 动它要同步改一大片)
* _build/ 下的一次性脚本(历史记录,不用改)
所以只改**用户能看到版本号的地方**:
1. android/build_apk.ps1 APK 文件名 + manifest 版本
2. docs/*.txt 三份文档
3. windows/lanremote/__init__.py __version__
4. 会断言版本号的测试
UA 保持 "LanRemote/1.4.0" 不动(它是协议标识,不是产品版本)。
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
OLD, NEW = "1.4.0", "1.5.0"
# 逐文件、逐模式改,写清楚每一处为什么改
PLAN = [
# (文件, 旧片段, 新片段, 说明)
("android/build_apk.ps1",
'$apkName = "ykxmz-watch-1.4.0.apk"',
'$apkName = "ykxmz-watch-1.5.0.apk"',
"APK 文件名"),
("android/build_apk.ps1",
'--version-code 3 `',
'--version-code 5 `',
"versionCode"),
("android/build_apk.ps1",
'--version-name "1.4.0" `',
'--version-name "1.5.0" `',
"versionName"),
("windows/lanremote/__init__.py",
'"1.4.0"',
'"1.5.0"',
"__version__"),
]
# 文档:把"版本 1.4.0"之类改掉
DOC_PATTERNS = [
(r"1\.4\.0", "1.5.0"),
]
# 测试里断言成品文件名的
TEST_PATTERNS = [
("ykxmz-1.4.0", "ykxmz-1.5.0"),
("ykxmz-watch-1.4.0", "ykxmz-watch-1.5.0"),
]
def main() -> int:
changed = []
# ---- 1. 精准替换 ----
for rel, old, new, why in PLAN:
path = ROOT / rel
if not path.is_file():
print(f" [跳过] {rel} 不存在")
continue
text = path.read_text(encoding="utf-8")
if old not in text:
print(f" [跳过] {rel}: 找不到 {old[:40]!r}(可能已改)")
continue
path.write_text(text.replace(old, new, 1),
encoding="utf-8", newline="\n")
changed.append(f"{rel}({why})")
# ---- 2. 文档 ----
for path in sorted((ROOT / "docs").glob("*.txt")):
raw = path.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
new_text = re.sub(r"1\.4\.0", "1.5.0", text)
if new_text != text:
path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
+ new_text.replace("\r\n", "\n").encode("utf-8"))
changed.append(f"docs/{path.name}")
# ---- 3. 断言成品文件名的测试 ----
for name in ("test_package_remote.py", "test_apk_contents.py",
"acceptance.py"):
path = ROOT / "_tests" / name
if not path.is_file():
continue
text = path.read_text(encoding="utf-8")
original = text
for old, new in TEST_PATTERNS:
text = text.replace(old, new)
if text != original:
path.write_text(text, encoding="utf-8", newline="\n")
changed.append(f"_tests/{name}")
print(f" 共改了 {len(changed)} 处:")
for item in changed:
print(f" {item}")
print()
print(" 注意:HTTP User-Agent 仍是 \"LanRemote/1.4.0\"(协议标识,不是产品版本)。")
# ---- 复核 ----
print()
print(" 复核关键位置:")
for rel, needle in (("android/build_apk.ps1", "ykxmz-watch-1.5.0.apk"),
("android/AndroidManifest.xml", '1.5.0'),
("_build/package_remote.py", '"1.5.0"')):
path = ROOT / rel
if path.is_file():
ok = needle in path.read_text(encoding="utf-8")
print(f" {rel}: {'✓' if ok else '✗'} 含 {needle}")
return 0
if __name__ == "__main__":
sys.exit(main())
+37 -3
View File
@@ -83,9 +83,27 @@ def main() -> int:
changed += 1
# ---- Windows EXE 版本信息 ----
if swap(ROOT / "windows" / "version_info.txt",
[(f'"{OLD}.0"', f'"{NEW}.0"')],
f"EXE 文件版本 {NEW}.0"):
# ⚠ 原来用的是 f'"{OLD}.0"',一旦 OLD 和文件里对不上就静默跳过
# (version_info.txt 就是这么从 1.7.1 一直漏到现在的)。
# 改成正则匹配任意四段版本。
import re as _re
_vi = ROOT / "windows" / "version_info.txt"
if _vi.is_file():
_raw = _vi.read_bytes()
_bom = _raw.startswith(b"\xef\xbb\xbf")
_txt = _raw.decode("utf-8-sig")
_new = _re.sub(
r'(StringStruct\("(?:File|Product)Version",\\s*")\\d+\\.\\d+\\.\\d+\\.\\d+("\))',
r'\\g<1>' + NEW + r'.0\\g<2>', _txt)
if _new != _txt:
_vi.write_bytes((b"\xef\xbb\xbf" if _bom else b"")
+ _new.replace("\\r\\n", "\\n").encode("utf-8"))
print(f" version_info.txt: EXE 文件版本 {NEW}.0")
changed += 1
else:
print(" [跳过] version_info.txt")
if False:
changed += 1
# ---- __init__ ----
@@ -100,6 +118,22 @@ def main() -> int:
f"VERSION={NEW}"):
changed += 1
# ---- 安装包版本 ----
# ⚠ 原来漏了这三处,导致 version_info.txt 从 1.7.1 起就一直没跟上
# (用户在"文件属性 -> 详细信息"里看到一个错三个版本的号)。
if swap(ROOT / "installer" / "ykxmz.iss",
[(f'#define MyAppVersion "{OLD}"',
f'#define MyAppVersion "{NEW}"'),
(f'ykxmz-setup-{OLD}.exe', f'ykxmz-setup-{NEW}.exe')],
f"安装包版本 {NEW}"):
changed += 1
# ---- 安装许可页上的版本 ----
if swap(ROOT / "installer" / "license-combined.txt",
[(f"版本:{OLD}", f"版本:{NEW}")],
f"许可页版本 {NEW}"):
changed += 1
# ---- 三份文档 ----
for path in sorted((ROOT / "docs").glob("*.txt")):
raw = path.read_bytes()
+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")"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# 设备刚重启过(up 4 分钟)—— 查开机后各服务有没有自己起来
set -u
echo "════════ 1. 开机多久了 ════════"
uptime | sed 's/^/ /'
echo " 开机于: $(who -b 2>/dev/null | awk '{print $3, $4}' || echo '?')"
echo
echo "════════ 2. 三个服务 ════════"
for svc in lanremote-relay lanremote-tunnel lanremote-nettune lanremote-watchdog.timer; do
printf " %-26s %s (%s)\n" "$svc" "$(systemctl is-active $svc 2>&1)" "$(systemctl is-enabled $svc 2>&1)"
done
echo
echo "════════ 3. 开机自启清单里有没有它们 ════════"
systemctl list-unit-files 2>/dev/null | grep -i lanremote | sed 's/^/ /'
echo
echo "════════ 4. 隧道连接数 ════════"
echo " $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') 条"
echo
echo "════════ 5. 网络调优参数还在吗 ════════"
for k in tcp_keepalive_time tcp_keepalive_intvl tcp_mtu_probing tcp_slow_start_after_idle; do
printf " %-30s = %s\n" "$k" "$(sysctl -n net.ipv4.$k 2>/dev/null || echo '?')"
done
echo " MSS 钳制规则: $(iptables -t mangle -S 2>/dev/null | grep -c 'TCPMSS' || echo 0) 条"
echo
echo "════════ 6. 本机中转 ════════"
echo " HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:8080/v1/health)"
echo
echo "════════ 7. 公网(浏览器实际走的)════════"
echo " 隧道地址: $(cat /root/lanremote-relay/public_url.txt 2>/dev/null || echo '(没有)')"
ok=0
for i in $(seq 1 10); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
-H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health)
[ "$c" = "200" ] && ok=$((ok+1))
printf " %2d: %s\n" "$i" "$c"
sleep 1
done
echo " 可用 $ok/10"
echo
echo "════════ 8. 新地址写进状态文件了吗 ════════"
cat /root/lanremote-relay/public_url.txt 2>/dev/null | sed 's/^/ /'
echo
echo "════════ 9. 当前 IP(换网络后)════════"
hostname -I | sed 's/^/ /'
echo
echo "════════ 10. watchdog 最近记录 ════════"
tail -8 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# 确认 BBR 能不能用,并把有效的调优持久化
set -u
echo "===== 1. 内核信息 ====="
echo " 版本: $(uname -r)"
echo " 架构: $(uname -m)"
echo
echo "===== 2. BBR 模块存在吗 ====="
found=$(find /lib/modules -name 'tcp_bbr*' 2>/dev/null | head -3)
if [ -n "$found" ]; then
echo " 找到模块文件:"
echo "$found" | sed 's/^/ /'
else
echo " 没有任何 tcp_bbr 模块文件"
fi
echo
echo "===== 3. 内核编译时开了 BBR 吗 ====="
if [ -f /proc/config.gz ]; then
zgrep -i CONFIG_TCP_CONG_BBR /proc/config.gz | sed 's/^/ /'
elif [ -f /boot/config-$(uname -r) ]; then
grep -i CONFIG_TCP_CONG_BBR /boot/config-$(uname -r) | sed 's/^/ /'
else
echo " 没有内核配置文件可查"
fi
echo
echo "===== 4. 试着手动加载 ====="
modprobe tcp_bbr 2>&1 | sed 's/^/ /' || true
echo " 加载后可用算法: $(sysctl -n net.ipv4.tcp_available_congestion_control)"
echo
echo "===== 5. 当前生效的网络参数 ====="
for k in net.ipv4.tcp_congestion_control net.core.default_qdisc \
net.core.rmem_max net.core.wmem_max net.ipv4.tcp_fastopen \
net.ipv4.tcp_tw_reuse net.ipv4.tcp_mtu_probing; do
printf " %-40s = %s\n" "$k" "$(sysctl -n $k 2>/dev/null || echo '?')"
done
echo
echo "===== 6. 既然没有 BBR,用 cubic 能调的都调上 ====="
cat > /etc/sysctl.d/98-ykxmz-net.conf <<'EOF'
# ykxmz 网络调优(4G 高丢包链路)
#
# ⚠ 这台设备的精简内核**没有 BBR**(只有 reno/cubic),
# 所以下面用 cubic,再把其它能帮上忙的参数调好。
# 如果以后换了带 BBR 的内核,把 tcp_congestion_control 改成 bbr 即可。
# TCP 缓冲:长途高延迟链路需要更大窗口
net.core.rmem_max = 4194304
net.core.wmem_max = 4194304
net.ipv4.tcp_rmem = 4096 87380 4194304
net.ipv4.tcp_wmem = 4096 65536 4194304
# 快速打开:省一个 RTT
net.ipv4.tcp_fastopen = 3
# 空闲后不重置拥塞窗口 —— 长轮询场景很关键,
# 否则每次轮询间隔后都要从慢启动重新爬
net.ipv4.tcp_slow_start_after_idle = 0
# 早重传,别干等
net.ipv4.tcp_retries2 = 8
# 复用 TIME_WAIT 连接
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
# 接收缓冲自动调节
net.ipv4.tcp_moderate_rcvbuf = 1
EOF
sysctl -p /etc/sysctl.d/98-ykxmz-net.conf 2>&1 | grep -v '^net.core.default_qdisc\|^net.ipv4.tcp_congestion_control' | sed 's/^/ /'
echo
echo " 最终拥塞控制: $(sysctl -n net.ipv4.tcp_congestion_control)"
echo " 最终队列: $(sysctl -n net.core.default_qdisc)"
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# 看各类资源的响应头,判断有哪些能被 Cloudflare 缓存却因为 no-store 被挡了。
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.1'
echo "════════ 各路径的缓存相关响应头 ════════"
for p in "/" "/v1/health" "/shot/pc-main.png" "/favicon.ico" \
"/download/ykxmz-server-1.8.1.zip"; do
echo " ── $p ──"
curl -sI --max-time 30 -H "User-Agent: $UA" "$BASE$p" 2>/dev/null \
| grep -iE '^HTTP|^cache-control|^cf-cache-status|^content-type|^content-length|^expires|^age' \
| tr -d '\r' | sed 's/^/ /'
done
echo
echo "════════ 静态图中的 cf-cache-status(HIT 说明缓存生效)════════"
for i in 1 2 3; do
s=$(curl -sI --max-time 20 -H "User-Agent: $UA" "$BASE/shot/pc-main.png" 2>/dev/null \
| grep -i '^cf-cache-status' | tr -d '\r' | awk '{print $2}')
echo " 第 $i 次: cf-cache-status = ${s:-?}"
sleep 1
done
echo
echo "════════ relay 源码里缓存头的设置 ════════"
grep -n 'Cache-Control' /root/lanremote-relay/relay.py | sed 's/^/ /'
echo
echo "════════ 下载路由的实现(看有没有做流式/分块)════════"
sed -n '/if path.startswith("\/download\/")/,/^ # /p' /root/lanremote-relay/relay.py \
| head -50 | sed 's/^/ /'
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# 看长测进度 + 实时连接数波动
set -u
echo "=== 长测进度 ==="
tail -20 /root/longstab_quic10.txt 2>/dev/null
echo
echo "=== 连接数实时(每 3 秒一次,共 12 次 = 36 秒)==="
for i in $(seq 1 12); do
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
printf " %2d: %s\n" "$i" "${n:-?}"
sleep 3
done
echo
echo "=== 这 36 秒里隧道日志的新事件 ==="
MARK=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
sleep 1
journalctl -u lanremote-tunnel --since '2 minutes ago' --no-pager 2>/dev/null \
| grep -E 'Lost connection|Registered tunnel|Failed to dial|no recent network' \
| tail -14 | sed 's/^/ /'
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
r'''查「默认中转服务器」在源码和已构建成品里各是什么值。
用户报告:软件默认中转服务器应该是**空白**、让用户自己填。
但给我的版本是填好的,上传的版本是清空的 —— 两者不一致。
先把所有源头找出来,看清楚再改。
'''
from __future__ import annotations
import re
import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
SOURCES = [
("windows/lanremote/server.py", r"DEFAULT_RELAY_URL\s*=\s*(.+)"),
("windows/lanremote/relay.py", r"192\.168\.\d+\.\d+:8080"),
("windows/run_agent.py", r"or\s+\"(192\.168[^\"]*|[^\"]*xxmxxs[^\"]*)\""),
("android/src/com/lanremote/watch/MainActivity.java",
r"DEFAULT_RELAY_URL\s*=\s*(.+);"),
]
print("=" * 70)
print(" 一、源码里的默认中转地址")
print("=" * 70)
for rel, pattern in SOURCES:
path = ROOT / rel
if not path.is_file():
print(" [不存在] %s" % rel)
continue
text = path.read_text(encoding="utf-8", errors="replace")
hits = re.findall(pattern, text, re.M)
print(" %s" % rel)
if hits:
for h in hits[:4]:
h = h if isinstance(h, str) else h[0]
print(" %s" % h.strip()[:100])
else:
print(" (没匹配到)")
print()
print("=" * 70)
print(" 二、已构建成品里到底是什么")
print("=" * 70)
# APK
apk = ROOT / "dist" / "ykxmz-watch-1.8.2.apk"
if apk.is_file():
print(" %s" % apk.name)
try:
with zipfile.ZipFile(apk) as z:
dex = [n for n in z.namelist() if n.endswith(".dex")]
found = []
for name in dex:
blob = z.read(name)
for needle in (b"https://relay.xxmxxs.top",
b"192.168.2.156",
b"192.168.10.105"):
if needle in blob:
found.append((name, needle.decode()))
if found:
for name, needle in sorted(set(found)):
print(" %s -> 含 %s" % (name, needle))
else:
print(" dex 里没有硬编码的中转地址(默认是空白)")
except Exception as exc: # noqa: BLE001
print(" 读 APK 失败: %s" % exc)
else:
print(" dist/ykxmz-watch-1.8.2.apk 不存在")
# exe / onedir
exe_dir = ROOT / "dist_exe" / "ykxmz"
print()
print(" %s" % exe_dir)
if exe_dir.is_dir():
hits = []
for f in exe_dir.rglob("*"):
if not f.is_file() or f.suffix.lower() not in (".py", ".pyc"):
continue
try:
blob = f.read_bytes()
except OSError:
continue
for needle in (b"192.168.2.156", b"192.168.10.105",
b"https://relay.xxmxxs.top"):
if needle in blob:
hits.append((f.relative_to(exe_dir).as_posix(),
needle.decode()))
if hits:
for name, needle in sorted(set(hits))[:10]:
print(" %-50s 含 %s" % (name, needle))
else:
print(" 没有硬编码的中转地址")
else:
print(" 不存在")
# 服务端 zip
print()
srv = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
if srv.is_file():
print(" %s" % srv.name)
try:
with zipfile.ZipFile(srv) as z:
for name in z.namelist():
if not name.endswith((".py", ".txt", ".sh")):
continue
try:
text = z.read(name).decode("utf-8", "replace")
except Exception: # noqa: BLE001
continue
for line in text.splitlines():
if ("192.168.2.156" in line or "192.168.10.105" in line
or "relay.xxmxxs.top" in line):
print(" %s: %s" % (name, line.strip()[:90]))
break
except Exception as exc: # noqa: BLE001
print(" 读 zip 失败: %s" % exc)
else:
print(" dist/ykxmz-server-1.8.2.zip 不存在")
print()
print("=" * 70)
print(" 三、结论")
print("=" * 70)
print(" 期望:默认中转服务器为**空白**,由用户自己填。")
print(" 实际:见上面。凡是出现硬编码地址的地方都要改成空字符串。")
+71
View File
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
r'''查 Gitea 自己报的 clone 地址,看 SSH 到底该连哪里。
现象:SSH 连得上(服务端回了 publickey,password),
但本机送出的公钥一直被拒。可能是:
* SSH 用的不是 22 端口
* 主机名不是 git.261826.xyz
* 密钥加到了别的账号
这个脚本把 Gitea 官方给出的地址拉出来对一下。
'''
from __future__ import annotations
import json
import sys
import urllib.request
from pathlib import Path
TOKEN_FILE = Path.home() / ".ykxmz" / "ezhub_token"
TOKEN = (TOKEN_FILE.read_text(encoding="utf-8").strip()
if TOKEN_FILE.is_file() else "")
BASE = "https://git.261826.xyz/api/v1"
HEAD = {"Authorization": "token " + TOKEN, "User-Agent": "ykxmz"}
def get(path: str):
with urllib.request.urlopen(
urllib.request.Request(BASE + path, headers=HEAD),
timeout=30) as resp:
return json.loads(resp.read().decode("utf-8", "replace"))
def main() -> int:
print(" 一、仓库自己声明的 clone 地址")
repo = get("/repos/xmz/ykxmz")
for key in ("clone_url", "ssh_url", "html_url", "default_branch",
"empty", "size"):
if key in repo:
print(" %-16s %s" % (key, repo[key]))
print()
print(" 二、当前用户注册了哪些 SSH 公钥")
try:
keys = get("/user/keys")
if not keys:
print(" **一把都没有** —— 密钥没加成功")
for k in keys:
print(" id=%-4s %-14s %s" % (k.get("id"), k.get("title"),
(k.get("key") or "")[:52] + "…"))
print(" fingerprint=%s" % (k.get("fingerprint") or "?"))
except Exception as exc: # noqa: BLE001
print(" 查询失败: %s" % exc)
print()
print(" 三、对照")
print(" 本机公钥指纹: SHA256:Acmu49YQcjw3m5WefOXSBzYNY2WPHLL2vvhnlYRAttA")
print(" 本机公钥内容: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMFMh2Zqwm+8"
"KFhU3DhzhOO5Sx7cIzybYqUsrbt1c3ly")
print()
print(" 四、服务端版本与配置")
try:
ver = get("/version")
print(" Gitea: %s" % ver.get("version"))
except Exception as exc: # noqa: BLE001
print(" 取版本失败: %s" % exc)
return 0
if __name__ == "__main__":
sys.exit(main())
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# 验证响应头 —— 上次改完只看了状态码和字节数,没看 Content-Type,
# 结果页面在浏览器里是"一屏源码"。
set -u
BASE=http://127.0.0.1:8080
PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null)
curl -s -c /tmp/ck.txt -o /dev/null --max-time 15 \
-d "username=xmz7891&password=$PASS" "$BASE/admin/login"
echo " 管理页响应头:"
curl -sI -b /tmp/ck.txt --max-time 15 "$BASE/zhongzhuanzhan" \
| head -4 | sed 's/^/ /'
echo
echo " 官网响应头:"
curl -sI --max-time 15 "$BASE/" | head -3 | sed 's/^/ /'
echo
echo " 产品截图响应头:"
curl -sI --max-time 15 "$BASE/shot/pc-main.png" | head -3 | sed 's/^/ /'
echo
echo " 登录页响应头:"
curl -sI --max-time 15 "$BASE/zhongzhuanzhan" | head -3 | sed 's/^/ /'
echo
echo " 判断:"
admin_ct=$(curl -sI -b /tmp/ck.txt --max-time 15 "$BASE/zhongzhuanzhan" \
| grep -i '^content-type' | tr -d '\r')
if echo "$admin_ct" | grep -qi 'text/html'; then
echo " ✓ 管理页是 text/html"
else
echo " ✗ 管理页 Content-Type 不对:$admin_ct"
fi
site_ct=$(curl -sI --max-time 15 "$BASE/" | grep -i '^content-type' | tr -d '\r')
if echo "$site_ct" | grep -qi 'text/html'; then
echo " ✓ 官网是 text/html"
else
echo " ✗ 官网 Content-Type 不对:$site_ct"
fi
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
# 502/530 但隧道连着 —— 查回源配置(这是我唯一还没验证过的一环)
set -u
BASE=http://127.0.0.1:8080
echo "════════ 1. 隧道 ingress 配置全文 ════════"
cat /root/.cloudflared/config.yml 2>/dev/null | sed 's/^/ /'
echo
echo "════════ 2. relay 到底监听哪个地址 ════════"
ss -lntp 2>/dev/null | grep 8080 | sed 's/^/ /'
echo " ---"
printf " 127.0.0.1:8080 -> HTTP %s\n" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:8080/v1/health)"
printf " localhost:8080 -> HTTP %s\n" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:8080/v1/health)"
printf " 192.168.10.105:8080 -> HTTP %s\n" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://192.168.10.105:8080/v1/health)"
echo
echo "════════ 3. cloudflared 能看到 relay 吗(同一台机器)════════"
echo " cloudflared 进程:"
pgrep -af 'cloudflared tunnel' | head -2 | sed 's/^/ /'
echo
echo "════════ 4. 隧道 metrics 全量 ════════"
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep -E '^cloudflared_tunnel' | sed 's/^/ /'
echo
echo "════════ 5. 最近的注册/掉线(看 4 条是不是刚连上的)════════"
tail -300 /root/lanremote-relay/tunnel.log 2>/dev/null \
| grep -E 'Registered|Lost|Retrying|i/o timeout|error' \
| tail -20 | sed 's/^/ /'
echo
echo "════════ 6. 公网连打 15 次(看是持续挂还是间歇)════════"
ok=0
for i in $(seq 1 15); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
-H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health)
[ "$c" = "200" ] && ok=$((ok+1))
printf " %2d: %s\n" "$i" "$c"
sleep 1
done
echo " 可用 $ok/15"
echo
echo "════════ 7. 每次请求是不是落到不同机房 ════════"
for i in 1 2 3 4 5 6; do
ray=$(curl -sI --max-time 12 -H 'User-Agent: LanRemote/1.4.0' \
https://relay.xxmxxs.top/v1/health 2>/dev/null \
| grep -i '^cf-ray' | tr -d '\r' | awk '{print $2}')
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \
-H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health)
printf " HTTP %s cf-ray %s\n" "$code" "$ray"
done
echo
echo "════════ 8. DNS 解析到哪 ════════"
getent ahostsv4 relay.xxmxxs.top 2>/dev/null | awk '{print $1}' | sort -u | sed 's/^/ v4: /'
getent ahostsv6 relay.xxmxxs.top 2>/dev/null | awk '{print $1}' | sort -u | head -3 | sed 's/^/ v6: /'
+140
View File
@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
r'''检查 Inno Setup 的 .iss 里有没有会害编译失败的写法。
踩过的三个坑,都是 Inno 的 [Code] 解析器**连注释内容也处理**:
1. 注释里写方括号区段名
{ 规范化,后面 [Run] 阶段用得到 }
报 "无效的区段标记",编译直接失败。踩了两次。
2. 注释里写花括号常量
{ 不带 --keep-dir 的话会 rmdir 删 {app} }
Inno 会把 {app} 展开,展开后花括号配不上 ->
报 "Syntax error",编译失败。踩了一次。
3. 注释里写 {param:XXX|}
Inno 会展开它,参数没传就展开成空,同样破坏括号配对。
所以规则:**[Code] 段落里,注释内容不能出现 [Section] 或 {常量}。**
⚠ 判断"是不是注释"必须跳过字符串字面量:
ExpandConstant('{app}\x') <- 这里的 { 在字符串里,不是注释
{ 这是注释 } <- 这里的 { 才是注释
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ISS = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
/ "installer" / "ykxmz.iss")
SECTIONS = {
"Setup", "Types", "Components", "Tasks", "Dirs", "Files", "Icons",
"Ini", "InstallDelete", "Languages", "Messages", "CustomMessages",
"Registry", "Run", "UninstallRun", "UninstallDelete", "Code",
}
def scan_comment_text(line: str, in_comment: bool):
"""返回 (这一行注释里的文字, 扫完后的 in_comment)。
关键:字符串字面量里的 { } 不算注释。
"""
out = []
cursor = 0
in_string = False
length = len(line)
while cursor < length:
ch = line[cursor]
if in_string:
if ch == "'":
# Pascal 用 '' 表示一个单引号
if cursor + 1 < length and line[cursor + 1] == "'":
cursor += 2
continue
in_string = False
cursor += 1
continue
if not in_comment:
if ch == "'":
in_string = True
elif ch == "{":
in_comment = True
cursor += 1
continue
# 在注释里
if ch == "}":
in_comment = False
cursor += 1
continue
out.append(ch)
cursor += 1
return "".join(out), in_comment
def main() -> int:
if not ISS.is_file():
print(" 找不到 %s" % ISS)
return 1
lines = ISS.read_text(encoding="utf-8").splitlines()
code_start = None
for index, line in enumerate(lines):
if line.strip() == "[Code]":
code_start = index
break
if code_start is None:
print(" 没有 [Code] 段落,跳过")
return 0
problems = []
in_comment = False
for index in range(code_start, len(lines)):
line = lines[index]
comment_text, in_comment = scan_comment_text(line, in_comment)
if not comment_text:
continue
# 只有**行首**的 [Section] 才会被 Inno 当成区段标记。
# 行中间的("交给 Run 段用...")不影响 —— 实测编译能过。
m = re.match(r"\s*\[([A-Za-z][A-Za-z0-9]*)\]", comment_text)
if m and m.group(1) in SECTIONS:
problems.append((index + 1,
"注释**行首**有区段名 [%s]" % m.group(1),
line.strip()))
# 花括号常量:注释里出现就会被展开
for match in re.finditer(
r"\{[A-Za-z][A-Za-z0-9]*(?:[:|][^}]*)?\}", comment_text):
problems.append((index + 1,
"注释里有会展开的常量 %s" % match.group(0),
line.strip()))
# 真区段必须顶格
for index, line in enumerate(lines):
if re.match(r"^\s+\[[A-Za-z]+\]\s*$", line):
problems.append((index + 1, "区段名前面有空格", line.strip()))
if problems:
print(" 发现 %d 个会让编译失败的问题:" % len(problems))
for lineno, why, text in problems:
print(" 行 %d: %s" % (lineno, why))
print(" %s" % text[:74])
return 1
print(" [Code] 段落里没有违规写法 ✓")
return 0
if __name__ == "__main__":
sys.exit(main())
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
r'''确认那 3 个失败是不是"电脑锁屏"导致的(不是代码问题)。
现象:
test_agent_protocol.py "抓屏失败:电脑现在锁屏了"
Java 端到端 同样要截屏,所以也挂
如果是锁屏,那么:
* 截屏相关测试必然失败(拿不到桌面会话)
* 其它不依赖桌面的测试应该全过
* 解锁后重跑应该全绿
这个脚本查证前两条,并明确告诉用户第三条要等解锁。
'''
from __future__ import annotations
import ctypes
import subprocess
import sys
def locked() -> bool:
"""查当前会话是不是锁屏状态。"""
user32 = ctypes.windll.User32
# OpenInputDesktop 失败 = 当前不在交互桌面(锁屏/切换用户)
handle = user32.OpenInputDesktop(0, False, 0x0001)
if handle:
user32.CloseDesktop(handle)
return False
return True
def session_info() -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-Process -Id $PID).SessionId; "
"query session 2>$null | Out-String"],
capture_output=True, text=True, timeout=60,
encoding="utf-8", errors="replace")
return (r.stdout or "").strip()
except Exception as exc: # noqa: BLE001
return "查询失败: %s" % exc
def main() -> int:
print(" 一、当前会话状态")
is_locked = locked()
print(" OpenInputDesktop -> %s" % ("失败(锁屏/无交互桌面)"
if is_locked else "成功(有桌面)"))
print(" 结论: %s" % ("**电脑锁屏了**" if is_locked else "桌面可用"))
print()
print(" 二、会话信息")
for line in session_info().splitlines()[:6]:
print(" " + line.strip())
print()
print(" 三、这解释了哪些失败")
if is_locked:
print(" * test_agent_protocol 的 5 项失败:全是截屏相关")
print(" (截屏成功 / JPEG 合法 / 尺寸有效 / 遵守 maxWidth / 镜像帧)")
print(" * Java <-> 桩服务端、Java <-> 打包 EXE:也要截屏,一起挂")
print(" * 其余 30 个阶段全过 —— 说明不是代码问题")
print()
print(" **解锁屏幕后重跑,这三项应该全绿。**")
print(" 软件本身的行为是对的:它没崩,而是明确告诉你")
print(" 「电脑现在锁屏了,先解锁再试」。")
else:
print(" 桌面可用 —— 那这些失败就是真的代码问题,需要继续查。")
return 0 if is_locked else 1
if __name__ == "__main__":
sys.exit(main())
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# 看可靠性测试进度和隧道事件
set -u
echo "── 测试进程 ──"
pgrep -af 'tpr.sh|reliability' | head -3
echo
echo "── 隧道最近 30 分钟事件 ──"
reconn=$(journalctl -u lanremote-tunnel --since '30 minutes ago' --no-pager 2>/dev/null | grep -c 'Registered tunnel connection')
lost=$(journalctl -u lanremote-tunnel --since '30 minutes ago' --no-pager 2>/dev/null | grep -c 'Lost connection')
iot=$(journalctl -u lanremote-tunnel --since '30 minutes ago' --no-pager 2>/dev/null | grep -c 'i/o timeout')
echo " 重连 $reconn 次 · 掉线 $lost 次 · i/o timeout $iot 次"
echo
echo "── 最近的掉线/重连明细 ──"
journalctl -u lanremote-tunnel --since '30 minutes ago' --no-pager 2>/dev/null \
| grep -E 'Registered tunnel connection|Lost connection' | tail -8 | sed 's/^/ /'
echo
echo "── watchdog 最近 12 行 ──"
tail -12 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
echo
echo "── 隧道连接数和落点 ──"
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep -E '^cloudflared_tunnel_ha_connections|server_locations.*} 1' | sed 's/^/ /'
echo
echo "── 公网现在通不通(10 次快速探测)──"
ok=0
for i in $(seq 1 10); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
-H 'User-Agent: LanRemote/1.8.1' https://relay.xxmxxs.top/v1/health)
[ "$c" = "200" ] && ok=$((ok+1))
printf " %d: %s\n" "$i" "$c"
sleep 1
done
echo " 可用 $ok/10"
+40
View File
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
r'''检查 relay 服务状态并重跑网页检查。'''
from __future__ import annotations
import sys
import time
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:
print(" 服务状态:")
_c, out, _e = run(
"for s in lanremote-relay lanremote-tunnel lanremote-nettune.service; do "
"printf ' %-26s %s\\n' $s $(systemctl is-active $s); done", timeout=60)
print(out)
print(" 监听端口:")
_c, out, _e = run("ss -tlnp 2>/dev/null | grep -E '8080|:80 ' | head -5",
timeout=60)
print(out or " (没在监听 8080)")
print(" 最后几行日志:")
_c, out, _e = run("tail -12 /root/lanremote-relay/relay.log 2>/dev/null",
timeout=60)
print(out)
print(" 等一下再跑检查…")
time.sleep(6)
_c, out, _e = run("bash /root/check_web.sh 2>&1", timeout=240)
print(out)
return 0
if __name__ == "__main__":
sys.exit(main())
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# 看中转到底吃多少资源 —— 好判断免费容器够不够
set -u
echo "════════ 进程占用 ════════"
for pat in relay.py cloudflared; do
pid=$(pgrep -f "$pat" | head -1)
if [ -n "${pid:-}" ]; then
rss=$(ps -o rss= -p "$pid" | tr -d ' ')
cpu=$(ps -o %cpu= -p "$pid" | tr -d ' ')
etime=$(ps -o etime= -p "$pid" | tr -d ' ')
printf " %-14s PID %-6s 内存 %5.1f MB CPU %s%% 已跑 %s\n" \
"$pat" "$pid" "$(echo "$rss/1024" | bc -l)" "$cpu" "$etime"
else
printf " %-14s 没在跑\n" "$pat"
fi
done
echo
echo "════════ 系统整体 ════════"
echo " CPU 核心: $(nproc)"
echo " 内存总量: $(free -m | awk '/Mem:/{print $2}') MB"
echo " 已用: $(free -m | awk '/Mem:/{print $3}') MB"
echo " 可用: $(free -m | awk '/Mem:/{print $7}') MB"
echo " 磁盘: $(df -h /root | awk 'NR==2{print $3" / "$2" ("$5" 已用)"}')"
echo " 负载: $(cat /proc/loadavg | cut -d' ' -f1-3)"
echo
echo "════════ 中转的数据目录(要跟着搬的东西)════════"
du -sh /root/lanremote-relay 2>/dev/null | sed 's/^/ /'
echo " ---"
du -sh /root/lanremote-relay/* 2>/dev/null | sort -h | tail -8 | sed 's/^/ /'
echo
echo "════════ 每天请求量(估算托管配额需求)════════"
echo " agent poll 间隔: $(grep -oE 'POLL_SECONDS = float\(os.environ.get\("RELAY_POLL", "[0-9]+"' /root/lanremote-relay/relay.py | grep -oE '"[0-9]+"' | tr -d '"') 秒"
echo " => 单台被控端: $((86400 / 15)) 次/天(按 15 秒算)"
echo " => 若 3 台: $((86400 / 15 * 3)) 次/天"
echo
echo "════════ 现在的中转总量(并发/会话)════════"
curl -s --max-time 8 http://127.0.0.1:8080/v1/health 2>/dev/null \
| python3 -c "
import sys, json
d = json.load(sys.stdin)
print(' 版本:', d.get('version'))
print(' 设备:', len(d.get('devices', [])), '台')
print(' 会话:', d.get('sessions', 0))
print(' 内存:', round(d.get('memoryKB', 0) / 1024), 'MB')
print(' 累计请求:', d.get('totalRequests'))
print(' 运行:', round(d.get('uptimeSeconds', 0) / 3600, 1), '小时')
" 2>/dev/null | sed 's/^/ /'
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# 公网间歇 530 —— 查隧道当前真实状态
set -u
echo " relay=$(systemctl is-active lanremote-relay) tunnel=$(systemctl is-active lanremote-tunnel)"
echo " 隧道连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
echo
echo "════════ watchdog 最近 15 行 ════════"
tail -15 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
echo
echo "════════ watchdog 怎么被调起来的 ════════"
echo " 服务: $(systemctl is-active lanremote-watchdog 2>&1)"
crontab -l 2>/dev/null | grep -i watch | sed 's/^/ cron: /'
systemctl list-timers --all 2>/dev/null | grep -i watch | sed 's/^/ timer: /'
echo
echo "════════ 隧道最近的重连频率(1500 行内)════════"
for kw in 'Registered tunnel connection' 'Lost connection' 'Retrying connection' 'i/o timeout' 'context deadline'; do
n=$(tail -1500 /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c "$kw")
printf " %-34s %s 次\n" "$kw" "$n"
done
echo
echo "════════ 出口 IP 可达性 ════════"
python3 - <<'PY'
import socket, time
ips = ["198.41.192.107", "198.41.200.43", "198.41.192.77",
"198.41.200.113", "198.41.192.227", "198.41.200.193",
"198.41.192.37", "198.41.200.63"]
ok = 0
for ip in ips:
s = socket.socket(); s.settimeout(3)
t = time.perf_counter()
try:
s.connect((ip, 7844))
print(" %-16s %4.0f ms" % (ip, (time.perf_counter()-t)*1000)); ok += 1
except OSError:
print(" %-16s 不通" % ip)
finally:
s.close()
print(" => %d/%d 可达" % (ok, len(ips)))
PY
echo
echo "════════ 公网连打 12 次 ════════"
ok=0
for i in $(seq 1 12); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
-H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health)
[ "$c" = "200" ] && ok=$((ok+1))
printf " %2d: %s\n" "$i" "$c"
sleep 1
done
echo " 可用 $ok/12"
echo
echo "════════ 本机 relay(排除源站问题)════════"
for i in 1 2 3; do
printf " %d: HTTP %s\n" "$i" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:8080/v1/health)"
done
echo
echo "════════ 网络质量 ════════"
ping -c 10 -W 2 1.1.1.1 2>/dev/null | tail -2 | sed 's/^/ /'
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# ⚠ 管理密码不再写在这里,从环境变量取:
# export RELAY_ADMIN_PASSWORD=...
: "${RELAY_ADMIN_PASSWORD:?请先 export RELAY_ADMIN_PASSWORD}"
# 官网 / 管理页 / favicon 的最终检查
set -u
BASE=http://127.0.0.1:8080
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
echo " 1) 官网 /"
curl -s -o /tmp/site.html -w ' HTTP %{http_code} %{size_download} 字节\n' \
--max-time 15 "$BASE/" 2>/dev/null
for n in 'ykxmz' '老师抬一下手腕' '电脑端' '服务器端' '手表端'; do
grep -q "$n" /tmp/site.html && echo " OK $n" || echo " XX $n"
done
echo
echo " 2) 管理页 /zhongzhuanzhan(未登录应看到登录框)"
curl -s -o /tmp/login.html --max-time 15 "$BASE/zhongzhuanzhan" 2>/dev/null
for n in 'name=username' 'name=password' 'favicon'; do
grep -q "$n" /tmp/login.html && echo " OK $n" || echo " XX $n"
done
echo
echo " 3) /admin 跳转"
curl -s -o /dev/null -w ' -> %{redirect_url}\n' --max-time 15 "$BASE/admin" 2>/dev/null
echo
echo " 4) 登录"
curl -s -c /tmp/ck2.txt -o /dev/null --max-time 15 -X POST \
-d "username=xmz7891&password=$RELAY_ADMIN_PASSWORD" "$BASE/admin/login" 2>/dev/null
curl -s -b /tmp/ck2.txt -o /tmp/admin.html --max-time 15 "$BASE/zhongzhuanzhan" 2>/dev/null
for n in 'id=dlPanel' '网络体检' '退出登录' 'favicon'; do
grep -q "$n" /tmp/admin.html && echo " OK $n" || echo " XX $n"
done
echo
echo " 5) favicon"
curl -s -o /dev/null -w ' HTTP %{http_code} %{size_download} 字节 %{content_type}\n' \
--max-time 15 "$BASE/favicon.ico" 2>/dev/null
echo
echo " 6) 公网官网"
curl -s -o /dev/null -w ' HTTP %{http_code} %{size_download} 字节\n' \
--max-time 40 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/ 2>/dev/null
+33
View File
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
r'''查 WiX Toolset 的独立二进制下载地址(不依赖 .NET SDK)。'''
from __future__ import annotations
import json
import sys
import urllib.request
API = "https://api.github.com/repos/wixtoolset/wix/releases"
def main() -> int:
req = urllib.request.Request(API, headers={
"User-Agent": "ykxmz-check",
"Accept": "application/vnd.github+json",
})
with urllib.request.urlopen(req, timeout=60) as resp:
releases = json.load(resp)
for rel in releases[:4]:
print(f" {rel.get('tag_name')} ({rel.get('published_at', '')[:10]})"
f"{' [pre]' if rel.get('prerelease') else ''}")
for asset in rel.get("assets", []):
name = asset.get("name", "")
size = asset.get("size", 0) / 1024 / 1024
print(f" {name:44} {size:6.2f} MB")
print(f" {asset.get('browser_download_url')}")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# 看配比对照有没有开始
set -u
echo " ── 隧道当前配比 ──"
for s in lanremote-tunnel lanremote-tunnel-h2; do
printf " %-24s %s (%s)\n" "$s" \
"$(grep -oE -- '--ha-connections [0-9]+' "/etc/systemd/system/$s.service" 2>/dev/null | tr '\n' ' ')" \
"$(systemctl is-active "$s" 2>/dev/null)"
done
echo
echo " ── 对照脚本/进程 ──"
pgrep -af 'ab_long|rp_ab|collapse|ratio' 2>/dev/null | head -5 | sed 's/^/ /' || echo " (没在跑)"
echo
echo " ── 最近 20 分钟内设备上改过的文件 ──"
find /root -maxdepth 1 -type f -mmin -20 2>/dev/null | head -10 | sed 's/^/ /' || true
echo
echo " ── 有没有对照的产物 ──"
ls -la --time-style=+%H:%M /root/*ab*.tsv /root/*ab*.out /root/*ratio*.tsv /root/*collapse*.tsv 2>/dev/null | sed 's/^/ /' || echo " (没有)"
echo
echo " ── 公网 ──"
curl -s --max-time 20 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
echo
echo " ── 时间 ──"
date '+ %H:%M:%S'
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# A/B 对照现状(我换密码重启中转打断了 A 组)
set -u
echo " ══ $(date '+%H:%M:%S') ══"
echo
echo " ── A 组 ──"
[ -f /root/ab_A.out ] && cat /root/ab_A.out | sed 's/^/ /'
n=0; [ -f /root/ab_A_health.tsv ] && n=$(wc -l < /root/ab_A_health.tsv)
echo " 样本: $n"
[ -f /root/ab_A_summary.txt ] && echo " ── 汇总 ──" && cat /root/ab_A_summary.txt | sed 's/^/ /'
[ -f /root/ab_A_health.tsv ] && echo " 最后 2 条:" && tail -2 /root/ab_A_health.tsv | sed 's/^/ /'
echo
echo " ── B 组 ──"
[ -f /root/ab_B.out ] && cat /root/ab_B.out | sed 's/^/ /' || echo " (没开始)"
n2=0; [ -f /root/ab_B_health.tsv ] && n2=$(wc -l < /root/ab_B_health.tsv)
echo " 样本: $n2"
echo
echo " ── 进程 ──"
pgrep -af 'rpab|rp_ab' 2>/dev/null | head -3 | sed 's/^/ /' || echo " (没有在跑)"
echo
echo " ── 记忆/CPU 采样(它加的那个)──"
[ -f /root/ab_A_res.tsv ] && head -3 /root/ab_A_res.tsv | sed 's/^/ /'
echo
echo " ── 当前配比 ──"
for s in lanremote-tunnel lanremote-tunnel-h2; do
printf " %-24s %s\n" "$s" \
"$(grep -oE -- '--ha-connections [0-9]+' "/etc/systemd/system/$s.service" 2>/dev/null | tr '\n' ' ')"
done
echo
echo " ── 公网 ──"
curl -s --max-time 15 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# 对比设备上的 relay 文件和仓库里的版本
set -u
D=/root/lanremote-relay
echo " ── 设备上的文件 ──"
ls -la --time-style=+%m-%d_%H:%M "$D"/*.py 2>/dev/null | sed 's/^/ /'
echo
echo " ── relay.py 版本号 ──"
grep -n '^VERSION' "$D/relay.py" 2>/dev/null | sed 's/^/ /'
echo
echo " ── admin.py 里有没有新功能 ──"
for kw in allowNew data-recode set_allow_new set_device_code; do
n=$(grep -c "$kw" "$D/admin.py" 2>/dev/null || echo 0)
n2=$(grep -c "$kw" "$D/relay.py" 2>/dev/null || echo 0)
printf " %-16s admin.py:%s relay.py:%s\n" "$kw" "$n" "$n2"
done
echo
echo " ── admin.py 大小和 md5 ──"
md5sum "$D/admin.py" 2>/dev/null | sed 's/^/ /'
wc -c "$D/admin.py" 2>/dev/null | sed 's/^/ /'
echo
echo " ── 服务状态 ──"
systemctl is-active lanremote-relay 2>/dev/null | sed 's/^/ relay: /'
echo " 启动于: $(systemctl show -p ActiveEnterTimestamp --value lanremote-relay 2>/dev/null)"
echo
echo " ── 实际返回的管理页开头(看是不是管理页)──"
KEY=$(cat "$D/data/admin_key.txt" 2>/dev/null)
curl -s --max-time 15 "http://127.0.0.1:8080/zhongzhuanzhan?key=$KEY" 2>/dev/null | head -c 400
echo
echo " (上面这段的头 400 字节)"
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# 看 relay-stability 的 30 次长轮询结果(目标 2)
set -u
echo " ── 汇总文件 ──"
[ -f /root/rp_poll30_summary.txt ] && cat /root/rp_poll30_summary.txt | sed 's/^/ /'
echo
echo " ── 明细(前 5 行看格式)──"
[ -f /root/rp_poll30.tsv ] && head -5 /root/rp_poll30.tsv | sed 's/^/ /'
echo
echo " ── 自己再算一遍 ──"
python3 - <<'PYEOF'
from pathlib import Path
p = Path("/root/rp_poll30.tsv")
if not p.is_file():
print(" 没有文件")
raise SystemExit(0)
rows = []
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line:
continue
parts = [x.strip() for x in line.replace(",", "\t").split("\t") if x.strip()]
if not parts:
continue
# 找出状态码和耗时
code = None
ms = None
for x in parts:
if x in ("200", "502", "530", "000", "401", "504"):
code = x
elif x.replace(".", "").isdigit() and "." not in x and len(x) >= 4:
try:
v = int(x)
if 500 <= v <= 300000 and ms is None:
ms = v
except ValueError:
pass
if code:
rows.append((code, ms))
if not rows:
print(" 解析不出样本,原始内容:")
print(p.read_text(encoding="utf-8", errors="replace")[:400])
raise SystemExit(0)
total = len(rows)
ok = sum(1 for c, _m in rows if c == "200")
print(" 样本 %d,成功 %d,可用率 %.1f%%" % (total, ok, ok * 100.0 / total))
good = sorted(m for c, m in rows if c == "200" and m)
if good:
def pc(q):
return good[min(int(len(good) * q), len(good) - 1)]
print(" 耗时: 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms"
% (good[0], good[len(good) // 2], pc(0.9), pc(0.99), good[-1]))
print(" (理想值 = 60000 + 隧道开销;>90000 的算异常长尾)")
outliers = [m for m in good if m > 90000]
print(" 异常长尾(>90 秒): %d 次 %s" % (len(outliers), outliers))
PYEOF
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# 看 relay-stability 在设备上跑到哪了
set -u
echo " ── 它起的进程 ──"
pgrep -af 'rp_|long2h|ab_long|collapse' 2>/dev/null | head -6 | sed 's/^/ /' || echo " (没有)"
echo
echo " ── /root 下它最近写的文件 ──"
ls -la --time-style=+%H:%M:%S /root/rp_* /root/*.tsv /root/*.txt /root/*.out 2>/dev/null \
| sort -k6 | tail -12 | sed 's/^/ /'
echo
echo " ── 那个 2 小时长测的输出 ──"
if [ -f /root/long2h_health.tsv ]; then
echo " 样本数: $(wc -l < /root/long2h_health.tsv)"
echo " 最后 3 行:"
tail -3 /root/long2h_health.tsv | sed 's/^/ /'
fi
echo
echo " ── 配比对照有没有开始(看隧道配置变没变)──"
for s in lanremote-tunnel lanremote-tunnel-h2; do
printf " %-24s %s\n" "$s" \
"$(grep -oE -- '--protocol [a-z0-9]+|--ha-connections [0-9]+' /etc/systemd/system/$s.service 2>/dev/null | tr '\n' ' ')"
done
for p in 20241 20242; do
n=$(curl -s --max-time 4 "http://127.0.0.1:$p/metrics" 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
printf " 端口 %s -> %s 条\n" "$p" "${n:-读不到}"
done
echo
echo " ── 公网自检 ──"
curl -s --max-time 20 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
echo
echo " ── 设备当前时间 ──"
date '+ %H:%M:%S'
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# 看设备上隧道现在的配比 + 连接数(判断 relay-stability 有没有在跑对照)
set -u
echo " ── 两个隧道单元的配置 ──"
for s in lanremote-tunnel lanremote-tunnel-h2; do
if [ -f "/etc/systemd/system/$s.service" ]; then
cfg=$(grep -oE -- '--protocol [a-z0-9]+|--ha-connections [0-9]+|--metrics [0-9.:]+' \
"/etc/systemd/system/$s.service" | tr '\n' ' ')
printf " %-24s %s (%s)\n" "$s" "$cfg" "$(systemctl is-active $s 2>/dev/null)"
else
printf " %-24s (不存在)\n" "$s"
fi
done
echo
echo " ── 实时连接数 ──"
for p in 20241 20242; do
n=$(curl -s --max-time 4 "http://127.0.0.1:$p/metrics" 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
printf " 端口 %s -> %s 条\n" "$p" "${n:-读不到}"
done
echo
echo " ── 公网自检 ──"
curl -s --max-time 20 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
echo
echo " ── relay-stability 的测量进程 ──"
pgrep -af 'rp_|longstab|bench' 2>/dev/null | head -5 | sed 's/^/ /' || echo " (没有在跑)"
echo
echo " ── 它最近写的文件 ──"
ls -la --time-style=+%H:%M:%S /root/rp_* /root/*.tsv 2>/dev/null | tail -8 | sed 's/^/ /' || true
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# 干净可用率测量:没有并发下载,间隔 3 秒,30 次
set -u
BASE=https://relay.xxmxxs.top
UA='LanRemote/1.8.0'
echo " 先静置 20 秒,等 4G 链路缓过来…"
sleep 20
echo
echo " 隧道连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
echo
ok=0
fail=0
declare -a bad
for i in $(seq 1 30); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 \
-H "User-Agent: $UA" "$BASE/v1/health")
if [ "$c" = "200" ]; then
ok=$((ok + 1))
else
fail=$((fail + 1))
bad+=("$i:$c")
fi
printf " %2d: %s\n" "$i" "$c"
sleep 3
done
echo
echo " ──────────────────────────────"
echo " 可用 $ok/30 = $((ok * 100 / 30))%"
echo " 失败 $fail 次: ${bad[*]:-无}"
echo
echo " 失败时隧道连接数变化(看是不是掉到 1):"
echo " $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
echo
echo " watchdog 最近 8 行:"
tail -8 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
echo
echo " 网络质量:"
ping -c 10 -W 2 1.1.1.1 2>/dev/null | tail -2 | sed 's/^/ /'
+158
View File
@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
r'''统一清理两个平台发行版里的重复附件。
背景:
重发时如果只"复用已有发行版",附件会一次次堆积。
Gitee 尤其绕:`GET /releases/tags/{tag}` 返回的 `assets`
**只有 name 和 download_url,没有 id**,所以没法删。
但另一个接口有 id:
GET /repos/{o}/{r}/releases/{id}/attach_files -> 带 id
DELETE /repos/{o}/{r}/releases/{id}/attach_files/{attach_id}
Gitea/EzHub 则直接有 assets[].id。
策略:同名附件只保留最新的一份,其余删掉。
支持 --dry 先看要删什么。
'''
from __future__ import annotations
import json
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
GT = (ROOT / "_build" / ".gitee_token").read_text(encoding="utf-8").strip()
ET = (Path.home() / ".ykxmz" / "ezhub_token").read_text(
encoding="utf-8").strip()
TAG = "v1.8.0"
# 这两个是 Gitee 自动生成的源码包,不是我们传的,不要动
KEEP_ALWAYS = {"v1.8.0.zip", "v1.8.0.tar.gz"}
def http(url: str, method: str = "GET", kind: str = "gitea",
token: str = "", tries: int = 3):
"""带重试的请求。网络会抖,不重试会误判。"""
headers = {"User-Agent": "ykxmz-clean"}
if kind == "gitee":
sep = "&" if "?" in url else "?"
url = url + sep + "access_token=" + token
else:
headers["Authorization"] = "token " + token
last = None
for attempt in range(1, tries + 1):
try:
req = urllib.request.Request(url, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=90) as resp:
text = resp.read().decode("utf-8", "replace")
return resp.status, (json.loads(text) if text.strip() else {})
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")[:200]
except Exception as exc: # noqa: BLE001
last = exc
if attempt < tries:
time.sleep(4)
return 0, str(last)
def list_gitee_assets(rid: int) -> list:
"""Gitee 要单独调 attach_files 才拿得到 id。"""
status, data = http(
"https://gitee.com/api/v5/repos/xmz7891/ykxmz/releases/%s"
"/attach_files" % rid, kind="gitee", token=GT)
if status != 200 or not isinstance(data, list):
print(" [失败] 取 Gitee 附件列表 HTTP %s: %s" % (status, data))
return []
return data
def list_gitea_assets(rid: int) -> list:
status, data = http(
"https://git.261826.xyz/api/v1/repos/xmz/ykxmz/releases/%s"
% rid, kind="gitea", token=ET)
if status != 200:
print(" [失败] 取 EzHub 发行版 HTTP %s" % status)
return []
return data.get("assets") or []
def clean(platform: str, rid: int, assets: list, dry: bool) -> int:
groups = defaultdict(list)
for a in assets:
name = a.get("name") or "?"
if name in KEEP_ALWAYS:
continue
groups[name].append(a)
removed = 0
for name, items in sorted(groups.items()):
if len(items) <= 1:
continue
# 新的排前面
items.sort(key=lambda x: str(x.get("created_at") or ""), reverse=True)
keep = items[0]
print(" %-32s %d 份 -> 保留 id=%s" % (name, len(items),
keep.get("id")))
for extra in items[1:]:
aid = extra.get("id")
if dry:
print(" (演练)会删 id=%s" % aid)
removed += 1
continue
if platform == "gitee":
url = ("https://gitee.com/api/v5/repos/xmz7891/ykxmz"
"/releases/%s/attach_files/%s" % (rid, aid))
else:
url = ("https://git.261826.xyz/api/v1/repos/xmz/ykxmz"
"/releases/%s/assets/%s" % (rid, aid))
status, body = http(url, method="DELETE",
kind=("gitee" if platform == "gitee"
else "gitea"),
token=(GT if platform == "gitee" else ET))
if status in (200, 204):
print(" ✓ 删了 id=%s" % aid)
removed += 1
else:
print(" ✗ id=%s HTTP %s %s"
% (aid, status, str(body)[:80]))
return removed
def main() -> int:
dry = "--dry" in sys.argv
total = 0
print(" ═══ Gitee ═══")
status, rel = http("https://gitee.com/api/v5/repos/xmz7891/ykxmz"
"/releases/tags/" + TAG, kind="gitee", token=GT)
if status == 200:
rid = rel.get("id")
assets = list_gitee_assets(rid)
print(" 发行版 id=%s,可删附件 %d 个" % (rid, len(assets)))
total += clean("gitee", rid, assets, dry)
else:
print(" [失败] HTTP %s" % status)
print()
print(" ═══ EzHub ═══")
status, rel = http("https://git.261826.xyz/api/v1/repos/xmz/ykxmz"
"/releases/tags/" + TAG, kind="gitea", token=ET)
if status == 200:
rid = rel.get("id")
assets = list_gitea_assets(rid)
print(" 发行版 id=%s,附件 %d 个" % (rid, len(assets)))
total += clean("gitea", rid, assets, dry)
else:
print(" [失败] HTTP %s" % status)
print()
print(" %s %d 个" % ("演练:会删" if dry else "共删", total))
return 0
if __name__ == "__main__":
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
r'''清理 EzHub 发行版里重复的附件。
为什么会重复:
release_all.py 发现标签已有发行版时会"复用它",
但**没有先删同名附件**,于是每跑一次就多传一遍。
我调试时跑了几次,同一个文件就堆了 2~3 份。
这个脚本:同名附件只保留最新的一份,其余删掉。
(Gitea 的附件用 id 区分,创建时间看 created_at。)
'''
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.request
from collections import defaultdict
from pathlib import Path
TOKEN = (Path.home() / ".ykxmz" / "ezhub_token").read_text(
encoding="utf-8").strip()
API = "https://git.261826.xyz/api/v1/repos/xmz/ykxmz"
TAG = "v1.8.0"
HEAD = {"Authorization": "token " + TOKEN, "User-Agent": "ykxmz-clean"}
def call(url: str, method: str = "GET"):
req = urllib.request.Request(url, method=method, headers=HEAD)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
text = resp.read().decode("utf-8", "replace")
return resp.status, (json.loads(text) if text.strip() else {})
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")[:200]
return exc.code, body
def main() -> int:
dry = "--dry" in sys.argv
status, release = call("%s/releases/tags/%s" % (API, TAG))
if status != 200:
print(" [失败] 取发行版 HTTP %s: %s" % (status, release))
return 1
rid = release["id"]
assets = release.get("assets") or []
print(" 发行版 id=%s,附件 %d 个" % (rid, len(assets)))
# 按文件名分组
groups = defaultdict(list)
for a in assets:
groups[a.get("name") or "?"].append(a)
print()
print(" 分组情况:")
to_delete = []
for name, items in sorted(groups.items()):
# 新的排在前面(created_at 降序)
items.sort(key=lambda x: x.get("created_at") or "", reverse=True)
keep = items[0]
print(" %-32s 共 %d 份,保留 id=%s(%s)"
% (name, len(items), keep.get("id"),
(keep.get("created_at") or "")[:19]))
for extra in items[1:]:
print(" 删 id=%s(%s)" % (extra.get("id"),
(extra.get("created_at")
or "")[:19]))
to_delete.append(extra)
if not to_delete:
print()
print(" ✓ 没有重复,不用清理")
return 0
print()
if dry:
print(" 演练:会删 %d 个(去掉 --dry 真删)" % len(to_delete))
return 0
print(" 开始删除 %d 个重复附件…" % len(to_delete))
ok = 0
for asset in to_delete:
status, body = call("%s/releases/%s/assets/%s"
% (API, rid, asset.get("id")), method="DELETE")
if status in (200, 204):
print(" ✓ 删了 id=%s %s" % (asset.get("id"),
asset.get("name")))
ok += 1
else:
print(" ✗ id=%s HTTP %s %s" % (asset.get("id"), status, body))
print()
print(" 删除 %d/%d" % (ok, len(to_delete)))
# 复查
status, release = call("%s/releases/tags/%s" % (API, TAG))
assets = release.get("assets") or []
print()
print(" 现在剩 %d 个附件:" % len(assets))
for a in assets:
print(" %-32s %8.2f MB"
% (a.get("name"), (a.get("size") or 0) / 1024 / 1024))
return 0
if __name__ == "__main__":
sys.exit(main())
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# 安全清理随身 WiFi 上的垃圾。
#
# 原则:先看清楚再删,删之前把「为什么它一定是垃圾」写下来。
# 中转相关的目录(/root/lanremote-relay、/root/.cloudflared)一律不碰。
set -u
BEFORE=$(df -m / | awk 'NR==2 {print $4}')
echo "════════ 清理前:可用 ${BEFORE} MB ════════"
echo
echo "════════ 0. 先看 nginx 到底在提供什么(决定动不动它)════════"
echo " 在跑: $(systemctl is-active nginx)"
echo " 根目录配置:"
grep -hE '^\s*(root|server_name|listen)' /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf 2>/dev/null | head -10 | sed 's/^/ /'
echo " /var/www 内容:"
ls -1 /var/www 2>/dev/null | sed 's/^/ /'
echo " 首页大小: $(stat -c%s /var/www/html/index.nginx-debian.html 2>/dev/null || echo 0) 字节"
echo " → 这是 Debian 装 nginx 的默认欢迎页,和中转无关。"
echo " **但为了安全我不删 nginx**(万一有人拿它做别的),只报告。"
echo
echo "════════ 1. 删 /var/lib/mysql(113 MB)════════"
echo " 依据:"
echo " * dpkg 里没有任何 mysql/mariadb 包(0 个已安装)"
echo " * 没有任何 mysqld 进程"
echo " * 没有 mariadb/mysql 的 systemd 服务"
echo " → 这是历史遗留的数据目录,包都卸了,目录没删干净。"
if [ -d /var/lib/mysql ]; then
echo " 删除前确认没有进程占用: $(fuser -m /var/lib/mysql 2>/dev/null | wc -l) 个"
if [ "$(pgrep -c -f 'mysqld|mariadb' 2>/dev/null || echo 0)" -eq 0 ] \
&& [ "$(dpkg -l 2>/dev/null | grep -cE '^ii +(mysql-server|mariadb-server)')" -eq 0 ]; then
rm -rf /var/lib/mysql
echo " ✓ 已删除 /var/lib/mysql"
else
echo " ! 检测到 mysql 相关包或进程,**跳过**(安全第一)"
fi
else
echo " (不存在,跳过)"
fi
echo
echo "════════ 2. 清 apt 缓存和索引(约 162 MB)════════"
echo " 依据:/var/cache/apt 是已下载的 .deb 缓存,随时可重下;"
echo " /var/lib/apt/lists 是索引,apt update 会重建。"
apt-get clean 2>/dev/null
rm -rf /var/lib/apt/lists/* 2>/dev/null
echo " ✓ 已清理"
echo
echo "════════ 3. 压缩 journald 日志(约 10 MB)════════"
journalctl --vacuum-size=8M 2>&1 | tail -2 | sed 's/^/ /'
echo
echo "════════ 4. 清理我遗留的调试脚本(173 个)════════"
echo " 这些是我这几次调试时上传到 /root 的临时脚本(ab*.sh、bench.sh 等),"
echo " 中转本身不需要它们。"
echo " ⚠ 保留:中转目录、.cloudflared、以及任何看起来像正式运维脚本的"
n=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
echo " 清理前: $n 个"
mkdir -p /root/.trash-debug
# 只移动我确定是调试用的(a开头/ab开头/bench/check开头等),保留其他
for f in /root/a*.sh /root/ab*.sh /root/bench*.sh /root/check_*.sh /root/diag_*.sh \
/root/dl*.sh /root/final*.sh /root/post_*.sh /root/probe*.py /root/smart_*.sh \
/root/setup_*.sh /root/test_*.sh /root/why_*.sh /root/fa*.sh /root/tc*.sh \
/root/cc*.sh /root/sa*.sh /root/sq.sh /root/dc.sh /root/vc*.sh; do
[ -f "$f" ] && mv "$f" /root/.trash-debug/ 2>/dev/null
done
moved=$(ls -1 /root/.trash-debug 2>/dev/null | wc -l)
echo " 已移走 $moved 个到 /root/.trash-debug(先不真删,确认没问题再删)"
echo " /root 下剩余: $(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l) 个"
echo
echo "════════ 5. 中转目录(绝对不动)════════"
du -sh /root/lanremote-relay /root/.cloudflared 2>/dev/null | sed 's/^/ /'
echo " → 这两个是命根子,一个字节都没碰"
echo
AFTER=$(df -m / | awk 'NR==2 {print $4}')
echo "════════ 结果 ════════"
echo " 可用空间: ${BEFORE} MB -> ${AFTER} MB (腾出 $((AFTER - BEFORE)) MB)"
df -h / | sed 's/^/ /'
echo
echo " 磁盘总览:"
du -xhd1 / 2>/dev/null | sort -rh | head -8 | sed 's/^/ /'
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# 补删 /var/lib/mysql(上一条脚本的整数判断写错了,误跳过)
set -u
echo "════════ 删除前的三重确认 ════════"
echo " 1. 有没有 mysqld/mariadb 进程:"
n=$(pgrep -f 'mysqld|mariadb' 2>/dev/null | wc -l)
echo " $n 个"
echo " 2. dpkg 里有没有 mysql/mariadb 包:"
p=$(dpkg -l 2>/dev/null | grep -cE '^ii +(mysql|mariadb)' || true)
echo " $p 个"
echo " 3. 有没有相关 systemd 服务:"
s=$(systemctl list-unit-files 2>/dev/null | grep -cE '^(mysql|mariadb)' || true)
echo " $s 个"
echo " 4. 目录大小:$(du -sh /var/lib/mysql 2>/dev/null | awk '{print $1}')"
echo " 5. 有没有别的进程打开着里面的文件:"
fuser -m /var/lib/mysql 2>/dev/null | wc -l | sed 's/^/ /'
if [ "$n" -eq 0 ] && [ "$p" -eq 0 ] && [ "$s" -eq 0 ]; then
echo
echo " → 三项都是 0,确认是卸载残留的数据目录,删除"
rm -rf /var/lib/mysql
echo " ✓ 已删除"
else
echo
echo " ! 还有引用,**不删**"
fi
echo
echo "════════ 把剩余的调试脚本也移走 ════════"
# 上一条只移走了一部分(通配符没覆盖全)。
# 现在把 /root 下**所有顶层** .sh/.py 都移进回收目录 ——
# 中转的运行时代码在 /root/lanremote-relay/ 里,不受影响。
before=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
mkdir -p /root/.trash-debug
for f in /root/*.sh /root/*.py; do
[ -f "$f" ] || continue
mv "$f" /root/.trash-debug/ 2>/dev/null
done
after=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
echo " /root 顶层脚本: $before -> $after"
echo " 回收目录里的: $(ls -1 /root/.trash-debug 2>/dev/null | wc -l) 个"
echo " ⚠ 没有真删,放在 /root/.trash-debug;确认中转一切正常后再 rm -rf"
echo
echo "════════ 确认中转没被影响 ════════"
echo " relay 服务: $(systemctl is-active lanremote-relay)"
echo " 隧道(quic): $(systemctl is-active lanremote-tunnel)"
echo " 隧道(h2): $(systemctl is-active lanremote-tunnel-h2)"
echo " 自检: $(curl -s --max-time 10 http://127.0.0.1:8080/v1/health | head -c 70)"
echo
echo " 中转目录完好: $(ls /root/lanremote-relay/ | tr '\n' ' ')"
echo
echo "════════ 最终空间 ════════"
df -h / | sed 's/^/ /'
echo
du -xhd1 / 2>/dev/null | sort -rh | head -8 | sed 's/^/ /'
+161
View File
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
r'''把开机加速的修复固化到仓库(脚本 + 文档 + 提交)。
事故:用户早上撞上 Error 1033。
查出设备**掉电重启**过(上次开机日志是空的 = 直接断电)。
重启后要 **2 分 10 秒**公网才恢复 —— 那段时间浏览器就是 1033。
根因:
这台机器上 NetworkManager 和 systemd-networkd 同时在跑,
但 wlan0 归 **NetworkManager** 管。
NetworkManager-wait-online -> 开机 9 秒就成功
systemd-networkd-wait-online -> 等一个不归它管的网络,
干等 2 分钟超时,然后 failed
network-online.target 要等**两个都完成**,
于是被那个失败的服务拖了整整 2 分钟,
relay / tunnel / net-tune 全跟着等。
修法:
1. mask 掉 systemd-networkd-wait-online(网络不归它管)
2. watchdog 首次检查从"开机后 2 分钟"提前到 45 秒
实测:
修之前 Startup finished in 3.846s + 2min 6.358s = 2分10秒
修之后 Startup finished in 3.218s + 14.060s = 17.3秒
掉电重启后公网恢复时间:**24 秒**
'''
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
GIT = r"C:\Program Files\Git\bin\git.exe"
# 把设备上的实际做法固化成脚本
SCRIPT = '''#!/bin/bash
# 加快掉电重启后的恢复速度。
#
# 背景(实测数据):
# 用户撞上的 Cloudflare Error 1033 是设备**掉电重启**造成的。
# 修之前开机要 2 分 10 秒公网才恢复,那段时间浏览器就是 1033。
#
# 根因:这台机器 NetworkManager 和 systemd-networkd 同时在跑,
# 但 wlan0 归 NetworkManager 管。
# NetworkManager-wait-online -> 9 秒成功
# systemd-networkd-wait-online -> 等不归它管的网络,
# 干等 2 分钟超时然后 failed
# network-online.target 要等两个都完成,
# 被那个失败的拖了整整 2 分钟,
# relay / tunnel / net-tune 全跟着等。
#
# 修完:用户空间 2分6秒 -> 14秒;掉电重启后 24 秒公网恢复。
set -u
echo " 1. mask 掉 systemd-networkd-wait-online(网络不归它管)"
systemctl disable --now systemd-networkd-wait-online.service 2>&1 | sed 's/^/ /'
systemctl mask systemd-networkd-wait-online.service 2>&1 | sed 's/^/ /'
echo " 2. watchdog 首次检查提前到开机后 45 秒"
cat > /etc/systemd/system/lanremote-watchdog.timer <<'EOF'
[Unit]
Description=每 20 秒检查一次隧道连接数
[Timer]
# 原来 OnBootSec=2min。开机后隧道几十秒就连上了,
# 干等 2 分钟才第一次检查没必要。
OnBootSec=45s
OnUnitActiveSec=20s
AccuracySec=5s
Unit=lanremote-watchdog.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl restart lanremote-watchdog.timer
echo " 3. 确认"
echo " networkd-wait-online: $(systemctl is-enabled systemd-networkd-wait-online 2>&1)(应为 masked)"
echo " watchdog timer: $(systemctl is-active lanremote-watchdog.timer 2>&1)"
systemctl list-dependencies network-online.target --no-pager 2>/dev/null | sed 's/^/ /'
'''
MSG = """修开机慢 2 分钟:掉电重启后要 2 分 10 秒才能恢复
用户早上撞上 Cloudflare Error 1033。查出来是设备**掉电重启**:
上次开机到重启之间没有任何日志落盘(直接断电的特征),
dmesg 里还有 "l13: voltage operation not allowed"。
重启本身不是软件问题,但**恢复太慢**:
Startup finished in 3.846s (kernel) + 2min 6.358s (userspace) = 2分10秒
那 2 分钟里浏览器看到的就是 1033。
根因:
这台机器 NetworkManager 和 systemd-networkd 同时在跑,
但 wlan0 归 NetworkManager 管。
NetworkManager-wait-online -> 开机 9 秒成功
systemd-networkd-wait-online -> 等一个不归它管的网络,
干等 2 分钟超时,然后 failed
network-online.target 要等**两个都完成**,
被那个失败的拖了整整 2 分钟,
relay / tunnel / net-tune 全跟着等。
修法:
1. mask 掉 systemd-networkd-wait-online(网络不归它管,只会拖后腿)
2. watchdog 首次检查从"开机后 2 分钟"提前到 45 秒
实测(重启验证过):
修之前 Startup finished in 3.846s + 2min 6.358s = 2分10秒
修之后 Startup finished in 3.218s + 14.060s = 17.3秒
掉电重启后公网恢复时间:**24 秒**(快了 5 倍多)
配套加了 scripts/speed-up-boot.sh,换设备时直接跑一遍。
顺带确认重启后一切自动恢复:
三个服务 active + enabled ✓
keepalive 30/10/3、MTU 探测、MSS 钳制、slow_start=0 全部持久化 ✓
隧道 4 条连接 http2 ✓
官网 / 管理页 / 截图全部 200 ✓
"""
def save_script() -> None:
target = ROOT / "relay" / "speed-up-boot.sh"
target.write_text(SCRIPT, encoding="utf-8", newline="\n")
print(" 已写 %s(%d 行)" % (target.name, SCRIPT.count("\n")))
def commit() -> None:
print()
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_c11.txt"
msg_file.write_bytes(MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
def main() -> int:
save_script()
commit()
return 0
if __name__ == "__main__":
sys.exit(main())
+68
View File
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
r'''提交最后的两个修复。'''
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
GIT = r"C:\Program Files\Git\bin\git.exe"
MSG = """修 --quiet 卸载日志的两个问题(静态检查抓出来的)
1. run_agent.py 用了 Path 但没导入
静态检查报 "undefined name 'Path'"。它被 try/except 吞了,
所以卸载功能本身没坏,但**日志从来没写成过** ——
属于"看着没事、其实功能是死的",最容易被漏掉的那种。
2. 日志写到刚被删掉的数据目录里
原来是 Path(data_dir())/"uninstall.log",但 do_uninstall()
会把整个数据目录删掉,写完等于白写。
改到 %TEMP%\\ykxmz-uninstall.log。
现在日志能正常落盘,内容长这样:
开始卸载 ykxmz
已停止其它 ykxmz.exe 实例(本进程 15572 除外)
没有开机自启项
已删除数据目录 ...
程序目录交给安装包的卸载器删除
卸载完成。
最终全量套件(33 个阶段):
30 PASS + 3 SKIP(锁屏) + 0 FAIL
新增的「安装包完整周期」PASS
"""
def main() -> int:
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_cmsg5.txt"
msg_file.write_bytes(MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
status = subprocess.run([GIT, "status", "--short"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" 工作区: %s" % ("干净 ✓" if not (status.stdout or "").strip()
else (status.stdout or "").strip()[:160]))
return 0
if __name__ == "__main__":
sys.exit(main())
+93
View File
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
r'''提交这轮修复,顺便看看有没有现成的手表截图能放到官网上。'''
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
GIT = r"C:\Program Files\Git\bin\git.exe"
MSG = """修真凶:4G 链路静默断连导致隧道"假活"(用户撞上 Error 1033)
现象:用户早上打开 relay.xxmxxs.top 看到 Cloudflare Error 1033。
watchdog 日志把过程记下来了:
公网不通(HTTP 000,ha_connections=2) <- 隧道显示连着,公网不通
公网不通(HTTP 502,ha_connections=2)
公网恢复(HTTP 200) <- 自己又好了
也就是说:**间歇性假活**,短样本测不出来。
我之前只测 15~25 次就说"可用率 100%",太草率了。
根因:4G 链路会**静默断开** TCP 连接 —— 不发 FIN/RST,双方都以为还连着。
Cloudflare 把请求发到这条僵尸连接上,就回 502/530/1033。
Linux 默认 tcp_keepalive_time = **7200 秒(2 小时)**才发现连接死了,
所以中间这段时间服务一直"看着在线、实际打不开"。
tunnel.log 最近 3000 行的佐证:
i/o timeout 212 次
Retrying connection 127 次
Lost connection 22 次
修法:
1. TCP keepalive 调到 30 秒 + 每 10 秒探测 3 次
=> 最多约 60 秒发现僵尸连接,cloudflared 立刻重连
(/etc/sysctl.d/97-ykxmz-keepalive.conf)
2. 隧道连接数 2 -> 4,多几条冗余
3. 修 relay 从不清理设备记录的问题:
self.devices 只增不减,被控端每换端口/重装就多一条,
实测一天攒了 75 条僵尸记录,/v1/health 从几 KB 涨到 20KB。
加 DEVICE_TTL(6 小时)在 snapshot() 里清掉。
实测(修完立刻验证):
设备侧 公网 15/15 HTTP 200
Windows 公网 25/25,中位 520 ms,最快 463 ms
health 20931 字节 / 75 台 -> 152 字节 / 0 台
"""
def commit() -> None:
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_cmsg6.txt"
msg_file.write_bytes(MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
def main() -> int:
commit()
print()
print(" 官网现在有什么图可用:")
for folder in ("_tests/shots", "android", "windows", "docs", "_build"):
base = ROOT / folder
if not base.is_dir():
continue
pics = [p for p in base.rglob("*")
if p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp")
and p.is_file() and p.stat().st_size > 3000]
if pics:
print(" %s/ (%d 张)" % (folder, len(pics)))
for p in sorted(pics)[:8]:
print(" %-46s %6.1f KB"
% (p.name, p.stat().st_size / 1024))
return 0
if __name__ == "__main__":
sys.exit(main())
+138
View File
@@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
r'''最终验证 + 提交这一轮(隧道修复 + 管理页重做)。'''
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
GIT = r"C:\Program Files\Git\bin\git.exe"
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
MSG = """隧道"假活"真因修复 + 中转管理后台重做
用户反馈两件事:
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。现在统一定义了常量。
二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
"""
def verify() -> None:
print(" 一、验证配对码清理")
out = run(
"curl -s -b /tmp/ck.txt --max-time 20 "
"http://127.0.0.1:8080/zhongzhuanzhan | "
"grep -A2 '配对码占用' | head -3",
timeout=120)[1]
print(" " + (out or "").strip())
code = run(
"curl -s --max-time 10 http://127.0.0.1:8080/v1/health | "
"python3 -c \"import sys,json; d=json.load(sys.stdin); "
"print('设备', len(d.get('devices',[])))\"",
timeout=60)[1]
print(" " + (code or "").strip())
def commit() -> None:
print()
print(" 二、提交")
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_cmsg7.txt"
msg_file.write_bytes(MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
def main() -> int:
verify()
commit()
return 0
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
r'''提交测试套件的三处修复。'''
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
GIT = r"C:\Program Files\Git\bin\git.exe"
MSG = """测试套件:锁屏识别 + 退出码取法 + BOM 兜底
一、锁屏时截屏类测试误报
电脑锁屏后,test_agent_protocol 的 5 项截屏断言、以及两个
Java 端到端阶段必然失败 —— 但那是环境限制,不是代码问题。
不区分的话汇总表一片红,真正的失败反而被淹没。
现在启动时用 OpenInputDesktop 探测,锁屏时这些阶段记
SKIP(锁屏) 而不是 FAIL,并在开头黄字提示。
二、管道后读 $LASTEXITCODE 不可靠
形如
& python xxx.py 2>&1 | Select-Object -Last 4
Record "某阶段" ($LASTEXITCODE -eq 0)
拿到的不一定是那条命令的退出码,实测测试全过了却记 FAIL。
改成先把码存进变量再走管道。共修 4 处。
三、edit 工具会剥掉 .ps1 的 UTF-8 BOM
没有 BOM 的 .ps1 会被 PowerShell 按 GBK 读,中文全乱码,
接着一堆 "Unexpected token" 语法错误,脚本直接跑不了。
这个坑今天踩了两次。加了 _build/fix_ps1_bom.py 统一补 BOM,
并顺带检查 .bat 是不是 GBK。
跑出来:33 个阶段 —— 30 PASS + 3 SKIP(锁屏),0 FAIL。
"""
def main() -> int:
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_cmsg3.txt"
msg_file.write_bytes(MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
status = subprocess.run([GIT, "status", "--short"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
left = (status.stdout or "").strip()
print(" 工作区: %s" % ("干净 ✓" if not left else left[:200]))
return 0
if __name__ == "__main__":
sys.exit(main())
+102
View File
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
r'''把 watchdog 的宽限期修复同步到仓库并提交。'''
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
GIT = r"C:\Program Files\Git\bin\git.exe"
WATCHDOG = ROOT / "relay" / "watchdog.sh"
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
MSG = """修看门狗重启死循环(我上一版改过头了)
用户报 502 Bad Gateway,Cloudflare 的图显示
「Host: relay.xxmxxs.top - Error」,也就是源站/隧道这一跳挂了。
查 watchdog 日志,发现它在**每 50 秒重启一次隧道**:
[10:31:57] 只剩 1 条连接 -> 重启隧道
[10:32:22] 重启完
[10:32:24] 又只剩 1 条 -> 距上次才 27 秒,先不重启
[10:32:47] 距上次 50 秒 -> 又重启
... 循环不停
根因(我上一版的错):
隧道在**质量差的 4G 上重建 4 条连接要 30~60 秒**。
而我上一版把 MIN_RESTART_GAP 设成 45 秒,并且
"公网不通 + 连接数<=1"就立刻重启 ——
结果每次隧道还没把连接建齐,就被下一次重启打断,
**永远停在"只剩 1 条"的状态**。
等于我自己制造了不稳定。
修法:
1. 重启后给 GRACE=150 秒宽限期,这期间绝不重启,
让 cloudflared 专心把连接建齐
2. MIN_RESTART_GAP 从 45 提到 150(和宽限期一致)
3. "立刻重启"的判定加一条:距上次重启必须超过宽限期
实测(停掉 watchdog -> 手动重启隧道 -> 观察):
10 秒内建齐 4 条连接,公网立刻 200
宽限期生效后公网 20/20 = 100%,延迟 ~690ms
顺带一个教训:之前测出 80%、20% 的 000,
是我自己后台还跑着一个观察任务在并发打接口造成的干扰。
**测稳定性的时候,测量工具本身不能成为干扰源。**
"""
def sync_watchdog() -> None:
"""把设备上改好的 watchdog.sh 拉回本地仓库。"""
_c, out, _e = run("cat /root/lanremote-relay/watchdog.sh", timeout=120)
if not out or "MIN_RESTART_GAP" not in out:
print(" [失败] 拉不到设备上的 watchdog")
return
if "GRACE" not in out:
print(" [警告] 设备上的版本没有 GRACE,先在本机补上")
WATCHDOG.write_text(out, encoding="utf-8", newline="\n")
print(" 已同步 watchdog.sh(%d 行)" % out.count("\n"))
print(" 关键参数:")
for line in out.splitlines():
if line.startswith(("MIN_RESTART_GAP", "GRACE", "THRESHOLD",
"STALE_MINUTES")):
print(" " + line.strip())
def commit() -> None:
print()
subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False)
msg_file = ROOT / "_build" / "_c13.txt"
msg_file.write_bytes(MSG.encode("utf-8"))
r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)],
cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
msg_file.unlink(missing_ok=True)
print(" commit exit=%s" % r.returncode)
show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
print(" " + (show.stdout or "").strip())
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"'
push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]:
print(" " + line)
def main() -> int:
sync_watchdog()
commit()
return 0
if __name__ == "__main__":
sys.exit(main())
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
# 对照实验:QUIC vs HTTP/2,连接数 2 vs 4。
#
# 背景:
# cloudflared 自己的 precheck 说 "TCP 7844 blocked"(运营商墙了),
# 而 --protocol http2 走的正是 TCP 7844。
# UDP 那边 precheck 显示 region1 成功、region2 失败 —— 部分可用。
#
# 实测公网:/favicon.ico 能 200,其它路径 530。
# 530 = 边缘找不到可用的隧道连接(请求落 SEA,隧道在 LAX)。
#
# 所以要把"协议 + 连接数"这四种组合都跑一遍,用数据选,不靠猜。
set -u
PUB=https://relay.xxmxxs.top
UNIT=/etc/systemd/system/lanremote-tunnel.service
RELAY_DIR=/root/lanremote-relay
probe() {
# 返回 "可用数/总数"
local ok=0
for i in $(seq 1 12); do
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \
-H 'User-Agent: LanRemote/1.4.0' "$PUB/v1/health")
[ "$c" = "200" ] && ok=$((ok + 1))
sleep 0.6
done
echo "$ok"
}
set_combo() {
local proto="$1" conns="$2"
echo " ---- 设置 protocol=$proto ha-connections=$conns ----"
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$UNIT"
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$UNIT"
systemctl daemon-reload
systemctl restart lanremote-tunnel
sleep 35
}
report() {
local label="$1"
local conns
conns=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
local ok
ok=$(probe)
printf " %-28s 连接 %-3s 公网 %2s/12\n" "$label" "${conns:-?}" "$ok"
return 0
}
echo "════════ 对照实验开始(每种组合约 1.5 分钟)════════"
echo
echo "组合 1/4:HTTP/2 + 2 连接"
set_combo http2 2
report "http2 + 2"
echo
echo "组合 2/4:HTTP/2 + 4 连接"
set_combo http2 4
report "http2 + 4"
echo
echo "组合 3/4:QUIC + 2 连接"
set_combo quic 2
report "quic + 2"
echo
echo "组合 4/4:QUIC + 4 连接"
set_combo quic 4
report "quic + 4"
echo
echo "════════ 各组合的落地机房 ════════"
grep -E 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null | tail -8 \
| grep -oE 'location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /'
echo
echo " 说明:这个脚本只做测量,最终选哪种组合由数据定,"
echo " 跑完我会把最优的一组写回 unit 并重启。"
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
r"""数据目录自动回退:写不进程序目录就转 ProgramData。
背景:
原来的 data_dir() 就是 app_dir()/data —— 数据存在 exe 旁边。
绿色版没问题,但**做成安装包装到 Program Files 之后**,
普通用户对那个目录没有写权限,配置会存不下去。
用户选的做法:
"能写 exe 旁边就写,不能就转 ProgramData"
这样两种情况都对:
* 绿色版 / 装在 D 盘 / 便携 U 盘 -> 还是存在旁边,拔走就带走配置
* 装在 Program Files -> 自动转 C:\ProgramData\ykxmz,
所有用户共用一份(配对码不会因为换 Windows 账号而丢)
顺带加一个 YKXMZ_DATA 环境变量,测试和特殊部署可以强制指定。
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
CONFIG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
r"\lanremote\config.py"))
OLD = '''def app_dir() -> Path:
"""程序所在目录(兼容 PyInstaller 冻结环境)。"""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
def data_dir() -> Path:
return app_dir() / "data"'''
NEW = '''def app_dir() -> Path:
"""程序所在目录(兼容 PyInstaller 冻结环境)。"""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
# 数据目录只算一次,之后缓存 —— 每个命令都去试写一次太浪费
_DATA_DIR_CACHE: Path | None = None
def _can_write(folder: Path) -> bool:
"""这个目录能不能写(不存在就先建)。"""
try:
folder.mkdir(parents=True, exist_ok=True)
probe = folder / ".write-test"
probe.write_text("x", encoding="utf-8")
probe.unlink()
return True
except OSError:
return False
def data_dir() -> Path:
"""配置 / 令牌 / 接收文件的存放目录。
优先级:
1. 环境变量 YKXMZ_DATA(测试和特殊部署用)
2. 程序目录下的 data/ —— 绿色版、便携 U 盘,拔走就带走配置
3. %PROGRAMDATA%\\ykxmz\\data —— 装到 Program Files 时用这个
为什么要有第 3 条:
安装包默认装到 C:\\Program Files\\ykxmz,
普通用户对那里**没有写权限**(Windows 的硬规矩,不是 bug)。
不转走的话,配对码和令牌会存不下去,用户会莫名其妙连不上。
ProgramData 是所有用户可写的公共位置,换 Windows 账号也不丢配置。
"""
global _DATA_DIR_CACHE
if _DATA_DIR_CACHE is not None:
return _DATA_DIR_CACHE
override = os.environ.get("YKXMZ_DATA")
if override:
_DATA_DIR_CACHE = Path(override)
return _DATA_DIR_CACHE
beside = app_dir() / "data"
if _can_write(beside):
_DATA_DIR_CACHE = beside
return _DATA_DIR_CACHE
base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData"
common = Path(base) / "ykxmz" / "data"
try:
common.mkdir(parents=True, exist_ok=True)
except OSError:
# 兜底:连 ProgramData 都写不了(极罕见),退回用户目录
common = (Path(os.environ.get("LOCALAPPDATA")
or Path.home()) / "ykxmz" / "data")
common.mkdir(parents=True, exist_ok=True)
_DATA_DIR_CACHE = common
return _DATA_DIR_CACHE'''
def main() -> int:
text = CONFIG.read_text(encoding="utf-8")
if "YKXMZ_DATA" in text:
print(" 已经改过")
return 0
if OLD not in text:
print(" [失败] 锚点没找到")
return 1
text = text.replace(OLD, NEW, 1)
# 确认 os 已经 import 了
if "\nimport os" not in text:
text = text.replace("import sys\n", "import os\nimport sys\n", 1)
print(" 补了 import os")
CONFIG.write_text(text, encoding="utf-8", newline="\n")
print(" data_dir 加了自动回退(ProgramData)")
try:
ast.parse(text)
print(" config.py 语法 OK")
except SyntaxError as exc:
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
r'''临时调试:在首页分支里打一行日志,看它到底有没有被执行、extra 传的是什么。
(这个文件只改运行环境里的 relay.py,不改本地仓库。)
'''
from __future__ import annotations
import sys
from pathlib import Path
import paramiko
HOST = "192.168.10.105"
USER = "root"
PASSWORD = "1313144"
REMOTE = "/root/lanremote-relay/relay.py"
def run(client, cmd: str, timeout: int = 60) -> str:
_in, out, err = client.exec_command(cmd, timeout=timeout)
return (out.read().decode("utf-8", "replace")
+ err.read().decode("utf-8", "replace"))
def main() -> int:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASSWORD, timeout=20,
look_for_keys=False, allow_agent=False)
try:
# 1. 看看首页分支前后到底长什么样
print(" 首页分支附近(1400-1415):")
print(run(client, "sed -n '1400,1415p' " + REMOTE))
# 2. 检查 _send 是否真的会跳过默认头
print(" _send 的头部逻辑(895-920):")
print(run(client, "sed -n '895,920p' " + REMOTE))
# 3. 用 python 直接调这份代码的 _send 看结果
probe = r'''
import sys
sys.path.insert(0, "/root/lanremote-relay")
import relay, io
captured = []
class Fake:
def send_response(self, code): captured.append(("__status__", code))
def send_header(self, k, v): captured.append((k, v))
def end_headers(self): pass
wfile = io.BytesIO()
f = Fake()
relay.Handler._send(f, 200, b"hi", "text/html; charset=utf-8",
{"Cache-Control": "public, max-age=60"})
cc = [v for k, v in captured if k.lower() == "cache-control"]
print(" _send 发出的 Cache-Control 值:", cc)
print(" 头数量:", len(cc))
'''
Path("/tmp/probe_send.py").write_text(probe, encoding="utf-8")
client.exec_command("cat > /tmp/probe_send.py <<'PYEOF'\n"
+ probe + "\nPYEOF\n")[1].channel.recv_exit_status()
print(" _send 实测:")
print(run(client, "python3 /tmp/probe_send.py 2>&1"))
# 4. 看 do_GET 里 "/" 前面有没有别的 return
print(" do_GET 的 return 位置(1174-1412):")
print(run(client, "awk 'NR>=1174 && NR<=1412 && "
"/return|if path/ {print NR\": \"$0}' " + REMOTE
+ " | head -40"))
finally:
client.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
r'''最小复现:只解压两个 zip,看进程会不会被杀。
线索:test_package_remote.py 有时跑 1.6 秒、只打印一行"一、解压"就没了,
退出码 -1。1.6 秒正好是解压 18.85 MB / 1001 个文件的时间,
所以要确认"是不是解压这一步出的事"。
每一步都 flush 打印,这样即使被杀也能看到死在哪一行。
'''
from __future__ import annotations
import shutil
import sys
import tempfile
import time
import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
AGENT = ROOT / "dist" / "ykxmz-agent-1.8.2.zip"
SERVER = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
def say(msg: str) -> None:
print(" %s" % msg, flush=True)
def main() -> int:
work = Path(tempfile.mkdtemp(prefix="dbg_extract_"))
say("工作目录 %s" % work)
for label, path in (("被控端", AGENT), ("服务器端", SERVER)):
say("准备解压 %s (%s)" % (label, path.name))
say(" 文件存在: %s 大小 %.2f MB"
% (path.is_file(), path.stat().st_size / 1024 / 1024))
t0 = time.time()
with zipfile.ZipFile(path) as z:
names = z.namelist()
say(" 条目数 %d" % len(names))
for i, name in enumerate(names):
z.extract(name, work)
if i % 200 == 0:
say(" ... 已解压 %d/%d(%.1fs)"
% (i, len(names), time.time() - t0))
say(" %s 解压完成,用了 %.1f 秒" % (label, time.time() - t0))
say("全部解压完成")
agent_dir = work / AGENT.stem
exe = agent_dir / "ykxmz.exe"
say("ykxmz.exe 在不在: %s" % exe.is_file())
if exe.is_file():
say(" exe 大小 %.2f MB" % (exe.stat().st_size / 1024 / 1024))
say("存活检查:睡 3 秒")
time.sleep(3)
say("还活着 ✓")
shutil.rmtree(work, ignore_errors=True)
say("清理完成,正常退出")
return 0
if __name__ == "__main__":
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More