7 Commits
Author SHA1 Message Date
xmz7891 5e769928f9 1.6.0:重写 README,面向老师 / 电教 / 开发者 / 普通用户
README
- 原来的 README 是开发者视角(在解释我为什么没做某功能),
  而且通篇 AI 腔。这次按用户视角重写,讲怎么用。
- 开头就分流:老师 / 电教 / 开发者 / 普通用户,各看各的
- 定位改成教室电脑手表遥控,主场景是老师上课管教室那台电脑
- 加了电教专用一节:机房同传部署、脚本批量推、网络规划、权限收口
- 加了开发者一节:三端怎么编译、怎么跑测试、怎么打包
- 加了通信协议一节
- 常见问题、排错顺序、错误对照表都补全

版本号
- 1.5.0 -> 1.6.0(versionCode 107)
- 顺手写了通用的版本升级脚本 _build/bump_version_generic.py,
  以后升版本一条命令就行,不用手改十几个文件
2026-10-06 20:20:11 +08:00
xmz7891 b3630a0107 中转站加下载区 + 大文件流式发送
下载区
- 管理页加一张「下载最新版」卡片,就一个按钮
  自动指向 /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 19:21:34 +08:00
xmz7891 e89ff18e4c chore: 排除 cloudflared 二进制与测试临时数据 2026-10-06 19:09:06 +08:00
xmz7891 1ada27ef11 chore: 排除 PyInstaller / Android 构建中间产物 2026-10-06 19:08:44 +08:00
xmz7891 7f267dd900 ykxmz 1.5.0锛氭墜琛ㄧ TLS 璇佷功鍏煎 + 涓夌鍒嗗紑鎵撳寘 + 涓嬭浇鍖?
鎵嬭〃绔?- 鍐呯疆 GTS Root R4 / GlobalSign Root CA
  淇?SSLHandshakeException: Chain validation failed
  锛堝粔浠锋墜琛ㄧ郴缁熸牴璇佷功搴撳父缂?GTS Root R4锛?- 杩炴帴閿欒缈昏瘧鎴愪汉璇濓細璇佷功 / 鏃堕棿 / 瑙f瀽 / 瓒呮椂鍒嗗埆缁欐彁绀?- 缁忎腑杞繛涓婂悗鐢ㄦ湇鍔″櫒鎶ョ殑鍦板潃鍒锋柊宸查厤瀵硅褰曪紙鍘熸潵涓€鐩存樉绀烘棫 IP锛?- 鐗堟湰鍙?1.3.0 -> 1.5.0锛圓ndroidManifest 涔嬪墠婕忔敼浜嗭級

鏈嶅姟鍣ㄧ
- 鏀寔 chunked 璇锋眰浣擄細淇瘡 16 绉掍竴娆$殑杩炴帴閿欎贡涓庡ぇ闈㈢Н 502
- 缃戠粶璋冧紭 net-tune.sh锛氬す MSS 1400 + 璺緞 MTU 鎺㈡祴
  锛堝叕缃戝彲鐢ㄧ巼 48% -> 100%锛屽疄娴?15 鍒嗛挓 180/180锛?  鍋氭垚寮€鏈烘湇鍔?lanremote-nettune.service锛屽苟浼氳嚜鍔ㄩ噸寤洪毀閬?- 鏂板涓嬭浇鍖猴細GET /v1/downloads 鍒楄〃 + GET /download/<鏂囦欢>
  鍙厑璁哥櫧鍚嶅崟鍚庣紑銆佷弗鏍兼牎楠屾枃浠跺悕锛堟尅鐩綍绌胯秺锛?  绠$悊椤靛姞涓嬭浇鍗$墖
- 闅ч亾鏀?HTTP/2 + 鍙岃繛鎺ワ紙QUIC 鍦ㄨ繖鏉$嚎璺笂鍙敤鐜囧彧鏈?48%锛?- 缃戠粶浣撴銆佹壒閲忓垹闄よ澶?
鎵撳寘鏂瑰紡鍙樻洿
- 鍘熸潵鏄笁涓儴鍒嗗悎鎴愪竴涓?zip锛岀幇鍦?*鍒嗘垚涓変釜鐙珛浜х墿**锛?    ykxmz-agent-1.5.0.zip    琚帶绔?    ykxmz-server-1.5.0.zip   鏈嶅姟鍣ㄧ
    ykxmz-watch-1.5.0.apk    鎺у埗绔紙鐩存帴缁?APK锛屼笉鎵?zip锛?  涓変釜鍖?鏂囦欢閮藉甫涓婅鍙崗璁?/ 瀹夎鍓嶅繀璇?/ 瀹夎鍚庨』鐭?
鏂囨。
- 璁稿彲鍗忚 / 瀹夎鍓嶅繀璇?/ 瀹夎鍚庨』鐭ワ紙浣滆€咃細灏忛害瀛愶級
2026-10-06 19:08:02 +08:00
xmz7891 5b424c39c0 chore: 排除 dist/ 构建产物(改用 release 附件分发) 2026-10-06 19:01:39 +08:00
xmz7891 b705216e4a ykxmz 1.5.0锛氫慨澶嶆墜琛ㄧ TLS 璇佷功鏍¢獙 + MTU 淇缃戠粶绋冲畾鎬?
琚帶绔?- 淇 MTU 闂锛氬す MSS + 璺緞 MTU 鎺㈡祴锛堝叕缃戝彲鐢ㄧ巼 48% -> 100%锛?- 澶嶇敤 HTTP 杩炴帴锛堝厤鎺夋瘡娆℃柊寤?TLS 鐨?30% 鎻℃墜澶辫触锛?- 姣忓垎閽熷洖澶存壘灞€鍩熺綉锛岃嚜鍔ㄥ垏鍥炲揩 400 鍊嶇殑璺緞
- 鐢婚潰蹇冭烦甯э細闈欐鏃剁渷娴侀噺锛屽鎴风浠嶇煡閬撴祦鏄椿鐨?- 閿佸睆鏃剁粰鍑哄彲鎿嶄綔鐨勬彁绀?
鎵嬭〃绔?- 鍐呯疆 GTS Root R4 / GlobalSign Root CA锛屼慨 SSLHandshakeException
- 杩炴帴閿欒缈昏瘧鎴愪汉璇濓紙璇佷功/鏃堕棿/瑙f瀽/瓒呮椂鍒嗗埆鎻愮ず锛?- 缁忎腑杞繛涓婂悗鐢ㄦ湇鍔″櫒鎶ョ殑鍦板潃鍒锋柊宸查厤瀵硅褰?- 鏂板灞€鍩熺綉杩炴帴椤甸潰銆佹湇鍔″櫒寤惰繜妫€娴?
鏈嶅姟鍣ㄧ
- 鏀寔 chunked 璇锋眰浣擄紙淇瘡 16 绉掍竴娆$殑杩炴帴閿欎贡涓庡ぇ闈㈢Н 502锛?- 缃戠粶璋冧紭鑴氭湰 net-tune.sh锛堝紑鏈鸿嚜鍔ㄥ簲鐢級
- 绠$悊椤碉細鎵归噺鍒犻櫎銆佺綉缁滀綋妫€銆佷笅杞藉尯
- 闅ч亾鏀?HTTP/2 + 鍙岃繛鎺?
鏂囨。
- 璁稿彲鍗忚 / 瀹夎鍓嶅繀璇?/ 瀹夎鍚庨』鐭ワ紙浣滆€咃細灏忛害瀛愶級
2026-10-06 19:01:05 +08:00
697 changed files with 4332 additions and 60556 deletions
-20
View File
@@ -59,23 +59,3 @@ desktop.ini
# ---- 测试收发的临时数据 ---- # ---- 测试收发的临时数据 ----
/_tests/received/ /_tests/received/
# 令牌(绝对不要进仓库)
/_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
-681
View File
@@ -1,681 +0,0 @@
ykxmz —— 教室多媒体辅助工具
Copyright (C) 2026 小麦子
本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的
GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。
本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或
特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。
你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本
(见本文件下半部分)。如果没有,见 <https://www.gnu.org/licenses/>。
--------------------------------------------------------------------------
⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》):
本软件是一套远程控制工具,禁止用于未经同意的他人设备、
禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**,
和本文件(约束**代码**的授权)互不冲突。
--------------------------------------------------------------------------
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/>.
+30 -142
View File
@@ -1,10 +1,10 @@
# ykxmz · 教室电脑手表控制 # ykxmz · 教室电脑手表遥控
老师在讲台上抬一下手腕,就能把教室里那台电脑管了。 老师在讲台上抬一下手腕,就能把教室里那台电脑管了。
放课件、放音频、投个图、看看有没有在跑无关程序、下课关机 —— 不用走回讲台去点鼠标。 放课件、放音频、投个图、看看谁在打游戏、下课关机 —— 不用走回讲台去点鼠标。
> 名字来自"控制手表"的拼音首字母,读作"控制拇指"也行。 > 名字来自"遥控手表"的拼音首字母,读作"遥控拇指"也行。
--- ---
@@ -23,7 +23,7 @@
## 这东西是干嘛的 ## 这东西是干嘛的
一句话:**用手表控制电脑。** 一句话:**用手表遥控电脑。**
为什么用手表而不是手机?因为老师上课时手上是空的,手机在兜里,掏手机的动作比抬手大得多。手表抬一下就行,还不打断讲课。 为什么用手表而不是手机?因为老师上课时手上是空的,手机在兜里,掏手机的动作比抬手大得多。手表抬一下就行,还不打断讲课。
@@ -32,7 +32,7 @@
- 课件全屏投到教室大屏 - 课件全屏投到教室大屏
- 放听力音频(电脑音箱比手表响多了) - 放听力音频(电脑音箱比手表响多了)
- 看看学生机是不是在打游戏,直接把游戏进程结了 - 看看学生机是不是在打游戏,直接把游戏进程结了
- 课堂专注模式,下课一键恢复 - 屏蔽游戏网站,下课一键恢复
- 下课关机、锁屏 - 下课关机、锁屏
- 喊一声"上课了"让电脑播音(TTS) - 喊一声"上课了"让电脑播音(TTS)
@@ -46,35 +46,13 @@
| 装在哪 | 是什么 | 文件 | | 装在哪 | 是什么 | 文件 |
|---|---|---| |---|---|---|
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-1.8.2.exe` | | 教室电脑(Windows) | 被控端 | `ykxmz-agent-1.5.0.zip` |
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.8.2.zip` | | 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.5.0.zip` |
| 手表(安卓) | 控制端 | `ykxmz-watch-1.8.2.apk` | | 手表(安卓) | 控制端 | `ykxmz-watch-1.5.0.apk` |
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。 **只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
**要在办公室管教室?** 那必须**自己部署一台中转站**(见 [跨网络](#跨网络在办公室管教室))。 **要在办公室管教室?** 那必须装中转站(见 [跨网络](#跨网络在办公室管教室))。
> ### ⚠️ 软件里不带任何中转地址
>
> 这一点先讲清楚,免得白折腾:
>
> **这个软件默认是"空的" —— 没有任何内置的中转服务器地址,
> 也没有官方服务器可以用,作者不对外提供中转服务。**
>
> - **同一个 WiFi 下用(教室里)**:不需要中转,装完就能连,什么地址都不用填。
> - **要跨网络用(办公室管教室)**:**你得自己部署一台中转站**。
> 服务端压缩包里有一键脚本,一条命令装好:
>
> ```bash
> bash deploy.sh
> ```
>
> 装完它会打印一个属于你自己的公网地址,把它填到电脑和手表上。
> 详见 [装中转站](#装中转站)。
>
> 为什么不做一个"填个公共地址就能用"?因为中转站的带宽是实打实的成本,
> 谁用谁出;而且**中转站能看到经过它的字节流**,
> 自己搭一台,数据才真正只在你自己手里。
--- ---
@@ -82,9 +60,9 @@
### 第一步:电脑上装被控端 ### 第一步:电脑上装被控端
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。 解压 `ykxmz-agent-1.5.0.zip`,双击里面的 `ykxmz.exe`。
第一次运行会弹出窗口,填两样: 弹出的窗口里填两样:
- **6 位数字** —— 自己定,好记就行,比如 `789113` - **6 位数字** —— 自己定,好记就行,比如 `789113`
- 两个勾:`设置开机自启`、`放行防火墙`(放行那个要过一次管理员确认) - 两个勾:`设置开机自启`、`放行防火墙`(放行那个要过一次管理员确认)
@@ -92,21 +70,18 @@
点确定,屏幕会黑闪一下(在加防火墙规则),然后窗口消失,程序转到后台跑。 点确定,屏幕会黑闪一下(在加防火墙规则),然后窗口消失,程序转到后台跑。
> **窗口关了程序还在跑**,这是故意的 —— 上课时讲台上不该有个窗口杵着。 > **窗口关了程序还在跑**,这是故意的 —— 上课时讲台上不该有个窗口杵着。
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。 > 想再打开窗口,**再双击一次 EXE** 就行,不会起第二个进程。
> 想彻底退出,窗口上点「退出」。 > 想彻底退出,窗口上点「退出」。
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
### 第二步:手表上装 App ### 第二步:手表上装 App
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 把 `ykxmz-watch-1.5.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
打开 App,首页有四个入口: 打开 App,首页有四个入口:
``` ```
┌──────────────────────────┐ ┌──────────────────────────┐
│ 控制手表 │ │ 遥控手表 │
│ 和电脑连同一个 WiFi,不用输 IP │ │ 和电脑连同一个 WiFi,不用输 IP │
├──────────────────────────┤ ├──────────────────────────┤
│ 中转设置(跨网络用) │ ← 办公室管教室才需要 │ 中转设置(跨网络用) │ ← 办公室管教室才需要
@@ -142,8 +117,8 @@
| 按钮 | 干什么 | | 按钮 | 干什么 |
|---|---| |---|---|
| **课堂专注模式** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 | | **屏蔽游戏网站** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
| **关闭游戏窗口** | 扫一遍常见游戏进程名,找到就结束掉 | | **结束游戏进程** | 扫一遍常见游戏进程名,找到就结束掉 |
| **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 | | **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 |
| **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** | | **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** |
@@ -213,61 +188,19 @@
### 装中转站 ### 装中转站
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行, 把 `ykxmz-server-1.5.0.zip` 解压到那台小机器上(随身 WiFi、树莓派、云主机都行,Debian/Ubuntu 系统),然后:
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
把 `ykxmz-server-1.8.2.zip` 解压到那台机器上,然后一条命令:
```bash ```bash
bash deploy.sh sudo bash install.sh
``` ```
它会自己做完这些,不用你一步步敲: 装完会打印一个公网地址,长这样:
1. 装 cloudflared(已经装了就用现成的)
2. 把中转程序装到 `/root/lanremote-relay/`
3. 网络调优(keepalive、MSS、MTU、开机加速)
4. 注册 systemd 服务,设成开机自启
5. 配好 Cloudflare 隧道
6. 启动、自检,然后把**公网地址**打印出来
装完会打印一个属于你自己的公网地址,长这样:
``` ```
https://relay.你的域名 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 第三方包 —— 服务端是纯标准库,不装任何依赖
### 告诉电脑中转地址 ### 告诉电脑中转地址
@@ -279,34 +212,19 @@ ykxmz.exe --relay "192.168.1.50:8080;https://relay.你的域名"
两个地址用分号隔开,**按顺序试,第一个通的就用**。这样在教室里走局域网(快、稳),出了学校自动切隧道。 两个地址用分号隔开,**按顺序试,第一个通的就用**。这样在教室里走局域网(快、稳),出了学校自动切隧道。
如果只在跨网络的场景用,也可以只填一个域名:
```
ykxmz.exe --relay https://relay.你的域名
```
看看当前用的是哪个地址:
```
ykxmz.exe --relay-status
```
### 告诉手表中转地址 ### 告诉手表中转地址
手表首页点「中转设置」,填上面同一个地址。 手表首页点「中转设置」,填上面同一个地址。
然后回家首页点「输入配对码连接」,填电脑上那 6 位数字。 然后回家首页点「输入配对码连接」,填电脑上那 6 位数字。
### 中转站的公网地址打开是什么 ### 中转站怎么管
打开就是**一个普通的官网页面** —— 介绍、截图、三个文件的下载按钮, 浏览器打开中转站的公网地址,用管理密码登录(默认 `120830`,装完后强烈建议改)。
最新版的安装包挂在上面,点一下就能下。
安装包和截图这类静态文件**由 Cloudflare 的边缘节点直接发送**, 能看:哪些电脑在线、配对码是什么、有多少会话、内存用了多少。
所以下载速度和那台小机器的带宽无关(实测比走隧道回源快几十倍)。 能做:踢掉某个会话、删掉某个配对码绑定、跑个网络体检。
下载区:最新版的三个文件都挂在上面,点一下就能下。
想看运行状态、排查问题、重启服务,**看服务端压缩包里的
`README-server.txt`** —— 那份文档是给运维的人看的,里面写了日常命令。
--- ---
@@ -446,7 +364,7 @@ windows\build_exe.ps1
```powershell ```powershell
android\build_apk.ps1 android\build_apk.ps1
# 流程:aapt2 → javac → d8 → zipalign → apksigner # 流程:aapt2 → javac → d8 → zipalign → apksigner
# 产物:dist\ykxmz-watch-1.8.2.apk # 产物:dist\ykxmz-watch-1.5.0.apk
``` ```
最低支持 Android 7.0(API 24),targetSdk 34。 最低支持 Android 7.0(API 24),targetSdk 34。
@@ -456,13 +374,9 @@ android\build_apk.ps1
Python 3 标准库,没有第三方包。 Python 3 标准库,没有第三方包。
```bash ```bash
bash relay/deploy.sh bash relay/install.sh
``` ```
`deploy.sh` 是一键部署(装服务 + 调优 + 配隧道 + 自检);
只想调网络参数的话是 `relay/deploy-tune.sh`。
老的 `relay/install.sh`(匿名快速隧道)仍在包里,只为兼容以前装过的机器。
### 跑测试 ### 跑测试
```powershell ```powershell
@@ -494,22 +408,7 @@ python _build\package_remote.py
二进制数据(图片、文件、音频)走 **Base64** 分块传输,单次会话上限 256 MB。 二进制数据(图片、文件、音频)走 **Base64** 分块传输,单次会话上限 256 MB。
跨网络时同样的消息套在 **HTTP 长轮询**里(单次挂 **60 秒**, 跨网络时同样的消息套在 HTTP 长轮询里,中转站按配对码把两端字节对接,**它不解析内容**。
有数据立刻返回、不空等),中转站按配对码把两端字节对接,**它不解析内容**。
跨网络这条链路上还有几个实测调出来的设计,改代码前值得知道:
- **隧道两种协议同时跑**(QUIC + HTTP/2,合计 10 条连接)。
两种协议各有各的毛病 —— QUIC 尾部延迟好但建连容易超时,
HTTP/2 建连可靠但丢包时容易被队头阻塞卡住。
同时跑就是互相兜底:一种犯病,另一种还在服务。
- **长轮询 60 秒**。隧道每次请求要多花 1~2 秒,这个开销是按次数收的;
拉到 60 秒把请求数降到 1/4,撞上丢包窗口的概率也降到 1/4。
**命令响应速度不变**(有数据立刻返回,不是等满 60 秒)。
- **偶发失败不当场判离线**。4G 链路上本来就有百分之几的偶发抖动、
一两秒自己恢复;连续失败 3 次才算真的离线。
- **静态资源交给 Cloudflare 边缘缓存**。下载走边缘节点,
不用穿隧道回那台小机器,快几十倍也更省流量。
Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相打通的测试。 Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相打通的测试。
@@ -530,18 +429,7 @@ Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相
不会。只要 `data/` 目录还在,服务器就认原来那个码。母盘部署时注意别把 `data/` 一起发出去。 不会。只要 `data/` 目录还在,服务器就认原来那个码。母盘部署时注意别把 `data/` 一起发出去。
**问:走中转会不会很慢?** **问:走中转会不会很慢?**
实测局域网 3 毫秒左右;跨网络要绕到中转站再回来,约 1~2 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。 实测局域网 3 毫秒左右,公网中转 1~3 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。
**问:必须自己搭中转站吗?能用作者的服务器吗?**
必须自己搭。**软件不内置任何中转地址,作者也不对外提供中转服务。**
跨网络的话,服务端 zip 里有 `deploy.sh`,一条命令装好(见 [装中转站](#装中转站))。
只在同一个 WiFi 下用的话,压根不需要中转站。
**问:中转站会不会看到我的数据?**
它转发的是加密前的字节流,所以**技术上能**。这正是"自己搭一台"的理由 —— 搭在你自己手上,数据就只经过你自己的机器。
**问:下载安装包为什么比以前快这么多?**
最新版把静态文件交给 Cloudflare 的边缘节点缓存了。原来每次下载都要穿过隧道回到那台小机器上取,实测只有几 KB/s,15 MB 的安装包根本下不动;现在从离你最近的边缘节点直接发,实测快了四十多倍,也不占小机器的流量。
**问:能在手机上用吗?** **问:能在手机上用吗?**
协议是通的,但界面是按手表的小屏幕设计的(大按钮、两列、可滚动),手机上会显得按钮特别大。要做手机版得重写布局。 协议是通的,但界面是按手表的小屏幕设计的(大按钮、两列、可滚动),手机上会显得按钮特别大。要做手机版得重写布局。
@@ -559,7 +447,7 @@ Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相
1. 手表上显示的那句错误(App 现在是中文的,直接念出来就行) 1. 手表上显示的那句错误(App 现在是中文的,直接念出来就行)
2. 电脑上 `工具箱.bat` → 选 `5` 的日志内容 2. 电脑上 `工具箱.bat` → 选 `5` 的日志内容
3. 跨网络的话,把你那台中转站的公网地址发过来(不用给任何密码) 3. 中转站的公网地址(不用给密码)
--- ---
-229
View File
@@ -1,229 +0,0 @@
## 2.1.0
- **网站挂了会自己救**:中转服务器上常驻一个监测单元,每 60 秒从**公网**
查一次(不是查本机 —— 本机通不代表用户能打开)。连续 3 次不通就按阶梯
自动抢救:
| 级 | 动作 | 代价 |
|---|---|---|
| 1 | 重启隧道 | 不动中转,在线设备不掉线 |
| 2 | 重启中转服务 | 设备要重连,但很快 |
| 3 | 重新施加网络调优 | MSS / MTU 跑偏时用 |
| 4 | 重启无线网卡 | 4G / WiFi 卡死时用 |
| 5 | 重启整机 | 最后的底牌,**默认不开** |
每做完一级立刻复验,好了就停。救完 10 分钟内不再动手(防抖 ——
否则预案刚重启完隧道还没连上,下一次检查又救一遍,
来回重启反而永远连不上)。
- **网站出问题会自动留下诊断报告**:万一五级都没救回来,服务端会把现场
(内存、进程、隧道连接数、路由和 MTU、三份日志尾部、内核异常)
整理成一份 txt 留在服务端,需要排查时直接取来看就行。
只保留最近 30 份,不会把设备塞满。
每次自动抢救成功,也会把「第几级奏效」记进知识库,
下次同类问题优先试历史上管用的那一级。
- **网络热门音效**:手表上新增「音效」按钮,点一下电脑就响。
内置 8 个(冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 / 警报 / 鼓点 / 弹簧)。
> 说明一下**版权**:网上那些「冰冰冰」「啊哈哈哈」是有版权的真实录音,
> 不能打包进安装包。所以内置的是**程序合成**的版本(正弦/方波现场算),
> 名字对着感觉来,但不是那些录音本身。
> 想要一模一样的一手素材,把 mp3/wav 丢进电脑的 `data\sfx\` 目录,
> 点「刷新」就出现在列表里,照样能远程播。目录里放了一份说明。
- **修「安卓设置页一片空白」**:设置页建好了但**忘了挂到窗口上**
(少了 `root.addView`),`showOnly()` 让它可见也没用 —— 它根本不在
视图树里。已修,并加了断言盯着「每个面板都必须挂到 root、
并且纳入 showOnly()」—— 这类漏挂编译不报错、只在运行时表现为空白。
- **全量测试改成后台静默**:会开窗口的 6 个阶段(含开**全屏**那个)
现在跑在独立的 Windows 隐藏桌面上 —— 窗口是**真的**(全屏、可见、
几何、Win32 枚举都真实),但屏幕上什么都看不到,也不会锁你的屏。
顺带修了一个一直误判的检测:判断「锁屏」时只看 `OpenInputDesktop`
成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。
所以截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了。
改成看输入桌面的**名字**。现在锁屏也能跑全量:该 SKIP 的 SKIP,
该跑的照跑。
## 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
@@ -1,87 +0,0 @@
# 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
@@ -1,2 +0,0 @@
passed=34 failed=0
-2
View File
@@ -1,2 +0,0 @@
passed=18 failed=0
-1
View File
@@ -1 +0,0 @@
gBp85DSPF9vKr12jTb6XIPt3
-8
View File
@@ -1,8 +0,0 @@
{
"lantest": {
"name": "测试手表",
"token": "6ec9b2878357045a389acd97bab84871",
"paired_at": 1791303182.5310695,
"last_seen": 1791303182.5336223
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"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
@@ -1,8 +0,0 @@
{
"watch-reinstalled": {
"name": "手表",
"token": "45be12c203e6d66d5a042091e9ba5900",
"paired_at": 1791299138.656342,
"last_seen": 1791299138.656342
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"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
@@ -1,2 +0,0 @@
@echo off␍
"C:\Program Files\ykxmz\unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
-26
View File
@@ -1,26 +0,0 @@
{
"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
@@ -1,26 +0,0 @@
{
"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
@@ -1,158 +0,0 @@
============================================================
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
@@ -0,0 +1,16 @@
[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
@@ -1,8 +0,0 @@
{
"watch-probe-1": {
"name": "手表",
"token": "79ce7c72c1ad0526a6efe153375cc22c",
"paired_at": 1791298726.377039,
"last_seen": 1791298726.377039
}
}
-29
View File
@@ -1,29 +0,0 @@
{
"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"
}
View File
View File
-178
View File
@@ -1,178 +0,0 @@
#!/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
@@ -1,127 +0,0 @@
#!/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
@@ -1,107 +0,0 @@
#!/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
@@ -1,127 +0,0 @@
#!/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
@@ -1,150 +0,0 @@
#!/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
@@ -1,133 +0,0 @@
#!/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
@@ -1,133 +0,0 @@
#!/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
@@ -1,72 +0,0 @@
#!/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
@@ -1,101 +0,0 @@
#!/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
@@ -1,69 +0,0 @@
#!/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
@@ -1,75 +0,0 @@
#!/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
@@ -1,200 +0,0 @@
# -*- 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
@@ -1,183 +0,0 @@
# -*- 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
@@ -1,112 +0,0 @@
# -*- 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
@@ -1,112 +0,0 @@
# -*- 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
@@ -1,126 +0,0 @@
# -*- 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
@@ -1,206 +0,0 @@
# -*- 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
@@ -1,105 +0,0 @@
# -*- 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
@@ -1,106 +0,0 @@
# -*- 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
@@ -1,143 +0,0 @@
# -*- 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())
-135
View File
@@ -1,135 +0,0 @@
# -*- coding: utf-8 -*-
r'''让 deploy.sh 在装新机器时把「监测单元」也装上。
== 为什么必须做这一步 ==
监测单元是我新加的:monitor.sh 每 60 秒查一次公网,
挂了就叫 rescue.sh 抢救。但如果 deploy.sh 不认识它,
**新装的机器上监测根本不会启动** —— 而且不会报任何错,
只是静默地不跑。等网站真挂了才发现"预案没触发",那就晚了。
部署脚本里 watchdog 已经是个现成的样板(同样是 timer + oneshot),
照着加一份即可。
== 顺便 ==
deploy.sh 里本来就有"包里有才装"的写法(`if [ -f ... ]`),
这里也沿用 —— 老的服务端 zip(没有 monitor.sh)重跑部署时
不会因为找不到文件而报错。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "relay" / "deploy.sh"
ANCHOR = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true'''
NEW = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
fi
# ---- 网站监测单元(如果包里有)----
#
# 和看门狗的区别很重要:
# 看门狗 看**隧道连接数** —— 本地指标,判断"隧道自己好不好"
# 监测单元 看**公网 HTTP** —— 用户视角,判断"用户能不能打开"
#
# 两个都要。实测过:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200,
# 看门狗也可能觉得没事;只有从公网测才发现 502。
if [ -f "$RELAY_DIR/monitor.sh" ]; then
mkdir -p "$RELAY_DIR/rescue_reports"
chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null || true
if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then
cat > "$RELAY_DIR/rescue_knowledge.md" <<'KEOF'
# 紧急预案知识库
每次自动抢救成功,这里会追加一条「第几级奏效」。
以后遇到同类症状,监测单元会优先试历史上奏效的那一级。
分级(从代价最小到最大):
1 重启隧道 不动中转,在线设备不掉线
2 重启中转服务 设备要重连,但很快
3 重新施加网络调优 MSS / MTU 跑偏时用
4 重启无线网卡 4G / WiFi 卡死时用
5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1)
KEOF
fi
# 包里直接带了单元文件就用包里的(内容随版本更新);
# 没有就现场生成一份等价的 —— 兼容老 zip。
if [ -f "$RELAY_DIR/lanremote-monitor.service" ]; then
cp "$RELAY_DIR/lanremote-monitor.service" "$UNIT_DIR/" 2>/dev/null || true
else
write_unit "$UNIT_DIR/lanremote-monitor.service" <<EOF
[Unit]
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
TimeoutStartSec=360
ExecStart=/bin/bash $RELAY_DIR/monitor.sh
Environment=RESCUE_REBOOT=0
Environment=FAIL_THRESHOLD=3
Environment=COOLDOWN=600
StandardOutput=append:$RELAY_DIR/monitor.log
StandardError=append:$RELAY_DIR/monitor.log
[Install]
WantedBy=multi-user.target
EOF
fi
if [ -f "$RELAY_DIR/lanremote-monitor.timer" ]; then
cp "$RELAY_DIR/lanremote-monitor.timer" "$UNIT_DIR/" 2>/dev/null || true
else
write_unit "$UNIT_DIR/lanremote-monitor.timer" <<'EOF'
[Unit]
Description=每 60 秒检查一次网站是否可用
[Timer]
OnBootSec=90s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=lanremote-monitor.service
[Install]
WantedBy=timers.target
EOF
fi
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable lanremote-monitor.timer >/dev/null 2>&1 || true
systemctl start lanremote-monitor.timer >/dev/null 2>&1 || true
say " 网站监测单元已启用(每 60 秒查一次公网)"'''
def main() -> int:
raw = DEPLOY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if "lanremote-monitor.timer" in text:
print(" · deploy.sh 已经加过了")
return 0
if ANCHOR not in text:
print(" ✗ 找不到锚点")
return 1
if text.count(ANCHOR) != 1:
print(" ! 锚点出现 %d 次" % text.count(ANCHOR))
text = text.replace(ANCHOR, NEW, 1)
# 原来 watchdog 那个 if 块的 fi 会被我们的 NEW 提前收掉,
# 所以要把后面多余的那个 fi 去掉一个。先看看形状。
import re
DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"")
+ text.encode("utf-8"))
print(" ✓ deploy.sh 加了监测单元的安装")
return 0
if __name__ == "__main__":
sys.exit(main())
-55
View File
@@ -1,55 +0,0 @@
# -*- 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())
-261
View File
@@ -1,261 +0,0 @@
# -*- coding: utf-8 -*-
r'''后台加一个「抢救报告」分区。
用户要求:
「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个分区
专门放还有下载这个 txt」
rescue.sh 抢救失败时会往 relay/rescue_reports/ 写一份诊断报告 .txt。
这里让后台能:
* 列出这些报告(时间、大小、一句话结论)
* 点一下下载看内容
== 为什么不复用 /download/ ==
/download/ 是**给用户**的(安装包、APK),带 cf 长缓存
(max-age=604800, immutable)——因为文件名带版本号、内容永不改变。
抢救报告正相反:内容会变、是给管理员看的、不该被边缘缓存。
所以单独开一条 /rescue/<名字>,发 no-store。
'''
from __future__ import annotations
import html
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
ADMIN = ROOT / "relay" / "admin.py"
def patch(path: Path, pairs, label: str):
raw = path.read_bytes()
had_bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
results = []
for old, new, what in pairs:
if old not in text:
results.append(("✗", what, "找不到锚点"))
continue
if text.count(old) != 1:
results.append(("!", what, "锚点 %d 处" % text.count(old)))
text = text.replace(old, new, 1)
results.append(("✓", what, ""))
print(" ── %s ──" % label)
for m, w, why in results:
print(" %s %s%s" % (m, w, (" —— " + why) if why else ""))
if any(m == "✗" for m, _w, _y in results):
return False
path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
+ text.encode("utf-8"))
return True
# ===========================================================================
# relay.py
# ===========================================================================
RELAY_LIST = '''
# 抢救报告目录。rescue.sh 抢救失败时往这里写 .txt 诊断报告。
# 和 downloads/ 分开:那个是给用户的安装包(带长缓存),
# 这个是给管理员看的诊断(必须不缓存)。
RESCUE_DIR = Path(__file__).resolve().parent / "rescue_reports"
RESCUE_KEEP = 30 # 只保留最近 30 份(460MB 的小机器,别塞满)
def _safe_rescue_name(name: str) -> str:
"""校验抢救报告文件名。只允许 <时间戳>.txt 这种形状。"""
name = (name or "").strip()
if not name or len(name) > 80:
return ""
if "/" in name or "\\\\" in name or ".." in name:
return ""
if not name.endswith(".txt"):
return ""
for ch in name:
if ch.isalnum() or ch in "._-":
continue
return ""
return name
def _list_rescue_reports() -> list:
"""列出抢救报告(按时间倒序)。
顺带读每个文件的前几行,抽出结论 —— 后台列表上直接显示,
不用一个个点进去看。
"""
items = []
try:
if not RESCUE_DIR.is_dir():
return items
for entry in sorted(RESCUE_DIR.glob("*.txt"), reverse=True):
if not entry.is_file():
continue
safe = _safe_rescue_name(entry.name)
if not safe:
continue
stat = entry.stat()
summary = ""
try:
with entry.open("r", encoding="utf-8", errors="replace") as fh:
head = fh.read(1200)
for line in head.splitlines():
s = line.strip()
if s.startswith("判断:"):
summary = s.replace("判断:", "").strip()
break
if not summary:
for line in head.splitlines():
s = line.strip()
if s.startswith("公网 ") and "->" in s:
summary = s
break
except OSError:
pass
items.append({
"name": entry.name,
"size": stat.st_size,
"mtime": stat.st_mtime,
"summary": summary[:120],
})
except OSError:
return items
items.sort(key=lambda i: i["mtime"], reverse=True)
return items[:RESCUE_KEEP]
'''
ADMIN_CALL = ''' admin_path=ADMIN_PATH,'''
ADMIN_CALL_NEW = ''' admin_path=ADMIN_PATH,
rescue_fn=_list_rescue_reports,'''
# 下载路由(GET):插在 /download/ 之前
RESCUE_ROUTE_ANCHOR = ''' if path.startswith("/download/"):
wanted = urllib.parse.unquote(path[len("/download/"):])
safe = _safe_download_name(wanted)'''
RESCUE_ROUTE = ''' if path.startswith("/rescue/"):
# 抢救报告下载。单独一条路由,**不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
wanted = urllib.parse.unquote(path[len("/rescue/"):])
safe = _safe_rescue_name(wanted)
if not safe:
self._json(400, {"ok": False, "error": "文件名不合法"})
return
target = RESCUE_DIR / safe
if not target.is_file():
self._json(404, {"ok": False, "error": "没有这份报告"})
return
try:
body = target.read_bytes()
except OSError as exc:
self._json(500, {"ok": False, "error": f"读不到: {exc}"})
return
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
quoted = urllib.parse.quote(safe)
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quoted}")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
'''
# ===========================================================================
# admin.py
# ===========================================================================
ADMIN_SIG = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int,
downloads_fn) -> str:'''
ADMIN_SIG_NEW = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int,
downloads_fn, rescue_fn=None) -> str:'''
ADMIN_CARD_ANCHOR = ''' <div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody">%(dl)s</div>
</div>
</div>'''
ADMIN_CARD = ''' <div class="tool">
<h4>下载区</h4>
<div class="d">官网下载按钮指向这里的文件。</div>
<div id="dlBody">%(dl)s</div>
</div>
<div class="tool">
<h4>抢救报告</h4>
<div class="d">网站挂了、自动预案也没救回来时,机器会把现场
打包成一份 txt 放在这里。点开看看是哪一段断了。</div>
<div id="rescueBody">%(rescue)s</div>
</div>
</div>'''
def _rescue_rows(items: list) -> str:
"""抢救报告列表的 HTML。照 dl_rows 的风格(页面里直接渲染)。"""
if not items:
return ('<div class="out">还没有报告 —— 说明网站一直没出过'
'「自动救不回来」的问题。</div>')
import time as _t
import urllib.parse as _up
rows = []
for it in items:
when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"]))
size = it["size"] / 1024
size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"])
summary = html.escape(it.get("summary") or "(没有结论行)")
href = "/rescue/" + _up.quote(it["name"])
rows.append(
'<div class="dlrow"><span class="nm">'
'<a href="%s" style="color:#0a84ff">%s</a>'
'<span style="color:#6b7280;font-size:12px;margin-left:8px">'
'%s</span></span>'
'<span class="sz" style="max-width:46%%;text-align:right">'
'%s</span></div>'
% (href, when, size_s, summary))
return "".join(rows)
def main() -> int:
ok = True
ok &= patch(RELAY, [
("DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"",
"DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"\n"
+ RELAY_LIST, "加 rescue_reports 列表函数"),
(RESCUE_ROUTE_ANCHOR, RESCUE_ROUTE + RESCUE_ROUTE_ANCHOR,
"加 /rescue/ 下载路由(no-store)"),
(ADMIN_CALL, ADMIN_CALL_NEW, "把 rescue_fn 传给后台页"),
], "relay.py")
ok &= patch(ADMIN, [
(ADMIN_SIG, ADMIN_SIG_NEW, "render_admin 加 rescue_fn 参数"),
(ADMIN_CARD_ANCHOR, ADMIN_CARD, "后台加「抢救报告」卡片"),
# 算报告列表(照 dl_rows 的写法)
(''' return """<!doctype html>''',
''' # 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉)
try:
rescue_items = rescue_fn() if rescue_fn else []
except Exception: # noqa: BLE001
rescue_items = []
rescue_rows = _rescue_rows(rescue_items)
return """<!doctype html>''',
"算抢救报告列表"),
('"dl": dl_rows,', '"dl": dl_rows,\n "rescue": rescue_rows,',
"把报告列表塞进模板"),
], "admin.py")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
-406
View File
@@ -1,406 +0,0 @@
# -*- 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
@@ -1,62 +0,0 @@
# -*- 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
@@ -1,79 +0,0 @@
# -*- 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
@@ -1,401 +0,0 @@
# -*- 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())
-137
View File
@@ -1,137 +0,0 @@
# -*- coding: utf-8 -*-
r'''手表端加「音效」—— 用现成的 showChooser(),不新建面板。
== 为什么改方案 ==
第一版我给音效页写了完整的新面板(FrameLayout + ScrollView + 动态列表),
后来发现 MainActivity 里**已经有 showChooser(title, labels, Chooser)**
这个现成的选择器 —— pickVoice() 就是这么选 TTS 音色的。
用选择器的话:
* 不用新建面板 -> 不用碰 root.addView / showOnly(那两个地方
刚因为"漏挂"出过一次全白的事故)
* 代码量少一半,出错面小得多
* 而且和"选音色"的操作手感一致,用户不用学两套
所以改成:首页加一个「音效」按钮 -> 弹出音效列表 -> 点哪个放哪个。
== 服务端配合 ==
服务端返回的是 {"sfx":[{"name":...},...]} 这种嵌套结构,
而手表的 stringList(reply, key) 只认扁平的字符串数组。
audio_list 早就这么做了(同时返回 files / names / paths),
这里跟着加一个 "names",手表直接用。
'''
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")
# ---------------------------------------------------------------- 方法
METHOD_ANCHOR = " /** 选择电脑上的 TTS 音色。 */"
METHOD_NEW = ''' /** 让电脑放一个音效(网络热门音效那类)。
*
* 用 showChooser 而不是新建面板 —— 见 _build/add_watch_sfx.py 里的说明:
* pickVoice() 已经是这个套路,复用省事也更不容易出错。
*/
private void pickSfx() {
appendLog("正在获取音效…");
io.execute(new Runnable() {
@Override
public void run() {
try {
final Map<String, Object> reply =
client.call(Json.obj("cmd", "sfx_list"), 15000);
// 服务端同时给了扁平的 names(和 audio_list 一个约定)
final List<String> names = stringList(reply, "names");
final String dir = Json.str(reply, "dir", "");
ui.post(new Runnable() {
@Override
public void run() {
if (names.isEmpty()) {
toast("电脑上还没有音效(或没连上)");
appendLog("音效列表是空的。"
+ (dir.isEmpty() ? "" : "素材目录:" + dir));
return;
}
showChooser("音效(点一下电脑就响)", names,
new Chooser() {
@Override
public void onPick(int index) {
String one = names.get(index);
sendCommand(Json.obj("cmd", "sfx_play",
"name", one), "音效 " + one);
}
});
}
});
} catch (final IOException e) {
postError("获取音效", e);
}
}
});
}
''' + METHOD_ANCHOR
def main() -> int:
text = SRC.read_text(encoding="utf-8")
out: list = []
def patch(old: str, new: str, what: str) -> None:
nonlocal text
if old not in text:
out.append(("✗", what, "找不到锚点"))
return
if text.count(old) != 1:
out.append(("!", what, "锚点 %d 处" % text.count(old)))
text = text.replace(old, new, 1)
out.append(("✓", what, ""))
# 1) 插方法
patch(METHOD_ANCHOR, METHOD_NEW, "插入 pickSfx()")
# 2) 首页加按钮(放在「音效」该在的位置:设置按钮之后)
patch(''' box.addView(sfxButton);''', ''' box.addView(sfxButton);''',
"(占位,见下)") if False else None
patch(''' box.addView(relayButton);''',
''' box.addView(relayButton);
// 音效:点一下电脑就响。用户要求"可以直接远程播放"。
Button sfxButton = bigButton("音效(让电脑放一声)", C_ACCENT, 14);
LinearLayout.LayoutParams sfxParams = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(52));
sfxParams.setMargins(0, 0, 0, dp(8));
sfxButton.setLayoutParams(sfxParams);
sfxButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
pickSfx();
}
});
box.addView(sfxButton);''',
"首页加「音效」按钮")
print(" ── 补丁结果 ──")
for mark, what, why in out:
print(" %s %s%s" % (mark, what, (" —— " + why) if why else ""))
if any(m == "✗" for m, _w, _y in out):
print()
print(" 有锚点没匹配,没有写回")
return 1
SRC.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 已写回 MainActivity.java")
return 0
if __name__ == "__main__":
sys.exit(main())
-481
View File
@@ -1,481 +0,0 @@
<!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
@@ -1,481 +0,0 @@
<!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
@@ -1,463 +0,0 @@
<!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>
View File
View File
-89
View File
@@ -1,89 +0,0 @@
#!/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
@@ -1,98 +0,0 @@
#!/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
@@ -1,129 +0,0 @@
#!/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
@@ -1,73 +0,0 @@
#!/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
@@ -1,67 +0,0 @@
#!/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"
-104
View File
@@ -1,104 +0,0 @@
# -*- coding: utf-8 -*-
r'''给 /rescue/ 加上管理鉴权。
== 问题 ==
我把 /rescue/ 路由插在 do_GET 的第 1531 行,而管理页的登录校验
(self._admin_gate())在第 1723 行 —— **路由在鉴权之前**。
也就是说:任何人只要知道
https://relay.xxmxxs.top/rescue/20261008-173000.txt
就能直接下载诊断报告。那里面有什么:
* 内网 IP(192.168.2.x)、主机名
* 进程列表、内存占用
* 中转和隧道的日志尾部
* MTU / 路由 / iptables 规则
这些是**运维信息**,不该公开。对比一下:/download/ 是给用户的
安装包,公开是对的;/rescue/ 是给自己看的,必须登录。
(同样的问题 /shot/ 也有 —— 那个是屏幕共享截图,本来就该拦。
这次先修 /rescue/,顺手看一眼 /shot/。)
== 改法 ==
在 /rescue/ 分支开头加一句 self._admin_gate(),
和 /v1/admin/* 那些接口一样。do_GET 和 do_HEAD 都要加。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
# do_GET 里的(有 body 的那份)
GET_OLD = ''' if path.startswith("/rescue/"):
# 抢救报告下载。**单独一条路由,不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
GET_NEW = ''' if path.startswith("/rescue/"):
# 抢救报告下载。**单独一条路由,不发缓存头** ——
# 内容会变,而且不该让 Cloudflare 边缘留着。
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
#
# ⚠ 必须登录才能下。报告里有内网 IP、进程表、日志尾部、
# MTU/路由/iptables —— 是运维信息,不是给所有人看的。
# 第一版把这条路由插在 _admin_gate() **之前**,
# 等于匿名可下,实测踩到。
if not self._admin_gate():
return
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
# do_HEAD 里的(只有 headers)
HEAD_OLD = ''' if path.startswith("/rescue/"):
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
HEAD_NEW = ''' if path.startswith("/rescue/"):
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
# 同样要登录(见 do_GET 那段注释)。
if not self._admin_gate():
return no()
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
def main() -> int:
raw = RELAY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
changed = []
for old, new, what in ((GET_OLD, GET_NEW, "do_GET"),
(HEAD_OLD, HEAD_NEW, "do_HEAD")):
if new.split("\n")[0] in text and "_admin_gate" in text.split(old)[0][-600:]:
changed.append(("·", what, "好像已经加过了"))
continue
if old not in text:
changed.append(("✗", what, "找不到锚点"))
continue
text = text.replace(old, new, 1)
changed.append(("✓", what, "已加鉴权"))
print(" ── 结果 ──")
for m, w, why in changed:
print(" %s %-10s %s" % (m, w, why))
if any(m == "✗" for m, _w, _y in changed):
return 1
RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
print(" 已写回 relay.py")
return 0
if __name__ == "__main__":
sys.exit(main())
-158
View File
@@ -1,158 +0,0 @@
#!/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
@@ -1,64 +0,0 @@
#!/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
@@ -1,180 +0,0 @@
# -*- 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
@@ -1,30 +0,0 @@
==============================================================
从 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
@@ -1,49 +0,0 @@
#!/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/^/ /'
-92
View File
@@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
r'''解开"nettune 死锁",然后把根因修掉。
== 死锁是怎么回事 ==
lanremote-nettune.service:
Before=lanremote-tunnel.service <- 关键
ExecStart=/bin/bash net-tune.sh
net-tune.sh 第 4 步:
systemctl restart lanremote-tunnel <- 在这里永远阻塞
为什么阻塞:systemd 正在启动 nettune,而 nettune 声明了"我必须在
隧道之前启动完"。脚本却在**自己还没结束的时候**去 restart 隧道 ——
那要求隧道先停、再等 nettune 结束……systemd 检测到这是顺序冲突,
restart 就一直挂着不返回。
证据:CGroup 里能看到 net-tune.sh(13388)下面挂着
systemctl restart lanremote-tunnel(13428),
etime 09:32 —— 卡了 9 分半,nettune 一直是 activating。
后果:nettune 卡住 -> 隧道被 Before= 挡着起不来 -> 公网 530。
这就是用户说的"网站还是容易挂"的真因之一。
== 为什么以前没暴露 ==
设备上原来那份 unit 的 ExecStart 里有个 `iptables ... || ...`,
systemd 不支持 ||,所以它**在第 2 条 ExecStart 就报错退出了**,
根本没走到"重启隧道"那一步 —— 死锁被那个 bug 遮住了。
我把 unit 换成正确形状之后,死锁才露出来。
== 这一步做什么 ==
1. 杀掉卡住的 systemctl 和 net-tune.sh(解开当前死锁)
2. 拉起隧道(网站先恢复)
3. 把 net-tune.sh 的"重启隧道"改成**只在手动跑时做**
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
echo "════ 1. 解开死锁 ════"
# 先杀卡住的 systemctl(不然它会一直拽着 systemd 的 job)
pkill -f 'systemctl restart lanremote-tunnel' 2>/dev/null && echo " 杀了卡住的 systemctl" || echo " 没有卡住的 systemctl"
sleep 2
# 再让 nettune 的脚本结束(杀它的子进程已经让主脚本继续往下走了)
systemctl stop lanremote-nettune 2>/dev/null
systemctl reset-failed lanremote-nettune 2>/dev/null
sleep 2
echo " nettune 现在: $(systemctl is-active lanremote-nettune)"
echo
echo "════ 2. 拉起隧道(网站先恢复)════"
systemctl reset-failed lanremote-tunnel lanremote-tunnel-h2 2>/dev/null
systemctl start lanremote-tunnel lanremote-tunnel-h2
sleep 15
echo " tunnel: $(systemctl is-active lanremote-tunnel)"
echo " tunnel-h2: $(systemctl is-active lanremote-tunnel-h2)"
echo
echo "════ 3. 验公网 ════"
for i in 1 2 3; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/v1/health)
echo " 第 $i 次: $code"
[ "$code" = "200" ] && break
sleep 6
done
echo
echo "════ 4. 隧道连接数 ════"
for p in 20241 20242; do
n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
echo " $p -> ${n:-拿不到}"
done
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
-116
View File
@@ -1,116 +0,0 @@
# -*- 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
@@ -1,59 +0,0 @@
# -*- 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
@@ -0,0 +1,124 @@
# -*- 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())
+3 -37
View File
@@ -83,27 +83,9 @@ def main() -> int:
changed += 1 changed += 1
# ---- Windows EXE 版本信息 ---- # ---- Windows EXE 版本信息 ----
# ⚠ 原来用的是 f'"{OLD}.0"',一旦 OLD 和文件里对不上就静默跳过 if swap(ROOT / "windows" / "version_info.txt",
# (version_info.txt 就是这么从 1.7.1 一直漏到现在的)。 [(f'"{OLD}.0"', f'"{NEW}.0"')],
# 改成正则匹配任意四段版本。 f"EXE 文件版本 {NEW}.0"):
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 changed += 1
# ---- __init__ ---- # ---- __init__ ----
@@ -118,22 +100,6 @@ def main() -> int:
f"VERSION={NEW}"): f"VERSION={NEW}"):
changed += 1 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")): for path in sorted((ROOT / "docs").glob("*.txt")):
raw = path.read_bytes() raw = path.read_bytes()
-42
View File
@@ -1,42 +0,0 @@
#!/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
@@ -1,57 +0,0 @@
#!/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
@@ -1,78 +0,0 @@
#!/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
@@ -1,33 +0,0 @@
#!/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
@@ -1,23 +0,0 @@
#!/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
@@ -1,128 +0,0 @@
# -*- 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(" 实际:见上面。凡是出现硬编码地址的地方都要改成空字符串。")
-96
View File
@@ -1,96 +0,0 @@
# -*- coding: utf-8 -*-
r'''看一眼中转服务器的完整状态。
为什么写成文件:这一轮我用 `python -c @"..."@` 传长命令失败了好几次 ——
PowerShell 的双引号 heredoc 会做变量插值、还会把 `$(...)` 当子表达式
在本机执行(systemctl / iptables 跑到 Windows 上了)。凡是超过两行
的远端命令,一律写成文件跑。
用法:
python _build/check_device.py
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run # noqa: E402
REMOTE = r'''
RELAY=/root/lanremote-relay
echo "════ 服务 ════"
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 lanremote-nettune; do
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
done
printf ' %-24s %s\n' "monitor.timer" "$(systemctl is-active lanremote-monitor.timer 2>&1)"
printf ' %-24s %s\n' "watchdog.timer" "$(systemctl is-active lanremote-watchdog.timer 2>&1)"
echo
echo "════ 网络 ════"
printf ' %-24s %s\n' "wlan0 MTU" "$(cat /sys/class/net/wlan0/mtu 2>/dev/null)"
printf ' %-24s %s\n' "MSS 规则" "$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条"
printf ' %-24s %s\n' "tcp_mtu_probing" "$(cat /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null)"
printf ' %-24s %s\n' "默认路由" "$(ip route show default 2>/dev/null | head -1)"
echo
echo "════ 内存 / 运行时长 ════"
printf ' %-24s %s\n' "运行时长" "$(uptime -p 2>/dev/null)"
free -m 2>/dev/null | sed -n '2p' | awk '{printf " %-24s %s/%s MB\n", "内存 used/total", $3, $2}'
echo
echo "════ 隧道连接数 ════"
for p in 20241 20242; do
n=$(curl -s --max-time 5 "http://127.0.0.1:$p/metrics" 2>/dev/null \
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
printf ' %-24s %s\n' "指标端口 $p" "${n:-拿不到}"
done
echo
echo "════ 公网自测 ════"
printf ' %-24s %s\n' "公网 /v1/health" "$(curl -s -o /dev/null -w '%{http_code} (%{time_total}s)' --max-time 15 https://relay.xxmxxs.top/v1/health 2>/dev/null)"
printf ' %-24s %s\n' "本机 /v1/health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/v1/health 2>/dev/null)"
printf ' %-24s %s\n' "公网首页" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/ 2>/dev/null)"
printf ' %-24s %s\n' "后台抢救报告路由" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/rescue/none.txt 2>/dev/null)"
echo
echo "════ 监测单元状态 ════"
cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /' || echo " (还没有状态文件)"
echo " ── monitor.log 尾部 ──"
tail -5 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /' || echo " (还没有日志)"
echo
echo "════ 抢救报告 ════"
n=$(ls $RELAY/rescue_reports 2>/dev/null | wc -l)
echo " 共 $n 份"
ls -la $RELAY/rescue_reports/ 2>/dev/null | tail -n +4 | awk '{printf " %s %s\n", $6" "$7" "$8, $9}'
echo
echo "════ 知识库 ════"
if [ -f $RELAY/rescue_knowledge.md ]; then
echo " 行数:$(wc -l < $RELAY/rescue_knowledge.md)"
tail -6 $RELAY/rescue_knowledge.md | sed 's/^/ /'
else
echo " (没有)"
fi
echo
echo "════ 定时器下次触发 ════"
systemctl list-timers lanremote-monitor.timer lanremote-watchdog.timer --no-pager 2>/dev/null | head -4 | sed 's/^/ /'
echo
echo "════ 后台页有没有「抢救报告」分区 ════"
printf ' 匹配次数: %s\n' "$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan 2>/dev/null | grep -c '抢救报告')"
'''
def main() -> int:
out = run(REMOTE, timeout=300)
text = out[1] if isinstance(out, tuple) else str(out)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
-71
View File
@@ -1,71 +0,0 @@
# -*- 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
@@ -1,43 +0,0 @@
#!/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
@@ -1,60 +0,0 @@
#!/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
@@ -1,140 +0,0 @@
# -*- 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
@@ -1,75 +0,0 @@
# -*- 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
@@ -1,39 +0,0 @@
#!/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
@@ -1,40 +0,0 @@
# -*- 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
@@ -1,52 +0,0 @@
#!/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
@@ -1,66 +0,0 @@
#!/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
@@ -1,47 +0,0 @@
#!/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
@@ -1,33 +0,0 @@
# -*- 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
@@ -1,31 +0,0 @@
#!/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
@@ -1,32 +0,0 @@
#!/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
@@ -1,36 +0,0 @@
#!/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
@@ -1,63 +0,0 @@
#!/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
@@ -1,40 +0,0 @@
#!/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
@@ -1,35 +0,0 @@
#!/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
@@ -1,46 +0,0 @@
#!/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
@@ -1,158 +0,0 @@
# -*- 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
@@ -1,108 +0,0 @@
# -*- 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
@@ -1,85 +0,0 @@
#!/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
@@ -1,58 +0,0 @@
#!/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/^/ /'

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