diff --git a/README.md b/README.md index 1078290..e99b641 100644 --- a/README.md +++ b/README.md @@ -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,12 +92,15 @@ 点确定,屏幕会黑闪一下(在加防火墙规则),然后窗口消失,程序转到后台跑。 > **窗口关了程序还在跑**,这是故意的 —— 上课时讲台上不该有个窗口杵着。 -> 想再打开窗口,**再双击一次 EXE** 就行,不会起第二个进程。 +> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。 > 想彻底退出,窗口上点「退出」。 +> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版: +> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。 + ### 第二步:手表上装 App -把 `ykxmz-watch-1.5.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 +把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 打开 App,首页有四个入口: @@ -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. 跨网络的话,把你那台中转站的公网地址发过来(不用给任何密码) --- diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md index 1d8b18e..e19aaf5 100644 --- a/_build/CHANGELOG.md +++ b/_build/CHANGELOG.md @@ -6,12 +6,14 @@ 现在改走边缘缓存:同一个文件实测从 **17 KB/s 提到 695 KB/s**, APK 从 3 KB/s 提到 101 KB/s,并且不再消耗 4G 流量。 -- **跨网络连接更稳**:隧道改成**两种协议同时跑** - (QUIC 5 条 + HTTP/2 5 条,共 10 条连接)。 - 两种协议各有各的毛病 —— QUIC 尾部延迟好但建连容易超时, - HTTP/2 建连可靠但丢包时容易卡住。同时跑就是互相兜底: - 一边犯病另一边还在服务。 - 实测 25 分钟连续可用率 **50/50 = 100%**(改之前是 96~98%)。 +- **跨网络连接更稳**:隧道改成**两种协议同时跑**,一共 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 完全不受保护,包一大就被中间设备悄悄丢掉。 diff --git a/_build/_adv_relay_result.txt b/_build/_adv_relay_result.txt new file mode 100644 index 0000000..3187679 --- /dev/null +++ b/_build/_adv_relay_result.txt @@ -0,0 +1,2 @@ +passed=34 failed=0 + diff --git a/_build/_adv_relay_result2.txt b/_build/_adv_relay_result2.txt new file mode 100644 index 0000000..5d5dd7b --- /dev/null +++ b/_build/_adv_relay_result2.txt @@ -0,0 +1,2 @@ +passed=18 failed=0 + diff --git a/_build/_b.txt b/_build/_b.txt new file mode 100644 index 0000000..e87bf5e --- /dev/null +++ b/_build/_b.txt @@ -0,0 +1,35 @@ +═══ 1. 默认中转地址确实是空 ═══ + [PASS] DEFAULT_RELAY_URL 是空白 + +═══ 2. RelayLink.start('') 的行为 ═══ + [PASS] start('') 返回 False + [PASS] start('') 没有新建线程 + [PASS] start('') 之后 candidates 是空的 + +═══ 3. enable_relay 连续调用两次的返回值 ═══ + 第 1 次 enable_relay() -> False + 第 2 次 enable_relay() -> False + 日志: ['没配中转地址,只走局域网(需要跨网络时在设置里填中转地址)', '没配置中转地址,跳过', '没配中转地址,只走局域网(需要跨网络时在设置里填中转地址)'] + [PASS] 第 1 次调用返回 False(没有中转可连) + [PASS] 第 2 次调用也应返回 False + +═══ 4. 这个返回值会影响谁 ═══ + server.py 里调用 enable_relay 的地方: + 160: # 顺序很重要:enable_relay() 会挨个试中转地址, + 185: self.enable_relay() + + 全仓库搜索 enable_relay 的调用点: + windows\lanremote\relay.py:317: 对外给 server.enable_relay() 判断用 —— + windows\lanremote\server.py:160: # 顺序很重要:enable_relay() 会挨个试中转地址, + windows\lanremote\server.py:185: self.enable_relay() + windows\lanremote\server.py:308: def enable_relay(self, url: str = "") -> bool: + +═══ 5. 空白默认下局域网发现仍可用吗 ═══ + [PASS] discovery 模块能导入(不依赖 DEFAULT_RELAY_URL) + [PASS] discover() 能跑完不抛异常 + [PASS] discover() 返回的是列表 + · 发现到 0 台设备(本机没有 agent 在跑,0 台是正常的) + +============================================================== + 通过 9 项,失败 0 项 +============================================================== diff --git a/_build/_cmsg.txt b/_build/_cmsg.txt deleted file mode 100644 index 0b8e50f..0000000 --- a/_build/_cmsg.txt +++ /dev/null @@ -1,47 +0,0 @@ -管理页新增:允许/禁止新设备接入 + 修改设备配对码 - -== 功能一:允许新设备接入开关 == - -关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。 -状态存在 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 阶段。 diff --git a/_build/_cmsg4.txt b/_build/_cmsg4.txt new file mode 100644 index 0000000..3df3d53 --- /dev/null +++ b/_build/_cmsg4.txt @@ -0,0 +1,164 @@ +修「经中转几次后永久哑掉」——「按一下没反应」的真因 + +== 现象 == + +用户报: + * 局域网能搜到电脑,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 就是字符串里的裸换行 -> + 整段 ", + "lanIps": [], "port": 0, "version": "1.8.2", + "online": True, "idleSeconds": 1, "sessions": 0, + "queuedToClient": 0, "queuedToAgent": 0, + "bytesIn": 0, "bytesOut": 0}, + ] + snap4["codeCount"] = 1 + html4 = admin.render_admin(snap4, "https://x", "/zhongzhuanzhan", 1, + lambda: []) + check("设备名里的 " not in html4, + "发现原始 ", html, re.S) + if not m: + print(" ✗ 产物里没有 ", None), + ("引号", "dev\"'`", None), + ("路径分隔符", "../../etc/passwd", None), + ("Unicode", "设备-😀-ünïcode", None), + ("只有空格", " ", False), + ("空字符串", "", False), + ] + for label, bad, expect in cases: + r = reg(bad, fresh_code()) + got = r.get("ok") is True + if expect is None: + # 未定义期望:只记录实际行为,不判对错 + verdict = "接受" if got else "拒绝" + print(f" · {label:18s} -> {verdict}" + + ("" if got else f"({r.get('error','')})")) + if got and (" 标签(未转义的 deviceId)", + raw_img not in page, + "发现原始标签 -> 存储型 XSS") + check("管理页没有原始