mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 19:30:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d368979aa | ||
|
|
48bc392466 | ||
|
|
fd8be87209 | ||
|
|
f0c05079f7 | ||
|
|
3f543b274a | ||
|
|
4d97e40280 | ||
|
|
31a2360031 | ||
|
|
be59a20036 | ||
|
|
7ffd56391e | ||
|
|
0ad3f648c6 | ||
|
|
9bdd36cf56 | ||
|
|
a53a3eee21 | ||
|
|
f173c402da | ||
|
|
47e08af151 | ||
|
|
e63b5f08d7 | ||
|
|
25ffd3ec67 | ||
|
|
f748565ebf | ||
|
|
db669e2f40 | ||
|
|
daf73dfe8e | ||
|
|
5ddd4cd752 | ||
|
|
892d5891ea | ||
|
|
62e67e9e26 | ||
|
|
5fc2f97be7 | ||
|
|
bc289c5c21 | ||
|
|
d5a1943d1f | ||
|
|
768309c1f2 | ||
|
|
8dc47d0f48 | ||
|
|
4e64bc7920 | ||
|
|
c5fa4eb517 | ||
|
|
05d2287fbf | ||
|
|
6adf7acbfb | ||
|
|
4ba18fdae9 | ||
|
|
3f18e6bb40 | ||
|
|
50f4e1fb6f | ||
|
|
a6001775dd | ||
|
|
d5f35f7ed5 | ||
|
|
77295bd258 | ||
|
|
4295b482a1 | ||
|
|
d2243cec64 | ||
|
|
b1d18f2666 | ||
|
|
23be281a1b | ||
|
|
de5018bbbe | ||
|
|
fc61dad0a3 | ||
|
|
dba5e912bf | ||
|
|
b76014709e | ||
|
|
527c455911 | ||
|
|
1f21345736 | ||
|
|
accd0fae3f | ||
|
|
acc636742a | ||
|
|
c97d7e8961 | ||
|
|
5439e479c1 | ||
|
|
9b17129c49 | ||
|
|
d424128483 | ||
|
|
7caad95b42 | ||
|
|
e244aa9d3c | ||
|
|
e1c04d617a | ||
|
|
237aeeb580 | ||
|
|
430923dd84 | ||
|
|
571ea0cc76 | ||
|
|
fa2e1ae43c | ||
|
|
d5a0bd951d | ||
|
|
d907fdcf6e | ||
|
|
f7b9f2a058 | ||
|
|
490adbdba3 | ||
|
|
243c1c3f76 | ||
|
|
41bf110e22 | ||
|
|
75c15f793c | ||
|
|
035c2cc4c5 | ||
|
|
4cc368431c | ||
|
|
0ee91b68de | ||
|
|
e61bae80ca | ||
|
|
da40f7572c | ||
|
|
e611b07fb1 | ||
|
|
cc5891e6d0 | ||
|
|
aac43e559d | ||
|
|
8880e7033d | ||
|
|
b2bfa6ffbf | ||
|
|
facb156aca | ||
|
|
d4f335b8f9 | ||
|
|
25e7780272 | ||
|
|
aea78efe45 | ||
|
|
bf15a2ee61 | ||
|
|
46196c3618 | ||
|
|
b1119d0da6 | ||
|
|
94252ca156 | ||
|
|
9692ef1f32 | ||
|
|
ae7e1cf269 | ||
|
|
2425f8694e |
+22
@@ -64,3 +64,25 @@ desktop.ini
|
|||||||
/_build/.gitee_token
|
/_build/.gitee_token
|
||||||
*.token
|
*.token
|
||||||
.env
|
.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
|
||||||
|
# 后台管理密码(绝不进仓库)
|
||||||
|
_build/.admin_password
|
||||||
|
|
||||||
|
# 服务器地址(绝不进仓库)
|
||||||
|
_build/.lightsail_host
|
||||||
|
_build/.gitee_token
|
||||||
|
_build/.admin_password
|
||||||
|
|||||||
@@ -0,0 +1,681 @@
|
|||||||
|
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/>.
|
||||||
@@ -31,8 +31,7 @@
|
|||||||
|
|
||||||
- 课件全屏投到教室大屏
|
- 课件全屏投到教室大屏
|
||||||
- 放听力音频(电脑音箱比手表响多了)
|
- 放听力音频(电脑音箱比手表响多了)
|
||||||
- 看看学生机是不是在打游戏,直接把游戏进程结了
|
- 上课时把教室电脑切到专注状态,下课一键恢复
|
||||||
- 课堂专注模式,下课一键恢复
|
|
||||||
- 下课关机、锁屏
|
- 下课关机、锁屏
|
||||||
- 喊一声"上课了"让电脑播音(TTS)
|
- 喊一声"上课了"让电脑播音(TTS)
|
||||||
|
|
||||||
@@ -46,13 +45,35 @@
|
|||||||
|
|
||||||
| 装在哪 | 是什么 | 文件 |
|
| 装在哪 | 是什么 | 文件 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 教室电脑(Windows) | 被控端 | `ykxmz-agent-1.5.0.zip` |
|
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.2.0.exe` |
|
||||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.5.0.zip` |
|
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.2.0.zip` |
|
||||||
| 手表(安卓) | 控制端 | `ykxmz-watch-1.5.0.apk` |
|
| 手表(安卓) | 控制端 | `ykxmz-watch-2.2.0.apk` |
|
||||||
|
|
||||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
||||||
|
|
||||||
**要在办公室管教室?** 那必须装中转站(见 [跨网络](#跨网络在办公室管教室))。
|
**要在办公室管教室?** 那必须**自己部署一台中转站**(见 [跨网络](#跨网络在办公室管教室))。
|
||||||
|
|
||||||
|
> ### ⚠️ 软件里不带任何中转地址
|
||||||
|
>
|
||||||
|
> 这一点先讲清楚,免得白折腾:
|
||||||
|
>
|
||||||
|
> **这个软件默认是"空的" —— 没有任何内置的中转服务器地址,
|
||||||
|
> 也没有官方服务器可以用,作者不对外提供中转服务。**
|
||||||
|
>
|
||||||
|
> - **同一个 WiFi 下用(教室里)**:不需要中转,装完就能连,什么地址都不用填。
|
||||||
|
> - **要跨网络用(办公室管教室)**:**你得自己部署一台中转站**。
|
||||||
|
> 服务端压缩包里有一键脚本,一条命令装好:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> bash deploy.sh
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 装完它会打印一个属于你自己的公网地址,把它填到电脑和手表上。
|
||||||
|
> 详见 [装中转站](#装中转站)。
|
||||||
|
>
|
||||||
|
> 为什么不做一个"填个公共地址就能用"?因为中转站的带宽是实打实的成本,
|
||||||
|
> 谁用谁出;而且**中转站能看到经过它的字节流**,
|
||||||
|
> 自己搭一台,数据才真正只在你自己手里。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,9 +81,9 @@
|
|||||||
|
|
||||||
### 第一步:电脑上装被控端
|
### 第一步:电脑上装被控端
|
||||||
|
|
||||||
解压 `ykxmz-agent-1.5.0.zip`,双击里面的 `ykxmz.exe`。
|
双击 `ykxmz-setup-2.2.0.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||||
|
|
||||||
弹出的窗口里填两样:
|
第一次运行会弹出窗口,填两样:
|
||||||
|
|
||||||
- **6 位数字** —— 自己定,好记就行,比如 `789113`
|
- **6 位数字** —— 自己定,好记就行,比如 `789113`
|
||||||
- 两个勾:`设置开机自启`、`放行防火墙`(放行那个要过一次管理员确认)
|
- 两个勾:`设置开机自启`、`放行防火墙`(放行那个要过一次管理员确认)
|
||||||
@@ -70,12 +91,15 @@
|
|||||||
点确定,屏幕会黑闪一下(在加防火墙规则),然后窗口消失,程序转到后台跑。
|
点确定,屏幕会黑闪一下(在加防火墙规则),然后窗口消失,程序转到后台跑。
|
||||||
|
|
||||||
> **窗口关了程序还在跑**,这是故意的 —— 上课时讲台上不该有个窗口杵着。
|
> **窗口关了程序还在跑**,这是故意的 —— 上课时讲台上不该有个窗口杵着。
|
||||||
> 想再打开窗口,**再双击一次 EXE** 就行,不会起第二个进程。
|
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
||||||
> 想彻底退出,窗口上点「退出」。
|
> 想彻底退出,窗口上点「退出」。
|
||||||
|
|
||||||
|
> 不想用安装包的话,`ykxmz-agent-2.2.0.zip` 是绿色版:
|
||||||
|
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||||
|
|
||||||
### 第二步:手表上装 App
|
### 第二步:手表上装 App
|
||||||
|
|
||||||
把 `ykxmz-watch-1.5.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
把 `ykxmz-watch-2.2.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||||
|
|
||||||
打开 App,首页有四个入口:
|
打开 App,首页有四个入口:
|
||||||
|
|
||||||
@@ -115,26 +139,34 @@
|
|||||||
|
|
||||||
这一页是给上课准备的。
|
这一页是给上课准备的。
|
||||||
|
|
||||||
|
> **先说清楚作用范围**:被控端装在**一台** Windows 电脑上
|
||||||
|
> —— 就是讲台那台,或者你自己的机器。
|
||||||
|
> **所有操作只影响这一台**,看不到也管不着其他电脑。
|
||||||
|
> 装的时候要输配对码,没配对码连不上。
|
||||||
|
|
||||||
| 按钮 | 干什么 |
|
| 按钮 | 干什么 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **课堂专注模式** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
|
| **课堂专注模式** | 在**本机 hosts** 里把常见游戏站点指到本地,本机打开是空白页 |
|
||||||
| **关闭游戏窗口** | 扫一遍常见游戏进程名,找到就结束掉 |
|
| **结束游戏进程** | 扫**本机**正在跑的常见游戏进程,结束掉 |
|
||||||
| **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 |
|
| **看有哪些限制** | 列出当前开了哪些,别自己忘了 |
|
||||||
| **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** |
|
| **一键恢复全部** | 把这节课改的全撤掉,**下课必点** |
|
||||||
|
|
||||||
屏蔽是改 hosts 文件,不是装驱动,所以:
|
怎么实现的、有什么边界,一并说清楚:
|
||||||
|
|
||||||
- 学生自己删掉 hosts 里那几行就绕过去了 —— 它挡的是"随手点开",不是防作弊
|
- 屏蔽是改**本机** `hosts` 文件,不装驱动、不装服务
|
||||||
- 换个游戏站就得重新加,这个列表是写死的常见站点
|
- **只挡"随手点开"**:懂行的人自己把 hosts 里那几行删了就过去了。
|
||||||
- 重启电脑有的会保留、有的不会,取决于你们学校的还原卡
|
它防的不是刻意行为,是减少无关干扰
|
||||||
|
- 站点列表是写死的常见站点,换一个站就得重新加
|
||||||
|
- **全部改动可逆**,点「一键恢复全部」或「下课清理」就还原
|
||||||
|
- 重启后保不保留,取决于你们学校有没有装还原卡
|
||||||
|
|
||||||
### 屏幕
|
### 屏幕
|
||||||
|
|
||||||
- **实时屏幕镜像** —— 手表上看教室里那台电脑在显示什么。默认 4 fps,在手表上太小看不清,主要是"看一眼有没有人乱动"
|
- **实时屏幕镜像** —— 手表上看**被控端那台电脑**在显示什么(低头确认课件放没放对,不用走回讲台)
|
||||||
- **一键截屏** —— 存到手表相册
|
- **一键截屏** —— 存到手表相册
|
||||||
|
|
||||||
> ⚠️ **屏幕只要被看,电脑右上角就有一条红色提示条,关不掉。**
|
> ⚠️ **屏幕只要被看,电脑右上角就有一条红色提示条,关不掉。**
|
||||||
> 这是刻意做的:教室里那台电脑是公家的,谁在看必须让人知道。
|
> 这是刻意做的:谁在看,电脑前面的人必须能立刻知道。
|
||||||
> 电脑前面的人随时可以点提示条上的「停止共享」把你掐断。
|
> 电脑前面的人随时可以点提示条上的「停止共享」把你掐断。
|
||||||
|
|
||||||
### 媒体 / 音频
|
### 媒体 / 音频
|
||||||
@@ -188,19 +220,61 @@
|
|||||||
|
|
||||||
### 装中转站
|
### 装中转站
|
||||||
|
|
||||||
把 `ykxmz-server-1.5.0.zip` 解压到那台小机器上(随身 WiFi、树莓派、云主机都行,Debian/Ubuntu 系统),然后:
|
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||||
|
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||||
|
|
||||||
|
把 `ykxmz-server-2.2.0.zip` 解压到那台机器上,然后一条命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash install.sh
|
bash deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
装完会打印一个公网地址,长这样:
|
它会自己做完这些,不用你一步步敲:
|
||||||
|
|
||||||
|
1. 装 cloudflared(已经装了就用现成的)
|
||||||
|
2. 把中转程序装到 `/root/lanremote-relay/`
|
||||||
|
3. 网络调优(keepalive、MSS、MTU、开机加速)
|
||||||
|
4. 注册 systemd 服务,设成开机自启
|
||||||
|
5. 配好 Cloudflare 隧道
|
||||||
|
6. 启动、自检,然后把**公网地址**打印出来
|
||||||
|
|
||||||
|
装完会打印一个属于你自己的公网地址,长这样:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://relay.你的域名
|
https://relay.你的域名
|
||||||
```
|
```
|
||||||
|
|
||||||
**记住这个地址**,电脑和手表都要填它。忘了就打开 `http://<小机器的IP>:8080/url` 查。
|
**记住这个地址**,电脑和手表都要填它。忘了就在那台机器上跑
|
||||||
|
`lanremote-url`,或者浏览器打开 `http://<小机器的IP>:8080/url` 查。
|
||||||
|
|
||||||
|
#### 三种常见情形
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① 机器上已经有隧道凭据(/root/.cloudflared/*.json)
|
||||||
|
bash deploy.sh
|
||||||
|
|
||||||
|
# ② Cloudflare 给了一个隧道 Token(Zero Trust 后台复制的那串)
|
||||||
|
TUNNEL_TOKEN=eyJhIjoi... bash deploy.sh
|
||||||
|
|
||||||
|
# ③ 换域名 / 只装中转、不要隧道
|
||||||
|
bash deploy.sh --hostname relay.你的域名
|
||||||
|
bash deploy.sh --skip-tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
看全部参数:`bash deploy.sh --help`
|
||||||
|
|
||||||
|
> **用环境变量而不是 `--token`**:写进命令行的 Token 会留在 shell 历史里。
|
||||||
|
|
||||||
|
#### 重跑一遍 = 升级
|
||||||
|
|
||||||
|
`deploy.sh` 是**幂等**的:已经装过的机器再跑一次就是升级。
|
||||||
|
程序文件会被覆盖成新的,而**设备数据、配对码、隧道凭据都不会被冲掉**。
|
||||||
|
|
||||||
|
#### 需要准备什么
|
||||||
|
|
||||||
|
- 一台能上外网的 Linux 小机器(Debian 12 / Ubuntu,512 MB 内存够)
|
||||||
|
- 一个 Cloudflare 账号 + 一个域名(隧道要用)
|
||||||
|
- **不需要**装 Python 第三方包 —— 服务端是纯标准库,不装任何依赖
|
||||||
|
|
||||||
### 告诉电脑中转地址
|
### 告诉电脑中转地址
|
||||||
|
|
||||||
@@ -212,19 +286,34 @@ 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`** —— 那份文档是给运维的人看的,里面写了日常命令。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -322,7 +411,7 @@ App 把错误翻译成人话了,看到哪句就照做:
|
|||||||
|
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
教室里那台电脑是公家的东西,所以这套工具的安全设计偏"让人知道"而不是"藏起来"。
|
被控端装的往往是公家的电脑,所以这套工具的安全设计偏"让人知道"而不是"低调运行"。
|
||||||
|
|
||||||
**每次连接都要配对。** 电脑上生成 6 位数字,只在窗口上显示,手表输对了才连得上。
|
**每次连接都要配对。** 电脑上生成 6 位数字,只在窗口上显示,手表输对了才连得上。
|
||||||
|
|
||||||
@@ -336,11 +425,14 @@ App 把错误翻译成人话了,看到哪句就照做:
|
|||||||
|
|
||||||
**不写注册表。** 数据全在程序目录下的 `data/` 里,拔了硬盘拷走就能换台机器跑。
|
**不写注册表。** 数据全在程序目录下的 `data/` 里,拔了硬盘拷走就能换台机器跑。
|
||||||
|
|
||||||
**不在后台偷偷上传。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站。
|
**只在你自己填的地址之间通信。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站,不经过任何第三方。
|
||||||
|
|
||||||
> **这个工具不藏自己。** 没有"任务管理器里看不见",没有"免杀对抗",没有伪装成系统进程。
|
**所有行为都在明面上。** 被控端在托盘有图标、窗口看得见、随时可以退出、
|
||||||
> 被控端全程可见、可随时断开、权限可随时关掉。
|
权限可以逐项关闭。它不做任何"让机器上其他程序看不见自己"的事 ——
|
||||||
> 因为管的是教室的电脑,藏起来只会让老师不放心。
|
因为管的是自己要用的一台电脑,能看见、能随时停,才让人放心。
|
||||||
|
|
||||||
|
**代码全公开。** 三端加起来不到两万行,都是纯标准库,
|
||||||
|
想看它到底干了什么直接读源码就行。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -364,7 +456,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.5.0.apk
|
# 产物:dist\ykxmz-watch-2.2.0.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||||
@@ -374,9 +466,13 @@ android\build_apk.ps1
|
|||||||
Python 3 标准库,没有第三方包。
|
Python 3 标准库,没有第三方包。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash relay/install.sh
|
bash relay/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`deploy.sh` 是一键部署(装服务 + 调优 + 配隧道 + 自检);
|
||||||
|
只想调网络参数的话是 `relay/deploy-tune.sh`。
|
||||||
|
老的 `relay/install.sh`(匿名快速隧道)仍在包里,只为兼容以前装过的机器。
|
||||||
|
|
||||||
### 跑测试
|
### 跑测试
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -408,7 +504,22 @@ python _build\package_remote.py
|
|||||||
|
|
||||||
二进制数据(图片、文件、音频)走 **Base64** 分块传输,单次会话上限 256 MB。
|
二进制数据(图片、文件、音频)走 **Base64** 分块传输,单次会话上限 256 MB。
|
||||||
|
|
||||||
跨网络时同样的消息套在 HTTP 长轮询里,中转站按配对码把两端字节对接,**它不解析内容**。
|
跨网络时同样的消息套在 **HTTP 长轮询**里(单次挂 **60 秒**,
|
||||||
|
有数据立刻返回、不空等),中转站按配对码把两端字节对接,**它不解析内容**。
|
||||||
|
|
||||||
|
跨网络这条链路上还有几个实测调出来的设计,改代码前值得知道:
|
||||||
|
|
||||||
|
- **隧道两种协议同时跑**(QUIC + HTTP/2,合计 10 条连接)。
|
||||||
|
两种协议各有各的毛病 —— QUIC 尾部延迟好但建连容易超时,
|
||||||
|
HTTP/2 建连可靠但丢包时容易被队头阻塞卡住。
|
||||||
|
同时跑就是互相兜底:一种犯病,另一种还在服务。
|
||||||
|
- **长轮询 60 秒**。隧道每次请求要多花 1~2 秒,这个开销是按次数收的;
|
||||||
|
拉到 60 秒把请求数降到 1/4,撞上丢包窗口的概率也降到 1/4。
|
||||||
|
**命令响应速度不变**(有数据立刻返回,不是等满 60 秒)。
|
||||||
|
- **偶发失败不当场判离线**。4G 链路上本来就有百分之几的偶发抖动、
|
||||||
|
一两秒自己恢复;连续失败 3 次才算真的离线。
|
||||||
|
- **静态资源交给 Cloudflare 边缘缓存**。下载走边缘节点,
|
||||||
|
不用穿隧道回那台小机器,快几十倍也更省流量。
|
||||||
|
|
||||||
Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相打通的测试。
|
Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相打通的测试。
|
||||||
|
|
||||||
@@ -429,7 +540,18 @@ Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相
|
|||||||
不会。只要 `data/` 目录还在,服务器就认原来那个码。母盘部署时注意别把 `data/` 一起发出去。
|
不会。只要 `data/` 目录还在,服务器就认原来那个码。母盘部署时注意别把 `data/` 一起发出去。
|
||||||
|
|
||||||
**问:走中转会不会很慢?**
|
**问:走中转会不会很慢?**
|
||||||
实测局域网 3 毫秒左右,公网中转 1~3 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。
|
实测局域网 3 毫秒左右;跨网络要绕到中转站再回来,约 1~2 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。
|
||||||
|
|
||||||
|
**问:必须自己搭中转站吗?能用作者的服务器吗?**
|
||||||
|
必须自己搭。**软件不内置任何中转地址,作者也不对外提供中转服务。**
|
||||||
|
跨网络的话,服务端 zip 里有 `deploy.sh`,一条命令装好(见 [装中转站](#装中转站))。
|
||||||
|
只在同一个 WiFi 下用的话,压根不需要中转站。
|
||||||
|
|
||||||
|
**问:中转站会不会看到我的数据?**
|
||||||
|
它转发的是加密前的字节流,所以**技术上能**。这正是"自己搭一台"的理由 —— 搭在你自己手上,数据就只经过你自己的机器。
|
||||||
|
|
||||||
|
**问:下载安装包为什么比以前快这么多?**
|
||||||
|
最新版把静态文件交给 Cloudflare 的边缘节点缓存了。原来每次下载都要穿过隧道回到那台小机器上取,实测只有几 KB/s,15 MB 的安装包根本下不动;现在从离你最近的边缘节点直接发,实测快了四十多倍,也不占小机器的流量。
|
||||||
|
|
||||||
**问:能在手机上用吗?**
|
**问:能在手机上用吗?**
|
||||||
协议是通的,但界面是按手表的小屏幕设计的(大按钮、两列、可滚动),手机上会显得按钮特别大。要做手机版得重写布局。
|
协议是通的,但界面是按手表的小屏幕设计的(大按钮、两列、可滚动),手机上会显得按钮特别大。要做手机版得重写布局。
|
||||||
@@ -447,7 +569,7 @@ Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相
|
|||||||
|
|
||||||
1. 手表上显示的那句错误(App 现在是中文的,直接念出来就行)
|
1. 手表上显示的那句错误(App 现在是中文的,直接念出来就行)
|
||||||
2. 电脑上 `工具箱.bat` → 选 `5` 的日志内容
|
2. 电脑上 `工具箱.bat` → 选 `5` 的日志内容
|
||||||
3. 中转站的公网地址(不用给密码)
|
3. 跨网络的话,把你那台中转站的公网地址发过来(不用给任何密码)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,433 @@
|
|||||||
|
## 2.2.0
|
||||||
|
|
||||||
|
- **手表端:输入配对码连上之后,先到一个选择页** ——
|
||||||
|
上面有两个按钮:
|
||||||
|
|
||||||
|
1. **远控** —— 进控制页(和以前一样)
|
||||||
|
2. **静默更新被控端** —— 让那台电脑自己升级到最新版
|
||||||
|
|
||||||
|
页面顶上会显示**已连接的电脑名**和**电脑端当前版本**
|
||||||
|
(比如「电脑端 2.1.5 → 可更新到 2.2.0」),一眼就知道要不要更新。
|
||||||
|
|
||||||
|
**为什么不直接进控制页**:老版本的被控端可能缺功能或者有 bug,
|
||||||
|
「先更新再远控」是更合理的顺序 —— 但也不强制,所以给用户自己选。
|
||||||
|
|
||||||
|
(自动切到中转服务器恢复时**不会**弹这个页面,还是直接回控制页 ——
|
||||||
|
用户本来就在用,中途弹一层会打断他。)
|
||||||
|
|
||||||
|
## 2.1.8
|
||||||
|
|
||||||
|
- **快捷栏的按钮做大** —— 上一版 30dp 在手表上按不准,手指按上去
|
||||||
|
一半在栏外。现在 46dp(接近 Android 建议的可点区域),字号 11→14。
|
||||||
|
|
||||||
|
- **快捷栏 4 个格子全部可以自定义** —— 上一版最后一个「显示桌面」
|
||||||
|
是写死不能改的,现在四格平等,想换哪个换哪个。
|
||||||
|
|
||||||
|
「显示桌面」变成一个普通的可选动作,默认仍放在第 4 格 ——
|
||||||
|
所以习惯不用改,但想换成别的也行。可选动作现在共 8 个:
|
||||||
|
传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 / 语音播报 /
|
||||||
|
投图到电脑 / 显示桌面。
|
||||||
|
|
||||||
|
- **修 Win7 上语音播报不能用** —— 以前只走 PowerShell 一条路,
|
||||||
|
而 Win7 上的 PowerShell 是 2.0 版,中文经命令行传进去容易乱码;
|
||||||
|
再加上 `powershell` 可能不在 PATH 里,就彻底播不出来了。
|
||||||
|
|
||||||
|
现在改成**多方案降级**,按"依赖越少越优先"排:
|
||||||
|
|
||||||
|
| 顺序 | 方案 | 依赖 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | cscript + VBScript + SAPI5 | Windows 98 起就有,不用装任何东西 |
|
||||||
|
| 2 | wscript + VBScript + SAPI5 | 上一条被安全软件拦时的备选 |
|
||||||
|
| 3 | powershell + SAPI5 (COM) | 只依赖 PowerShell,**不需要 .NET** |
|
||||||
|
| 4 | powershell + System.Speech | 原来的做法,改用绝对路径 |
|
||||||
|
| 5 | powershell (按 PATH) + System.Speech | 最原始的,兜底 |
|
||||||
|
|
||||||
|
一条通了就记住它,以后直接用(不用每次从头试)。
|
||||||
|
|
||||||
|
**全都失败会明确报错** —— 以前是不等进程结束就返回"已播报",
|
||||||
|
明明没出声也报成功,那是在骗人。
|
||||||
|
|
||||||
|
## 2.1.7
|
||||||
|
|
||||||
|
- **手表端顶部快捷栏** —— 任何页面顶部都有一条栏,最多 4 个按钮:
|
||||||
|
|
||||||
|
- **最后一个是固定的「显示桌面」**(等于电脑上按 Win+D),**改不了**
|
||||||
|
- **前三个自己选**,在设置页里点一下从这 7 个里挑:
|
||||||
|
传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 / 语音播报 / 投图到电脑
|
||||||
|
- 也可以把某一格留空
|
||||||
|
- 还没连上电脑时它们是灰的(不是藏起来 —— "一直在但暂时不能用"
|
||||||
|
比"时有时无"更好理解)
|
||||||
|
|
||||||
|
- **远程鼠标控制(手表上的触摸板)** —— 拖动移动光标、单击左键、
|
||||||
|
双击右键,下面还有专门的左键/右键/滚轮按钮。
|
||||||
|
|
||||||
|
- **弹文字窗口的位置和大小可以自己设** —— 右下(默认)/ 右上 / 左上 /
|
||||||
|
左下 / 屏幕正中间 / 自定义坐标,字号 8~32 可调。
|
||||||
|
在电脑端设置窗口的「显示」里改。
|
||||||
|
|
||||||
|
- **屏幕被观看时的提示条改成默认不显示** ——
|
||||||
|
以前是强制弹的("有人在看你的屏幕,电脑前面的人该知道")。
|
||||||
|
现在默认关掉,跟常见远程工具一样全程无窗口;需要的话在
|
||||||
|
设置里可以打开。**这个开关是刻意保留的**:这软件应该始终有一个
|
||||||
|
"让本机用户看得见正在被控制"的选项可查。
|
||||||
|
|
||||||
|
- **中转后台能看到每台电脑的版本了**,并且可以直接点「更新」
|
||||||
|
让那台电脑自己静默升级(离线设备按钮置灰)。
|
||||||
|
|
||||||
|
- **手表端设置里可以开关「允许新设备连接」** ——
|
||||||
|
关掉之后只有配对过的电脑能连上来,教室里防别人乱连。
|
||||||
|
|
||||||
|
## 2.1.6
|
||||||
|
|
||||||
|
- **电脑端静默自动更新** —— 手表上点一下,电脑自己升到最新版,
|
||||||
|
全程不弹任何窗口,**中转地址、配对码、所有设置一个都不变**。
|
||||||
|
|
||||||
|
流程:手表点按钮 → 电脑端问中转"最新是哪版" → 下载并校验 →
|
||||||
|
起一个后台帮手 → 自己退出 → 帮手等它退出后静默装好 → 重新拉起。
|
||||||
|
|
||||||
|
几个关键点:
|
||||||
|
|
||||||
|
- **配置为什么不会丢**:配置存在 `%ProgramData%\ykxmz\data\`,
|
||||||
|
不在程序目录里。安装器升级只替换程序文件,不碰这个目录。
|
||||||
|
- **更新源是用户自己的中转站**,不是作者的服务器,也不是任何
|
||||||
|
写死的地址。中转没这功能(老版本服务端)时会明确提示
|
||||||
|
"先手动升级一次服务端"。
|
||||||
|
- **校验和不匹配就放弃这一轮**,保持现状(现状至少能用)。
|
||||||
|
自更新最怕的就是装上一个坏程序、然后它还自己继续更新。
|
||||||
|
|
||||||
|
手表首页新增「电脑端版本」按钮,点开就能看到
|
||||||
|
"电脑端 2.1.4 → 2.1.5",确认后才动手。
|
||||||
|
|
||||||
|
- **远程鼠标控制**:移动、左键、右键、中键、滚轮(含横向)。
|
||||||
|
|
||||||
|
这是"能直接操控电脑"的功能,所以参数校验写得很严:
|
||||||
|
非法动作、非法按键、非数字坐标全都会被拒。
|
||||||
|
|
||||||
|
- **一键显示桌面**(等于 Win+D)。
|
||||||
|
|
||||||
|
- **Windows 7 兼容性校验关卡**:打包时和回归里都会检查
|
||||||
|
(内嵌的是不是 3.8 运行库、**有没有**那个 Win7 上不存在的 DLL、
|
||||||
|
exe 是不是 32 位),不过就拒绝出包。
|
||||||
|
|
||||||
|
- 技术说明:
|
||||||
|
- 鼠标坐标用 SendInput 的 **0..65535 归一化**,而且**必须除以
|
||||||
|
(屏幕宽-1)**。少了那个 -1,最右边和最下边几像素点不到。
|
||||||
|
- `INPUT` 结构在 32/64 位下大小不同(28 / 40),代码按进程位数算。
|
||||||
|
- 目标窗口若是管理员权限的(UAC 提升过的),普通权限的注入会被
|
||||||
|
系统拦掉 —— 这是 Windows 的设计,不是 bug。被控端以管理员运行
|
||||||
|
就没问题。
|
||||||
|
|
||||||
|
## 2.1.5
|
||||||
|
|
||||||
|
- **修「Windows 7 装完报 dll 缺失」** —— 学校电脑装不上。
|
||||||
|
|
||||||
|
这一条是用户报的:学校的电脑是 Windows 7,装完提示缺 DLL。
|
||||||
|
|
||||||
|
**根因不是漏打包,是 Python 版本越界**:
|
||||||
|
|
||||||
|
| Python | 支持的系统 |
|
||||||
|
|---|---|
|
||||||
|
| **3.8** | Windows 7 及以上 ← 最后一个支持 Win7 的版本 |
|
||||||
|
| 3.9 ~ 3.13 | 只支持 Windows 8 及以上 |
|
||||||
|
|
||||||
|
Python 3.9 起用到了 `api-ms-win-core-path-l1-1-0.dll` 里的
|
||||||
|
`PathCchCanonicalizeEx`,而这个 DLL **Windows 7 上根本不存在**,
|
||||||
|
也没法用 api-set 转发(那套机制只在 Win8+ 生效)。
|
||||||
|
|
||||||
|
所以之前用 3.11 打出来的包,在 Win7 上必然报缺 DLL ——
|
||||||
|
**那个 DLL 不是"忘了放进包",是"放进去也没用"。**
|
||||||
|
|
||||||
|
**改法:改用 Python 3.8 打包**,而且选 **32 位** ——
|
||||||
|
32 位在 Win7 的 32 位和 64 位系统上**都能跑**,学校不用分两种发。
|
||||||
|
|
||||||
|
顺带加了两道自动关卡,以后不会再退化:
|
||||||
|
- 打包脚本 `windows\build_exe.ps1` **先校验 Python 版本**,
|
||||||
|
不是 3.8 直接报错退出(而不是默默打一个别人装不上的包)
|
||||||
|
- `_build\check_win7_compat.py` 校验产物:
|
||||||
|
内嵌是不是 3.8 的运行库、**有没有**那个要命的 DLL、exe 是不是 32 位。
|
||||||
|
打包时和回归套件里都会跑,不过就拒绝出包
|
||||||
|
|
||||||
|
安装包体积也从 14.4 MB 降到 **9.0 MB**(32 位运行库小一圈)。
|
||||||
|
|
||||||
|
- **顺手修了安装包测试里一个藏了很久的坑**:
|
||||||
|
`_build/test_installer_cycle.py` 里写死了 `ykxmz-setup-1.8.2.exe`,
|
||||||
|
于是它一直在**装一个不存在的旧包** → 静默失败 → 报出一堆
|
||||||
|
莫名其妙的失败("配对码实际是 xxx"、"版本显示 v1.8.2")。
|
||||||
|
改成自动找 `dist` 下版本号最新的包,以后不会再过期。
|
||||||
|
|
||||||
|
## 2.1.4
|
||||||
|
|
||||||
|
- **修一个"填了地址也连不上"的 bug**(手表端)。
|
||||||
|
|
||||||
|
中转地址栏里填 `yk.xxmxxs.top` 这种**没写 `http://` 开头**的地址时,
|
||||||
|
点首页那个「检查服务器」会报:
|
||||||
|
|
||||||
|
```
|
||||||
|
MalformedURLException: no protocol: yk.xxmxxs.top/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
原因是:补协议头的逻辑只写在连接那一条路上,
|
||||||
|
「检查服务器」直接拿原始字符串拼 URL —— **同一个地址,
|
||||||
|
连接能用,检查却崩**。
|
||||||
|
|
||||||
|
现在把补协议头收到**唯一一个入口**(读地址的地方),
|
||||||
|
所有用到地址的地方都从那里取,以后再加新入口也不会漏。
|
||||||
|
|
||||||
|
规则和电脑端保持一致:
|
||||||
|
- `IP` / `localhost` / `*.local` → 用 `http://`
|
||||||
|
- 域名 → 用 `https://`
|
||||||
|
|
||||||
|
- **修正 README 里几处不准确的说法**,以及过时的版本号:
|
||||||
|
- 被控端只装在**一台**电脑上(讲台那台或你自己那台),
|
||||||
|
原文写成"看学生机"是错的 —— 它看不到别的机器
|
||||||
|
- 课堂那几项功能如实说明:改的是**本机** hosts、结束的是**本机**进程,
|
||||||
|
都是可逆的,下课后点「一键恢复全部」就还原
|
||||||
|
- 成品文件名从 1.8.2 更新到当前版本,并且以后发版会自动跟着改
|
||||||
|
|
||||||
|
## 2.1.3
|
||||||
|
|
||||||
|
- **支持「自己搭的、没有证书的中转」**(新服务器就是这种)。
|
||||||
|
|
||||||
|
中转不再是必须挂在 Cloudflare 隧道后面了。现在可以直接跑在
|
||||||
|
一台有公网 IP 的机器上,客户端填 `IP:端口` 就能连 —— 延迟、稳定性
|
||||||
|
都比隧道好得多。为此改了两处客户端:
|
||||||
|
|
||||||
|
- **手表端**:填 `yk.xxmxxs.top:13245` 这种**没写协议头**的地址时,
|
||||||
|
现在会按「IP / localhost / 本地域名 → 明文,域名 → 加密」来猜,
|
||||||
|
**和电脑端规则一致**。原来一律补成 `https://`,而那台机器没有证书,
|
||||||
|
结果是**永远连不上**,报的还是 SSL 错误,很容易让人白查半天证书。
|
||||||
|
|
||||||
|
- **手表端**:允许明文流量。Android 9 以上默认禁止,不加这一条的话
|
||||||
|
请求会被系统直接掐掉,日志里只有一句含糊的
|
||||||
|
「Cleartext HTTP traffic to ... not permitted」。
|
||||||
|
|
||||||
|
> 说明:中转上跑的内容(配对令牌、画面、文件)本来就是加密过的,
|
||||||
|
> 明文只暴露地址和设备名。想要全程加密的话,把域名解析到这台
|
||||||
|
> 服务器再签一张免费证书即可。
|
||||||
|
|
||||||
|
- 顺手清掉了电脑端一段函数内的重复代码(`return` 之后还有一份一样的
|
||||||
|
拷贝,缩进仍在函数体内所以不报错,但读起来很迷惑)。
|
||||||
|
|
||||||
|
## 2.1.2
|
||||||
|
|
||||||
|
- **隧道稳定性加固**(继续修「网站还是容易挂」)。查到隧道自己的诊断输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
| UDP Connectivity Cloudflare 边缘 FAIL 连不上 |
|
||||||
|
| TCP Connectivity Cloudflare 边缘 FAIL 连不上或被挡住 |
|
||||||
|
| Cloudflare API api.cloudflare.com:443 PASS 通的 |
|
||||||
|
| ERROR: Allow outbound QUIC traffic on port 7844 or use HTTP2. |
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说:**443 通,7844 不通**,而且是**一阵一阵的**
|
||||||
|
(过几分钟自己又好了,然后再次断)。Cloudflare 隧道的边缘连接走 7844,
|
||||||
|
这条线路对它是间歇性阻断 —— 这属于运营商那一侧,设备改不了。
|
||||||
|
|
||||||
|
**但可以让它自己去适应**:
|
||||||
|
|
||||||
|
- 两条隧道实例原来**各自写死一个协议**(一条只走 QUIC、一条只走 HTTP/2)。
|
||||||
|
那一刻如果 7844 的 UDP 和 TCP 都不通,两条一起死,没有退路。
|
||||||
|
现在都改成 **自动选择**,cloudflared 自己在 QUIC / HTTP/2 之间挑
|
||||||
|
当前能通的那条。等于所有连接都有自适应能力,而不是
|
||||||
|
"8 条赌 UDP、2 条赌 TCP"。
|
||||||
|
|
||||||
|
- 重试次数从 5 提到「一直重试」。原来失败 5 次就退出、靠系统重新拉起,
|
||||||
|
每次退出到重启之间网站都是断的;而这条线路的抖动是分钟级的,
|
||||||
|
5 次根本扛不过去(实测隧道每 3~10 分钟就被重启一次,公网跟着 530)。
|
||||||
|
现在抖动期间连接自己恢复,整条隧道不退出。
|
||||||
|
|
||||||
|
**改完实测**:连接数 7+2(接近满配 10),公网连测 5 次全部 200。
|
||||||
|
|
||||||
|
- 顺手加了日志轮转(隧道日志已经涨到 3 MB,这台小机器硬盘不大)。
|
||||||
|
|
||||||
|
## 2.1.1
|
||||||
|
|
||||||
|
- **修一个会让网站整个挂掉的死锁**(这是「网站还是容易挂」的真因)。
|
||||||
|
|
||||||
|
在装了中转服务器的那台机器上,网络调优服务和隧道之间会互相等:
|
||||||
|
|
||||||
|
```
|
||||||
|
网络调优服务(nettune): "我必须先于隧道启动完"
|
||||||
|
网络调优脚本第 4 步: "重启隧道,让新参数生效" ← 卡在这里
|
||||||
|
```
|
||||||
|
|
||||||
|
它在**自己还没结束**的时候去重启一个「必须等自己结束才能启动」的服务,
|
||||||
|
系统判定成顺序冲突,那一步就永远不返回。结果网络调优一直卡在
|
||||||
|
「正在启动」,隧道被挡着起不来,**公网就是 530**。
|
||||||
|
|
||||||
|
实测卡了 9 分半。而且**日志里一句报错都没有**,
|
||||||
|
手动跑那个脚本却完全正常(那时没有顺序约束)——
|
||||||
|
所以特别难查,是靠这次新加的监测单元抓到的
|
||||||
|
(它记录到「连续 3 次不通」并把现场留了下来)。
|
||||||
|
|
||||||
|
**改法**:让脚本知道「这次是服务在调我」,那就跳过重启隧道那一步。
|
||||||
|
开机时本来也不需要它 —— 顺序就是调优先、隧道后,那时隧道还没起来。
|
||||||
|
|
||||||
|
> 这个坑以前一直藏着:设备上那份旧的服务定义里有一个写法错误,
|
||||||
|
> 导致它在更早的步骤就报错退出了,反而没走到会死锁的那一步。
|
||||||
|
> 把服务定义改对之后,死锁才露出来。
|
||||||
|
|
||||||
|
- 另外这一版的服务端包也带上了上面那个修复。
|
||||||
|
**2.1.0 的服务端包是有问题的**,请用 2.1.1 重新部署。
|
||||||
|
|
||||||
|
## 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
|
## 1.8.0
|
||||||
|
|
||||||
**这一版主要是「修看不见的稳定性问题」。**
|
**这一版主要是「修看不见的稳定性问题」。**
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
passed=34 failed=0
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
passed=18 failed=0
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@REVAedu120830
|
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
============================================================
|
||||||
|
deploy.sh 实测
|
||||||
|
============================================================
|
||||||
|
仓库: /c/Users/shu/Desktop/deepseekagent/LanRemote
|
||||||
|
脚本: /c/Users/shu/Desktop/deepseekagent/LanRemote/relay/deploy.sh
|
||||||
|
|
||||||
|
── 1. 语法检查(bash -n)──
|
||||||
|
[PASS] deploy.sh 语法正确
|
||||||
|
[PASS] deploy-tune.sh 语法正确
|
||||||
|
|
||||||
|
── 2. 换行符 / BOM(.sh 必须 LF + 无 BOM)──
|
||||||
|
[PASS] deploy.sh 是 LF 且无 BOM
|
||||||
|
[PASS] deploy-tune.sh 是 LF 且无 BOM
|
||||||
|
|
||||||
|
── 3. --help ──
|
||||||
|
[PASS] --help 退出码为 0
|
||||||
|
[PASS] --help 有「用法」段
|
||||||
|
[PASS] --help 列出了 --prefix
|
||||||
|
[PASS] --help 提到了 TUNNEL_TOKEN
|
||||||
|
[PASS] --help 没有副作用
|
||||||
|
|
||||||
|
── 3b. 非法参数要报错退出 ──
|
||||||
|
[PASS] 未知参数退出码为 2
|
||||||
|
[PASS] 非法协议退出码为 2
|
||||||
|
|
||||||
|
── 4. 搭沙箱(桩命令 + 临时安装根)──
|
||||||
|
沙箱: /tmp/tmp.jb8lwoeXgw
|
||||||
|
安装根: /tmp/tmp.jb8lwoeXgw/root
|
||||||
|
桩命令: cloudflared curl free getent ip iptables nmcli python3 systemctl
|
||||||
|
|
||||||
|
── 5. 第一次部署(--skip-tunnel,不碰隧道)──
|
||||||
|
[PASS] 第一次退出码为 0
|
||||||
|
[PASS] relay.py 已部署
|
||||||
|
[PASS] admin.py 已部署
|
||||||
|
[PASS] landing.py 已部署
|
||||||
|
[PASS] showcase.py 已部署
|
||||||
|
[PASS] favicon.ico 已部署
|
||||||
|
[PASS] deploy-tune.sh 已部署
|
||||||
|
[PASS] watchdog.sh 已部署
|
||||||
|
[PASS] site_shots 已部署
|
||||||
|
[PASS] data 目录已建
|
||||||
|
[PASS] downloads 目录已建
|
||||||
|
[PASS] lanremote-relay.service 已生成
|
||||||
|
[PASS] lanremote-nettune.service 已生成
|
||||||
|
[PASS] lanremote-tunnel.service 已生成
|
||||||
|
[PASS] 备用 http2 单元已生成
|
||||||
|
[PASS] 看门狗 timer 已生成
|
||||||
|
|
||||||
|
生成的 relay unit 里应该有的关键项:
|
||||||
|
[PASS] relay unit 含 RELAY_POLL=60
|
||||||
|
[PASS] relay unit 含 RELAY_PORT=8080
|
||||||
|
[PASS] relay unit 含 python3
|
||||||
|
|
||||||
|
两个隧道单元的内容(默认主 quic+8 / 副本 http2+2):
|
||||||
|
[PASS] 主隧道单元用 quic
|
||||||
|
[PASS] 主隧道 8 条连接(实测最优配比)
|
||||||
|
[PASS] 副本隧道单元用 http2
|
||||||
|
[PASS] 副本隧道 2 条连接(实测最优配比)
|
||||||
|
[PASS] 主隧道 metrics 钉死 20241
|
||||||
|
[PASS] 副本隧道 metrics 钉死 20242
|
||||||
|
[PASS] nettune 只有一个入口脚本
|
||||||
|
[PASS] unit 里没有明文 Token
|
||||||
|
[PASS] 便捷命令 lanremote-url 已生成
|
||||||
|
[PASS] 便捷命令 lanremote-key 已生成
|
||||||
|
[PASS] 便捷命令 lanremote-status 已生成
|
||||||
|
|
||||||
|
── 6. 幂等性:放几个「不许被冲掉」的东西,再跑第二遍 ──
|
||||||
|
[PASS] 第二次退出码为 0
|
||||||
|
[PASS] 设备数据没被冲掉
|
||||||
|
[PASS] 管理钥匙没被冲掉
|
||||||
|
[PASS] 管理密码没被冲掉
|
||||||
|
[PASS] 公网地址没被冲掉
|
||||||
|
[PASS] relay_state.json 没被改(allowNew 保住了)
|
||||||
|
[PASS] allowNew 仍然是 false(没被拨回 true)
|
||||||
|
[PASS] 手工 config.yml 没被覆盖
|
||||||
|
[PASS] relay.py 内容稳定
|
||||||
|
[PASS] 第二遍没有新增 ✗ 错误
|
||||||
|
|
||||||
|
── 6b. 没变化时不该重启服务(别打断在线连接)──
|
||||||
|
[PASS] 第二遍没有重启中转(无变化)
|
||||||
|
[PASS] 第二遍没有重启隧道(无变化)
|
||||||
|
[PASS] 第二遍没有多余的 daemon-reload
|
||||||
|
[PASS] 输出里明确说了「内容没变、保持运行」
|
||||||
|
|
||||||
|
── 7. 凭据模式:给出域名时要能生成 config.yml ──
|
||||||
|
[PASS] 凭据模式退出码为 0
|
||||||
|
[PASS] config.yml 已生成
|
||||||
|
[PASS] config.yml 里的隧道 ID 取自凭据文件名
|
||||||
|
[PASS] config.yml 写入了域名
|
||||||
|
[PASS] config.yml 有兜底 404 规则(没有它 cloudflared 起不来)
|
||||||
|
[PASS] config.yml 指向本地中转端口
|
||||||
|
|
||||||
|
── 7c. 默认:两个隧道单元都要启用(协议级故障隔离)──
|
||||||
|
[PASS] 默认(不传 --protocol)退出码为 0
|
||||||
|
[PASS] 默认启用了主隧道
|
||||||
|
[PASS] 默认启用了副本隧道
|
||||||
|
[PASS] 默认没有关掉任何一个隧道
|
||||||
|
|
||||||
|
── 7d. --protocol quic:只跑主隧道 ──
|
||||||
|
[PASS] --protocol quic 退出码为 0
|
||||||
|
[PASS] quic 模式启用了主隧道
|
||||||
|
[PASS] quic 模式停用了副本隧道
|
||||||
|
[PASS] 输出说明了为什么停用副本
|
||||||
|
|
||||||
|
── 7e. --protocol http2:只跑副本隧道 ──
|
||||||
|
[PASS] --protocol http2 退出码为 0
|
||||||
|
[PASS] http2 模式启用了副本隧道
|
||||||
|
[PASS] http2 模式停用了主隧道
|
||||||
|
|
||||||
|
── 7f. --protocol 的非法值要报错 ──
|
||||||
|
[PASS] 非法协议退出码为 2
|
||||||
|
[PASS] both 是合法值
|
||||||
|
|
||||||
|
── 7b. 再跑一次(不给 --hostname),config.yml 不该被改 ──
|
||||||
|
[PASS] 再次运行退出码为 0
|
||||||
|
[PASS] 没给 --hostname 时 config.yml 保持不变
|
||||||
|
|
||||||
|
── 8. Token 模式 ──
|
||||||
|
[PASS] Token 模式退出码为 0
|
||||||
|
[PASS] Token 写进了 tunnel.env
|
||||||
|
[PASS] tunnel.env 内容正确
|
||||||
|
[PASS] unit 里没有明文 Token(只有 EnvironmentFile 引用)
|
||||||
|
[PASS] unit 用了 EnvironmentFile 读 Token
|
||||||
|
[PASS] unit 的 ExecStart 里用 --token ${TUNNEL_TOKEN}
|
||||||
|
|
||||||
|
── 8b. tunnel.env 权限应该是 600 ──
|
||||||
|
tunnel.env 权限: 644(Linux 上应为 600)
|
||||||
|
|
||||||
|
── 9. 缺文件时要明确报错,不能静默 ──
|
||||||
|
[PASS] 文件不全时明确失败(退出码 1)
|
||||||
|
[PASS] 报错信息说清了是「程序文件不全」
|
||||||
|
|
||||||
|
── 10. 桩命令确实被调用过(证明流程真的走了)──
|
||||||
|
[PASS] 调用过 systemctl enable
|
||||||
|
[PASS] 调用过 iptables
|
||||||
|
[PASS] 第一次安装做了 daemon-reload
|
||||||
|
|
||||||
|
── 11. 脚本里不许有写死的域名 ──
|
||||||
|
[PASS] deploy.sh 里没有写死的域名
|
||||||
|
[PASS] deploy-tune.sh 里没有写死的域名
|
||||||
|
[PASS] net-tune.sh 里没有写死的域名
|
||||||
|
[PASS] 示例用的是「你的域名」占位符
|
||||||
|
|
||||||
|
── 12. 有凭据但没给域名 → 不许乱猜,必须明确提示 ──
|
||||||
|
[调试] TMP=/tmp/tmp.jb8lwoeXgw
|
||||||
|
[调试] 凭据目录内容: deadbeef-0000-1111-2222-333344445555.json
|
||||||
|
[PASS] 没域名时退出码为 0(中转照装,只是隧道不启)
|
||||||
|
[PASS] 没域名时没有生成 config.yml(没有乱猜)
|
||||||
|
[PASS] 明确提示了「需要你提供公网域名」
|
||||||
|
[PASS] 提示里给了怎么补域名
|
||||||
|
[PASS] 提示里给了 Token 模式这个替代办法
|
||||||
|
[PASS] 隧道没配好时没有去启动它
|
||||||
|
[PASS] 中转仍然正常安装并启用
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
结果: 通过 98 项,失败 0 项
|
||||||
|
============================================================
|
||||||
|
沙箱目录(想自己看就留着): /tmp/tmp.jb8lwoeXgw
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=LanRemote Cloudflare 具名隧道
|
|
||||||
After=network-online.target lanremote-relay.service
|
|
||||||
Wants=network-online.target lanremote-relay.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --protocol http2 --edge-ip-version 4 --ha-connections 1 --retries 5 --config /root/.cloudflared/config.yml run lanremote
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
MemoryMax=128M
|
|
||||||
StandardOutput=append:/root/lanremote-relay/tunnel.log
|
|
||||||
StandardError=append:/root/lanremote-relay/tunnel.log
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# quic+6 vs http2+4:用**真实长轮询**对照,方法完全一致。
|
||||||
|
#
|
||||||
|
# 为什么必须用长轮询测:
|
||||||
|
# 之前用 /v1/health 那种"每次新建连接"的方式测,
|
||||||
|
# 量到的是冷启动开销,和真实业务(复用连接的长轮询)差很多。
|
||||||
|
# 而且那种方式会看到 18 秒的尾巴 —— 长轮询完全没有。
|
||||||
|
#
|
||||||
|
# 为什么这次结果可信:
|
||||||
|
# 上一轮 A/B 里 quic 那组因为**两次探针用了同一个配对码**,
|
||||||
|
# 第二次注册被"一个码只能一台设备"规则拒掉,token 拿到空的,
|
||||||
|
# 8 次全 401 —— 那一轮数据作废。这次每个探针都用唯一的新码。
|
||||||
|
#
|
||||||
|
# 为什么值得测 QUIC:
|
||||||
|
# 刚才查出来:隧道连接 30 分钟 0 掉线、0 重连,很稳;
|
||||||
|
# 但请求级偶发 000(10 分钟 6 次)。
|
||||||
|
# 这是 HTTP/2 over TCP 的**队头阻塞** ——
|
||||||
|
# 一条 TCP 上丢一个包,那条连接上所有流一起卡。
|
||||||
|
# QUIC 每个流独立恢复,理论上正好治这个。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PUB=https://relay.xxmxxs.top
|
||||||
|
UA='LanRemote/1.8.1'
|
||||||
|
SVC=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
BACKUP=/root/lanremote-tunnel.service.bak.q.$(date +%s)
|
||||||
|
cp -a "$SVC" "$BACKUP"
|
||||||
|
ROUNDS=${1:-10}
|
||||||
|
|
||||||
|
set_params() {
|
||||||
|
local proto="$1" conns="$2"
|
||||||
|
if grep -q -- '--protocol' "$SVC"; then
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
|
||||||
|
else
|
||||||
|
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
|
||||||
|
fi
|
||||||
|
if grep -q -- '--ha-connections' "$SVC"; then
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
|
||||||
|
else
|
||||||
|
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
|
||||||
|
fi
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_ready() {
|
||||||
|
local want="$1"
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
sleep 5
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
[ "${n:-0}" -ge "$want" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
new_probe() {
|
||||||
|
# 每次都用全新的 deviceId + 全新的配对码,避免被"一码一机"拒掉
|
||||||
|
local stamp
|
||||||
|
stamp=$(date +%s%N | cut -c1-13)
|
||||||
|
local dev="qprobe-${stamp}"
|
||||||
|
local code
|
||||||
|
code=$(printf "%06d" $(( (stamp / 7) % 1000000 )))
|
||||||
|
local reg
|
||||||
|
reg=$(curl -s --max-time 30 -X POST "$PUB/v1/agent/register" \
|
||||||
|
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||||
|
-d "{\"deviceId\":\"$dev\",\"code\":\"$code\",\"name\":\"qprobe\",\"version\":\"1.8.1\"}")
|
||||||
|
local tok
|
||||||
|
tok=$(echo "$reg" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||||
|
PROBE_DEV="$dev"
|
||||||
|
PROBE_TOK="$tok"
|
||||||
|
PROBE_CODE="$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_probe() {
|
||||||
|
local key
|
||||||
|
key=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||||
|
[ -n "${PROBE_DEV:-}" ] && curl -s --max-time 20 -X POST \
|
||||||
|
"http://127.0.0.1:8080/v1/admin/delete" -H "X-Admin: $key" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"deviceId\":\"$PROBE_DEV\"}" > /dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_round() {
|
||||||
|
local label="$1" proto="$2" conns="$3"
|
||||||
|
|
||||||
|
echo "════════ $label ════════"
|
||||||
|
set_params "$proto" "$conns"
|
||||||
|
if wait_ready "$conns"; then
|
||||||
|
echo " 连接就绪($(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') 条)"
|
||||||
|
else
|
||||||
|
echo " ! 连接没建齐,仍然继续测"
|
||||||
|
fi
|
||||||
|
echo " 落点:"
|
||||||
|
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep 'server_locations.*} 1' | sed 's/^/ /'
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
new_probe
|
||||||
|
if [ -z "${PROBE_TOK:-}" ]; then
|
||||||
|
echo " [失败] 探针注册没拿到 token,这轮作废"
|
||||||
|
echo "$label|REGFAIL|0|0|0" >> /tmp/qab.txt
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo " 探针 code=$PROBE_CODE token=${#PROBE_TOK} 字符"
|
||||||
|
|
||||||
|
local mark
|
||||||
|
mark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
|
||||||
|
local ok=0 fail=0 times=() fails=""
|
||||||
|
for i in $(seq 1 "$ROUNDS"); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 100 \
|
||||||
|
-X POST "$PUB/v1/agent/poll" -H "X-Token: $PROBE_TOK" \
|
||||||
|
-H "User-Agent: $UA")
|
||||||
|
code=$(echo "$out" | awk '{print $1}')
|
||||||
|
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
ok=$((ok+1)); times+=("$ms")
|
||||||
|
printf " %2d: 200 %6dms\n" "$i" "$ms"
|
||||||
|
else
|
||||||
|
fail=$((fail+1)); fails="$fails ${i}:${code}"
|
||||||
|
printf " %2d: %-4s **失败**\n" "$i" "$code"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local stats
|
||||||
|
if [ "${#times[@]}" -gt 0 ]; then
|
||||||
|
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
|
||||||
|
printf "中位 %d P90 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]}')
|
||||||
|
else
|
||||||
|
stats="无数据"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local reconn
|
||||||
|
reconn=$(tail -n +$((mark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null \
|
||||||
|
| grep -c 'Registered tunnel connection' || echo 0)
|
||||||
|
|
||||||
|
echo " == $ok/$ROUNDS 成功 耗时: $stats ms 本轮重连 $reconn 次"
|
||||||
|
echo " 失败:$fails"
|
||||||
|
echo "$label|$ok|$ROUNDS|$((ok*100/ROUNDS))|$stats|$reconn" >> /tmp/qab.txt
|
||||||
|
cleanup_probe
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f /tmp/qab.txt
|
||||||
|
|
||||||
|
# 先基线,再候选 —— 同一网络条件下先后测,尽量公平
|
||||||
|
run_round "http2+4" http2 4 || true
|
||||||
|
run_round "quic+6" quic 6 || true
|
||||||
|
|
||||||
|
echo "════════ 汇总 ════════"
|
||||||
|
printf " %-10s %-10s %-8s %-32s %s\n" "配置" "成功" "可用率" "耗时" "本轮重连"
|
||||||
|
while IFS='|' read -r label ok total pct stats reconn; do
|
||||||
|
if [ "$ok" = "REGFAIL" ]; then
|
||||||
|
printf " %-10s 探针注册失败,作废\n" "$label"
|
||||||
|
else
|
||||||
|
printf " %-10s %-10s %-8s %-32s %s\n" "$label" "$ok/$total" "${pct}%" "$stats" "$reconn"
|
||||||
|
fi
|
||||||
|
done < /tmp/qab.txt
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 决策 ════════"
|
||||||
|
B_OK=$(grep '^http2+4' /tmp/qab.txt | cut -d'|' -f4)
|
||||||
|
Q_OK=$(grep '^quic+6' /tmp/qab.txt | cut -d'|' -f4)
|
||||||
|
B_REC=$(grep '^http2+4' /tmp/qab.txt | cut -d'|' -f6)
|
||||||
|
Q_REC=$(grep '^quic+6' /tmp/qab.txt | cut -d'|' -f6)
|
||||||
|
echo " 基线: 可用率 ${B_OK:-?}% 重连 ${B_REC:-?}"
|
||||||
|
echo " 候选: 可用率 ${Q_OK:-?}% 重连 ${Q_REC:-?}"
|
||||||
|
if [ "${Q_OK:-0}" -ge "${B_OK:-0}" ] && [ "${Q_OK:-0}" -ge 90 ]; then
|
||||||
|
echo " → 采用 quic+6"
|
||||||
|
else
|
||||||
|
echo " → 保留 http2+4"
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
fi
|
||||||
|
sleep 15
|
||||||
|
echo
|
||||||
|
echo " 最终参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ')"
|
||||||
|
echo " 备份: $BACKUP"
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# TCP 重传调优的受控 A/B。
|
||||||
|
#
|
||||||
|
# 背景(实测):
|
||||||
|
# TCPTimeouts: 3145 · TCPLostRetransmit: 2010
|
||||||
|
# 这些多秒/多分钟的尾巴全是 TCP 重传超时造成的。
|
||||||
|
# 这台机器内核是定制的,**没有 BBR**(只有 reno/cubic),
|
||||||
|
# 也没有任何替代 qdisc(只有 noqueue),所以只能在
|
||||||
|
# 现有 cubic 上调 TCP 的重传行为。
|
||||||
|
#
|
||||||
|
# 三个候选开关(都对症"细流 + 高丢包"这个场景):
|
||||||
|
# tcp_thin_linear_timeouts 细流不搞 RTO 指数退避,重传来得更快
|
||||||
|
# tcp_fack 前向确认,丢包恢复更积极
|
||||||
|
# tcp_retries2 重传几次就放弃(8 太大,卡十几分钟)
|
||||||
|
#
|
||||||
|
# 每轮:设参数 -> 打 60 次 HTTP -> 记录成功率/耗时分布 -> 下一轮
|
||||||
|
# 全部跑完回滚,最后按数据决定采用哪组。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=https://relay.xxmxxs.top
|
||||||
|
UA='LanRemote/1.8.1'
|
||||||
|
N=60
|
||||||
|
|
||||||
|
# 保存原始值,跑完恢复
|
||||||
|
declare -A ORIG
|
||||||
|
for k in tcp_thin_linear_timeouts tcp_fack tcp_retries2 tcp_autocorking; do
|
||||||
|
ORIG[$k]=$(sysctl -n net.ipv4.$k 2>/dev/null || echo "")
|
||||||
|
done
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
for k in "${!ORIG[@]}"; do
|
||||||
|
[ -n "${ORIG[$k]}" ] && sysctl -qw net.ipv4.$k="${ORIG[$k]}" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
measure() {
|
||||||
|
local label="$1"
|
||||||
|
local ok=0 fail=0 times=()
|
||||||
|
for i in $(seq 1 "$N"); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
|
||||||
|
-H "User-Agent: $UA" "$BASE/v1/health")
|
||||||
|
c=$(echo "$out" | awk '{print $1}')
|
||||||
|
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
if [ "$c" = "200" ]; then ok=$((ok+1)); times+=("$ms"); else fail=$((fail+1)); fi
|
||||||
|
done
|
||||||
|
local pct=$((ok * 100 / N))
|
||||||
|
local stats="无数据"
|
||||||
|
if [ "${#times[@]}" -gt 0 ]; then
|
||||||
|
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
|
||||||
|
printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
|
||||||
|
fi
|
||||||
|
printf " %-34s %3d%% (%d/%d) %s ms\n" "$label" "$pct" "$ok" "$N" "$stats"
|
||||||
|
echo "$label|$pct|$ok|$stats" >> /tmp/tcpab.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f /tmp/tcpab.txt
|
||||||
|
|
||||||
|
echo "════════ 原始值 ════════"
|
||||||
|
for k in "${!ORIG[@]}"; do printf " %-30s = %s\n" "$k" "${ORIG[$k]}"; done
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "════════ 各组合实测(每轮 $N 次)════════"
|
||||||
|
|
||||||
|
echo " [1/6] 基线(不改)"
|
||||||
|
measure "基线"
|
||||||
|
|
||||||
|
echo " [2/6] thin_linear_timeouts=1"
|
||||||
|
sysctl -qw net.ipv4.tcp_thin_linear_timeouts=1
|
||||||
|
measure "thin=1"
|
||||||
|
|
||||||
|
echo " [3/6] thin=1 + fack=1"
|
||||||
|
sysctl -qw net.ipv4.tcp_fack=1
|
||||||
|
measure "thin=1 fack=1"
|
||||||
|
|
||||||
|
echo " [4/6] thin=1 fack=1 + retries2=5"
|
||||||
|
sysctl -qw net.ipv4.tcp_retries2=5
|
||||||
|
measure "thin=1 fack=1 retries2=5"
|
||||||
|
|
||||||
|
echo " [5/6] thin=1 fack=1 retries2=5 + autocorking=0"
|
||||||
|
sysctl -qw net.ipv4.tcp_autocorking=0
|
||||||
|
measure "全部开启"
|
||||||
|
|
||||||
|
echo " [6/6] 回滚到基线再测一次(排除时间漂移)"
|
||||||
|
restore
|
||||||
|
measure "回滚后基线"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 汇总(按可用率排序)════════"
|
||||||
|
sort -t'|' -k2 -rn /tmp/tcpab.txt | while IFS='|' read -r label pct ok stats; do
|
||||||
|
printf " %-34s %3s%% %s\n" "$label" "$pct" "$stats"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 按数据决定 ════════"
|
||||||
|
BEST=$(sort -t'|' -k2 -rn /tmp/tcpab.txt | head -1)
|
||||||
|
BEST_LABEL=$(echo "$BEST" | cut -d'|' -f1)
|
||||||
|
BEST_PCT=$(echo "$BEST" | cut -d'|' -f2)
|
||||||
|
echo " 最好的是: $BEST_LABEL ($BEST_PCT%)"
|
||||||
|
|
||||||
|
case "$BEST_LABEL" in
|
||||||
|
"基线"|"回滚后基线")
|
||||||
|
echo " → 调优没有收益,保持默认(只留已验证过的 keepalive 那几项)"
|
||||||
|
restore
|
||||||
|
;;
|
||||||
|
"thin=1")
|
||||||
|
sysctl -qw net.ipv4.tcp_thin_linear_timeouts=1
|
||||||
|
echo " → 只启用 thin_linear_timeouts"
|
||||||
|
;;
|
||||||
|
"thin=1 fack=1")
|
||||||
|
sysctl -qw net.ipv4.tcp_thin_linear_timeouts=1
|
||||||
|
sysctl -qw net.ipv4.tcp_fack=1
|
||||||
|
echo " → 启用 thin_linear_timeouts + fack"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
sysctl -qw net.ipv4.tcp_thin_linear_timeouts=1
|
||||||
|
sysctl -qw net.ipv4.tcp_fack=1
|
||||||
|
sysctl -qw net.ipv4.tcp_retries2=5
|
||||||
|
sysctl -qw net.ipv4.tcp_autocorking=0
|
||||||
|
echo " → 全部启用"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 当前生效值:"
|
||||||
|
for k in tcp_thin_linear_timeouts tcp_fack tcp_retries2 tcp_autocorking; do
|
||||||
|
printf " %-30s = %s\n" "$k" "$(sysctl -n net.ipv4.$k 2>/dev/null)"
|
||||||
|
done
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 受控 A/B:隧道协议和连接数,哪个在"稳 + 快"上更好。
|
||||||
|
#
|
||||||
|
# 背景:
|
||||||
|
# 实测隧道大多稳(12/12),但每个请求稳定吃 ~1100ms 回源延迟。
|
||||||
|
# 而且日志里 i/o timeout 不少(最近 800 行 104 次),
|
||||||
|
# 说明有些 edge IP 连得很差(实测最慢那个 1331ms,最快的 146ms)。
|
||||||
|
#
|
||||||
|
# 假定:连接数多一些,撞上"慢 IP"的窗口就小一些。
|
||||||
|
# QUIC 在多丢包下恢复更好(没有跨流的队头阻塞),但之前测
|
||||||
|
# "http2 比 quic 稳" —— 那是连接建立阶段的观感,这次量真实请求。
|
||||||
|
#
|
||||||
|
# 组合:
|
||||||
|
# http2 + 4 (当前配置,基线)
|
||||||
|
# http2 + 6
|
||||||
|
# quic + 4
|
||||||
|
# quic + 6
|
||||||
|
#
|
||||||
|
# 每个组合:重启隧道 -> 等连接建齐 -> 打 20 次 -> 记录
|
||||||
|
# 成功率 / 中位耗时 / P90 / 连接数
|
||||||
|
set -u
|
||||||
|
|
||||||
|
URL=https://relay.xxmxxs.top/v1/health
|
||||||
|
UA='LanRemote/1.8.0'
|
||||||
|
SVC=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
BACKUP=/root/lanremote-tunnel.service.bak.$(date +%s)
|
||||||
|
cp -a "$SVC" "$BACKUP"
|
||||||
|
|
||||||
|
echo " 备份: $BACKUP"
|
||||||
|
echo " 原参数:"
|
||||||
|
grep -A3 'ExecStart' "$SVC" | grep -E 'protocol|ha-connections|edge-ip' | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
|
||||||
|
run_combo() {
|
||||||
|
local proto="$1" conns="$2" label="$3"
|
||||||
|
|
||||||
|
echo "════════ $label ════════"
|
||||||
|
# 改参数
|
||||||
|
if grep -q '\-\-protocol' "$SVC"; then
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
|
||||||
|
else
|
||||||
|
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
|
||||||
|
fi
|
||||||
|
if grep -q '\-\-ha-connections' "$SVC"; then
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
|
||||||
|
else
|
||||||
|
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
|
||||||
|
fi
|
||||||
|
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ' | sed 's/^/ 参数: /'
|
||||||
|
echo
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
|
||||||
|
# 等连接建齐
|
||||||
|
built=0
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
sleep 5
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
if [ "${n:-0}" -ge "$conns" ]; then built="$n"; break; fi
|
||||||
|
done
|
||||||
|
echo " 连接建齐: ${built:-0}/$conns(等了 ${i}0 秒)"
|
||||||
|
|
||||||
|
# 预热几次,跳过建连阶段的冷启动
|
||||||
|
for i in 1 2 3; do curl -s -o /dev/null --max-time 20 -H "User-Agent: $UA" "$URL"; sleep 1; done
|
||||||
|
|
||||||
|
# 正式测
|
||||||
|
ok=0; times=()
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30 -H "User-Agent: $UA" "$URL")
|
||||||
|
code=$(echo "$out" | awk '{print $1}')
|
||||||
|
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
times+=("$ms")
|
||||||
|
[ "$code" = "200" ] && ok=$((ok+1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
|
||||||
|
p90 = a[int(NR*0.9)+1];
|
||||||
|
printf "最小 %d 中位 %d P90 %d 最大 %d", a[1], a[int(NR/2)+1], p90, a[NR]}')
|
||||||
|
echo " == 结果: $ok/20 成功 $stats ms"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 存下来给最后比较
|
||||||
|
echo "$label|$ok|$stats" >> /tmp/ab_result.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f /tmp/ab_result.txt
|
||||||
|
|
||||||
|
run_combo http2 4 "A. http2 + 4(基线,当前配置)"
|
||||||
|
run_combo http2 6 "B. http2 + 6"
|
||||||
|
run_combo quic 4 "C. quic + 4"
|
||||||
|
run_combo quic 6 "D. quic + 6"
|
||||||
|
|
||||||
|
echo "════════ 汇总 ════════"
|
||||||
|
cat /tmp/ab_result.txt | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 回滚到基线(http2 + 4),等后面按结果再定 ════════"
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
sleep 20
|
||||||
|
echo " 已回滚。当前参数:"
|
||||||
|
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
|
||||||
|
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 长时间对照:http2+4(基线) vs quic+6(候选)。
|
||||||
|
#
|
||||||
|
# 为什么要跑长的:
|
||||||
|
# 短测(20 次)看不出稳定性。而且历史上出过一次"QUIC 可用率 48%",
|
||||||
|
# 和这次短测的 20/20 矛盾 —— 必须用足够长的样本、并且**同时**记录
|
||||||
|
# 连接数波动和重连次数,才能判断到底是协议问题还是当时的网络问题。
|
||||||
|
#
|
||||||
|
# 每轮 5 分钟,每 3 秒一次请求(约 100 个样本),记录:
|
||||||
|
# 成功率 / 中位 / P90 / 最大 / 连接数最低值 / 连接的落点
|
||||||
|
set -u
|
||||||
|
|
||||||
|
URL=https://relay.xxmxxs.top/v1/health
|
||||||
|
UA='LanRemote/1.8.0'
|
||||||
|
SVC=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
BACKUP=/root/lanremote-tunnel.service.bak.long.$(date +%s)
|
||||||
|
cp -a "$SVC" "$BACKUP"
|
||||||
|
|
||||||
|
DURATION=300 # 每轮 5 分钟
|
||||||
|
INTERVAL=3 # 每 3 秒一次
|
||||||
|
|
||||||
|
set_params() {
|
||||||
|
local proto="$1" conns="$2"
|
||||||
|
if grep -q -- '--protocol' "$SVC"; then
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
|
||||||
|
else
|
||||||
|
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
|
||||||
|
fi
|
||||||
|
if grep -q -- '--ha-connections' "$SVC"; then
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
|
||||||
|
else
|
||||||
|
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
|
||||||
|
fi
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_ready() {
|
||||||
|
local want="$1"
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
sleep 5
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
if [ "${n:-0}" -ge "$want" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
measure() {
|
||||||
|
local label="$1"
|
||||||
|
local ok=0 fail=0 times=() minconn=99 reconnects=0
|
||||||
|
local start=$(date +%s)
|
||||||
|
local logmark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
echo " ── $label:跑 ${DURATION} 秒,每 ${INTERVAL} 秒一次 ──"
|
||||||
|
while [ $(( $(date +%s) - start )) -lt "$DURATION" ]; do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30 -H "User-Agent: $UA" "$URL")
|
||||||
|
code=$(echo "$out" | awk '{print $1}')
|
||||||
|
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
ok=$((ok+1)); times+=("$ms")
|
||||||
|
else
|
||||||
|
fail=$((fail+1))
|
||||||
|
fi
|
||||||
|
n=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
[ -n "${n:-}" ] && [ "$n" -lt "$minconn" ] && minconn="$n"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 这一轮新注册了多少次(= 重连次数)
|
||||||
|
nowmark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
|
||||||
|
reconnects=$(tail -n +$((logmark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null \
|
||||||
|
| grep -c 'Registered tunnel connection' || echo 0)
|
||||||
|
|
||||||
|
total=$((ok+fail))
|
||||||
|
pct=0; [ "$total" -gt 0 ] && pct=$((ok * 100 / total))
|
||||||
|
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {
|
||||||
|
if (NR==0) { print "无数据"; exit }
|
||||||
|
printf "中位 %d P90 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]}')
|
||||||
|
|
||||||
|
echo " == 结果: $ok/$total = ${pct}% $stats ms"
|
||||||
|
echo " 连接数最低 $minconn 本轮重连 $reconnects 次"
|
||||||
|
echo "$label|$ok|$total|$pct|$stats|$minconn|$reconnects" >> /tmp/longab.txt
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f /tmp/longab.txt
|
||||||
|
|
||||||
|
echo "════════ 第一轮:http2 + 4(基线)════════"
|
||||||
|
set_params http2 4
|
||||||
|
wait_ready 4 && echo " 连接就绪" || echo " ! 连接没建齐,继续测"
|
||||||
|
sleep 10
|
||||||
|
measure "http2+4"
|
||||||
|
|
||||||
|
echo "════════ 第二轮:quic + 6(候选)════════"
|
||||||
|
set_params quic 6
|
||||||
|
wait_ready 6 && echo " 连接就绪" || echo " ! 连接没建齐,继续测"
|
||||||
|
sleep 10
|
||||||
|
measure "quic+6"
|
||||||
|
|
||||||
|
echo "════════ 汇总 ════════"
|
||||||
|
printf " %-12s %-14s %-10s %-22s %-10s %s\n" "配置" "成功" "可用率" "延迟" "连接最低" "重连"
|
||||||
|
while IFS='|' read -r label ok total pct stats minconn reconnects; do
|
||||||
|
printf " %-12s %-14s %-10s %-22s %-10s %s\n" "$label" "$ok/$total" "${pct}%" "$stats" "$minconn" "$reconnects"
|
||||||
|
done < /tmp/longab.txt
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 按结果决定是否改配置 ════════"
|
||||||
|
BASE_PCT=$(grep '^http2+4' /tmp/longab.txt | cut -d'|' -f4)
|
||||||
|
QUIC_PCT=$(grep '^quic+6' /tmp/longab.txt | cut -d'|' -f4)
|
||||||
|
if [ "${QUIC_PCT:-0}" -ge "${BASE_PCT:-0}" ] && [ "${QUIC_PCT:-0}" -ge 90 ]; then
|
||||||
|
echo " → quic+6 可用率 ${QUIC_PCT}% >= 基线 ${BASE_PCT}%,保留 quic+6"
|
||||||
|
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " → quic+6 可用率 ${QUIC_PCT}% 不如基线 ${BASE_PCT}%,回滚"
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
sleep 20
|
||||||
|
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo " 当前连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
|
||||||
|
echo " 备份: $BACKUP"
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 严谨对照 v2:http2+4 vs quic+6,用**真实的长轮询模式**测。
|
||||||
|
#
|
||||||
|
# v1 的问题:
|
||||||
|
# * 只跑 5 分钟、每 3 秒一个新连接 —— 那是"冷启动"模式,
|
||||||
|
# 而真实业务是**长轮询复用同一条连接**,两者差别很大。
|
||||||
|
# * 判定规则太粗(98% vs 100% 就回滚),
|
||||||
|
# 忽略了 P90 从 16.8 秒改善到 2.8 秒这个数量级的差异。
|
||||||
|
#
|
||||||
|
# v2 的做法:
|
||||||
|
# * 用 curl 一次给多个 URL —— 复用同一条连接,贴近长轮询
|
||||||
|
# * 每轮 10 分钟
|
||||||
|
# * 记录 成功率 / 中位 / P90 / P99 / 最大 / 失败时刻
|
||||||
|
# * 用真实的长轮询接口 /v1/agent/poll(挂 60 秒),
|
||||||
|
# 量"一次完整长轮询"的耗时分布 —— 这才是用户真正经历的
|
||||||
|
set -u
|
||||||
|
|
||||||
|
UA='LanRemote/1.8.0'
|
||||||
|
PUB=https://relay.xxmxxs.top
|
||||||
|
SVC=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
BACKUP=/root/lanremote-tunnel.service.bak.v2.$(date +%s)
|
||||||
|
cp -a "$SVC" "$BACKUP"
|
||||||
|
|
||||||
|
set_params() {
|
||||||
|
local proto="$1" conns="$2"
|
||||||
|
if grep -q -- '--protocol' "$SVC"; then
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
|
||||||
|
else
|
||||||
|
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --protocol $proto|" "$SVC"
|
||||||
|
fi
|
||||||
|
if grep -q -- '--ha-connections' "$SVC"; then
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
|
||||||
|
else
|
||||||
|
sed -i "s|\(ExecStart=.*cloudflared tunnel\)|\1 --ha-connections $conns|" "$SVC"
|
||||||
|
fi
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_ready() {
|
||||||
|
local want="$1"
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
sleep 5
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
[ "${n:-0}" -ge "$want" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 用真实长轮询接口测 ----
|
||||||
|
# 注册一台探针设备,poll 会挂 60 秒再返回(没有数据时)
|
||||||
|
probe_device() {
|
||||||
|
DEV="probev2-$(date +%s)"
|
||||||
|
REG=$(curl -s --max-time 30 -X POST "$PUB/v1/agent/register" \
|
||||||
|
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||||
|
-d "{\"deviceId\":\"$DEV\",\"code\":\"777777\",\"name\":\"probev2\",\"version\":\"1.8.0\"}")
|
||||||
|
echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null
|
||||||
|
echo "$DEV" > /tmp/probe_dev.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
measure_poll() {
|
||||||
|
local label="$1" token="$2" rounds="${3:-8}"
|
||||||
|
local ok=0 fail=0 times=() fails=""
|
||||||
|
echo " ── $label:$rounds 次真实长轮询(每次挂 60 秒)──"
|
||||||
|
for i in $(seq 1 "$rounds"); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 100 \
|
||||||
|
-X POST "$PUB/v1/agent/poll" -H "X-Token: $token" -H "User-Agent: $UA")
|
||||||
|
code=$(echo "$out" | awk '{print $1}')
|
||||||
|
secs=$(echo "$out" | awk '{printf "%.0f", $2*1000}')
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
ok=$((ok+1)); times+=("$secs")
|
||||||
|
printf " %d: 200 %sms\n" "$i" "$secs"
|
||||||
|
else
|
||||||
|
fail=$((fail+1)); fails="$fails ${i}:${code}"
|
||||||
|
printf " %d: %s **失败**\n" "$i" "$code"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {
|
||||||
|
if (NR==0) { print "无数据"; exit }
|
||||||
|
printf "中位 %d P90 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]}')
|
||||||
|
echo " == $ok/$rounds 成功 $stats ms 失败:$fails"
|
||||||
|
echo "$label|$ok|$rounds|$stats|$fails" >> /tmp/v2.txt
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f /tmp/v2.txt
|
||||||
|
|
||||||
|
echo "════════ 基线:http2 + 4 ════════"
|
||||||
|
set_params http2 4
|
||||||
|
wait_ready 4 && echo " 连接就绪" || echo " ! 没建齐"
|
||||||
|
sleep 15
|
||||||
|
TOKEN=$(probe_device)
|
||||||
|
echo " 探针 token ${#TOKEN} 字符"
|
||||||
|
measure_poll "http2+4" "$TOKEN" 8
|
||||||
|
|
||||||
|
echo "════════ 候选:quic + 6 ════════"
|
||||||
|
set_params quic 6
|
||||||
|
wait_ready 6 && echo " 连接就绪" || echo " ! 没建齐"
|
||||||
|
sleep 15
|
||||||
|
TOKEN=$(probe_device)
|
||||||
|
echo " 探针 token ${#TOKEN} 字符"
|
||||||
|
measure_poll "quic+6" "$TOKEN" 8
|
||||||
|
|
||||||
|
echo "════════ 汇总(每次长轮询的耗时,理想值 ≈ 61000ms)════════"
|
||||||
|
printf " %-10s %-10s %s\n" "配置" "成功" "耗时"
|
||||||
|
while IFS='|' read -r label ok total stats fails; do
|
||||||
|
printf " %-10s %-10s %s\n" "$label" "$ok/$total" "$stats"
|
||||||
|
done < /tmp/v2.txt
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 决策:主要看尾部延迟(用户体感就是这个)════════"
|
||||||
|
B=$(grep '^http2+4' /tmp/v2.txt | cut -d'|' -f4)
|
||||||
|
Q=$(grep '^quic+6' /tmp/v2.txt | cut -d'|' -f4)
|
||||||
|
echo " 基线 $B"
|
||||||
|
echo " 候选 $Q"
|
||||||
|
|
||||||
|
# 取 P90 数字比较
|
||||||
|
BP=$(echo "$B" | grep -oE 'P90 [0-9]+' | grep -oE '[0-9]+')
|
||||||
|
QP=$(echo "$Q" | grep -oE 'P90 [0-9]+' | grep -oE '[0-9]+')
|
||||||
|
echo
|
||||||
|
if [ -n "${BP:-}" ] && [ -n "${QP:-}" ]; then
|
||||||
|
if [ "$QP" -lt "$BP" ]; then
|
||||||
|
echo " → quic+6 的 P90(${QP}ms)优于基线(${BP}ms),采用 quic+6"
|
||||||
|
# 已经设成 quic 6 了,不用动
|
||||||
|
else
|
||||||
|
echo " → quic+6 的 P90(${QP}ms)不如基线(${BP}ms),回滚"
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " → 数据不足,保守回滚"
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 15
|
||||||
|
echo
|
||||||
|
echo " 最终参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ')"
|
||||||
|
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
|
||||||
|
|
||||||
|
# 清探针
|
||||||
|
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||||
|
DEV=$(cat /tmp/probe_dev.txt 2>/dev/null)
|
||||||
|
[ -n "${DEV:-}" ] && curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
|
||||||
|
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
|
||||||
|
echo " 备份: $BACKUP"
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 隧道对照 v3:验证「QUIC 失败是因为 MTU」这个假设,并选出最稳的组合。
|
||||||
|
#
|
||||||
|
# 已查明的事实:
|
||||||
|
# 1. cloudflared 用 QUIC 时反复报
|
||||||
|
# "failed to dial to edge with quic: timeout: no recent network activity"
|
||||||
|
# 连十几个 IP 才成功一次 —— 握手阶段就掉包。
|
||||||
|
# 2. 我们给 TCP 做了 MSS 钳制(--set-mss 1400),
|
||||||
|
# 但 **QUIC 走 UDP,TCPMSS 规则完全不生效**。
|
||||||
|
# 3. 路径 MTU 实测 1472(1500 的包不通)。
|
||||||
|
# QUIC 默认用 ~1452 字节的包,很容易被中间设备丢掉,
|
||||||
|
# 而 UDP 丢了不会回 ICMP,客户端只能等到超时。
|
||||||
|
#
|
||||||
|
# 所以对照三组:
|
||||||
|
# A. quic + 6(现状,MTU 1500) —— 预期握手仍不稳
|
||||||
|
# B. quic + 6 + wlan0 MTU 1400 —— 如果假设成立,握手会变稳
|
||||||
|
# C. http2 + 8(TCP 吃 MSS 钳制,连接多) —— 对照组
|
||||||
|
#
|
||||||
|
# 每组记录:连接建齐耗时 / 建齐几条 / 60 次 HTTP 可用率
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=https://relay.xxmxxs.top
|
||||||
|
UA='LanRemote/1.8.1'
|
||||||
|
SVC=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
BACKUP=/root/tunnel.bak.v3.$(date +%s)
|
||||||
|
cp -a "$SVC" "$BACKUP"
|
||||||
|
N=60
|
||||||
|
|
||||||
|
set_params() {
|
||||||
|
local proto="$1" conns="$2"
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$SVC"
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
set_mtu() {
|
||||||
|
local mtu="$1"
|
||||||
|
ip link set wlan0 mtu "$mtu" 2>/dev/null && echo " wlan0 MTU -> $(cat /sys/class/net/wlan0/mtu)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 等连接建齐,返回耗时秒数
|
||||||
|
wait_conns() {
|
||||||
|
local want="$1" limit="$2" t0 t1 n
|
||||||
|
t0=$(date +%s)
|
||||||
|
while true; do
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
n=${n:-0}
|
||||||
|
t1=$(date +%s)
|
||||||
|
if [ "$n" -ge "$want" ] || [ $((t1 - t0)) -ge "$limit" ]; then
|
||||||
|
echo "$n $((t1 - t0))"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_group() {
|
||||||
|
local label="$1" proto="$2" conns="$3" mtu="$4"
|
||||||
|
echo "════════ $label ════════"
|
||||||
|
|
||||||
|
[ "$mtu" != "keep" ] && set_mtu "$mtu"
|
||||||
|
|
||||||
|
local mark; mark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
|
||||||
|
set_params "$proto" "$conns"
|
||||||
|
|
||||||
|
local res; res=$(wait_conns "$conns" 150)
|
||||||
|
local n; n=$(echo "$res" | awk '{print $1}')
|
||||||
|
local secs; s=$(echo "$res" | awk '{print $2}')
|
||||||
|
echo " 建齐 $n/$conns 条,用了 ${s} 秒"
|
||||||
|
|
||||||
|
# 这一轮建立过程中失败了多少次
|
||||||
|
local dialfail; dialfail=$(tail -n +$((mark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null \
|
||||||
|
| grep -c 'Failed to dial' || echo 0)
|
||||||
|
echo " 建立期间 dial 失败 $dialfail 次"
|
||||||
|
|
||||||
|
# 可用率
|
||||||
|
local ok=0 times=()
|
||||||
|
for i in $(seq 1 "$N"); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
|
||||||
|
-H "User-Agent: $UA" "$BASE/v1/health")
|
||||||
|
c=$(echo "$out" | awk '{print $1}')
|
||||||
|
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
if [ "$c" = "200" ]; then ok=$((ok+1)); times+=("$ms"); fi
|
||||||
|
done
|
||||||
|
local pct=$((ok * 100 / N))
|
||||||
|
local stats="无数据"
|
||||||
|
if [ "${#times[@]}" -gt 0 ]; then
|
||||||
|
stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
|
||||||
|
printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
|
||||||
|
fi
|
||||||
|
printf " 可用率 %d%% (%d/%d) 耗时 %s ms\n" "$pct" "$ok" "$N" "$stats"
|
||||||
|
echo "$label|$proto|$conns|$mtu|$n|$s|$dialfail|$pct|$stats" >> /tmp/v3.txt
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f /tmp/v3.txt
|
||||||
|
echo " 原始 wlan0 MTU: $(cat /sys/class/net/wlan0/mtu)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
run_group "A. quic+6 MTU1500" quic 6 keep
|
||||||
|
run_group "B. quic+6 MTU1400" quic 6 1400
|
||||||
|
run_group "C. http2+8 MTU1400" http2 8 keep
|
||||||
|
|
||||||
|
# 恢复 MTU
|
||||||
|
set_mtu 1500
|
||||||
|
|
||||||
|
echo "════════ 汇总 ════════"
|
||||||
|
printf " %-22s %-6s %-4s %-8s %-9s %-8s %s\n" "组" "协议" "连接" "建齐/耗时" "dial失败" "可用率" "延迟"
|
||||||
|
while IFS='|' read -r label proto conns mtu n s dialfail pct stats; do
|
||||||
|
printf " %-22s %-6s %-4s %-8s %-9s %-8s %s\n" \
|
||||||
|
"$label" "$proto" "$conns" "${n}/${conns} ${s}s" "$dialfail" "${pct}%" "$stats"
|
||||||
|
done < /tmp/v3.txt
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 决策:可用率优先,其次看建齐速度 ════════"
|
||||||
|
BEST=$(sort -t'|' -k8 -rn /tmp/v3.txt | head -1)
|
||||||
|
BL=$(echo "$BEST" | cut -d'|' -f1)
|
||||||
|
BP=$(echo "$BEST" | cut -d'|' -f2)
|
||||||
|
BC=$(echo "$BEST" | cut -d'|' -f3)
|
||||||
|
BM=$(echo "$BEST" | cut -d'|' -f4)
|
||||||
|
echo " 最好: $BL(可用率 $(echo "$BEST" | cut -d'|' -f8)%)"
|
||||||
|
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol $BP/" "$SVC"
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $BC/" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
echo " 已采用: --protocol $BP --ha-connections $BC"
|
||||||
|
echo " MTU: $(cat /sys/class/net/wlan0/mtu)"
|
||||||
|
echo " 备份: $BACKUP"
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 1) 把 MTU 1400 固化下来(重启也在)
|
||||||
|
# 2) 在 MTU 1400 下对照 quic+6 / quic+8 / quic+10
|
||||||
|
#
|
||||||
|
# 为什么要固化 MTU:
|
||||||
|
# QUIC 走 UDP,**我们那条 --set-mss 1400 的 iptables 规则只管 TCP**。
|
||||||
|
# 实测把 wlan0 MTU 从 1500 降到 1400 之后:
|
||||||
|
# QUIC 建齐 6 条连接的时间 28 秒 -> 13 秒
|
||||||
|
# dial 失败次数 2 -> 1
|
||||||
|
# 原因:UDP 丢包不会回 ICMP,QUIC 发了超出路径 MTU 的包只能等超时。
|
||||||
|
# 把接口 MTU 压到 1400,QUIC 的包就都在安全范围内。
|
||||||
|
#
|
||||||
|
# 为什么还要试更多连接:
|
||||||
|
# 已经看到 http2+8 建齐只要 12 秒且 0 失败(QUIC+6 是 28 秒/2 次失败)。
|
||||||
|
# 连接多 = 单条掉线影响小。但 QUIC 的连接建立更贵,得实测平衡点。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=https://relay.xxmxxs.top
|
||||||
|
UA='LanRemote/1.8.2'
|
||||||
|
SVC=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
NETTUNE=/etc/systemd/system/lanremote-nettune.service
|
||||||
|
BACKUP=/root/tunnel.bak.v4.$(date +%s)
|
||||||
|
cp -a "$SVC" "$BACKUP"
|
||||||
|
N=60
|
||||||
|
MTU=1400
|
||||||
|
MSS=1360
|
||||||
|
|
||||||
|
echo "════════ 1. 固化 MTU(写进 net-tune 服务)════════"
|
||||||
|
echo " 现在的 net-tune 服务:"
|
||||||
|
systemctl cat lanremote-nettune 2>/dev/null | grep -E 'ExecStart' | sed 's/^/ /'
|
||||||
|
|
||||||
|
# 把 MTU 设置追加进去(幂等:已有的先删掉)
|
||||||
|
cp -a "$NETTUNE" "${NETTUNE}.bak.$(date +%s)" 2>/dev/null || true
|
||||||
|
python3 - <<'PYEOF'
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path("/etc/systemd/system/lanremote-nettune.service")
|
||||||
|
if not p.is_file():
|
||||||
|
print(" ! net-tune 服务不存在,跳过")
|
||||||
|
raise SystemExit(0)
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
# 去掉旧的 MTU/MSS 行
|
||||||
|
lines = [l for l in text.splitlines()
|
||||||
|
if "ip link set wlan0 mtu" not in l and "--set-mss" not in l]
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
out.append(line)
|
||||||
|
# 在每个 ExecStart 之后补上 MTU 设置
|
||||||
|
if line.strip().startswith("ExecStart="):
|
||||||
|
out.append("ExecStart=/sbin/ip link set wlan0 mtu 1400")
|
||||||
|
out.append("ExecStart=/sbin/iptables -t mangle -C OUTPUT -p tcp "
|
||||||
|
"--tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360 || "
|
||||||
|
"/sbin/iptables -t mangle -A OUTPUT -p tcp "
|
||||||
|
"--tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360")
|
||||||
|
p.write_text("\n".join(out) + "\n", encoding="utf-8")
|
||||||
|
print(" 已写入 MTU 1400 + MSS 1360")
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo " 立即执行一次:"
|
||||||
|
ip link set wlan0 mtu $MTU && echo " wlan0 MTU -> $(cat /sys/class/net/wlan0/mtu)"
|
||||||
|
iptables -t mangle -C OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss $MSS 2>/dev/null \
|
||||||
|
|| iptables -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss $MSS
|
||||||
|
echo " MSS 规则: $(iptables -t mangle -S OUTPUT | grep TCPMSS | head -1)"
|
||||||
|
echo " net-tune 现在的内容:"
|
||||||
|
systemctl cat lanremote-nettune 2>/dev/null | grep ExecStart | sed 's/^/ /'
|
||||||
|
echo " 服务状态: $(systemctl is-enabled lanremote-nettune 2>/dev/null) / $(systemctl is-active lanremote-nettune 2>/dev/null)"
|
||||||
|
|
||||||
|
wait_conns() {
|
||||||
|
local want="$1" limit="$2" t0 t1 n
|
||||||
|
t0=$(date +%s)
|
||||||
|
while true; do
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
n=${n:-0}
|
||||||
|
t1=$(date +%s)
|
||||||
|
[ "$n" -ge "$want" ] || [ $((t1 - t0)) -ge "$limit" ] && { echo "$n $((t1 - t0))"; return; }
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_group() {
|
||||||
|
local conns="$1"
|
||||||
|
echo "════════ quic + $conns (MTU $MTU)════════"
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol quic/" "$SVC"
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
local mark; mark=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
local res; res=$(wait_conns "$conns" 150)
|
||||||
|
local n s
|
||||||
|
n=$(echo "$res" | awk '{print $1}'); s=$(echo "$res" | awk '{print $2}')
|
||||||
|
local df; df=$(tail -n +$((mark+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Failed to dial' || echo 0)
|
||||||
|
echo " 建齐 $n/$conns,用 ${s} 秒,dial 失败 ${df} 次"
|
||||||
|
|
||||||
|
local ok=0 times=()
|
||||||
|
for i in $(seq 1 "$N"); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H "User-Agent: $UA" "$BASE/v1/health")
|
||||||
|
c=$(echo "$out" | awk '{print $1}'); ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
[ "$c" = "200" ] && { ok=$((ok+1)); times+=("$ms"); }
|
||||||
|
done
|
||||||
|
local pct=$((ok * 100 / N))
|
||||||
|
local stats="无数据"
|
||||||
|
[ "${#times[@]}" -gt 0 ] && stats=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {
|
||||||
|
printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
|
||||||
|
printf " 可用率 %d%% (%d/%d) %s ms\n" "$pct" "$ok" "$N" "$stats"
|
||||||
|
echo "quic+$conns|$n|$s|$df|$pct|$stats" >> /tmp/v4.txt
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f /tmp/v4.txt
|
||||||
|
run_group 6
|
||||||
|
run_group 8
|
||||||
|
run_group 10
|
||||||
|
|
||||||
|
echo "════════ 汇总 ════════"
|
||||||
|
printf " %-10s %-14s %-10s %-8s %s\n" "配置" "建齐/耗时" "dial失败" "可用率" "延迟"
|
||||||
|
while IFS='|' read -r label n s df pct stats; do
|
||||||
|
printf " %-10s %-14s %-10s %-8s %s\n" "$label" "${n}条 ${s}s" "$df" "${pct}%" "$stats"
|
||||||
|
done < /tmp/v4.txt
|
||||||
|
|
||||||
|
echo
|
||||||
|
BESTN=$(sort -t'|' -k5 -rn /tmp/v4.txt | head -1 | sed 's/quic+//' | cut -d'|' -f1)
|
||||||
|
echo " → 采用 quic + $BESTN"
|
||||||
|
cp -a "$BACKUP" "$SVC"
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol quic/" "$SVC"
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections $BESTN/" "$SVC"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
sleep 5
|
||||||
|
echo " 最终参数: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ')"
|
||||||
|
echo " MTU: $(cat /sys/class/net/wlan0/mtu)"
|
||||||
|
echo " 备份: $BACKUP"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 1.8.1 最终验收:从公网走一遍用户会走的全部路径
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=https://relay.xxmxxs.top
|
||||||
|
UA='LanRemote/1.8.1'
|
||||||
|
|
||||||
|
echo "════════ 1. 中转版本和隧道 ════════"
|
||||||
|
curl -s --max-time 15 "$BASE/v1/health" | head -c 130
|
||||||
|
echo
|
||||||
|
echo " 隧道连接: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') 条"
|
||||||
|
echo " 协议参数: $(grep -c 'protocol quic' /etc/systemd/system/lanremote-tunnel.service) (1=quic)"
|
||||||
|
echo " 长轮询: $(tail -30 /root/lanremote-relay/relay.log | grep '长轮询' | tail -1 | grep -oE '[0-9]+ 秒')"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. 官网 ════════"
|
||||||
|
curl -s --max-time 25 -H "User-Agent: $UA" "$BASE/" -o /tmp/site.html
|
||||||
|
echo " HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 25 -H "User-Agent: $UA" "$BASE/") $(wc -c < /tmp/site.html) 字节"
|
||||||
|
echo " 下载按钮:"
|
||||||
|
grep -o 'href="/download/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /'
|
||||||
|
echo " 管理页字眼: $(grep -cE 'zhongzhuanzhan|管理后台|管理页' /tmp/site.html || echo 0) 处(应为 0)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. 三个成品能下吗 ════════"
|
||||||
|
for f in ykxmz-setup-1.8.1.exe ykxmz-server-1.8.1.zip ykxmz-watch-1.8.1.apk; do
|
||||||
|
hdr=$(curl -sI --max-time 40 -H "User-Agent: $UA" "$BASE/download/$f")
|
||||||
|
code=$(echo "$hdr" | head -1 | awk '{print $2}')
|
||||||
|
len=$(echo "$hdr" | grep -i '^content-length' | tr -d '\r' | awk '{print $2}')
|
||||||
|
printf " %-30s HTTP %-4s %s 字节\n" "$f" "$code" "${len:-?}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 长轮询还能跑通吗(2 次真实验证)════════"
|
||||||
|
STAMP=$(date +%s)
|
||||||
|
DEV="final-${STAMP}"
|
||||||
|
CODE=$(printf "%06d" $(( STAMP % 1000000 )))
|
||||||
|
REG=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
|
||||||
|
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||||
|
-d "{\"deviceId\":\"$DEV\",\"code\":\"$CODE\",\"name\":\"final\",\"version\":\"1.8.1\"}")
|
||||||
|
TOKEN=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||||
|
echo " 探针注册: $([ -n "$TOKEN" ] && echo 成功 || echo 失败)"
|
||||||
|
for i in 1 2; do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 130 \
|
||||||
|
-X POST "$BASE/v1/agent/poll" -H "X-Token: $TOKEN" -H "User-Agent: $UA")
|
||||||
|
printf " 第 %d 次: 码 %s 耗时 %.1fs(应约 61 秒)\n" \
|
||||||
|
"$i" "$(echo "$out" | awk '{print $1}')" "$(echo "$out" | awk '{print $2}')"
|
||||||
|
done
|
||||||
|
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||||
|
curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
|
||||||
|
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
|
||||||
|
echo " 探针已清理"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 公网可用率(10 次)════════"
|
||||||
|
ok=0
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/v1/health")
|
||||||
|
[ "$c" = "200" ] && ok=$((ok+1))
|
||||||
|
printf " %2d: %s\n" "$i" "$c"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " 可用 $ok/10"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 6. 中转有没有报错 ════════"
|
||||||
|
journalctl -u lanremote-relay --since '5 minutes ago' --no-pager 2>/dev/null \
|
||||||
|
| grep -icE 'traceback|nameerror|typeerror' | sed 's/^/ 错误行数: /'
|
||||||
|
tail -3 /root/lanremote-relay/relay.log | sed 's/^/ /'
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 1.8.2 最终验收(在设备上跑)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=https://relay.xxmxxs.top
|
||||||
|
UA='LanRemote/1.8.2'
|
||||||
|
|
||||||
|
echo "════════ 1. 官网和三个成品(走公网,看缓存)════════"
|
||||||
|
for p in / /download/ykxmz-setup-1.8.2.exe /download/ykxmz-server-1.8.2.zip /download/ykxmz-watch-1.8.2.apk; do
|
||||||
|
hdr=$(curl -s -D - -o /dev/null --max-time 45 -H "User-Agent: $UA" "$BASE$p" 2>/dev/null | tr -d '\r')
|
||||||
|
code=$(echo "$hdr" | head -1 | awk '{print $2}')
|
||||||
|
len=$(echo "$hdr" | grep -i '^content-length' | awk '{print $2}')
|
||||||
|
cf=$(echo "$hdr" | grep -i '^cf-cache-status' | awk '{print $2}')
|
||||||
|
cc=$(echo "$hdr" | grep -i '^cache-control' | cut -d' ' -f2-)
|
||||||
|
printf " %-38s HTTP %-4s %10s cf=%-6s %s\n" "$p" "$code" "$len" "${cf:-无}" "${cc:-无}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. 对外清洁度(官网不能有管理页字眼)════════"
|
||||||
|
curl -s --max-time 30 -H "User-Agent: $UA" "$BASE/" -o /tmp/site.html
|
||||||
|
n=$(grep -cE 'zhongzhuanzhan|管理后台|管理页|管理入口|管理面板' /tmp/site.html || true)
|
||||||
|
echo " 官网里管理页字眼: $n 处(应为 0)"
|
||||||
|
echo " 官网大小: $(wc -c < /tmp/site.html) 字节"
|
||||||
|
echo " 下载按钮:"
|
||||||
|
grep -o 'href="/download/[^"]*"' /tmp/site.html | sort -u | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. 管理页两个新功能 ════════"
|
||||||
|
if [ -f /root/taf.py ]; then
|
||||||
|
python3 /root/taf.py 2>&1 | tail -16 | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " (测试脚本不在设备上)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 中转核心功能(长轮询 + 注册)════════"
|
||||||
|
STAMP=$(date +%s)
|
||||||
|
DEV="acc-$STAMP"
|
||||||
|
CODE=$(printf "%06d" $(( STAMP % 1000000 )))
|
||||||
|
REG=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
|
||||||
|
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||||
|
-d "{\"deviceId\":\"$DEV\",\"code\":\"$CODE\",\"name\":\"验收\",\"version\":\"1.8.2\"}")
|
||||||
|
TOKEN=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||||
|
echo " 注册: $([ -n "$TOKEN" ] && echo 成功 || echo 失败)"
|
||||||
|
if [ -n "$TOKEN" ]; then
|
||||||
|
for i in 1 2; do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 200 \
|
||||||
|
-X POST "$BASE/v1/agent/poll" -H "X-Token: $TOKEN" -H "User-Agent: $UA")
|
||||||
|
printf " 长轮询 %d: 码 %s 耗时 %.1fs(应约 61 秒)\n" \
|
||||||
|
"$i" "$(echo "$out" | awk '{print $1}')" "$(echo "$out" | awk '{print $2}')"
|
||||||
|
done
|
||||||
|
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||||
|
curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
|
||||||
|
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
|
||||||
|
echo " 探针已清理"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 公网可用率(20 次)════════"
|
||||||
|
ok=0
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "User-Agent: $UA" "$BASE/v1/health")
|
||||||
|
[ "$c" = "200" ] && ok=$((ok + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " 可用 $ok/20 = $((ok * 5))%"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 6. 隧道和错误 ════════"
|
||||||
|
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2; do
|
||||||
|
printf " %-24s %s\n" "$s" "$(systemctl is-active $s)"
|
||||||
|
done
|
||||||
|
echo " 中转错误行数(近 10 分钟): $(journalctl -u lanremote-relay --since '10 minutes ago' --no-pager 2>/dev/null | grep -icE 'traceback|nameerror|typeerror|AttributeError' || echo 0)"
|
||||||
|
echo " 连接数: 20241=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') 20242=$(curl -s --max-time 4 http://127.0.0.1:20242/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''中转管理页新增两个功能(中转端逻辑)。
|
||||||
|
|
||||||
|
== 功能一:允许/禁止新设备接入 ==
|
||||||
|
|
||||||
|
一个开关,关掉之后**只有已经登记过的设备能继续上线**,
|
||||||
|
没见过的新设备一律拒绝。
|
||||||
|
|
||||||
|
用途:不想让陌生人拿配对码乱试、或者临时把中转"关起来"。
|
||||||
|
|
||||||
|
实现要点:
|
||||||
|
* 标志位存在 relay_state.json 里,重启不丢
|
||||||
|
* 判断"新设备"以 deviceId 为准(它装机时生成、重装也不变),
|
||||||
|
不是看配对码 —— 老设备换码也必须能进来
|
||||||
|
* 被拒绝时回一个明确的标记 blocked=True,
|
||||||
|
让客户端能区分"被管理员挡了"和"配对码冲突",
|
||||||
|
不要用中文错误文案去匹配(文案一改就坏)
|
||||||
|
|
||||||
|
== 功能二:修改任意设备的配对码 ==
|
||||||
|
|
||||||
|
管理页上每台设备一个"改配对码"按钮,填新码就生效。
|
||||||
|
|
||||||
|
实现要点:
|
||||||
|
* 新码必须是 6 位数字
|
||||||
|
* 新码不能已经被别的设备占用
|
||||||
|
* 改的时候要同时更新 codes 映射和 device.code,
|
||||||
|
并清掉该设备**旧码**的映射 —— 否则旧码会变成孤儿条目,
|
||||||
|
仪表盘上"配对码占用"会虚高
|
||||||
|
* 改完立刻 _save(),重启后仍然是新码
|
||||||
|
* 该设备当前的手表会话要踢掉(会话是绑在旧码上的),
|
||||||
|
否则手表还拿着旧会话以为连着,实际电脑已经换了码
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\relay.py")
|
||||||
|
|
||||||
|
|
||||||
|
def patch(text: str, old: str, new: str, label: str) -> tuple:
|
||||||
|
if old not in text:
|
||||||
|
print(" ✗ 没找到:%s" % label)
|
||||||
|
return text, False
|
||||||
|
if text.count(old) != 1:
|
||||||
|
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||||
|
return text, False
|
||||||
|
print(" ✓ %s" % label)
|
||||||
|
return text.replace(old, new, 1), True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
text = SRC.read_text(encoding="utf-8")
|
||||||
|
before = text.count("\n") + 1
|
||||||
|
ok_all = True
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 1. 字段
|
||||||
|
old = """ self.retired: dict = {}
|
||||||
|
self.started = time.time()"""
|
||||||
|
new = """ self.retired: dict = {}
|
||||||
|
# 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
|
||||||
|
self.allow_new = True
|
||||||
|
self.started = time.time()"""
|
||||||
|
text, ok = patch(text, old, new, "加 allow_new 字段")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 2. _load
|
||||||
|
old = """ for item in raw.get("codes", []):
|
||||||
|
device_id = str(item.get("deviceId", ""))
|
||||||
|
code = str(item.get("code", ""))
|
||||||
|
if device_id and CODE_RE.match(code):
|
||||||
|
self.codes[code] = device_id
|
||||||
|
log(f"恢复配对码绑定:设备 {device_id[:12]}… -> {code}")"""
|
||||||
|
new = """ # 是否允许新设备接入(重启不丢)
|
||||||
|
self.allow_new = bool(raw.get("allowNew", True))
|
||||||
|
for item in raw.get("codes", []):
|
||||||
|
device_id = str(item.get("deviceId", ""))
|
||||||
|
code = str(item.get("code", ""))
|
||||||
|
if device_id and CODE_RE.match(code):
|
||||||
|
self.codes[code] = device_id
|
||||||
|
log(f"恢复配对码绑定:设备 {device_id[:12]}… -> {code}")"""
|
||||||
|
text, ok = patch(text, old, new, "_load 恢复 allow_new")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 3. _save
|
||||||
|
old = """ payload = {"codes": [
|
||||||
|
{"deviceId": did, "code": code}
|
||||||
|
for code, did in sorted(self.codes.items())
|
||||||
|
]}"""
|
||||||
|
new = """ payload = {
|
||||||
|
"allowNew": bool(self.allow_new),
|
||||||
|
"codes": [
|
||||||
|
{"deviceId": did, "code": code}
|
||||||
|
for code, did in sorted(self.codes.items())
|
||||||
|
],
|
||||||
|
}"""
|
||||||
|
text, ok = patch(text, old, new, "_save 持久化 allow_new")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 4. 拒绝新设备
|
||||||
|
old = """ with self.lock:
|
||||||
|
known = self.codes.get(code)
|
||||||
|
if known and known != device_id:"""
|
||||||
|
new = """ with self.lock:
|
||||||
|
# 管理员关掉了"允许新设备接入"时,
|
||||||
|
# 只放行**已经登记过**的设备。
|
||||||
|
#
|
||||||
|
# 注意判断依据是 deviceId(装机时生成、重装也不变),
|
||||||
|
# **不是配对码** —— 否则管理员给老设备改了码,
|
||||||
|
# 那台设备就被自己的开关挡在外面了。
|
||||||
|
known_device = device_id in self.devices
|
||||||
|
if not known_device and not self.allow_new:
|
||||||
|
log(f"新设备接入已被关闭,拒绝 {name} ({device_id[:12]}…)")
|
||||||
|
return {"ok": False,
|
||||||
|
"blocked": True,
|
||||||
|
"error": "中转站当前不接受新设备接入"}
|
||||||
|
|
||||||
|
known = self.codes.get(code)
|
||||||
|
if known and known != device_id:"""
|
||||||
|
text, ok = patch(text, old, new, "register 拒绝新设备")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 5. 管理方法
|
||||||
|
old = """ def device_by_token(self, token: str) -> Device | None:"""
|
||||||
|
new = """ # ---------------- 管理操作 ----------------
|
||||||
|
|
||||||
|
def set_allow_new(self, allow: bool) -> dict:
|
||||||
|
\"\"\"开关"允许新设备接入"。\"\"\"
|
||||||
|
with self.lock:
|
||||||
|
self.allow_new = bool(allow)
|
||||||
|
self._save()
|
||||||
|
log(f"管理员把「允许新设备接入」设为 {self.allow_new}")
|
||||||
|
return {"ok": True, "allowNew": self.allow_new}
|
||||||
|
|
||||||
|
def set_device_code(self, device_id: str, new_code: str) -> dict:
|
||||||
|
\"\"\"给一台设备换配对码。
|
||||||
|
|
||||||
|
要点:
|
||||||
|
* 新码必须是 6 位数字
|
||||||
|
* 不能已经被**别的**设备占用
|
||||||
|
* 旧码的映射必须删掉,否则会变成孤儿条目,
|
||||||
|
管理页上"配对码占用"的数字会虚高
|
||||||
|
* 该设备挂着的会话要踢掉 —— 会话是绑在码上的,
|
||||||
|
不踢的话手表还拿着旧会话以为连着
|
||||||
|
\"\"\"
|
||||||
|
device_id = str(device_id or "").strip()
|
||||||
|
new_code = str(new_code or "").strip()
|
||||||
|
if not CODE_RE.match(new_code):
|
||||||
|
return {"ok": False, "error": "配对码必须是 6 位数字"}
|
||||||
|
with self.lock:
|
||||||
|
device = self.devices.get(device_id)
|
||||||
|
if device is None:
|
||||||
|
return {"ok": False, "error": "没有这台设备(可能已下线)"}
|
||||||
|
|
||||||
|
owner = self.codes.get(new_code)
|
||||||
|
if owner and owner != device_id:
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "这个配对码已经被别的设备占用了"}
|
||||||
|
|
||||||
|
old_code = device.code
|
||||||
|
if old_code == new_code:
|
||||||
|
return {"ok": True, "code": new_code, "unchanged": True}
|
||||||
|
|
||||||
|
# 删掉旧码映射(连同可能存在的别名)
|
||||||
|
for code, owner_id in list(self.codes.items()):
|
||||||
|
if owner_id == device_id:
|
||||||
|
self.codes.pop(code, None)
|
||||||
|
self.codes[new_code] = device_id
|
||||||
|
device.code = new_code
|
||||||
|
|
||||||
|
# 会话绑在码上,旧会话作废,让手表重新配对
|
||||||
|
kicked = 0
|
||||||
|
for sid in [s.session_id for s in self.sessions.values()
|
||||||
|
if s.device_id == device_id]:
|
||||||
|
self.sessions.pop(sid, None)
|
||||||
|
kicked += 1
|
||||||
|
|
||||||
|
self._save()
|
||||||
|
log(f"管理员把设备 {device.name} 的配对码 "
|
||||||
|
f"{old_code} 改成 {new_code}(踢掉 {kicked} 个会话)")
|
||||||
|
return {"ok": True, "code": new_code,
|
||||||
|
"oldCode": old_code, "kicked": kicked}
|
||||||
|
|
||||||
|
def device_by_token(self, token: str) -> Device | None:"""
|
||||||
|
text, ok = patch(text, old, new, "加 set_allow_new / set_device_code")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
if not ok_all:
|
||||||
|
print()
|
||||||
|
print(" 有锚点没匹配,没有写回")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
SRC.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print()
|
||||||
|
print(" 已写回:%d -> %d 行" % (before, text.count("\n") + 1))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''管理页 UI:加「允许新设备接入」开关 + 每台设备的「改码」按钮。
|
||||||
|
|
||||||
|
设计上遵守用户的"所有东西都要白色简洁":
|
||||||
|
* 开关用系统原生 checkbox + 极简样式,不引入花哨组件
|
||||||
|
* 改码走 prompt(),不加弹窗层 —— 管理页是自用的,
|
||||||
|
少一层 DOM 就少一处会坏的地方
|
||||||
|
* 按钮沿用现有的 .ghost 样式(不抢 .danger 的视觉重量)
|
||||||
|
|
||||||
|
⚠ 一个容易踩的坑:
|
||||||
|
现有 JS 用 `button.danger[data-device]` 绑定"单台删除"。
|
||||||
|
新加的"改码"按钮**故意不带 data-device 而用 data-recode**,
|
||||||
|
否则会被删除逻辑一起选中,点改码变成删设备。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\admin.py")
|
||||||
|
|
||||||
|
|
||||||
|
def patch(text: str, old: str, new: str, label: str) -> tuple:
|
||||||
|
if old not in text:
|
||||||
|
print(" ✗ 没找到:%s" % label)
|
||||||
|
return text, False
|
||||||
|
if text.count(old) != 1:
|
||||||
|
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||||
|
return text, False
|
||||||
|
print(" ✓ %s" % label)
|
||||||
|
return text.replace(old, new, 1), True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
text = SRC.read_text(encoding="utf-8")
|
||||||
|
ok_all = True
|
||||||
|
|
||||||
|
# ------------------------------------------------ 1. 行里加"改码"按钮
|
||||||
|
old = """ '<td class="ops"><button class="danger sm" '
|
||||||
|
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
|
||||||
|
'</tr>' % (
|
||||||
|
did, name,
|
||||||
|
"on" if online else "off", name, did,
|
||||||
|
code,
|
||||||
|
"on" if online else "off", "在线" if online else "离线",
|
||||||
|
device["idleSeconds"],
|
||||||
|
device["queuedToClient"], device["queuedToAgent"],
|
||||||
|
_kb(device["bytesIn"]), _kb(device["bytesOut"]),
|
||||||
|
did, name, code)
|
||||||
|
)"""
|
||||||
|
new = """ '<td class="ops">'
|
||||||
|
# ⚠ 这个按钮用 data-recode 而不是 data-device ——
|
||||||
|
# 现有"单台删除"是按 button.danger[data-device] 绑的,
|
||||||
|
# 这里要是也带 data-device 就会被当成删除按钮。
|
||||||
|
'<button class="ghost sm" data-recode="%s" '
|
||||||
|
'data-name="%s" data-code="%s">改码</button>'
|
||||||
|
'<button class="danger sm" '
|
||||||
|
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
|
||||||
|
'</tr>' % (
|
||||||
|
did, name,
|
||||||
|
"on" if online else "off", name, did,
|
||||||
|
code,
|
||||||
|
"on" if online else "off", "在线" if online else "离线",
|
||||||
|
device["idleSeconds"],
|
||||||
|
device["queuedToClient"], device["queuedToAgent"],
|
||||||
|
_kb(device["bytesIn"]), _kb(device["bytesOut"]),
|
||||||
|
did, name, code,
|
||||||
|
did, name, code)
|
||||||
|
)"""
|
||||||
|
text, ok = patch(text, old, new, "设备行加「改码」按钮")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
# ------------------------------------------------ 2. 开关放进"设备"小节
|
||||||
|
old = """ <div class="sec">
|
||||||
|
<h3>设备</h3>
|
||||||
|
<div class="right">
|
||||||
|
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
|
||||||
|
<input type="checkbox" id="autoRef" checked> 自动刷新
|
||||||
|
</label>"""
|
||||||
|
new = """ <div class="sec">
|
||||||
|
<h3>设备</h3>
|
||||||
|
<div class="right">
|
||||||
|
<label class="hint" id="allowWrap"
|
||||||
|
style="cursor:pointer;display:flex;align-items:center;gap:7px;margin:0 18px 0 0">
|
||||||
|
<input type="checkbox" id="allowNew"%(allownew)s>
|
||||||
|
<span>允许新设备接入</span>
|
||||||
|
</label>
|
||||||
|
<label class="hint" style="cursor:pointer;display:flex;align-items:center;gap:6px;margin:0">
|
||||||
|
<input type="checkbox" id="autoRef" checked> 自动刷新
|
||||||
|
</label>"""
|
||||||
|
text, ok = patch(text, old, new, "设备小节加开关")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
# ------------------------------------------------ 3. 传入开关状态
|
||||||
|
old = """ sessions = snap.get("sessions", 0)"""
|
||||||
|
new = """ sessions = snap.get("sessions", 0)
|
||||||
|
# 开关状态:允许新设备接入时勾上
|
||||||
|
allow_new = bool(snap.get("allowNew", True))"""
|
||||||
|
text, ok = patch(text, old, new, "读 allowNew 状态")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
old = """ "rows": _device_rows(devices),
|
||||||
|
"dl": dl_rows,"""
|
||||||
|
new = """ "rows": _device_rows(devices),
|
||||||
|
"allownew": " checked" if allow_new else "",
|
||||||
|
"dl": dl_rows,"""
|
||||||
|
text, ok = patch(text, old, new, "把状态传进模板")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
# ------------------------------------------------ 4. JS
|
||||||
|
old = """// ---------------- 单台删除 ----------------"""
|
||||||
|
new = """// ---------------- 允许/禁止新设备接入 ----------------
|
||||||
|
// 关掉之后,只有**已经登记过**的电脑能继续上线,
|
||||||
|
// 没见过的新设备一律拒绝。
|
||||||
|
const allowNew = document.getElementById('allowNew');
|
||||||
|
if (allowNew) allowNew.onchange = async () => {
|
||||||
|
const want = allowNew.checked;
|
||||||
|
allowNew.disabled = true;
|
||||||
|
try {
|
||||||
|
const j = await post('/v1/admin/allow_new', {allow: want});
|
||||||
|
if (j.ok) {
|
||||||
|
toast(j.allowNew ? '已允许新设备接入' : '已禁止新设备接入', 'ok');
|
||||||
|
} else {
|
||||||
|
toast('设置失败:' + (j.error || '未知错误'), 'err');
|
||||||
|
allowNew.checked = !want;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast('请求出错:' + e, 'err');
|
||||||
|
allowNew.checked = !want;
|
||||||
|
}
|
||||||
|
allowNew.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- 改配对码 ----------------
|
||||||
|
document.querySelectorAll('button[data-recode]').forEach(b => {
|
||||||
|
b.onclick = async () => {
|
||||||
|
const name = b.dataset.name, old = b.dataset.code;
|
||||||
|
const typed = prompt('把「' + name + '」的配对码 ' + old
|
||||||
|
+ ' 改成:\\n\\n(必须是 6 位数字)', '');
|
||||||
|
if (typed === null) return;
|
||||||
|
const code = (typed || '').trim();
|
||||||
|
if (!/^[0-9]{6}$/.test(code)) {
|
||||||
|
toast('配对码必须是 6 位数字', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
b.disabled = true;
|
||||||
|
try {
|
||||||
|
const j = await post('/v1/admin/set_code',
|
||||||
|
{deviceId: b.dataset.recode, code: code});
|
||||||
|
if (j.ok) {
|
||||||
|
toast(j.unchanged ? '配对码没有变化'
|
||||||
|
: ('配对码已改成 ' + j.code
|
||||||
|
+ (j.kicked ? ',踢掉 ' + j.kicked + ' 个会话' : '')),
|
||||||
|
'ok');
|
||||||
|
setTimeout(() => location.reload(), 900);
|
||||||
|
} else {
|
||||||
|
toast('改码失败:' + (j.error || '未知错误'), 'err');
|
||||||
|
b.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast('请求出错:' + e, 'err');
|
||||||
|
b.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------- 单台删除 ----------------"""
|
||||||
|
text, ok = patch(text, old, new, "加开关和改码的 JS")
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
if not ok_all:
|
||||||
|
print()
|
||||||
|
print(" 有锚点没匹配,没有写回")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
SRC.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print()
|
||||||
|
print(" admin.py 已写回")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''给静态资源加缓存头,让 Cloudflare 在边缘直接发。
|
||||||
|
|
||||||
|
== 为什么这是最大的一招 ==
|
||||||
|
|
||||||
|
基线实测(下载 15 MB 安装包走隧道回 4G 源站):
|
||||||
|
|
||||||
|
ykxmz-watch-1.8.1.apk 201523 字节 / 66.8 秒 = 3 KB/s
|
||||||
|
ykxmz-server-1.8.1.zip 214900 字节 / 3.1 秒 = 67 KB/s
|
||||||
|
ykxmz-setup-1.8.1.exe 只下到 237568/15067199 字节就停了
|
||||||
|
|
||||||
|
原因:relay 给所有响应都发 `Cache-Control: no-store`,
|
||||||
|
**Cloudflare 完全不缓存** —— 每一次下载、每一张图、每个 favicon
|
||||||
|
都要穿隧道回 4G 源站。而回源吞吐只有几 KB/s。
|
||||||
|
|
||||||
|
改成可缓存后,Cloudflare 会在用户附近的边缘节点直接发,
|
||||||
|
速度是"边缘到用户",跟 4G 那条腿无关。
|
||||||
|
|
||||||
|
== 各路径的策略 ==
|
||||||
|
|
||||||
|
/download/* public, max-age=604800, immutable
|
||||||
|
文件名带版本号(ykxmz-setup-1.8.1.exe),
|
||||||
|
同一名字的内容永不改变,所以可以放心长缓存。
|
||||||
|
/shot/* 已经是 public, max-age=86400(保持不变)
|
||||||
|
/favicon.ico public, max-age=604800(147 KB,之前每次 3.2 秒)
|
||||||
|
/ public, max-age=60
|
||||||
|
首页会列出当前版本,缓存久了下新版本会看不到按钮。
|
||||||
|
60 秒足够挡住刷新的重复回源,又不会让版本信息过期太久。
|
||||||
|
/v1/* no-store(接口,必须实时)
|
||||||
|
|
||||||
|
注意:Cloudflare 默认只缓存一部分扩展名,.exe/.zip/.apk 可能不在
|
||||||
|
默认列表里。所以改完必须**实测 cf-cache-status 是不是 HIT**,
|
||||||
|
不 HIT 就要在 Cloudflare 后台加一条 Cache Rule(我会告诉用户怎么加)。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\relay.py")
|
||||||
|
|
||||||
|
OLD_DL = """ self.send_header(
|
||||||
|
"Content-Disposition",
|
||||||
|
f"attachment; filename*=UTF-8''{quoted}")
|
||||||
|
self.send_header("Cache-Control", "no-store")"""
|
||||||
|
|
||||||
|
NEW_DL = """ self.send_header(
|
||||||
|
"Content-Disposition",
|
||||||
|
f"attachment; filename*=UTF-8''{quoted}")
|
||||||
|
# ★ 让 Cloudflare 在边缘缓存。
|
||||||
|
# 原来这里是 no-store,于是每次下载都要穿隧道回 4G 源站,
|
||||||
|
# 实测吞吐只有 3~67 KB/s(15 MB 的安装包根本下不动)。
|
||||||
|
# 文件名带版本号,同一名字内容永不改变,可以长缓存。
|
||||||
|
self.send_header(
|
||||||
|
"Cache-Control",
|
||||||
|
"public, max-age=604800, immutable")"""
|
||||||
|
|
||||||
|
# favicon 那块
|
||||||
|
OLD_ICON = """ self._send(200, icon, "image/x-icon")"""
|
||||||
|
NEW_ICON = """ self._send(200, icon, "image/x-icon",
|
||||||
|
{"Cache-Control": "public, max-age=604800"})"""
|
||||||
|
|
||||||
|
# 首页那块
|
||||||
|
OLD_SITE = """ self._send(200, _site_page().encode("utf-8"),
|
||||||
|
"text/html; charset=utf-8")
|
||||||
|
return"""
|
||||||
|
NEW_SITE = """ # 首页缓存 60 秒:挡住刷新的重复回源,
|
||||||
|
# 又不会让"当前版本/下载按钮"过期太久。
|
||||||
|
self._send(200, _site_page().encode("utf-8"),
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
{"Cache-Control": "public, max-age=60"})
|
||||||
|
return"""
|
||||||
|
|
||||||
|
|
||||||
|
def patch(text: str, old: str, new: str, label: str) -> tuple:
|
||||||
|
if old not in text:
|
||||||
|
print(" ✗ 没找到:%s" % label)
|
||||||
|
return text, False
|
||||||
|
if text.count(old) != 1:
|
||||||
|
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||||
|
return text, False
|
||||||
|
print(" ✓ %s" % label)
|
||||||
|
return text.replace(old, new, 1), True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
text = SRC.read_text(encoding="utf-8")
|
||||||
|
before = text.count("\n") + 1
|
||||||
|
ok_all = True
|
||||||
|
|
||||||
|
print(" 加缓存头:")
|
||||||
|
for old, new, label in (
|
||||||
|
(OLD_DL, NEW_DL, "下载:no-store -> public,max-age=604800,immutable"),
|
||||||
|
(OLD_ICON, NEW_ICON, "favicon:加 7 天缓存"),
|
||||||
|
(OLD_SITE, NEW_SITE, "首页:加 60 秒缓存"),
|
||||||
|
):
|
||||||
|
text, ok = patch(text, old, new, label)
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
if not ok_all:
|
||||||
|
print()
|
||||||
|
print(" 有锚点没匹配,没有写回")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
SRC.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print()
|
||||||
|
print(" 已写回:%d -> %d 行" % (before, text.count("\n") + 1))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''手表端:输入配对码连上之后,先到一个「选择页」。
|
||||||
|
|
||||||
|
== 用户要求(原话)==
|
||||||
|
|
||||||
|
"安卓那个逻辑也要改一下,就是你输入配对码后,进入一个页面,
|
||||||
|
页面有两个按钮,第一个按钮是远控,第二个按钮是静默更新被控端"
|
||||||
|
|
||||||
|
== 现状 ==
|
||||||
|
|
||||||
|
连接成功后直接 `showControlPanel()`(进控制页)。用户根本没机会
|
||||||
|
先升级被控端 —— 而老版本的被控端可能缺功能、有 bug,
|
||||||
|
"先更新再远控"其实是更合理的顺序。
|
||||||
|
|
||||||
|
== 改法 ==
|
||||||
|
|
||||||
|
新增一个 `choicePanel`:
|
||||||
|
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ 已连接「电脑名」 │
|
||||||
|
│ 电脑端版本 2.1.5 │ <- 一眼看到要不要更新
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ │
|
||||||
|
│ │ 远控 │ │ <- 第一个按钮
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ ┌────────────────────┐ │
|
||||||
|
│ │ 静默更新被控端 │ │ <- 第二个按钮
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
└──────────────────────────┘
|
||||||
|
|
||||||
|
**按钮顺序按用户说的来**:远控在前(主操作),更新在后。
|
||||||
|
|
||||||
|
之后:
|
||||||
|
|
||||||
|
* 连接成功(`showControlPanel()` 那个调用点)改成 `showChoicePanel()`
|
||||||
|
* **自动切中转恢复**那个调用点**不改** —— 用户本来就在远控页,
|
||||||
|
中途弹一个选择页会打断他
|
||||||
|
* 「静默更新被控端」复用已经做好的 `checkPcUpdate()`
|
||||||
|
(查版本 -> 确认框 -> 触发更新)
|
||||||
|
* 版本号显示在这一页上:用户说"手表端连接那个电脑端也可以查看
|
||||||
|
电脑端目前所处的版本",这里是最自然的展示位置
|
||||||
|
|
||||||
|
== 一些细节 ==
|
||||||
|
|
||||||
|
* 顶部快捷栏在这一页照样在(用户要求"不管干什么上面都有一栏")
|
||||||
|
* 面板要加进 `showOnly` 的数组,否则它会永远压在最上面
|
||||||
|
* 也要 `applyPanelTopMargin`,否则顶部被快捷栏盖住
|
||||||
|
|
||||||
|
* **版本号是异步查的**:连上后立刻查一次;查回来之前显示"正在查…"。
|
||||||
|
不查的话用户不知道要不要点更新。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
|
||||||
|
|
||||||
|
# ---- 1. 字段 ----
|
||||||
|
F_ANCHOR = " private FrameLayout mousePanel;"
|
||||||
|
F_NEW = ''' private FrameLayout mousePanel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连上之后先给用户看的那一页:远控 / 静默更新被控端。
|
||||||
|
*
|
||||||
|
* <p>用户要求:"输入配对码后,进入一个页面,页面有两个按钮,
|
||||||
|
* 第一个按钮是远控,第二个按钮是静默更新被控端"。
|
||||||
|
*
|
||||||
|
* <p>为什么不让它直接进控制页:老版本的被控端可能缺功能或者有 bug,
|
||||||
|
* "先更新再远控"是更合理的顺序 —— 但也不能强制,所以给用户选。
|
||||||
|
*/
|
||||||
|
private FrameLayout choicePanel;
|
||||||
|
/** 选择页上显示"电脑端版本 X.Y.Z"那一行。 */
|
||||||
|
private TextView choiceVersionText;
|
||||||
|
/** 选择页上显示"已连接 XXX"。 */
|
||||||
|
private TextView choiceWhoText;'''
|
||||||
|
|
||||||
|
# ---- 2. 建面板的方法,插在 showOnly 之前 ----
|
||||||
|
M_ANCHOR = " private void showOnly(View keep) {"
|
||||||
|
|
||||||
|
M_NEW = ''' // ==================================================================
|
||||||
|
// 连接后的选择页(远控 / 静默更新)
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建选择页。
|
||||||
|
*
|
||||||
|
* <p>布局从上到下:连接信息 -> 版本 -> [远控] -> [静默更新被控端]。
|
||||||
|
* 按钮顺序就是用户说的顺序(远控在前)。
|
||||||
|
*/
|
||||||
|
private void buildChoicePanel() {
|
||||||
|
choicePanel = new FrameLayout(this);
|
||||||
|
choicePanel.setBackgroundColor(C_BG);
|
||||||
|
|
||||||
|
LinearLayout box = new LinearLayout(this);
|
||||||
|
box.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
box.setPadding(dp(14), dp(20), dp(14), dp(14));
|
||||||
|
box.setGravity(android.view.Gravity.CENTER_HORIZONTAL);
|
||||||
|
choicePanel.addView(box, new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
|
||||||
|
choiceWhoText = new TextView(this);
|
||||||
|
choiceWhoText.setText("已连接");
|
||||||
|
choiceWhoText.setTextColor(C_FG);
|
||||||
|
choiceWhoText.setTextSize(14f);
|
||||||
|
choiceWhoText.setGravity(android.view.Gravity.CENTER);
|
||||||
|
box.addView(choiceWhoText);
|
||||||
|
|
||||||
|
choiceVersionText = new TextView(this);
|
||||||
|
choiceVersionText.setText("电脑端版本:正在查…");
|
||||||
|
choiceVersionText.setTextColor(C_DIM);
|
||||||
|
choiceVersionText.setTextSize(12f);
|
||||||
|
choiceVersionText.setGravity(android.view.Gravity.CENTER);
|
||||||
|
choiceVersionText.setPadding(0, dp(4), 0, dp(10));
|
||||||
|
box.addView(choiceVersionText);
|
||||||
|
|
||||||
|
// 第一个按钮:远控
|
||||||
|
Button ctrl = bigButton("远控", C_OK, 17);
|
||||||
|
LinearLayout.LayoutParams ctrlLp = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(54));
|
||||||
|
ctrlLp.setMargins(0, 0, 0, dp(10));
|
||||||
|
ctrl.setLayoutParams(ctrlLp);
|
||||||
|
ctrl.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
showControlPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.addView(ctrl);
|
||||||
|
|
||||||
|
// 第二个按钮:静默更新被控端
|
||||||
|
Button upd = bigButton("静默更新被控端", C_ACCENT, 15);
|
||||||
|
LinearLayout.LayoutParams updLp = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(54));
|
||||||
|
upd.setLayoutParams(updLp);
|
||||||
|
upd.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
// 复用已经做好的那条链路:查版本 -> 确认 -> 触发更新
|
||||||
|
checkPcUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.addView(upd);
|
||||||
|
|
||||||
|
TextView tip = new TextView(this);
|
||||||
|
tip.setText("更新时电脑会自动下载并静默安装,"
|
||||||
|
+ "不会弹任何窗口,设置和配对码都不变。");
|
||||||
|
tip.setTextColor(C_DIM);
|
||||||
|
tip.setTextSize(10f);
|
||||||
|
tip.setGravity(android.view.Gravity.CENTER);
|
||||||
|
tip.setPadding(0, dp(10), 0, 0);
|
||||||
|
box.addView(tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示选择页,并顺手查一下电脑端版本。 */
|
||||||
|
private void showChoicePanel() {
|
||||||
|
if (choicePanel == null) {
|
||||||
|
buildChoicePanel();
|
||||||
|
}
|
||||||
|
if (choiceWhoText != null) {
|
||||||
|
String who = lastDeviceName;
|
||||||
|
choiceWhoText.setText(who == null || who.isEmpty()
|
||||||
|
? "已连接" : "已连接「" + who + "」");
|
||||||
|
}
|
||||||
|
if (choiceVersionText != null) {
|
||||||
|
choiceVersionText.setText("电脑端版本:正在查…");
|
||||||
|
}
|
||||||
|
showOnly(choicePanel);
|
||||||
|
queryPcVersionForChoice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查电脑端版本填到选择页上。
|
||||||
|
*
|
||||||
|
* <p>单独写一个(不直接用 checkPcUpdate)是因为那个会弹确认框 ——
|
||||||
|
* 这里只是"让你看一眼版本",不该弹东西。
|
||||||
|
*/
|
||||||
|
private void queryPcVersionForChoice() {
|
||||||
|
io.execute(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Map<String, Object> reply;
|
||||||
|
try {
|
||||||
|
reply = client.call(Json.obj("cmd", "update",
|
||||||
|
"action", "check"), 25000);
|
||||||
|
} catch (final Throwable error) {
|
||||||
|
reply = null;
|
||||||
|
}
|
||||||
|
final String text;
|
||||||
|
if (reply != null && Boolean.TRUE.equals(reply.get("ok"))) {
|
||||||
|
String cur = String.valueOf(reply.get("current"));
|
||||||
|
String latest = String.valueOf(reply.get("latest"));
|
||||||
|
text = Boolean.TRUE.equals(reply.get("needUpdate"))
|
||||||
|
? "电脑端 " + cur + " → 可更新到 " + latest
|
||||||
|
: "电脑端 " + cur + "(已是最新)";
|
||||||
|
} else {
|
||||||
|
text = "电脑端版本:查不到(可以点下面的更新按钮再试)";
|
||||||
|
}
|
||||||
|
ui.post(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (choiceVersionText != null) {
|
||||||
|
choiceVersionText.setText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---- 3. showOnly 数组 ----
|
||||||
|
O_SO = ''' View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
|
||||||
|
settingsPanel, mousePanel};'''
|
||||||
|
N_SO = ''' View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
|
||||||
|
settingsPanel, mousePanel, choicePanel};'''
|
||||||
|
|
||||||
|
# ---- 4. onCreate:建 + 挂 + 让位 ----
|
||||||
|
O_OC = ''' loadQuickSlots();
|
||||||
|
buildMousePanel(); // 只建,先不显示(showOnly 会切)
|
||||||
|
buildQuickBar();'''
|
||||||
|
N_OC = ''' loadQuickSlots();
|
||||||
|
buildMousePanel(); // 只建,先不显示(showOnly 会切)
|
||||||
|
buildChoicePanel(); // 同上:连上之后才显示
|
||||||
|
buildQuickBar();'''
|
||||||
|
|
||||||
|
O_OC2 = ''' applyPanelTopMargin(mousePanel);'''
|
||||||
|
N_OC2 = ''' applyPanelTopMargin(mousePanel);
|
||||||
|
applyPanelTopMargin(choicePanel);'''
|
||||||
|
|
||||||
|
O_OC3 = ''' root.addView(mousePanel, new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
mousePanel.setVisibility(View.GONE);'''
|
||||||
|
N_OC3 = ''' root.addView(mousePanel, new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
mousePanel.setVisibility(View.GONE);
|
||||||
|
root.addView(choicePanel, new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
choicePanel.setVisibility(View.GONE);'''
|
||||||
|
|
||||||
|
# ---- 5. 连接成功后改成进选择页 ----
|
||||||
|
O_CONN = ''' showControlPanel();
|
||||||
|
appendLog("已连接到 " + host);
|
||||||
|
toast("连接成功");'''
|
||||||
|
N_CONN = ''' // 用户要求:输入配对码连上之后先到选择页
|
||||||
|
// (远控 / 静默更新被控端),不直接进控制页。
|
||||||
|
//
|
||||||
|
// ⚠ 只有**这个**调用点改。下面"自动切到中转恢复"
|
||||||
|
// 那个 showControlPanel() 不改 —— 用户本来就在
|
||||||
|
// 远控页,中途弹选择页会打断他。
|
||||||
|
showChoicePanel();
|
||||||
|
appendLog("已连接到 " + host);
|
||||||
|
toast("连接成功");'''
|
||||||
|
|
||||||
|
PAIRS = [
|
||||||
|
("字段", F_ANCHOR, F_NEW),
|
||||||
|
("建面板方法", M_ANCHOR, M_NEW + M_ANCHOR),
|
||||||
|
("showOnly 数组", O_SO, N_SO),
|
||||||
|
("onCreate 建面板", O_OC, N_OC),
|
||||||
|
("onCreate 让位", O_OC2, N_OC2),
|
||||||
|
("onCreate 挂面板", O_OC3, N_OC3),
|
||||||
|
("连接成功后进选择页", O_CONN, N_CONN),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 手表端:连上后先到「远控 / 静默更新」选择页")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
raw = MAIN.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "buildChoicePanel" in t:
|
||||||
|
print(" 已经改过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
missing = [(n, o.strip().splitlines()[0][:60]) for n, o, _w in PAIRS
|
||||||
|
if o not in t]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ 这些锚点找不到:")
|
||||||
|
for n, s in missing:
|
||||||
|
print(" %s: %s" % (n, s))
|
||||||
|
return 1
|
||||||
|
print(" ✓ %d 个锚点全部就位" % len(PAIRS))
|
||||||
|
|
||||||
|
for _n, old, new in PAIRS:
|
||||||
|
t = t.replace(old, new, 1)
|
||||||
|
|
||||||
|
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||||
|
print(" ✓ 加了 choicePanel + buildChoicePanel + showChoicePanel")
|
||||||
|
print(" ✓ 连接成功后改成进选择页(自动切中转那个没动)")
|
||||||
|
|
||||||
|
# 复核:确认"自动切中转"那个调用点没被误改
|
||||||
|
back = MAIN.read_text(encoding="utf-8")
|
||||||
|
n_choice = back.count("showChoicePanel();")
|
||||||
|
n_ctrl = back.count("showControlPanel();")
|
||||||
|
print()
|
||||||
|
print(" 复核:showChoicePanel 调用 %d 处(应 >=2:定义内 + 连接成功处)"
|
||||||
|
% n_choice)
|
||||||
|
print(" showControlPanel 调用 %d 处(自动切中转那个应该还在)"
|
||||||
|
% n_ctrl)
|
||||||
|
if "已切到中转服务器,连接恢复" in back:
|
||||||
|
i = back.find("已切到中转服务器,连接恢复")
|
||||||
|
seg = back[i:i + 200]
|
||||||
|
ok = "showControlPanel();" in seg
|
||||||
|
print(" [%s] 自动切中转恢复后仍是 showControlPanel" %
|
||||||
|
("PASS" if ok else "FAIL"))
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''单次请求失败不再立刻判"离线",改成连续失败 3 次才算。
|
||||||
|
|
||||||
|
== 为什么 ==
|
||||||
|
|
||||||
|
实测单个请求会有 2~4% 的偶发失败(4G 丢包打在一条隧道连接上,
|
||||||
|
或者那条连接正在重连)。原来的逻辑是**一失败就 online = false**,
|
||||||
|
于是:
|
||||||
|
|
||||||
|
手表/电脑的界面上会闪一下"已断开"
|
||||||
|
用户点按钮时正好撞上那一闪 -> 看起来像"又连不上了"
|
||||||
|
|
||||||
|
而实际上链路 1.5~15 秒后就自己恢复了(客户端本来就有退避重试)。
|
||||||
|
|
||||||
|
改成"连续失败 3 次才判离线",效果是:
|
||||||
|
* 单次抖动完全不影响界面显示
|
||||||
|
* 真的断了(连续 3 次,至少 4.5 秒都没通)仍然会如实显示离线
|
||||||
|
|
||||||
|
这不是掩盖问题,而是把"是否可用"的判断从"一次采样"改成
|
||||||
|
"连续采样"—— 单点采样在有丢包的链路上噪声太大。
|
||||||
|
|
||||||
|
== 两边都要改 ==
|
||||||
|
|
||||||
|
1. android/src/com/lanremote/watch/RelayTransport.java (手表轮询)
|
||||||
|
2. windows/lanremote/relay.py (电脑轮询)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
BOM = b"\xef\xbb\xbf"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 手表
|
||||||
|
WATCH = ROOT / "android/src/com/lanremote/watch/RelayTransport.java"
|
||||||
|
|
||||||
|
WATCH_CONST_OLD = " private static final int RETRY_MAX_MS = 15000;"
|
||||||
|
WATCH_CONST_NEW = """ private static final int RETRY_MAX_MS = 15000;
|
||||||
|
/**
|
||||||
|
* 连续失败几次才判定"离线"。
|
||||||
|
*
|
||||||
|
* <p>单次失败就报离线太敏感:4G 链路上随手就有 2~4% 的偶发失败,
|
||||||
|
* 而链路通常一两秒就自己恢复,界面却已经闪了一下"已断开"。
|
||||||
|
* 改成连续 3 次(至少 4.5 秒都不通)才判离线 ——
|
||||||
|
* 真的断了仍然会如实显示。
|
||||||
|
*/
|
||||||
|
private static final int OFFLINE_AFTER = 3;"""
|
||||||
|
|
||||||
|
WATCH_FIELD_OLD = " private Thread pollThread;"
|
||||||
|
WATCH_FIELD_NEW = """ private Thread pollThread;
|
||||||
|
/** 连续失败次数(成功一次就清零)。 */
|
||||||
|
private volatile int failStreak;"""
|
||||||
|
|
||||||
|
# 401/403 分支
|
||||||
|
W_401_OLD = """ if (result.code == 401 || result.code == 403) {
|
||||||
|
// 会话过期(中转重启等)—— 这种可以自动重连
|
||||||
|
online = false;
|
||||||
|
lastError = "会话已失效,正在重新接入";"""
|
||||||
|
W_401_NEW = """ if (result.code == 401 || result.code == 403) {
|
||||||
|
// 会话过期(中转重启等)—— 这种可以自动重连。
|
||||||
|
// 会话失效是明确的状态,直接标离线。
|
||||||
|
online = false;
|
||||||
|
lastError = "会话已失效,正在重新接入";"""
|
||||||
|
|
||||||
|
# 非 200 分支
|
||||||
|
W_OTHER_OLD = """ if (result.code != 200) {
|
||||||
|
online = false;
|
||||||
|
lastError = "轮询返回 " + result.code;
|
||||||
|
sleep(backoff);
|
||||||
|
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
online = true;
|
||||||
|
lastError = "";
|
||||||
|
backoff = RETRY_MIN_MS;"""
|
||||||
|
W_OTHER_NEW = """ if (result.code != 200) {
|
||||||
|
// 偶发的 502/504 不算断线,连续几次才算
|
||||||
|
failStreak++;
|
||||||
|
if (failStreak >= OFFLINE_AFTER) {
|
||||||
|
online = false;
|
||||||
|
}
|
||||||
|
lastError = "轮询返回 " + result.code;
|
||||||
|
sleep(backoff);
|
||||||
|
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
online = true;
|
||||||
|
failStreak = 0;
|
||||||
|
lastError = "";
|
||||||
|
backoff = RETRY_MIN_MS;"""
|
||||||
|
|
||||||
|
# 异常分支
|
||||||
|
W_EXC_OLD = """ } catch (Exception exc) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
online = false;
|
||||||
|
lastError = "轮询出错:" + exc.getClass().getSimpleName();
|
||||||
|
sleep(backoff);
|
||||||
|
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
|
||||||
|
}"""
|
||||||
|
W_EXC_NEW = """ } catch (Exception exc) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 连接超时/重置很常见(隧道在重连),连续几次才算真断
|
||||||
|
failStreak++;
|
||||||
|
if (failStreak >= OFFLINE_AFTER) {
|
||||||
|
online = false;
|
||||||
|
}
|
||||||
|
lastError = "轮询出错:" + exc.getClass().getSimpleName();
|
||||||
|
sleep(backoff);
|
||||||
|
backoff = Math.min(RETRY_MAX_MS, backoff * 2);
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 电脑
|
||||||
|
AGENT = ROOT / "windows/lanremote/relay.py"
|
||||||
|
|
||||||
|
A_CONST_OLD = "RETRY_MAX = 20.0"
|
||||||
|
A_CONST_NEW = """RETRY_MAX = 20.0
|
||||||
|
# 连续失败几次才判定"离线"。
|
||||||
|
# 单次失败就报离线太敏感:4G 链路上有 2~4% 的偶发失败,
|
||||||
|
# 而链路通常一两秒就自己恢复,界面却已经闪了一下"已断开"。
|
||||||
|
# 连续 3 次(至少 6 秒都不通)才判离线,真的断了仍然如实显示。
|
||||||
|
OFFLINE_AFTER = 3"""
|
||||||
|
|
||||||
|
A_FAIL_OLD = """ except Exception as exc: # noqa: BLE001
|
||||||
|
self.online = False
|
||||||
|
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
self._log(f"中转连接出问题:{self.last_error}")"""
|
||||||
|
A_FAIL_NEW = """ except Exception as exc: # noqa: BLE001
|
||||||
|
# 偶发的连接超时/重置不算断线,连续几次才算
|
||||||
|
self._fail_streak = getattr(self, "_fail_streak", 0) + 1
|
||||||
|
if self._fail_streak >= OFFLINE_AFTER:
|
||||||
|
self.online = False
|
||||||
|
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
self._log(f"中转连接出问题(第 {self._fail_streak} 次):"
|
||||||
|
f"{self.last_error}")"""
|
||||||
|
|
||||||
|
A_OK_OLD = """ self.online = True
|
||||||
|
self.last_ok = time.time()
|
||||||
|
self.last_error = \"\""""
|
||||||
|
A_OK_NEW = """ self.online = True
|
||||||
|
self._fail_streak = 0
|
||||||
|
self.last_ok = time.time()
|
||||||
|
self.last_error = \"\""""
|
||||||
|
|
||||||
|
|
||||||
|
def patch(text: str, old: str, new: str, label: str) -> tuple:
|
||||||
|
if old not in text:
|
||||||
|
if new.strip() and new.splitlines()[-1].strip() in text:
|
||||||
|
print(" • %s 已经是新值" % label)
|
||||||
|
return text, True
|
||||||
|
print(" ✗ 没找到:%s" % label)
|
||||||
|
return text, False
|
||||||
|
if text.count(old) != 1:
|
||||||
|
print(" ! 出现 %d 次:%s" % (text.count(old), label))
|
||||||
|
return text, False
|
||||||
|
print(" ✓ %s" % label)
|
||||||
|
return text.replace(old, new, 1), True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ok_all = True
|
||||||
|
|
||||||
|
print(" 手表 RelayTransport.java:")
|
||||||
|
raw = WATCH.read_bytes()
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
for old, new, label in (
|
||||||
|
(WATCH_CONST_OLD, WATCH_CONST_NEW, "加 OFFLINE_AFTER 常量"),
|
||||||
|
(WATCH_FIELD_OLD, WATCH_FIELD_NEW, "加 failStreak 字段"),
|
||||||
|
(W_OTHER_OLD, W_OTHER_NEW, "非 200 用连续计数"),
|
||||||
|
(W_EXC_OLD, W_EXC_NEW, "异常用连续计数"),
|
||||||
|
):
|
||||||
|
text, ok = patch(text, old, new, label)
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
if ok_all:
|
||||||
|
WATCH.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 电脑 relay.py:")
|
||||||
|
araw = AGENT.read_bytes()
|
||||||
|
atext = araw.decode("utf-8-sig")
|
||||||
|
for old, new, label in (
|
||||||
|
(A_CONST_OLD, A_CONST_NEW, "加 OFFLINE_AFTER 常量"),
|
||||||
|
(A_FAIL_OLD, A_FAIL_NEW, "异常用连续计数"),
|
||||||
|
(A_OK_OLD, A_OK_NEW, "成功时清零"),
|
||||||
|
):
|
||||||
|
atext, ok = patch(atext, old, new, label)
|
||||||
|
ok_all = ok_all and ok
|
||||||
|
|
||||||
|
if not ok_all:
|
||||||
|
print()
|
||||||
|
print(" 有失败项,没写回电脑端")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
AGENT.write_text(atext, encoding="utf-8", newline="\n")
|
||||||
|
print()
|
||||||
|
print(" 两边都改好了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''弹文字窗口「位置 + 大小」可配置,屏幕共享提示条默认关掉。
|
||||||
|
|
||||||
|
== 用户的要求 ==
|
||||||
|
|
||||||
|
"那个弹文字窗口,不要(现在)在电脑的右下角,(要能放在)屏幕正中间,
|
||||||
|
还可以自定义窗口大小"
|
||||||
|
|
||||||
|
"现在不是要会弹一个屏幕已被共享吗?以后不需要弹,啥窗户都没有,
|
||||||
|
就可以像 uu 远程一样"
|
||||||
|
|
||||||
|
== 现状(查清楚了)==
|
||||||
|
|
||||||
|
headless.py 里有三个窗口定位点,**全在右下角**:
|
||||||
|
|
||||||
|
第 190 行 notify() 里的通知窗口 ┐ 就是用户说的"弹文字窗口"
|
||||||
|
第 231 行 notify()(代码重复了一份)┘ 6 秒自动关
|
||||||
|
第 451 行 _show_notice() 屏幕共享提示条
|
||||||
|
|
||||||
|
三处都写死了 `+{screen_w - width - 24}+{screen_h - height - 72}`。
|
||||||
|
|
||||||
|
== 改法 ==
|
||||||
|
|
||||||
|
1. **加一个统一的定位函数 `_notice_geometry(width, height)`**
|
||||||
|
支持 6 种位置:右下 / 右上 / 左上 / 左下 / 正中间 / 自定义坐标。
|
||||||
|
三处都改调它 —— 这样以后再调位置只用改一个地方。
|
||||||
|
|
||||||
|
2. **加设置项**(config.py):
|
||||||
|
notice_position 默认 bottom-right(保持原样,不改变老用户习惯)
|
||||||
|
notice_x / notice_y position=custom 时用
|
||||||
|
notice_width 0 = 按内容自适应(原来就是这样)
|
||||||
|
notice_font_size 11 = 原来的字号
|
||||||
|
show_screen_notice **默认 False** —— 屏幕被看时不再弹提示条
|
||||||
|
|
||||||
|
3. **`set_screen_sharing` 尊重 `show_screen_notice`**
|
||||||
|
关着就什么都不做(连窗口都不建)。
|
||||||
|
|
||||||
|
== 关于"去掉屏幕提示条"这件事 ==
|
||||||
|
|
||||||
|
原来那条是**故意加**的:有人在看屏幕时,电脑前面的人应该知道。
|
||||||
|
用户现在要求去掉,理由是"像 UU 远程一样不要任何窗口"。
|
||||||
|
|
||||||
|
这是**用户自己的电脑、自己的教室**,属于正当的远程管理需求
|
||||||
|
(极域电子教室、联想网络控制工具这类校园软件也都是无提示的)。
|
||||||
|
但我在 README 和设置界面里都保留了这个选项的描述 ——
|
||||||
|
**能力还在,只是默认关**,这样万一以后有争议,能证明这软件有可见开关。
|
||||||
|
|
||||||
|
== 顺带发现的一个问题 ==
|
||||||
|
|
||||||
|
`notify()` 里那段建窗口的代码**被复制了两份**(第 166-201 和 206-250),
|
||||||
|
两份几乎一模一样。这次不动它(不属于本次需求,动它风险大),
|
||||||
|
但下划线标注一下,以后重构时知道。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CONFIG = ROOT / "windows" / "lanremote" / "config.py"
|
||||||
|
HEADLESS = ROOT / "windows" / "lanremote" / "headless.py"
|
||||||
|
|
||||||
|
CONFIG_ADD = ''' "screen_fps": 12,
|
||||||
|
# ---- 弹窗外观(用户要求"位置和大小都能自定义")----
|
||||||
|
#
|
||||||
|
# 位置:bottom-right / top-right / top-left / bottom-left / center / custom
|
||||||
|
# 默认保持右下角 —— 不改变老用户的使用习惯。
|
||||||
|
"notice_position": "bottom-right",
|
||||||
|
"notice_x": 0, # position=custom 时的横坐标(像素)
|
||||||
|
"notice_y": 0, # position=custom 时的纵坐标(像素)
|
||||||
|
"notice_width": 0, # 0 = 按内容自适应(240 起)
|
||||||
|
"notice_font_size": 11, # 标题字号;正文自动小 1 号
|
||||||
|
# 屏幕被观看时是否在本机弹提示条。
|
||||||
|
#
|
||||||
|
# ⚠ 原来这个提示条是**强制显示**的("有人在看你的屏幕,
|
||||||
|
# 电脑前面的人必须知道")。用户要求去掉,理由是"像 UU 远程一样
|
||||||
|
# 不要任何窗口"。
|
||||||
|
#
|
||||||
|
# 这是用户自己的电脑,属于正当的远程管理需求
|
||||||
|
# (极域电子教室这类校园软件也都是无提示的)。
|
||||||
|
# 所以能力保留、**默认关闭** —— 需要的话在设置里打开,
|
||||||
|
# 这样这软件始终有一个"可见性开关"可查。
|
||||||
|
"show_screen_notice": False,
|
||||||
|
}'''
|
||||||
|
|
||||||
|
GEOMETRY_HELPER = '''
|
||||||
|
def _notice_geometry(self, width: int, height: int) -> str:
|
||||||
|
"""算出弹窗该放哪。返回 Tk 的 geometry 字符串 `+x+y`。
|
||||||
|
|
||||||
|
用户要求"位置和大小都能自定义",所以这里支持 6 种位置。
|
||||||
|
⚠ 三处弹窗(通知 ×2、屏幕提示 ×1)都调这个函数 ——
|
||||||
|
以前三处各写死一遍右下角,改一次要改三个地方。
|
||||||
|
"""
|
||||||
|
screen_w, screen_h = self.screen_size()
|
||||||
|
pos = str(self.store.get("notice_position") or "bottom-right")
|
||||||
|
pad = 24 # 离屏幕边缘留一点空
|
||||||
|
pad_bottom = 72 # 底下多留些,避开任务栏
|
||||||
|
|
||||||
|
if pos == "custom":
|
||||||
|
try:
|
||||||
|
x = int(self.store.get("notice_x") or 0)
|
||||||
|
y = int(self.store.get("notice_y") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
x = y = 0
|
||||||
|
# 夹到屏幕内,免得设了个离谱的值窗口就找不到了
|
||||||
|
x = max(0, min(screen_w - width, x))
|
||||||
|
y = max(0, min(screen_h - height, y))
|
||||||
|
return f"+{x}+{y}"
|
||||||
|
|
||||||
|
if pos == "center":
|
||||||
|
return (f"+{max(0, (screen_w - width) // 2)}"
|
||||||
|
f"+{max(0, (screen_h - height) // 2)}")
|
||||||
|
|
||||||
|
# 四个角
|
||||||
|
left = pos in ("top-left", "bottom-left")
|
||||||
|
top = pos in ("top-left", "top-right")
|
||||||
|
x = pad if left else screen_w - width - pad
|
||||||
|
y = pad if top else screen_h - height - pad_bottom
|
||||||
|
return f"+{max(0, x)}+{max(0, y)}"
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
SCREEN_SHARING_OLD = ''' def set_screen_sharing(self, on: bool) -> None:
|
||||||
|
"""屏幕共享提示。
|
||||||
|
|
||||||
|
这是**故意保留**的:有人在看你的屏幕时,屏幕右下角会出现一条提示。
|
||||||
|
静默模式只是不显示主控制窗口,不是"让被看的人不知道"。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if on:
|
||||||
|
self._show_notice("屏幕正在被共享")
|
||||||
|
else:
|
||||||
|
self._hide_notice()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.log(f"共享提示失败: {exc}")'''
|
||||||
|
|
||||||
|
SCREEN_SHARING_NEW = ''' def set_screen_sharing(self, on: bool) -> None:
|
||||||
|
"""屏幕共享提示条 —— **默认不显示**。
|
||||||
|
|
||||||
|
用户要求:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。
|
||||||
|
|
||||||
|
所以这里先看设置项 `show_screen_notice`:
|
||||||
|
* 关着(默认)-> 什么都不做,连窗口都不建
|
||||||
|
* 开着 -> 行为跟以前一样,右下角(或用户设的位置)一条红条
|
||||||
|
|
||||||
|
保留这个开关而不是删掉代码,是因为这软件应该始终有一个
|
||||||
|
"让本机用户看得见正在被控制"的选项可查 ——
|
||||||
|
需要的时候在设置里打开就行。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self.store.get("show_screen_notice"):
|
||||||
|
# 设置关着:确保之前残留的提示条也收掉(比如用户
|
||||||
|
# 正在被看的时候把开关关了),然后什么都不做
|
||||||
|
if not on:
|
||||||
|
self._hide_notice()
|
||||||
|
return
|
||||||
|
if on:
|
||||||
|
self._show_notice("屏幕正在被共享")
|
||||||
|
else:
|
||||||
|
self._hide_notice()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.log(f"共享提示失败: {exc}")'''
|
||||||
|
|
||||||
|
|
||||||
|
def patch_file(path: Path, pairs: list, what: str) -> bool:
|
||||||
|
"""先校验所有锚点,再一次性替换。"""
|
||||||
|
raw = path.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
missing = [name for name, old, _new in pairs if old not in text]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ %s 这些锚点找不到:%s" % (what, ", ".join(missing)))
|
||||||
|
return False
|
||||||
|
for name, old, new in pairs:
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ %s(%d 处)" % (what, len(pairs)))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 弹窗位置/大小可配置 + 屏幕提示条默认关")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ---- config.py ----
|
||||||
|
cfg_raw = CONFIG.read_bytes()
|
||||||
|
cfg_bom = cfg_raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
cfg = cfg_raw.decode("utf-8-sig")
|
||||||
|
if "notice_position" in cfg:
|
||||||
|
print(" config.py 已经加过了")
|
||||||
|
else:
|
||||||
|
anchor = ' "screen_fps": 12,\n}'
|
||||||
|
if anchor not in cfg:
|
||||||
|
print(" ✗ config.py 找不到 screen_fps 结尾锚点")
|
||||||
|
return 1
|
||||||
|
cfg = cfg.replace(anchor, CONFIG_ADD, 1)
|
||||||
|
CONFIG.write_bytes((b"\xef\xbb\xbf" if cfg_bom else b"")
|
||||||
|
+ cfg.encode("utf-8"))
|
||||||
|
print(" ✓ config.py 加了 6 个设置项")
|
||||||
|
|
||||||
|
# ---- headless.py ----
|
||||||
|
html_raw = HEADLESS.read_bytes()
|
||||||
|
print()
|
||||||
|
ok = patch_file(HEADLESS, [
|
||||||
|
("_notice_geometry 方法", " def screen_size(self)",
|
||||||
|
GEOMETRY_HELPER.lstrip("\n") + " def screen_size(self)"),
|
||||||
|
("set_screen_sharing 尊重设置项", SCREEN_SHARING_OLD, SCREEN_SHARING_NEW),
|
||||||
|
], "headless.py 主体")
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ---- 三处写死的 geometry 改成调 helper ----
|
||||||
|
raw = HEADLESS.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
old_line = ('win.geometry(f"+{screen_w - width - 24}'
|
||||||
|
'+{screen_h - height - 72}")')
|
||||||
|
n = text.count(old_line)
|
||||||
|
if n == 0:
|
||||||
|
print(" ⚠ 没找到写死的 geometry 行(可能已改)")
|
||||||
|
else:
|
||||||
|
text = text.replace(old_line, "win.geometry(self._notice_geometry(width, height))")
|
||||||
|
HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ 替换了 %d 处写死的右下角定位" % n)
|
||||||
|
|
||||||
|
# ---- 字号可配置 ----
|
||||||
|
raw = HEADLESS.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
n2 = 0
|
||||||
|
for old_font, new_font in [
|
||||||
|
('font=("Microsoft YaHei UI", 11, "bold")',
|
||||||
|
'font=("Microsoft YaHei UI", self._notice_font(), "bold")'),
|
||||||
|
('font=("Microsoft YaHei UI", 10)',
|
||||||
|
'font=("Microsoft YaHei UI", self._notice_font() - 1)'),
|
||||||
|
]:
|
||||||
|
c = text.count(old_font)
|
||||||
|
if c:
|
||||||
|
text = text.replace(old_font, new_font)
|
||||||
|
n2 += c
|
||||||
|
if n2:
|
||||||
|
HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ 替换了 %d 处写死的字号" % n2)
|
||||||
|
|
||||||
|
# ---- 加 _notice_font 方法 ----
|
||||||
|
raw = HEADLESS.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
if "_notice_font" in text and "def _notice_font" not in text:
|
||||||
|
helper = '''
|
||||||
|
def _notice_font(self) -> int:
|
||||||
|
"""弹窗字号。用户要求"大小可以自定义",所以从设置里读。
|
||||||
|
|
||||||
|
夹在 8~32 之间:太小看不清,太大一个通知就把屏幕占满了。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
size = int(self.store.get("notice_font_size") or 11)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
size = 11
|
||||||
|
return max(8, min(32, size))
|
||||||
|
|
||||||
|
'''
|
||||||
|
text = text.replace(" def _notice_geometry(self, width: int, height: int) -> str:",
|
||||||
|
helper.lstrip("\n") + " def _notice_geometry(self, width: int, height: int) -> str:", 1)
|
||||||
|
HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ 加了 _notice_font 方法")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把快捷栏那一关加进 verify_all.ps1。
|
||||||
|
|
||||||
|
== 为什么用文件而不是 python -c ==
|
||||||
|
|
||||||
|
要写进 .ps1 的内容里有 `$stageOut`、`$(...)`。
|
||||||
|
用 `python -c "..."` 穿过 PowerShell 时,**PowerShell 会先解析一遍**,
|
||||||
|
把 `$stageOut` 变成 `\` —— 上次 3a12 就是这么坏的
|
||||||
|
(插进去变成 `\ = & python (Join-Path \ "..."`,语法直接错)。
|
||||||
|
|
||||||
|
凡是内容里带 `$` 的,一律写成 .py 文件再跑。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||||
|
|
||||||
|
ANCHOR = "# ---------------------------------------------------------------- 3a5"
|
||||||
|
|
||||||
|
STAGE = '''# ---------------------------------------------------------------- 3a13
|
||||||
|
Banner "3a13/9 手表快捷栏 + 允许新设备连接"
|
||||||
|
# 用户要求:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
|
||||||
|
# 自定义,最多可以加 4 个,其中有一个按钮不能改,
|
||||||
|
# 功能就是一键返回桌面"。
|
||||||
|
#
|
||||||
|
# 四个地方最容易漏,漏了都不会报错、只会让人困惑:
|
||||||
|
# 1. buildQuickBar 没挂进 onCreate -> 栏根本不显示
|
||||||
|
# 2. 没加进 showOnly 的数组 -> 面板永远压在上面
|
||||||
|
# 3. 第 4 格做成了可配置 -> 用户找半天"显示桌面"没了
|
||||||
|
# 4. 设置页没有入口 -> 说是能自定义其实改不了
|
||||||
|
$stageOut = & python (Join-Path $root "_tests\\test_quickbar.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "手表快捷栏" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = VERIFY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "手表快捷栏" in t and "test_quickbar" in t:
|
||||||
|
print(" 已经加过了")
|
||||||
|
return 0
|
||||||
|
if ANCHOR not in t:
|
||||||
|
print(" ✗ 找不到锚点")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
t = t.replace(ANCHOR, STAGE + ANCHOR, 1)
|
||||||
|
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||||
|
print(" ✓ 加了 3a13")
|
||||||
|
|
||||||
|
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||||
|
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_quickbar.py")',
|
||||||
|
'Record "手表快捷栏"'):
|
||||||
|
ok = token in back
|
||||||
|
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:56]))
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 顺手把那些 "3aN/7" 的分母改成 9(关卡总数变了)
|
||||||
|
import re
|
||||||
|
n = len(re.findall(r'Banner "3a\d+/7 ', back))
|
||||||
|
back = re.sub(r'(Banner "3a\d+)/7 ', r'\1/9 ', back)
|
||||||
|
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + back.encode("utf-8"))
|
||||||
|
print(" ✓ 把 %d 个 3aN/7 的分母改成 /9" % n)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''让「双击 exe」能打开设置窗口,并能设中转服务器。
|
||||||
|
|
||||||
|
== 用户报的两个问题 ==
|
||||||
|
|
||||||
|
问题 1:「被控端再安装时要能设置中转服务器,这是第一次设置」
|
||||||
|
—— SetupWindow 只有配对码/开机自启/放行防火墙,**没有中转**。
|
||||||
|
|
||||||
|
问题 2:「后面要想设置就要去点击 exe 去改配对码和开关新设备连接
|
||||||
|
还有中转服务器,现在如果再运行双击 exe 没反应,没运行也一样」
|
||||||
|
|
||||||
|
看 run_agent.py 的 main() 最后几行:
|
||||||
|
|
||||||
|
if not store.get("setup_done", False):
|
||||||
|
return run_setup(store)
|
||||||
|
# 已经设置过:直接静默,屏幕上什么都不出现
|
||||||
|
return run_silent(store, None)
|
||||||
|
|
||||||
|
—— 装完之后双击**永远静默**,什么窗口都不弹。
|
||||||
|
而且如果已经有一个实例在跑,InstanceLock 会直接 return,
|
||||||
|
连日志都不输出。
|
||||||
|
|
||||||
|
== 改法 ==
|
||||||
|
|
||||||
|
1. SetupWindow 加一个 mode:
|
||||||
|
mode="first" 第一次设置(保持老行为:防火墙+自启+转静默)
|
||||||
|
mode="settings" 已装好,只改配置
|
||||||
|
|
||||||
|
2. 窗口加两样东西:
|
||||||
|
* 中转服务器地址(可不填;只在一个 WiFi 里用就不需要)
|
||||||
|
* 允许新设备接入(开关)
|
||||||
|
|
||||||
|
3. main() 改成:
|
||||||
|
--setup -> settings 模式
|
||||||
|
--silent -> 静默(开机自启/安装包用,行为不变)
|
||||||
|
没参数 + 没设置过 -> first 模式
|
||||||
|
没参数 + 已经设置过 -> **settings 模式**(不再是静默)
|
||||||
|
|
||||||
|
4. 已经在跑的时候:不再"直接 exit 什么都不显示",而是
|
||||||
|
提示一句"被控端正在后台运行",然后**照样打开设置窗口**。
|
||||||
|
保存后重启后台实例让设置生效。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
UI = ROOT / "windows" / "lanremote" / "ui.py"
|
||||||
|
RUN = ROOT / "windows" / "run_agent.py"
|
||||||
|
|
||||||
|
|
||||||
|
def patch(path: Path, pairs, label: str) -> bool:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for old, new in pairs:
|
||||||
|
if old not in text:
|
||||||
|
print(" ✗ %s:找不到锚点 %r" % (label, old[:60]))
|
||||||
|
return False
|
||||||
|
if text.count(old) != 1:
|
||||||
|
print(" ! %s:锚点出现 %d 次" % (label, text.count(old)))
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
path.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print(" ✓ %s" % label)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 一、ui.py:加 mode + 中转字段 + 接入开关
|
||||||
|
# ================================================================
|
||||||
|
print("── ui.py ──")
|
||||||
|
|
||||||
|
ok &= patch(UI, [(
|
||||||
|
''' def __init__(self, store, suggested: str = ""):
|
||||||
|
self.store = store
|
||||||
|
self.suggested = (suggested or store.pairing_code()
|
||||||
|
or store.new_pairing_code())
|
||||||
|
self.result: dict | None = None''',
|
||||||
|
''' def __init__(self, store, suggested: str = "",
|
||||||
|
mode: str = "first"):
|
||||||
|
"""mode:
|
||||||
|
"first" 第一次设置(走防火墙 + 自启 + 转静默)
|
||||||
|
"settings" 已装好,只改配置(配对码/中转/接入开关)
|
||||||
|
"""
|
||||||
|
self.store = store
|
||||||
|
self.mode = mode if mode in ("first", "settings") else "first"
|
||||||
|
self.suggested = (suggested or store.pairing_code()
|
||||||
|
or store.new_pairing_code())
|
||||||
|
self.result: dict | None = None'''
|
||||||
|
)], "SetupWindow 加 mode")
|
||||||
|
|
||||||
|
# 标题按模式分
|
||||||
|
ok &= patch(UI, [(
|
||||||
|
''' self.root.title("ykxmz · 第一次设置")''',
|
||||||
|
''' self.root.title("ykxmz · " + (
|
||||||
|
"第一次设置" if self.mode == "first" else "设置"))'''
|
||||||
|
)], "窗口标题按模式分")
|
||||||
|
|
||||||
|
# 副标题
|
||||||
|
ok &= patch(UI, [(
|
||||||
|
''' tk.Label(titles, text="远程控制 · 第一次设置", bg=BG, fg=DIM,
|
||||||
|
font=FONT_SUB).pack(anchor="w")''',
|
||||||
|
''' tk.Label(titles,
|
||||||
|
text=("远程控制 · 第一次设置" if self.mode == "first"
|
||||||
|
else "远程控制 · 设置"),
|
||||||
|
bg=BG, fg=DIM,
|
||||||
|
font=FONT_SUB).pack(anchor="w")'''
|
||||||
|
)], "副标题按模式分")
|
||||||
|
|
||||||
|
# 在配对码卡片之后插入「中转服务器」卡片
|
||||||
|
ok &= patch(UI, [(
|
||||||
|
''' self.status = tk.Label(inner, text="", bg=CARD, fg=DANGER,
|
||||||
|
font=FONT_SMALL, wraplength=330,
|
||||||
|
justify="left", anchor="w")
|
||||||
|
self.status.pack(fill="x", pady=(6, 0))
|
||||||
|
''',
|
||||||
|
''' self.status = tk.Label(inner, text="", bg=CARD, fg=DANGER,
|
||||||
|
font=FONT_SMALL, wraplength=330,
|
||||||
|
justify="left", anchor="w")
|
||||||
|
self.status.pack(fill="x", pady=(6, 0))
|
||||||
|
|
||||||
|
# ================= 中转服务器卡片 =================
|
||||||
|
# 用户要求:安装时要能设中转服务器;装完之后双击 exe 也能回来改。
|
||||||
|
#
|
||||||
|
# 为什么不填也能用:手表和电脑在同一个 WiFi 时走局域网直连,
|
||||||
|
# 根本不需要中转。只有"在办公室管教室"这种跨网络场景才要填。
|
||||||
|
relay_card = self._card(page)
|
||||||
|
relay_card.pack(fill="x", pady=(12, 0))
|
||||||
|
relay_inner = tk.Frame(relay_card.winfo_children()[0], bg=CARD)
|
||||||
|
relay_inner.pack(fill="x", padx=16, pady=14)
|
||||||
|
|
||||||
|
tk.Label(relay_inner, text="中转服务器(可以不填)", bg=CARD, fg=FG,
|
||||||
|
font=FONT_LABEL).pack(anchor="w")
|
||||||
|
|
||||||
|
self.relay_entry = tk.Entry(
|
||||||
|
relay_inner, bg="#f6f8fa", fg=FG, relief="flat",
|
||||||
|
font=FONT_SUB, insertbackground=ACCENT)
|
||||||
|
self.relay_entry.pack(fill="x", pady=(8, 0), ipady=6)
|
||||||
|
_saved_relay = str(self.store.get("relay_url", "") or "").strip()
|
||||||
|
if _saved_relay:
|
||||||
|
self.relay_entry.insert(0, _saved_relay)
|
||||||
|
|
||||||
|
tk.Label(relay_inner,
|
||||||
|
text="手表和电脑在同一个 WiFi 时不需要填 —— 会直接走"
|
||||||
|
"局域网,几十毫秒。\\n"
|
||||||
|
"要在别的地方(比如办公室)管这台电脑时才填,"
|
||||||
|
"填你自己那台中转服务器的地址。",
|
||||||
|
bg=CARD, fg=MUTED, font=FONT_SMALL, justify="left",
|
||||||
|
wraplength=396).pack(anchor="w", pady=(8, 0))
|
||||||
|
|
||||||
|
self.relay_status = tk.Label(relay_inner, text="", bg=CARD,
|
||||||
|
fg=DANGER, font=FONT_SMALL,
|
||||||
|
wraplength=396, justify="left",
|
||||||
|
anchor="w")
|
||||||
|
self.relay_status.pack(fill="x", pady=(6, 0))
|
||||||
|
'''
|
||||||
|
)], "插入中转服务器卡片")
|
||||||
|
|
||||||
|
# 选项卡片里加「允许新设备接入」(只在 settings 模式显示)
|
||||||
|
ok &= patch(UI, [(
|
||||||
|
''' self.var_firewall = tk.BooleanVar(value=True)
|
||||||
|
self._check(opt_inner, "放行防火墙", self.var_firewall,
|
||||||
|
"会弹一次管理员确认,然后黑窗口一闪而过")''',
|
||||||
|
''' # 「允许新设备接入」只在设置模式给 ——
|
||||||
|
# 第一次设置时当然是允许的(不然手表连不上)。
|
||||||
|
if self.mode == "settings":
|
||||||
|
self.var_allow_new = tk.BooleanVar(
|
||||||
|
value=bool(self.store.get("allow_new_pairing", True)))
|
||||||
|
self._check(opt_inner, "允许新设备接入", self.var_allow_new,
|
||||||
|
"关掉之后,只有已经配对过的设备能连上;"
|
||||||
|
"新设备会被拒绝")
|
||||||
|
else:
|
||||||
|
self.var_allow_new = None
|
||||||
|
|
||||||
|
self.var_firewall = tk.BooleanVar(
|
||||||
|
value=(self.mode == "first"))
|
||||||
|
if self.mode == "first":
|
||||||
|
self._check(opt_inner, "放行防火墙", self.var_firewall,
|
||||||
|
"会弹一次管理员确认,然后黑窗口一闪而过")'''
|
||||||
|
)], "加「允许新设备接入」选项")
|
||||||
|
|
||||||
|
# 结果里带上 relay / allow_new
|
||||||
|
ok &= patch(UI, [(
|
||||||
|
''' self.result = {
|
||||||
|
"code": cleaned,
|
||||||
|
"autostart": bool(self.var_auto.get()),
|
||||||
|
"firewall": bool(self.var_firewall.get()),
|
||||||
|
}''',
|
||||||
|
''' # 中转地址:允许空(空 = 只走局域网)。
|
||||||
|
# 填了的话做一次最基本的格式检查,免得用户把域名打错还找不到原因。
|
||||||
|
relay = self.relay_entry.get().strip()
|
||||||
|
if relay:
|
||||||
|
if not relay.startswith(("http://", "https://")):
|
||||||
|
relay = "https://" + relay
|
||||||
|
# 只留 协议://主机[:端口],去掉多余路径
|
||||||
|
_m = re.match(r"^(https?://[^/\\s]+)", relay)
|
||||||
|
if not _m:
|
||||||
|
self._set_relay_status("中转地址看着不对,检查一下", DANGER)
|
||||||
|
return
|
||||||
|
relay = _m.group(1).rstrip("/")
|
||||||
|
self.result = {
|
||||||
|
"code": cleaned,
|
||||||
|
"autostart": bool(self.var_auto.get()),
|
||||||
|
"firewall": bool(self.var_firewall.get()),
|
||||||
|
"relay": relay,
|
||||||
|
"allow_new": (None if self.var_allow_new is None
|
||||||
|
else bool(self.var_allow_new.get())),
|
||||||
|
}'''
|
||||||
|
)], "结果带 relay / allow_new")
|
||||||
|
|
||||||
|
# 需要一个 _set_relay_status
|
||||||
|
ok &= patch(UI, [(
|
||||||
|
''' def _cancel(self) -> None:
|
||||||
|
self.result = None''',
|
||||||
|
''' def _set_relay_status(self, text: str, color: str) -> None:
|
||||||
|
try:
|
||||||
|
self.relay_status.configure(text=text, fg=color)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.result = None'''
|
||||||
|
)], "加 _set_relay_status")
|
||||||
|
|
||||||
|
# ui.py 需要 re
|
||||||
|
text = UI.read_text(encoding="utf-8")
|
||||||
|
if not re.search(r"^import re\b", text, re.M):
|
||||||
|
m = re.search(r"^(?:import |from )", text, re.M)
|
||||||
|
text = text[:m.start()] + "import re\n" + text[m.start():]
|
||||||
|
UI.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print(" ✓ ui.py 补 import re")
|
||||||
|
else:
|
||||||
|
print(" • ui.py 已有 import re")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 二、run_agent.py:双击打开设置窗口
|
||||||
|
# ================================================================
|
||||||
|
print()
|
||||||
|
print("── run_agent.py ──")
|
||||||
|
|
||||||
|
ok &= patch(RUN, [(
|
||||||
|
''' # 第一次运行:设置窗口 → 修防火墙 → 静默
|
||||||
|
if not store.get("setup_done", False):
|
||||||
|
return run_setup(store)
|
||||||
|
|
||||||
|
# 已经设置过:直接静默,屏幕上什么都不出现
|
||||||
|
return run_silent(store, None)''',
|
||||||
|
''' # 第一次运行:设置窗口 → 修防火墙 → 静默
|
||||||
|
if not store.get("setup_done", False):
|
||||||
|
return run_setup(store)
|
||||||
|
|
||||||
|
# 已经设置过、而且没给参数 —— 说明用户是**双击 exe**。
|
||||||
|
#
|
||||||
|
# 以前这里是 `return run_silent(store, None)`:直接后台跑,
|
||||||
|
# 屏幕上什么都不出现。用户想改配对码 / 中转地址 / 接入开关时
|
||||||
|
# 双击毫无反应,只能去翻命令行参数。
|
||||||
|
#
|
||||||
|
# 现在双击 = 打开设置窗口。后台运行走显式 --silent
|
||||||
|
# (开机自启和安装包都用的 --silent,所以不受影响)。
|
||||||
|
return run_settings(store)'''
|
||||||
|
)], "main() 双击改为打开设置窗口")
|
||||||
|
|
||||||
|
# 加 run_settings 函数(放在 run_setup 之后)
|
||||||
|
if "def run_settings(" not in RUN.read_text(encoding="utf-8"):
|
||||||
|
text = RUN.read_text(encoding="utf-8")
|
||||||
|
anchor = "def main(argv=None) -> int:"
|
||||||
|
if anchor not in text:
|
||||||
|
print(" ✗ 找不到 main()")
|
||||||
|
ok = False
|
||||||
|
else:
|
||||||
|
helper = '''def run_settings(store) -> int:
|
||||||
|
"""双击 exe 时打开的设置窗口。
|
||||||
|
|
||||||
|
和 run_setup 的区别:
|
||||||
|
run_setup 第一次装:走防火墙 + 开机自启 + 最后转静默
|
||||||
|
run_settings 已装好:只改配置,改完把后台实例重启一下让设置生效
|
||||||
|
|
||||||
|
为什么"已经有一个实例在跑"时也要照样开窗口:
|
||||||
|
以前 InstanceLock 拿不到就直接 return,用户双击后**什么都看不到**,
|
||||||
|
以为程序坏了。现在开窗、改完、重启后台实例。
|
||||||
|
"""
|
||||||
|
from lanremote.ui import SetupWindow
|
||||||
|
|
||||||
|
running = not InstanceLock.probe_free()
|
||||||
|
|
||||||
|
window = SetupWindow(store, mode="settings").show()
|
||||||
|
if window is None:
|
||||||
|
return 0 # 用户关掉了,什么都不改
|
||||||
|
|
||||||
|
changed = []
|
||||||
|
|
||||||
|
# ---- 配对码 ----
|
||||||
|
if window.get("code") and window["code"] != store.pairing_code():
|
||||||
|
try:
|
||||||
|
store.set_pairing_code(window["code"])
|
||||||
|
changed.append("配对码")
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"设置配对码失败:{exc}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# ---- 中转服务器(空 = 只走局域网)----
|
||||||
|
relay = str(window.get("relay", "") or "").strip()
|
||||||
|
if relay != str(store.get("relay_url", "") or "").strip():
|
||||||
|
store.set("relay_url", relay)
|
||||||
|
changed.append("中转地址")
|
||||||
|
|
||||||
|
# ---- 允许新设备接入 ----
|
||||||
|
allow_new = window.get("allow_new")
|
||||||
|
if allow_new is not None and bool(allow_new) != bool(
|
||||||
|
store.get("allow_new_pairing", True)):
|
||||||
|
store.set("allow_new_pairing", bool(allow_new))
|
||||||
|
changed.append("接入开关")
|
||||||
|
|
||||||
|
# ---- 开机自启 ----
|
||||||
|
try:
|
||||||
|
from lanremote.autostart import enable, disable, is_enabled
|
||||||
|
want = bool(window.get("autostart"))
|
||||||
|
if want != bool(is_enabled()):
|
||||||
|
(enable if want else disable)()
|
||||||
|
changed.append("开机自启")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---- 让后台实例重新读配置 ----
|
||||||
|
if changed and running:
|
||||||
|
_restart_background(store)
|
||||||
|
print("已保存:" + "、".join(changed) + "(后台实例已重启)")
|
||||||
|
elif changed:
|
||||||
|
print("已保存:" + "、".join(changed))
|
||||||
|
else:
|
||||||
|
print("没有改动")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _restart_background(store) -> None:
|
||||||
|
"""把正在跑的后台实例停掉,再用新配置起一个。
|
||||||
|
|
||||||
|
为什么要重启而不是"让它自己重读":
|
||||||
|
allow_new_pairing 是启动时读进 server.pairing_enabled 的,
|
||||||
|
中转地址也是启动时连的。文件改了但进程里还是旧值,
|
||||||
|
用户会以为设置没生效。
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
# 只杀"没有窗口"的那个(后台实例)。taskkill 按镜像名杀会连自己一起杀,
|
||||||
|
# 所以这里用 wmic/powershell 过滤命令行里带 --silent 的。
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-Command",
|
||||||
|
"Get-CimInstance Win32_Process -Filter \\"Name='ykxmz.exe'\\" | "
|
||||||
|
"Where-Object { $_.CommandLine -match '--silent' } | "
|
||||||
|
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
|
||||||
|
capture_output=True, timeout=20)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
time.sleep(1.2)
|
||||||
|
# 用 --silent 重新起一个,完全脱离本进程
|
||||||
|
try:
|
||||||
|
exe = sys.executable
|
||||||
|
subprocess.Popen([exe, "--silent"],
|
||||||
|
creationflags=getattr(subprocess,
|
||||||
|
"DETACHED_PROCESS", 0),
|
||||||
|
close_fds=True)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
text = text.replace(anchor, helper + anchor, 1)
|
||||||
|
RUN.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print(" ✓ 加 run_settings()")
|
||||||
|
else:
|
||||||
|
print(" • run_settings 已存在")
|
||||||
|
|
||||||
|
# --setup 也走 settings 模式
|
||||||
|
ok &= patch(RUN, [(
|
||||||
|
''' # --setup:重新设置
|
||||||
|
if args.setup:
|
||||||
|
return run_setup(store)''',
|
||||||
|
''' # --setup:重新打开设置窗口(和双击等效)
|
||||||
|
if args.setup:
|
||||||
|
return run_settings(store)'''
|
||||||
|
)], "--setup 走 settings 模式")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
print()
|
||||||
|
import ast
|
||||||
|
bad = []
|
||||||
|
for f in (UI, RUN):
|
||||||
|
try:
|
||||||
|
ast.parse(f.read_text(encoding="utf-8"))
|
||||||
|
except SyntaxError as exc:
|
||||||
|
bad.append("%s: 第 %s 行 %s" % (f.name, exc.lineno, exc.msg))
|
||||||
|
if bad:
|
||||||
|
print(" ✗ 语法错:")
|
||||||
|
for b in bad:
|
||||||
|
print(" %s" % b)
|
||||||
|
return 1
|
||||||
|
print(" ✓ 两个文件语法都通过")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''在一台服务器上加一个新网站(nginx 虚拟主机 + 证书)。
|
||||||
|
|
||||||
|
== 为什么需要这个 ==
|
||||||
|
|
||||||
|
一台服务器上跑多个网站是常态。做法是:**一个 nginx 占 80/443,
|
||||||
|
每个域名一个 server 块**,nginx 按 Host 头分流到各自的本地端口。
|
||||||
|
|
||||||
|
手写这套配置每次都要复制粘贴改半天,还容易漏:
|
||||||
|
* 忘了签证书 -> 浏览器报不安全
|
||||||
|
* 后端端口写错 -> 502
|
||||||
|
* 忘了把后端绑到 127.0.0.1 -> 服务直接暴露在公网
|
||||||
|
|
||||||
|
所以做成一键的。
|
||||||
|
|
||||||
|
== 用法 ==
|
||||||
|
|
||||||
|
python _build/add_site.py 域名 后端地址 [--php] [--root 目录]
|
||||||
|
|
||||||
|
例:
|
||||||
|
# 反代到一个本地服务
|
||||||
|
python _build/add_site.py blog.xxmxxs.top 127.0.0.1:9000
|
||||||
|
|
||||||
|
# WordPress 那种 PHP 站(走 php-fpm 的 unix socket 或端口)
|
||||||
|
python _build/add_site.py xxmxxs.top 127.0.0.1:9000 --php --root /opt/1panel/www/sites/xxmxxs.top/index
|
||||||
|
|
||||||
|
== 设计上的几个注意 ==
|
||||||
|
|
||||||
|
1. **默认只回源 127.0.0.1**
|
||||||
|
后端服务必须只绑本地。写 0.0.0.0 的话,别人直接打 IP:端口
|
||||||
|
就绕过 nginx 和 Cloudflare 了 —— 那正是用户不想要的。
|
||||||
|
|
||||||
|
2. **证书用 certbot 的 nginx 插件签**
|
||||||
|
它自己改配置、自己加 443。前提是**这个域名的 A 记录已经指过来**
|
||||||
|
(而且最好是橙色云朵,这样 certbot 的 HTTP-01 挑战能经 Cloudflare 回源)。
|
||||||
|
|
||||||
|
3. **不动别的站点**
|
||||||
|
只新建 /etc/nginx/sites-available/<域名>,再链到 sites-enabled。
|
||||||
|
已有的站点(比如遥控中转 ykxmz)一个字都不碰。
|
||||||
|
|
||||||
|
4. **默认 server 的坑**
|
||||||
|
如果有人用 IP 直接访问,nginx 会用**第一个** server 块回应。
|
||||||
|
所以配置文件开头有个显式的 default server 把它们挡掉 ——
|
||||||
|
这样 IP:port 扫不到任何真实站点。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run, upload # noqa: E402
|
||||||
|
|
||||||
|
TEMPLATE = """# {domain} —— 由 _build/add_site.py 生成
|
||||||
|
#
|
||||||
|
# 后端: {backend}
|
||||||
|
# 说明: nginx 按 server_name 分流;后端只应该监听 127.0.0.1,
|
||||||
|
# 这样外面只能通过域名(也就是经过 nginx + Cloudflare)访问。
|
||||||
|
|
||||||
|
server {{
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name {domain};
|
||||||
|
|
||||||
|
access_log /var/log/nginx/{safe}_access.log;
|
||||||
|
error_log /var/log/nginx/{safe}_error.log;
|
||||||
|
|
||||||
|
{body}
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size {max_body};
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROXY_BODY = """ location / {{
|
||||||
|
proxy_pass http://{backend};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PHP_BODY = """ root {root};
|
||||||
|
index index.php index.html;
|
||||||
|
|
||||||
|
location / {{
|
||||||
|
try_files $uri $uri/ /index.php?$args;
|
||||||
|
}}
|
||||||
|
|
||||||
|
location ~ \\.php$ {{
|
||||||
|
include snippets/fastcgi-php.conf;
|
||||||
|
fastcgi_pass {backend};
|
||||||
|
fastcgi_read_timeout 300s;
|
||||||
|
}}
|
||||||
|
|
||||||
|
location ~ /\\.ht {{
|
||||||
|
deny all;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="加一个网站")
|
||||||
|
ap.add_argument("domain", help="域名,例如 blog.xxmxxs.top")
|
||||||
|
ap.add_argument("backend", help="后端地址,例如 127.0.0.1:9000")
|
||||||
|
ap.add_argument("--php", action="store_true",
|
||||||
|
help="PHP 站(用 fastcgi_pass 而不是 proxy_pass)")
|
||||||
|
ap.add_argument("--root", default="", help="PHP 站的根目录")
|
||||||
|
ap.add_argument("--max-body", default="64m", help="上传大小上限,默认 64m")
|
||||||
|
ap.add_argument("--no-cert", action="store_true",
|
||||||
|
help="只配 80,先不签证书")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
domain = args.domain.strip()
|
||||||
|
backend = args.backend.strip()
|
||||||
|
safe = domain.replace(".", "_").replace("-", "_")
|
||||||
|
|
||||||
|
# ⚠ 后端必须是本地地址。写 0.0.0.0 或公网地址的话,
|
||||||
|
# 等于把后端服务直接暴露出去,nginx 和 Cloudflare 都白搭。
|
||||||
|
if not (backend.startswith("127.0.0.1:") or backend.startswith("localhost:")
|
||||||
|
or backend.startswith("unix:")):
|
||||||
|
print(" ⚠ 后端 %r 不是本地地址。" % backend)
|
||||||
|
print(" 后端必须只监听 127.0.0.1 —— 否则别人直接打 "
|
||||||
|
"IP:端口就绕过了 nginx 和 Cloudflare。")
|
||||||
|
print(" 要继续的话请显式确认(现在中止)。")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.php and not args.root:
|
||||||
|
print(" ✗ --php 需要同时给 --root(网站文件目录)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
body = (PHP_BODY.format(root=args.root, backend=backend)
|
||||||
|
if args.php else PROXY_BODY.format(backend=backend))
|
||||||
|
conf = TEMPLATE.format(domain=domain, backend=backend, safe=safe,
|
||||||
|
body=body, max_body=args.max_body)
|
||||||
|
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 加站点 %s -> %s" % (domain, backend))
|
||||||
|
print("=" * 58)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 写配置 ──")
|
||||||
|
import tempfile
|
||||||
|
tmp = Path(tempfile.gettempdir()) / ("nginx-%s.conf" % safe)
|
||||||
|
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/%s.conf" % safe)
|
||||||
|
out = run(
|
||||||
|
"sudo mv /tmp/{s}.conf /etc/nginx/sites-available/{d} && "
|
||||||
|
"sudo ln -sf /etc/nginx/sites-available/{d} "
|
||||||
|
"/etc/nginx/sites-enabled/{d} && "
|
||||||
|
"sudo nginx -t 2>&1 | tail -2".format(s=safe, d=domain),
|
||||||
|
timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
if "successful" not in out[1]:
|
||||||
|
print(" ✗ nginx 配置检查没过,撤回")
|
||||||
|
run("sudo rm -f /etc/nginx/sites-enabled/%s" % domain,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 重载 nginx ──")
|
||||||
|
out = run("sudo systemctl reload nginx && sleep 1 && "
|
||||||
|
"systemctl is-active nginx", timeout=120, quiet=True)
|
||||||
|
print(" nginx: " + out[1].strip())
|
||||||
|
|
||||||
|
if not args.no_cert:
|
||||||
|
print()
|
||||||
|
print(" ── 3. 签 Let's Encrypt 证书(顺带加上 443 和跳转)──")
|
||||||
|
out = run(
|
||||||
|
"sudo certbot --nginx -d {d} --non-interactive --agree-tos "
|
||||||
|
"--register-unsafely-without-email --redirect "
|
||||||
|
"--keep-until-expiring 2>&1 | tail -8".format(d=domain),
|
||||||
|
timeout=600, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 复核 ──")
|
||||||
|
# ⚠ 这里**不能用 .format()** —— 远端命令里有 curl 的 `%{http_code}`,
|
||||||
|
# 而 .format() 会把 `{http_code}` 当占位符,抛
|
||||||
|
# "KeyError/IndexError: http_code"。
|
||||||
|
# 和之前用 % 格式化踩的是**同一类坑**(内容里有格式符)。
|
||||||
|
# 凡是把域名等变量拼进含 `{...}` 的远端命令,一律用 replace。
|
||||||
|
probe = (
|
||||||
|
'printf " %-34s %s\\n" "本机带 Host 头" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 10 '
|
||||||
|
'-H \'Host: __D__\' http://127.0.0.1:80/)"; '
|
||||||
|
'printf " %-34s %s\\n" "本机 443 带 SNI" '
|
||||||
|
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 10 '
|
||||||
|
'--resolve __D__:443:127.0.0.1 https://__D__/)"; '
|
||||||
|
'printf " %-34s %s\\n" "公网 443" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 20 '
|
||||||
|
'https://__D__/)"'
|
||||||
|
).replace("__D__", domain)
|
||||||
|
out = run(probe, timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 已加好。注意:")
|
||||||
|
print(" * 这个域名的 DNS 要指向这台服务器(Cloudflare 橙色云朵)")
|
||||||
|
print(" * 后端 %s 必须只监听 127.0.0.1" % backend)
|
||||||
|
print(" * 不要动 /etc/nginx/sites-available/ykxmz(那是遥控中转)",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''给官网首页加 60 秒缓存。
|
||||||
|
|
||||||
|
(download 那处已经用 edit 工具改好了:no-store ->
|
||||||
|
public,max-age=604800,immutable。)
|
||||||
|
|
||||||
|
首页原来走 _send 的默认 Cache-Control: no-store,
|
||||||
|
每次刷新都穿隧道回 4G 源站。加 60 秒缓存:
|
||||||
|
* 挡住"刷新就重新回源"的浪费
|
||||||
|
* 又不会让页面上的当前版本号 / 下载按钮过期太久
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SRC = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay\relay.py")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
text = SRC.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
marker = 'self._send(200, _site_page().encode("utf-8"),\n'
|
||||||
|
idx = text.find(marker)
|
||||||
|
if idx < 0:
|
||||||
|
print(" ✗ 找不到首页的 _send")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 打印现场,确认空白
|
||||||
|
end = text.find("\n", text.find("return", idx))
|
||||||
|
print(" 现场:")
|
||||||
|
for line in text[idx:end + 1].splitlines():
|
||||||
|
print(" %r" % line)
|
||||||
|
|
||||||
|
old = ('self._send(200, _site_page().encode("utf-8"),\n'
|
||||||
|
' "text/html; charset=utf-8")')
|
||||||
|
new = ('# 缓存 60 秒:挡住"刷新就重新回源"的浪费,\n'
|
||||||
|
' # 又不会让页面上的当前版本号和下载按钮过期太久。\n'
|
||||||
|
' self._send(200, _site_page().encode("utf-8"),\n'
|
||||||
|
' "text/html; charset=utf-8",\n'
|
||||||
|
' {"Cache-Control": "public, max-age=60"})')
|
||||||
|
|
||||||
|
if old not in text:
|
||||||
|
print()
|
||||||
|
print(" ✗ 精确串还是没匹配上(看上面的 repr 对齐空白)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
SRC.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print()
|
||||||
|
print(" ✓ 已加首页缓存")
|
||||||
|
|
||||||
|
# 复核
|
||||||
|
for i, line in enumerate(
|
||||||
|
SRC.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
if "max-age=60" in line:
|
||||||
|
print(" 第 %d 行: %s" % (i, line.strip()))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把「语音播报降级链」加进 verify_all.ps1。
|
||||||
|
|
||||||
|
== 为什么用文件而不是 python -c ==
|
||||||
|
|
||||||
|
要写进 .ps1 的内容里有 `$stageOut`。用 `python -c "..."` 穿过
|
||||||
|
PowerShell 时会被先解析一遍,`$stageOut` 变成 `\` —— 上次 3a12
|
||||||
|
就是这么坏的。
|
||||||
|
|
||||||
|
凡是内容里带 `$` 的,一律写成 .py 文件再跑。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||||
|
|
||||||
|
ANCHOR = "# ---------------------------------------------------------------- 3a5"
|
||||||
|
|
||||||
|
STAGE = '''# ---------------------------------------------------------------- 3a14
|
||||||
|
Banner "3a14/9 语音播报降级链(修 Win7 上用不了)"
|
||||||
|
# 用户反馈:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"。
|
||||||
|
#
|
||||||
|
# 原来只有一条路(powershell + System.Speech),而 Win7 上
|
||||||
|
# PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后必须保证:
|
||||||
|
# * cscript/VBScript 排最前(从 Win98 就有,SAPI5 是系统组件,不用装东西)
|
||||||
|
# * 前一条失败要接着试下一条,不是试一条就放弃
|
||||||
|
# * 全失败要**报错**,不能像原来那样 Popen 不等待、明明没出声却说"已播报"
|
||||||
|
# * VBScript 用 ANSI 写(UTF-8 的话 cscript 读出来中文是乱码)
|
||||||
|
$stageOut = & python (Join-Path $root "_tests\\test_tts.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "语音播报降级" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = VERIFY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "test_tts.py" in t:
|
||||||
|
print(" 已经加过了")
|
||||||
|
return 0
|
||||||
|
if ANCHOR not in t:
|
||||||
|
print(" ✗ 找不到锚点")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
t = t.replace(ANCHOR, STAGE + ANCHOR, 1)
|
||||||
|
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||||
|
print(" ✓ 加了 3a14")
|
||||||
|
|
||||||
|
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||||
|
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_tts.py")',
|
||||||
|
'Record "语音播报降级"'):
|
||||||
|
ok = token in back
|
||||||
|
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:56]))
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''给中转加「最新版本查询」接口 —— 被控端静默自动更新的第一步。
|
||||||
|
|
||||||
|
== 为什么由中转来回答 ==
|
||||||
|
|
||||||
|
被控端已经知道中转地址(那是它的配置),而中转的 downloads/ 目录里
|
||||||
|
就放着各版本的成品。让中转直接回答"最新是哪版、在哪下、校验和多少",
|
||||||
|
就不用把任何地址写死在客户端里 —— 换个域名/换台服务器都不用改代码。
|
||||||
|
|
||||||
|
== 新增 ==
|
||||||
|
|
||||||
|
GET /v1/update/latest?platform=windows
|
||||||
|
|
||||||
|
200 {
|
||||||
|
"ok": true,
|
||||||
|
"platform": "windows",
|
||||||
|
"version": "2.1.5",
|
||||||
|
"name": "ykxmz-setup-2.1.5.exe",
|
||||||
|
"url": "/download/ykxmz-setup-2.1.5.exe",
|
||||||
|
"size": 9395078,
|
||||||
|
"sha256": "....",
|
||||||
|
"notes": "..." <- 可选,取 CHANGELOG 里那一节
|
||||||
|
}
|
||||||
|
|
||||||
|
没找到任何包时:
|
||||||
|
200 {"ok": false, "error": "还没有可用的安装包"}
|
||||||
|
|
||||||
|
== url 给相对路径 ==
|
||||||
|
|
||||||
|
给相对路径(`/download/xxx.exe`),让被控端用**它自己配置的中转地址**
|
||||||
|
拼成完整 URL。这样:
|
||||||
|
* 中转换域名/换 IP 不用改任何东西
|
||||||
|
* 走 Cloudflare 还是直连由被控端那边的配置决定
|
||||||
|
|
||||||
|
== sha256 要缓存 ==
|
||||||
|
|
||||||
|
安装包 9MB,每次请求都算一遍 sha256 大概 30 毫秒 —— 不算慢,
|
||||||
|
但这个接口会被所有被控端周期性调用(每台每小时一次的话,
|
||||||
|
一个学校几十台就是几十次),没必要重复算。
|
||||||
|
按「文件名 + 大小 + 修改时间」做键缓存。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
RELAY = ROOT / "relay" / "relay.py"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = RELAY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "_latest_package" in text:
|
||||||
|
print(" 已经加过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ⚠ **先把所有锚点都验一遍,再动手改。**
|
||||||
|
#
|
||||||
|
# 第一版是把替换都做在内存里、最后才 write_bytes ——
|
||||||
|
# 结果中途某个锚点没匹配上就 `return 1`,
|
||||||
|
# 前面几处改动**全丢了**(脚本还打印了"✓ 已加"),
|
||||||
|
# 而文件其实一个字没变。特别容易误判成"加好了"。
|
||||||
|
#
|
||||||
|
# 教训:**批处理脚本要幺先验后改,幺写一段落一段。**
|
||||||
|
ANCHORS = {
|
||||||
|
"import_json": "\nimport json",
|
||||||
|
"list_downloads": ' items.sort(key=lambda item: item["mtime"], reverse=True)\n return items\n',
|
||||||
|
"downloads_route": ' if path == "/v1/downloads":',
|
||||||
|
}
|
||||||
|
missing = [k for k, v in ANCHORS.items() if v not in text]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ 这些锚点找不到,一个字都不改:%s" % ", ".join(missing))
|
||||||
|
return 1
|
||||||
|
print(" ✓ 锚点全部就位")
|
||||||
|
|
||||||
|
# ---- 1. import hashlib ----
|
||||||
|
if "\nimport hashlib" not in text:
|
||||||
|
text = text.replace(ANCHORS["import_json"],
|
||||||
|
"\nimport hashlib\nimport json", 1)
|
||||||
|
print(" ✓ 加了 import hashlib")
|
||||||
|
|
||||||
|
# ---- 2. 插到 _list_downloads 之后 ----
|
||||||
|
anchor = ANCHORS["list_downloads"]
|
||||||
|
|
||||||
|
helper = anchor + '''
|
||||||
|
|
||||||
|
# 最新安装包的信息缓存。键 = (文件名, 大小, 修改时间)。
|
||||||
|
_PKG_CACHE: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_of(path: Path) -> str:
|
||||||
|
"""算文件的 sha256(分块读,别把 9MB 全塞内存)。"""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(256 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_package(platform: str = "windows") -> dict:
|
||||||
|
"""找 downloads/ 里最新的安装包,给被控端自更新用。
|
||||||
|
|
||||||
|
`platform` 现在只有 windows;留着参数是为了以后加别的平台
|
||||||
|
(手表/服务端不通过这个接口自更新,它们各有各的更新方式)。
|
||||||
|
|
||||||
|
返回 {} 表示"还没有可用的包"。
|
||||||
|
"""
|
||||||
|
# 各平台的成品名前缀
|
||||||
|
prefix = {
|
||||||
|
"windows": "ykxmz-setup-",
|
||||||
|
}.get(platform)
|
||||||
|
if not prefix:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
best = None # (版本元组, 路径)
|
||||||
|
try:
|
||||||
|
if not DOWNLOADS_DIR.is_dir():
|
||||||
|
return {}
|
||||||
|
for entry in DOWNLOADS_DIR.iterdir():
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
name = entry.name
|
||||||
|
if not name.startswith(prefix) or not name.endswith(".exe"):
|
||||||
|
continue
|
||||||
|
if not _safe_download_name(name):
|
||||||
|
continue
|
||||||
|
# 从文件名里抠版本号
|
||||||
|
mid = name[len(prefix):-len(".exe")]
|
||||||
|
parts = mid.split(".")
|
||||||
|
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
||||||
|
continue
|
||||||
|
ver = tuple(int(p) for p in parts)
|
||||||
|
# ⚠ 按**数字段**比大小,不能按字符串 ——
|
||||||
|
# 否则 "1.9.0" > "2.1.4"(字符 '9' > '1')。
|
||||||
|
if best is None or ver > best[0]:
|
||||||
|
best = (ver, entry)
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
ver, path = best
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
key = (path.name, st.st_size, int(st.st_mtime))
|
||||||
|
cached = _PKG_CACHE.get(key)
|
||||||
|
if cached:
|
||||||
|
digest = cached
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
digest = _sha256_of(path)
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
# 只留最近几个,别让字典涨
|
||||||
|
_PKG_CACHE.clear()
|
||||||
|
_PKG_CACHE[key] = digest
|
||||||
|
|
||||||
|
return {
|
||||||
|
"platform": platform,
|
||||||
|
"version": ".".join(str(p) for p in ver),
|
||||||
|
"name": path.name,
|
||||||
|
"url": "/download/" + quote(path.name),
|
||||||
|
"size": st.st_size,
|
||||||
|
"sha256": digest,
|
||||||
|
"mtime": st.st_mtime,
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
text = text.replace(anchor, helper, 1)
|
||||||
|
print(" ✓ 加了 _latest_package / _sha256_of")
|
||||||
|
|
||||||
|
# ---- 3. 路由 ----
|
||||||
|
route_anchor = ANCHORS["downloads_route"]
|
||||||
|
|
||||||
|
route = ''' if path == "/v1/update/latest":
|
||||||
|
# 被控端自更新用:最新版是哪版、在哪下、校验和多少。
|
||||||
|
# 公开接口 —— 文件名和哈希本来就在下载页上人人可见,
|
||||||
|
# 不含任何敏感信息。
|
||||||
|
platform = (urllib.parse.parse_qs(
|
||||||
|
urllib.parse.urlparse(self.path).query)
|
||||||
|
.get("platform", ["windows"])[0] or "windows")
|
||||||
|
pkg = _latest_package(platform)
|
||||||
|
if not pkg:
|
||||||
|
self._json(200, {"ok": False,
|
||||||
|
"error": "还没有可用的安装包"})
|
||||||
|
else:
|
||||||
|
self._json(200, dict(pkg, ok=True))
|
||||||
|
return
|
||||||
|
|
||||||
|
''' + route_anchor
|
||||||
|
text = text.replace(route_anchor, route, 1)
|
||||||
|
print(" ✓ 加了 /v1/update/latest 路由")
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''往 _tests/verify_all.ps1 里加两个关卡。
|
||||||
|
|
||||||
|
== 加哪两个 ==
|
||||||
|
|
||||||
|
3a10 静默自动更新链路(_tests/test_update_flow.py)
|
||||||
|
3a11 远程鼠标 + 显示桌面(_tests/test_mouse_control.py)
|
||||||
|
|
||||||
|
== 这个脚本自己踩过的坑,写下来免得再犯 ==
|
||||||
|
|
||||||
|
往 server.py 插方法时,我两次把 **dispatch 的兜底 return**
|
||||||
|
一起卷进了新方法里:
|
||||||
|
|
||||||
|
if cmd == P.CMD_XXX:
|
||||||
|
return self._do_xxx(msg)
|
||||||
|
|
||||||
|
return {"ok": False, "error": f"未知命令: {cmd}"} <- dispatch 的兜底
|
||||||
|
|
||||||
|
def _do_xxx(...): <- 我的新方法
|
||||||
|
...
|
||||||
|
return {"ok": False, "error": f"未知命令: {cmd}"} <- 被卷进来了!
|
||||||
|
|
||||||
|
结果 pyflakes 报 "undefined name 'cmd'",而 dispatch 丢了兜底分支。
|
||||||
|
|
||||||
|
这次改 PowerShell 里插关卡用**更笨但更稳**的办法:
|
||||||
|
直接在固定锚点**之前**插,而且插完立刻读回来看一眼结构。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||||
|
|
||||||
|
STAGES = '''
|
||||||
|
# ---------------------------------------------------------------- 3a10
|
||||||
|
Banner "3a10/7 静默自动更新链路"
|
||||||
|
# 用户要求:"手表端点击一个更新按钮,电脑端就可以自动进行静默更新,
|
||||||
|
# 其他什么设置都不变 …… 不弹任何窗口出来"
|
||||||
|
# 这条链是:中转 /v1/update/latest -> 被控端 updater.py 下载校验
|
||||||
|
# -> 分离 .cmd 帮手 -> 静默装 -> 重启。
|
||||||
|
#
|
||||||
|
# 这里验协议常量、版本比较(按数字段,不是字符串)、下载校验、
|
||||||
|
# 以及**服务端 dispatch 的结构没被补丁弄坏**。
|
||||||
|
$stageOut = & python (Join-Path $root "_tests\\test_update_flow.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "静默自动更新" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3a11
|
||||||
|
Banner "3a11/7 远程鼠标 + 一键显示桌面"
|
||||||
|
# 鼠标注入是最"有力量"的功能,参数校验写错的话表现是"手表点了没反应",
|
||||||
|
# 教室里没法排查。这里盯坐标归一化(除以 宽-1 那条规则)、
|
||||||
|
# 滚轮夹取、Win+D 的事件顺序、以及非法参数必须被拒。
|
||||||
|
$stageOut = & python (Join-Path $root "_tests\\test_mouse_control.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "远程鼠标控制" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
ANCHOR = '# ---------------------------------------------------------------- 3a5'
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = VERIFY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "静默自动更新链路" in text:
|
||||||
|
print(" 已经加过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if ANCHOR not in text:
|
||||||
|
print(" ✗ 找不到锚点 %r" % ANCHOR)
|
||||||
|
return 1
|
||||||
|
print(" ✓ 锚点就位")
|
||||||
|
|
||||||
|
text = text.replace(ANCHOR, STAGES.lstrip("\n") + "\n" + ANCHOR, 1)
|
||||||
|
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ 加了 3a10 / 3a11 两个关卡")
|
||||||
|
|
||||||
|
# 读回来确认结构:两个 Record 都在、锚点还在
|
||||||
|
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||||
|
for token in ('Record "静默自动更新"', 'Record "远程鼠标控制"', ANCHOR):
|
||||||
|
ok = token in back
|
||||||
|
print(" [%s] 读回确认:%s" % ("PASS" if ok else "FAIL",
|
||||||
|
token[:40]))
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''给 verify_all.ps1 追加阶段 —— **不能用 PowerShell heredoc**。
|
||||||
|
|
||||||
|
== 为什么单独写这个脚本 ==
|
||||||
|
|
||||||
|
我这一轮在 PowerShell 里用 `python -c @"..."@` 写 Python 代码
|
||||||
|
去改 verify_all.ps1,结果 **`$stageOut` 和 `$root` 被 PowerShell
|
||||||
|
当成变量给展开了**(`@"..."@` 是插值字符串),写进去变成:
|
||||||
|
|
||||||
|
= & python (Join-Path "_tests\check_version_consistency.py") 2>&1
|
||||||
|
Show-Fails
|
||||||
|
Record "版本号一致性" (0 -eq 0)
|
||||||
|
|
||||||
|
整个脚本直接语法错。**同一个坑踩了两次**(3a7 和 3a8)。
|
||||||
|
|
||||||
|
教训:凡是给 .ps1 加含 `$` 的内容,一律**写 Python 脚本文件再跑**,
|
||||||
|
不要用 `python -c @"..."@`。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python _build/add_verify_stage.py <锚点文字> <阶段号> <标题> <脚本相对路径> <Record 名>
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SUITE = ROOT / "_tests" / "verify_all.ps1"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 6:
|
||||||
|
print(__doc__)
|
||||||
|
return 1
|
||||||
|
anchor_text, stage_no, title, script_rel, record_name = sys.argv[1:6]
|
||||||
|
|
||||||
|
raw = SUITE.read_bytes()
|
||||||
|
had_bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if script_rel in text:
|
||||||
|
print(" 已经注册过了(%s 里提到了 %s)" % (SUITE.name, script_rel))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
lines = text.splitlines()
|
||||||
|
idx = None
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if ln.startswith("Record") and anchor_text in ln:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
if idx is None:
|
||||||
|
print(" ✗ 找不到锚点(Record 行含 %r)" % anchor_text)
|
||||||
|
print(" 现有 Record 行:")
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if ln.startswith("Record"):
|
||||||
|
print(" %d: %s" % (i, ln))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
block = [
|
||||||
|
"",
|
||||||
|
"# ---------------------------------------------------------------- %s"
|
||||||
|
% stage_no,
|
||||||
|
'Banner "%s"' % title,
|
||||||
|
"$stageOut = & python (Join-Path $root \"%s\") 2>&1"
|
||||||
|
% script_rel.replace("/", "\\"),
|
||||||
|
" Show-Fails $stageOut",
|
||||||
|
'Record "%s" ($LASTEXITCODE -eq 0)' % record_name,
|
||||||
|
]
|
||||||
|
lines[idx + 1:idx + 1] = block
|
||||||
|
|
||||||
|
out = "\n".join(lines) + "\n"
|
||||||
|
SUITE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + out.encode("utf-8"))
|
||||||
|
print(" ✓ 已在「%s」后面插入阶段 %s" % (anchor_text, stage_no))
|
||||||
|
print(" (BOM 保留:%s)" % had_bom)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''手表端:常驻顶部快捷栏。
|
||||||
|
|
||||||
|
== 用户的要求(原话)==
|
||||||
|
|
||||||
|
"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页自定义,
|
||||||
|
最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"
|
||||||
|
|
||||||
|
("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)
|
||||||
|
|
||||||
|
== 做什么 ==
|
||||||
|
|
||||||
|
1. **一条常驻的快捷栏**:贴在屏幕顶部,任何页面都在
|
||||||
|
(控制页 / 镜像页 / 设置页 / 局域网页面板切换时不动)
|
||||||
|
|
||||||
|
2. **最多 4 个按钮**:
|
||||||
|
* **1 个固定**:显示桌面(Win+D)—— 不给改,这是用户明确要求的
|
||||||
|
* **最多 3 个自定义**:从 7 个动作里挑
|
||||||
|
|
||||||
|
3. **7 个可选动作**(都有现成调用,见下面的 QUICK_ACTIONS):
|
||||||
|
file 传文件到电脑
|
||||||
|
mirror 屏幕镜像
|
||||||
|
mouse 鼠标控制(本补丁配套的触摸板)
|
||||||
|
sfx 音效
|
||||||
|
power 关机 / 锁屏
|
||||||
|
tts 语音播报
|
||||||
|
image 投图到电脑
|
||||||
|
|
||||||
|
4. **设置页里能改**:勾选哪 3 个动作。
|
||||||
|
|
||||||
|
== 为什么贴在 FrameLayout 最上层 ==
|
||||||
|
|
||||||
|
现有架构是:一个 FrameLayout `root`,5 个面板都 MATCH_PARENT 叠在里面,
|
||||||
|
靠 `showOnly()` 切可见性。
|
||||||
|
|
||||||
|
快捷栏也加到 `root`,**在 5 个面板之后 addView** —— 这样它在最上层:
|
||||||
|
* 切页面时它不受影响(不用每个页面各建一份)
|
||||||
|
* 面板填满整个屏幕也不会盖住它
|
||||||
|
|
||||||
|
代价:面板内容会被栏盖住一截,所以给每个面板加了 topMargin
|
||||||
|
(见 PATCH_PANEL_MARGIN)。
|
||||||
|
|
||||||
|
== 为什么栏在没连上时也显示 ==
|
||||||
|
|
||||||
|
用户说"不管干什么上面都有一栏",那就**一直在**。
|
||||||
|
没连上的时候按钮置灰(不是隐藏)—— "一直在但暂时不能用"
|
||||||
|
比"时有时无"更好理解,也不会让用户以为功能没了。
|
||||||
|
|
||||||
|
== 存储 ==
|
||||||
|
|
||||||
|
用 SharedPreferences(和 App 其他设置一致),键 `quick_slots`,
|
||||||
|
存逗号分隔的动作 id。默认给 file,mirror,sfx。
|
||||||
|
读取时会**过滤掉不认识的 id**(比如以后删了某个动作),
|
||||||
|
不够 3 个就按默认顺序补齐 —— 这样老配置永远不会让栏变空。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 字段
|
||||||
|
FIELDS_ANCHOR = " private Button updateButton;"
|
||||||
|
|
||||||
|
FIELDS = ''' private Button updateButton;
|
||||||
|
|
||||||
|
// ---------------- 顶部快捷栏 ----------------
|
||||||
|
//
|
||||||
|
// 用户要求:"不管干什么,上面都有一栏,栏里的按钮可以在设置页
|
||||||
|
// 自定义,最多可以加 4 个,其中有一个按钮不能改,
|
||||||
|
// 功能就是一键返回桌面"。
|
||||||
|
//
|
||||||
|
// 所以这里是 1 个固定(显示桌面)+ 最多 3 个自定义。
|
||||||
|
private LinearLayout quickBar;
|
||||||
|
private final java.util.List<Button> quickButtons =
|
||||||
|
new java.util.ArrayList<Button>();
|
||||||
|
/** 设置页里那 3 个位置各放什么动作。null 表示这个位置空着。 */
|
||||||
|
private String[] quickSlots = new String[]{"file", "mirror", "sfx"};
|
||||||
|
|
||||||
|
/** 快捷栏上能放的动作。id 会存进设置,**改名会让老配置失效**,
|
||||||
|
* 所以这几个英文 id 不要动,界面上显示的中文随便改。 */
|
||||||
|
private static final String[][] QUICK_ACTIONS = {
|
||||||
|
{"file", "传文件"},
|
||||||
|
{"mirror", "屏幕镜像"},
|
||||||
|
{"mouse", "鼠标控制"},
|
||||||
|
{"sfx", "音效"},
|
||||||
|
{"power", "关机"},
|
||||||
|
{"tts", "语音播报"},
|
||||||
|
{"image", "投图到电脑"},
|
||||||
|
};
|
||||||
|
private static final String QUICK_PREF = "quick_slots";
|
||||||
|
private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内
|
||||||
|
|
||||||
|
// ---------------- 鼠标控制(触摸板)----------------
|
||||||
|
private FrameLayout mousePanel;
|
||||||
|
private View mousePad;
|
||||||
|
private boolean mouseDragging = false;
|
||||||
|
private float mouseLastX = 0f;
|
||||||
|
private float mouseLastY = 0f;'''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 建栏方法
|
||||||
|
BUILD_ANCHOR = " private void showOnly(View keep) {"
|
||||||
|
|
||||||
|
BUILD_METHOD = ''' // ==================================================================
|
||||||
|
// 顶部快捷栏
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建快捷栏。挂在 root 最上层,任何页面都在。
|
||||||
|
*
|
||||||
|
* <p>布局:[动作1][动作2][动作3][显示桌面]
|
||||||
|
* 前三个按设置里的选择走,最后一个**永远**是显示桌面(不给改)。
|
||||||
|
*/
|
||||||
|
private void buildQuickBar() {
|
||||||
|
quickBar = new LinearLayout(this);
|
||||||
|
quickBar.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
quickBar.setBackgroundColor(0xE6185CE8); // 半透明蓝,压在任何页面上都看得清
|
||||||
|
quickBar.setPadding(dp(3), dp(3), dp(3), dp(3));
|
||||||
|
quickBar.setGravity(android.view.Gravity.CENTER_VERTICAL);
|
||||||
|
|
||||||
|
quickButtons.clear();
|
||||||
|
// 4 个按钮:3 个自定义 + 1 个固定
|
||||||
|
for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
||||||
|
final int slot = i;
|
||||||
|
Button b = new Button(this);
|
||||||
|
b.setTextSize(11f);
|
||||||
|
b.setAllCaps(false);
|
||||||
|
b.setTextColor(0xFFFFFFFF);
|
||||||
|
b.setBackgroundColor(0x33FFFFFF);
|
||||||
|
b.setPadding(dp(2), 0, dp(2), 0);
|
||||||
|
b.setMinWidth(0);
|
||||||
|
b.setMinimumWidth(0);
|
||||||
|
b.setMinHeight(0);
|
||||||
|
b.setMinimumHeight(0);
|
||||||
|
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||||
|
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
|
||||||
|
lp.setMargins(dp(2), 0, dp(2), 0);
|
||||||
|
b.setLayoutParams(lp);
|
||||||
|
b.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
onQuickClick(slot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
quickButtons.add(b);
|
||||||
|
quickBar.addView(b);
|
||||||
|
}
|
||||||
|
// 高度固定,免得按钮里的字多一个字整条栏就变高、页面跟着抖
|
||||||
|
FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));
|
||||||
|
barLp.gravity = android.view.Gravity.TOP;
|
||||||
|
quickBar.setLayoutParams(barLp);
|
||||||
|
|
||||||
|
refreshQuickBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按设置刷新快捷栏上的文字和可用状态。
|
||||||
|
*
|
||||||
|
* <p>连不上的时候置灰而不是隐藏 —— 用户说"不管干什么上面都有一栏",
|
||||||
|
* 时有时无会让人以为功能没了。
|
||||||
|
*/
|
||||||
|
private void refreshQuickBar() {
|
||||||
|
if (quickBar == null || quickButtons.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean connected = isConnected();
|
||||||
|
for (int i = 0; i < quickButtons.size(); i++) {
|
||||||
|
Button b = quickButtons.get(i);
|
||||||
|
String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";
|
||||||
|
if (id == null || id.isEmpty()) {
|
||||||
|
b.setText("-"); // 这个位置空着
|
||||||
|
b.setEnabled(false);
|
||||||
|
b.setAlpha(0.45f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
b.setText(quickLabel(id));
|
||||||
|
b.setEnabled(connected);
|
||||||
|
b.setAlpha(connected ? 1f : 0.5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String quickLabel(String id) {
|
||||||
|
if ("desktop".equals(id)) {
|
||||||
|
return "显示桌面"; // 固定那个
|
||||||
|
}
|
||||||
|
for (String[] pair : QUICK_ACTIONS) {
|
||||||
|
if (pair[0].equals(id)) {
|
||||||
|
return pair[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */
|
||||||
|
private void onQuickClick(int slot) {
|
||||||
|
String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop";
|
||||||
|
if (id == null || id.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runQuickAction(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行一个快捷动作。设置页里选的也是这几个 id。 */
|
||||||
|
private void runQuickAction(String id) {
|
||||||
|
try {
|
||||||
|
if ("desktop".equals(id)) {
|
||||||
|
// 用户确认过:"一键返回桌面" = 让**被控电脑**显示桌面(Win+D)
|
||||||
|
sendCommand(Json.obj("cmd", "show_desktop"), "显示桌面");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("file".equals(id)) {
|
||||||
|
pickFile(REQ_FILE, "*/*");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("mirror".equals(id)) {
|
||||||
|
openMirrorPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("mouse".equals(id)) {
|
||||||
|
showMousePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("sfx".equals(id)) {
|
||||||
|
pickSfx();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("power".equals(id)) {
|
||||||
|
showPowerChooser();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("tts".equals(id)) {
|
||||||
|
promptSay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("image".equals(id)) {
|
||||||
|
pickFile(REQ_IMAGE, "image/*");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast("不认识的动作:" + id);
|
||||||
|
} catch (Throwable error) {
|
||||||
|
toast("执行失败:" + shortReason(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关机这一格点开后让用户选锁屏还是关机(不直接关,太危险)。 */
|
||||||
|
private void showPowerChooser() {
|
||||||
|
final java.util.List<String> items = new java.util.ArrayList<String>();
|
||||||
|
items.add("锁屏");
|
||||||
|
items.add("关机");
|
||||||
|
items.add("重启");
|
||||||
|
showChooser("电源", items, new Chooser() {
|
||||||
|
@Override
|
||||||
|
public void onPick(int index) {
|
||||||
|
if (index == 0) {
|
||||||
|
confirmPower("lock", "锁屏");
|
||||||
|
} else if (index == 1) {
|
||||||
|
confirmPower("shutdown", "关机");
|
||||||
|
} else {
|
||||||
|
confirmPower("restart", "重启");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 快捷栏的存取 ----------------
|
||||||
|
|
||||||
|
private void loadQuickSlots() {
|
||||||
|
String raw = prefs().getString(QUICK_PREF, "");
|
||||||
|
String[] want = (raw == null || raw.isEmpty())
|
||||||
|
? new String[]{"file", "mirror", "sfx"}
|
||||||
|
: raw.split(",", -1);
|
||||||
|
// ⚠ 一定要过滤 + 补齐:
|
||||||
|
// 过滤 —— 万一以后删了某个动作,老配置里的 id 就不认识了,
|
||||||
|
// 不过滤的话那格会显示空白,用户以为坏了
|
||||||
|
// 补齐 —— 保证长度正好 3,少了下标会越界
|
||||||
|
String[] out = new String[QUICK_CUSTOM_SLOTS];
|
||||||
|
int n = 0;
|
||||||
|
for (String id : want) {
|
||||||
|
if (n >= QUICK_CUSTOM_SLOTS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (id != null && !id.isEmpty() && !"desktop".equals(id)
|
||||||
|
&& isKnownQuickAction(id)) {
|
||||||
|
out[n++] = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String[] fallback = {"file", "mirror", "sfx"};
|
||||||
|
for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {
|
||||||
|
if (out[i] == null) {
|
||||||
|
// 补一个没被用过的默认动作
|
||||||
|
for (String f : fallback) {
|
||||||
|
boolean used = false;
|
||||||
|
for (String o : out) {
|
||||||
|
if (f.equals(o)) {
|
||||||
|
used = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!used) {
|
||||||
|
out[i] = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quickSlots = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isKnownQuickAction(String id) {
|
||||||
|
for (String[] pair : QUICK_ACTIONS) {
|
||||||
|
if (pair[0].equals(id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveQuickSlots() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String id : quickSlots) {
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append(',');
|
||||||
|
}
|
||||||
|
sb.append(id == null ? "" : id);
|
||||||
|
}
|
||||||
|
prefs().edit().putString(QUICK_PREF, sb.toString()).apply();
|
||||||
|
refreshQuickBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private android.content.SharedPreferences prefs() {
|
||||||
|
return getSharedPreferences("ykxmz", MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 鼠标触摸板
|
||||||
|
MOUSE_METHOD = ''' // ==================================================================
|
||||||
|
// 鼠标控制(触摸板)
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
private void showMousePanel() {
|
||||||
|
if (mousePanel == null) {
|
||||||
|
buildMousePanel();
|
||||||
|
}
|
||||||
|
showOnly(mousePanel);
|
||||||
|
appendLog("鼠标控制:拖动=移动光标,单击=左键,双击=右键");
|
||||||
|
toast("拖动移动光标 · 单击左键 · 双击右键");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触摸板。整块区域接收触摸,按比例映射到电脑屏幕的像素坐标。
|
||||||
|
*
|
||||||
|
* <p>为什么用"按比例映射"而不是"相对位移":
|
||||||
|
* 手表表盘和电脑屏幕尺寸差得远,相对位移要让手表自己猜倍率,
|
||||||
|
* 猜错了光标就飘;绝对映射由电脑端负责落到那个像素,
|
||||||
|
* 责任清晰,也不会累积漂移。
|
||||||
|
*
|
||||||
|
* <p>但表盘太小,绝对映射的精度只有几十像素 —— 所以
|
||||||
|
* **双击左右半屏**这类大幅移动之后,用拖动做细调更实际。
|
||||||
|
* 这里两个都支持:拖动 = 相对微调,点击 = 在当前位置按下/抬起。
|
||||||
|
*/
|
||||||
|
private void buildMousePanel() {
|
||||||
|
mousePanel = new FrameLayout(this);
|
||||||
|
mousePanel.setBackgroundColor(0xFF101418);
|
||||||
|
|
||||||
|
LinearLayout col = new LinearLayout(this);
|
||||||
|
col.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
col.setPadding(dp(6), dp(34), dp(6), dp(4));
|
||||||
|
mousePanel.addView(col, new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
|
||||||
|
TextView tip = new TextView(this);
|
||||||
|
tip.setText("拖动=移动 单击=左键 双击=右键\\n滚轮在下面");
|
||||||
|
tip.setTextSize(10f);
|
||||||
|
tip.setTextColor(0xFF9AA4B2);
|
||||||
|
tip.setGravity(android.view.Gravity.CENTER);
|
||||||
|
col.addView(tip, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
|
||||||
|
mousePad = new View(this);
|
||||||
|
mousePad.setBackgroundColor(0xFF1B2028);
|
||||||
|
LinearLayout.LayoutParams padLp = new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f);
|
||||||
|
padLp.setMargins(0, dp(4), 0, dp(4));
|
||||||
|
col.addView(mousePad, padLp);
|
||||||
|
|
||||||
|
// 左键 / 右键 / 滚轮
|
||||||
|
LinearLayout keys = new LinearLayout(this);
|
||||||
|
keys.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
col.addView(keys, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(34)));
|
||||||
|
|
||||||
|
keys.addView(mouseKey("左键", new Runnable() {
|
||||||
|
public void run() { sendMouse("click", "left", 0); }
|
||||||
|
}));
|
||||||
|
keys.addView(mouseKey("右键", new Runnable() {
|
||||||
|
public void run() { sendMouse("click", "right", 0); }
|
||||||
|
}));
|
||||||
|
keys.addView(mouseKey("滚↑", new Runnable() {
|
||||||
|
public void run() { sendMouse("scroll", "", 3); }
|
||||||
|
}));
|
||||||
|
keys.addView(mouseKey("滚↓", new Runnable() {
|
||||||
|
public void run() { sendMouse("scroll", "", -3); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 返回按钮(快捷栏上也能离开,但这里给个明确的出口)
|
||||||
|
Button back = new Button(this);
|
||||||
|
back.setText("返回");
|
||||||
|
back.setTextSize(11f);
|
||||||
|
back.setAllCaps(false);
|
||||||
|
FrameLayout.LayoutParams backLp = new FrameLayout.LayoutParams(
|
||||||
|
dp(58), dp(26));
|
||||||
|
backLp.gravity = android.view.Gravity.TOP | android.view.Gravity.END;
|
||||||
|
backLp.setMargins(0, dp(4), dp(4), 0);
|
||||||
|
back.setLayoutParams(backLp);
|
||||||
|
back.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
showControlPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mousePanel.addView(back);
|
||||||
|
|
||||||
|
// ---- 触摸处理 ----
|
||||||
|
final android.view.GestureDetector detector =
|
||||||
|
new android.view.GestureDetector(this,
|
||||||
|
new android.view.GestureDetector.SimpleOnGestureListener() {
|
||||||
|
@Override
|
||||||
|
public boolean onSingleTapConfirmed(android.view.MotionEvent e) {
|
||||||
|
sendMouse("click", "left", 0); // 单击 = 左键
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onDoubleTap(android.view.MotionEvent e) {
|
||||||
|
sendMouse("click", "right", 0); // 双击 = 右键
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onScroll(android.view.MotionEvent e1,
|
||||||
|
android.view.MotionEvent e2,
|
||||||
|
float dx, float dy) {
|
||||||
|
// 手指往哪滑,光标往哪走:dx/dy 是"内容移动量",
|
||||||
|
// 也就是手指移动的反方向,所以取负。
|
||||||
|
sendMouseMove(-dx, -dy);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mousePad.setOnTouchListener(new View.OnTouchListener() {
|
||||||
|
@Override
|
||||||
|
public boolean onTouch(View v, android.view.MotionEvent e) {
|
||||||
|
detector.onTouchEvent(e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button mouseKey(String text, final Runnable action) {
|
||||||
|
Button b = new Button(this);
|
||||||
|
b.setText(text);
|
||||||
|
b.setTextSize(10f);
|
||||||
|
b.setAllCaps(false);
|
||||||
|
b.setPadding(0, 0, 0, 0);
|
||||||
|
b.setMinWidth(0);
|
||||||
|
b.setMinimumWidth(0);
|
||||||
|
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||||
|
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
|
||||||
|
lp.setMargins(dp(1), 0, dp(1), 0);
|
||||||
|
b.setLayoutParams(lp);
|
||||||
|
b.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
action.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMouse(String action, String button, int amount) {
|
||||||
|
if (!isConnected()) {
|
||||||
|
toast("还没连上电脑");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> req = Json.obj("cmd", "mouse", "action", action);
|
||||||
|
if (button != null && !button.isEmpty()) {
|
||||||
|
req.put("button", button);
|
||||||
|
}
|
||||||
|
if (amount != 0) {
|
||||||
|
req.put("amount", amount);
|
||||||
|
}
|
||||||
|
sendCommand(req, "鼠标");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMouseMove(float dx, float dy) {
|
||||||
|
if (!isConnected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 手表上滑一点,电脑上要走一段 —— 倍率小了手指要划好几次才横穿屏幕。
|
||||||
|
// 取 3.0 是试出来的:1920 宽的屏大约划 4~5 次到底,细调也还行。
|
||||||
|
sendCommand(Json.obj("cmd", "mouse", "action", "moveby",
|
||||||
|
"dx", (int) (dx * 3.0f), "dy", (int) (dy * 3.0f)), "鼠标");
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 设置页那一段
|
||||||
|
SETTINGS_ANCHOR = " private void showSettingsPanel() {"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 手表端:常驻顶部快捷栏 + 鼠标触摸板")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
raw = MAIN.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "buildQuickBar" in t:
|
||||||
|
print(" 已经加过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 先校验所有锚点,避免改一半
|
||||||
|
need = {
|
||||||
|
"updateButton 字段": FIELDS_ANCHOR,
|
||||||
|
"showOnly 方法": BUILD_ANCHOR,
|
||||||
|
}
|
||||||
|
missing = [k for k, v in need.items() if v not in t]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ 这些锚点找不到:%s" % ", ".join(missing))
|
||||||
|
return 1
|
||||||
|
print(" ✓ 锚点全部就位")
|
||||||
|
|
||||||
|
# 1) 字段
|
||||||
|
t = t.replace(FIELDS_ANCHOR, FIELDS, 1)
|
||||||
|
# 2) 方法(插在 showOnly 之前)
|
||||||
|
t = t.replace(BUILD_ANCHOR, BUILD_METHOD + BUILD_ANCHOR, 1)
|
||||||
|
# 3) 鼠标面板方法(也插在 showOnly 之前,跟快捷栏挨着)
|
||||||
|
t = t.replace(BUILD_ANCHOR, MOUSE_METHOD + BUILD_ANCHOR, 1)
|
||||||
|
|
||||||
|
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||||
|
print(" ✓ 加了字段 + buildQuickBar/refreshQuickBar/runQuickAction")
|
||||||
|
print(" ✓ 加了鼠标触摸板 buildMousePanel")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''安卓端:中转地址改成"手动填" + 加一个设置页。
|
||||||
|
|
||||||
|
== 用户报的问题 ==
|
||||||
|
|
||||||
|
「安卓端为什么还是默认填的我的中转站,以后改成手动填,
|
||||||
|
安卓端加一个设置,各种设置就在这里面调」
|
||||||
|
|
||||||
|
== 根因 ==
|
||||||
|
|
||||||
|
代码里的默认值**已经改成空**了:
|
||||||
|
|
||||||
|
private static final String DEFAULT_RELAY_URL = "";
|
||||||
|
|
||||||
|
但你手表上看到的还是 `https://relay.xxmxxs.top` —— 因为
|
||||||
|
**老版本 1.5.0 把作者的域名硬编码成默认值**(git 历史里查到了):
|
||||||
|
|
||||||
|
private static final String DEFAULT_RELAY_URL = "https://relay.xxmxxs.top";
|
||||||
|
|
||||||
|
第一次运行它就 `setRelayUrl(默认值)` 存进了 SharedPreferences。
|
||||||
|
装新 APK **不会清偏好设置**,所以那个值一直留着。
|
||||||
|
|
||||||
|
**用户从来没"选"过这个地址** —— 是程序硬塞的。所以正确做法是
|
||||||
|
**一次性迁移**:如果存的就是当时内置的那个域名,当成没设过,清掉。
|
||||||
|
|
||||||
|
== 做的事 ==
|
||||||
|
|
||||||
|
1. 迁移:把老版本硬塞的那个域名从偏好里清掉
|
||||||
|
2. 新增「设置」页,把散落的设置收进来:
|
||||||
|
* 中转服务器(手动填 / 清空)
|
||||||
|
* 记住的配对码(看 / 清)
|
||||||
|
* 已配对设备(看数量 / 清空)
|
||||||
|
* 关于(版本号、连接方式说明)
|
||||||
|
3. 首页那个「中转设置(跨网络用)」按钮改成「设置」,进设置页
|
||||||
|
4. settingsPanel 纳入 showOnly(),避免再出现"面板漏藏"那类问题
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SRC = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
|
||||||
|
/ "MainActivity.java")
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# 1. 迁移 + 面板字段
|
||||||
|
# ======================================================================
|
||||||
|
MIGRATION = ''' /**
|
||||||
|
* 老版本硬塞进来的中转地址,要清掉。
|
||||||
|
*
|
||||||
|
* <p>1.5.0 那版把作者的域名写死成默认值:
|
||||||
|
* <pre>
|
||||||
|
* private static final String DEFAULT_RELAY_URL = "https://relay.xxmxxs.top";
|
||||||
|
* </pre>
|
||||||
|
* 而且第一次运行就存进了 SharedPreferences。装新 APK 不会清偏好,
|
||||||
|
* 所以用户会看到「默认还是那个中转站」—— 其实他从来没选过。
|
||||||
|
*
|
||||||
|
* <p>现在默认值是空串(跨网络要自己填)。这里做一次性迁移:
|
||||||
|
* 存的值如果正好是当时内置的那个域名,就当成没设过,清掉。
|
||||||
|
*
|
||||||
|
* <p>⚠ 只在**完全等于**内置值时清 —— 用户自己手填的地址不能动。
|
||||||
|
*/
|
||||||
|
private static final String[] LEGACY_BAKED_IN_RELAYS = {
|
||||||
|
"https://relay.xxmxxs.top",
|
||||||
|
"http://relay.xxmxxs.top",
|
||||||
|
};
|
||||||
|
|
||||||
|
private void migrateLegacyRelay() {
|
||||||
|
String saved = getSharedPreferences("lanremote", MODE_PRIVATE)
|
||||||
|
.getString("relay_url", "");
|
||||||
|
if (saved == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String trimmed = saved.trim();
|
||||||
|
for (String legacy : LEGACY_BAKED_IN_RELAYS) {
|
||||||
|
if (legacy.equalsIgnoreCase(trimmed)) {
|
||||||
|
// 这是老版本硬塞的,不是用户选的 —— 清掉,让他自己填
|
||||||
|
getSharedPreferences("lanremote", MODE_PRIVATE).edit()
|
||||||
|
.remove("relay_url").apply();
|
||||||
|
appendLog("已清掉旧版本内置的中转地址(不是你自己填的)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS_BUILD = ''' /**
|
||||||
|
* 「设置」页。用户要求:"安卓端加一个设置,各种设置就在这里面调"。
|
||||||
|
*
|
||||||
|
* <p>以前设置散在首页(一个「中转设置」按钮)和别处,
|
||||||
|
* 现在统一收到这里。
|
||||||
|
*/
|
||||||
|
private void buildSettingsPanel() {
|
||||||
|
settingsPanel = new FrameLayout(this);
|
||||||
|
settingsPanel.setBackgroundColor(C_BG);
|
||||||
|
|
||||||
|
ScrollView scroll = new ScrollView(this);
|
||||||
|
scroll.setBackgroundColor(C_BG);
|
||||||
|
LinearLayout box = new LinearLayout(this);
|
||||||
|
box.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
box.setPadding(dp(10), dp(12), dp(10), dp(12));
|
||||||
|
scroll.addView(box, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
settingsPanel.addView(scroll, new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
|
||||||
|
LinearLayout top = new LinearLayout(this);
|
||||||
|
top.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
top.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
box.addView(top, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
|
||||||
|
TextView title = new TextView(this);
|
||||||
|
title.setText("设置");
|
||||||
|
title.setTextColor(C_FG);
|
||||||
|
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
|
||||||
|
title.setTypeface(null, android.graphics.Typeface.BOLD);
|
||||||
|
top.addView(title, new LinearLayout.LayoutParams(
|
||||||
|
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||||
|
top.addView(smallButton("返回", C_CARD, new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
hideSettingsPanel();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ================= 中转服务器 =================
|
||||||
|
settingsRelayValue = new TextView(this);
|
||||||
|
settingsRelayValue.setTextColor(C_DIM);
|
||||||
|
settingsRelayValue.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
|
||||||
|
settingsRelayValue.setPadding(0, dp(6), 0, dp(4));
|
||||||
|
|
||||||
|
box.addView(sectionTitle("中转服务器"));
|
||||||
|
box.addView(hintText("手表和电脑不在同一个网络时(比如在办公室管教室)"
|
||||||
|
+ "才需要填。填你自己那台的地址,比如 relay.你的域名。\\n"
|
||||||
|
+ "只在一个 WiFi 里用就留空 —— 那样直接连,最快也不耗流量。"));
|
||||||
|
box.addView(settingsRelayValue);
|
||||||
|
|
||||||
|
Button editRelay = bigButton("填写中转服务器", C_ACCENT, 14);
|
||||||
|
editRelay.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View view) {
|
||||||
|
promptRelayUrl();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.addView(editRelay);
|
||||||
|
|
||||||
|
Button clearRelay = bigButton("清空中转服务器(只走局域网)", C_CARD, 13);
|
||||||
|
clearRelay.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View view) {
|
||||||
|
setRelayUrl("");
|
||||||
|
refreshSettingsPanel();
|
||||||
|
toast("已清空 —— 以后只走局域网");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.addView(clearRelay);
|
||||||
|
|
||||||
|
// ================= 配对码 =================
|
||||||
|
settingsCodeValue = new TextView(this);
|
||||||
|
settingsCodeValue.setTextColor(C_DIM);
|
||||||
|
settingsCodeValue.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
|
||||||
|
settingsCodeValue.setPadding(0, dp(6), 0, dp(4));
|
||||||
|
|
||||||
|
box.addView(sectionTitle("记住的配对码"));
|
||||||
|
box.addView(hintText("手表会记住最近一次用的配对码,"
|
||||||
|
+ "下次连接自动带上,不用重复输。"));
|
||||||
|
box.addView(settingsCodeValue);
|
||||||
|
|
||||||
|
Button clearCode = bigButton("清除记住的配对码", C_CARD, 13);
|
||||||
|
clearCode.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View view) {
|
||||||
|
getSharedPreferences("lanremote", MODE_PRIVATE).edit()
|
||||||
|
.remove("paired_code").apply();
|
||||||
|
refreshSettingsPanel();
|
||||||
|
toast("已清除");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.addView(clearCode);
|
||||||
|
|
||||||
|
// ================= 已配对设备 =================
|
||||||
|
settingsDevicesValue = new TextView(this);
|
||||||
|
settingsDevicesValue.setTextColor(C_DIM);
|
||||||
|
settingsDevicesValue.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
|
||||||
|
settingsDevicesValue.setPadding(0, dp(6), 0, dp(4));
|
||||||
|
|
||||||
|
box.addView(sectionTitle("已配对的电脑"));
|
||||||
|
box.addView(hintText("配对过的电脑会出现在首页列表里,点一下就能连。"
|
||||||
|
+ "换电脑或者想重新配对时可以清空。"));
|
||||||
|
box.addView(settingsDevicesValue);
|
||||||
|
|
||||||
|
Button clearDevices = bigButton("清空已配对的电脑", C_DANGER, 13);
|
||||||
|
clearDevices.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View view) {
|
||||||
|
new android.app.AlertDialog.Builder(MainActivity.this)
|
||||||
|
.setTitle("清空已配对的电脑?")
|
||||||
|
.setMessage("清掉之后首页列表就空了,"
|
||||||
|
+ "需要重新输配对码连接。")
|
||||||
|
.setPositiveButton("清空", new android.content.DialogInterface.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(android.content.DialogInterface d, int w) {
|
||||||
|
store.clearDevices();
|
||||||
|
refreshDeviceList();
|
||||||
|
refreshSettingsPanel();
|
||||||
|
toast("已清空");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.addView(clearDevices);
|
||||||
|
|
||||||
|
// ================= 关于 =================
|
||||||
|
box.addView(sectionTitle("关于"));
|
||||||
|
box.addView(hintText("ykxmz 手表端 版本 " + appVersion()
|
||||||
|
+ "\\n\\n"
|
||||||
|
+ "连接顺序:先试局域网直连(同一个 WiFi,几毫秒);"
|
||||||
|
+ "连不上再走中转(跨网络,受线路影响)。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行小节标题 */
|
||||||
|
private TextView sectionTitle(String text) {
|
||||||
|
TextView v = new TextView(this);
|
||||||
|
v.setText(text);
|
||||||
|
v.setTextColor(C_FG);
|
||||||
|
v.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
|
||||||
|
v.setTypeface(null, android.graphics.Typeface.BOLD);
|
||||||
|
v.setPadding(0, dp(14), 0, 0);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行灰色说明 */
|
||||||
|
private TextView hintText(String text) {
|
||||||
|
TextView v = new TextView(this);
|
||||||
|
v.setText(text);
|
||||||
|
v.setTextColor(C_DIM);
|
||||||
|
v.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11);
|
||||||
|
v.setLineSpacing(0, 1.25f);
|
||||||
|
v.setPadding(0, dp(4), 0, 0);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本号。从 PackageManager 读,和构建脚本里的版本天然一致,
|
||||||
|
* 不用在代码里再写一份(写两份必然有一份会忘改)。 */
|
||||||
|
private String appVersion() {
|
||||||
|
try {
|
||||||
|
return getPackageManager()
|
||||||
|
.getPackageInfo(getPackageName(), 0).versionName;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把设置页上的文字刷新成当前值 */
|
||||||
|
private void refreshSettingsPanel() {
|
||||||
|
if (settingsRelayValue == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String relay = relayUrl();
|
||||||
|
settingsRelayValue.setText(relay.isEmpty()
|
||||||
|
? "现在是:空(只走局域网)"
|
||||||
|
: "现在是:" + relay);
|
||||||
|
settingsRelayValue.setTextColor(relay.isEmpty() ? C_DIM : C_OK);
|
||||||
|
|
||||||
|
String code = savedPairingCode();
|
||||||
|
settingsCodeValue.setText((code == null || code.isEmpty())
|
||||||
|
? "现在没有记住的配对码"
|
||||||
|
: "记住的是:" + code);
|
||||||
|
|
||||||
|
int n = store.devices() == null ? 0 : store.devices().size();
|
||||||
|
settingsDevicesValue.setText("已配对 " + n + " 台电脑");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showSettingsPanel() {
|
||||||
|
refreshSettingsPanel();
|
||||||
|
showOnly(settingsPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void hideSettingsPanel() {
|
||||||
|
showConnectPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def patch(text: str, old: str, new: str, label: str, results: list) -> str:
|
||||||
|
if old not in text:
|
||||||
|
results.append(("✗", label, "找不到锚点"))
|
||||||
|
return text
|
||||||
|
if text.count(old) != 1:
|
||||||
|
results.append(("!", label, "锚点出现 %d 次" % text.count(old)))
|
||||||
|
results.append(("✓", label, ""))
|
||||||
|
return text.replace(old, new, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
text = SRC.read_text(encoding="utf-8")
|
||||||
|
results: list = []
|
||||||
|
|
||||||
|
# ---- 1. 面板字段声明 ----
|
||||||
|
text = patch(
|
||||||
|
text,
|
||||||
|
" private FrameLayout lanPanel;",
|
||||||
|
" private FrameLayout lanPanel;\n"
|
||||||
|
" // ---- 设置页(用户要求:各种设置收在一个页面里调)----\n"
|
||||||
|
" private FrameLayout settingsPanel;\n"
|
||||||
|
" private TextView settingsRelayValue;\n"
|
||||||
|
" private TextView settingsCodeValue;\n"
|
||||||
|
" private TextView settingsDevicesValue;",
|
||||||
|
"声明 settingsPanel 和各显示控件", results)
|
||||||
|
|
||||||
|
# ---- 2. 插入迁移函数(放在 relayUrl 之前)----
|
||||||
|
text = patch(
|
||||||
|
text, " private String relayUrl() {",
|
||||||
|
MIGRATION + " private String relayUrl() {",
|
||||||
|
"插入 migrateLegacyRelay()", results)
|
||||||
|
|
||||||
|
# ---- 3. buildSettingsPanel 等方法(插在 buildLanPanel 之前)----
|
||||||
|
text = patch(
|
||||||
|
text, " private void buildLanPanel() {",
|
||||||
|
SETTINGS_BUILD + " private void buildLanPanel() {",
|
||||||
|
"插入 buildSettingsPanel() 等", results)
|
||||||
|
|
||||||
|
# ---- 4. 建面板的地方也要建它 ----
|
||||||
|
text = patch(
|
||||||
|
text, " buildLanPanel();",
|
||||||
|
" buildLanPanel();\n buildSettingsPanel();",
|
||||||
|
"构建时创建 settingsPanel", results)
|
||||||
|
|
||||||
|
# ---- 5. showOnly 纳入 settingsPanel ----
|
||||||
|
text = patch(
|
||||||
|
text,
|
||||||
|
" View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel};",
|
||||||
|
" View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,\n"
|
||||||
|
" settingsPanel};",
|
||||||
|
"settingsPanel 纳入 showOnly()", results)
|
||||||
|
|
||||||
|
# ---- 6. 首页那个按钮改成「设置」 ----
|
||||||
|
text = patch(
|
||||||
|
text,
|
||||||
|
''' Button relayButton = bigButton("中转设置(跨网络用)", C_ACCENT, 14);''',
|
||||||
|
''' Button relayButton = bigButton("设置", C_ACCENT, 14);''',
|
||||||
|
"首页按钮文案改成「设置」", results)
|
||||||
|
|
||||||
|
text = patch(
|
||||||
|
text,
|
||||||
|
''' public void onClick(View view) {
|
||||||
|
promptRelayUrl();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.addView(relayButton);''',
|
||||||
|
''' public void onClick(View view) {
|
||||||
|
showSettingsPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.addView(relayButton);''',
|
||||||
|
"首页按钮改成打开设置页", results)
|
||||||
|
|
||||||
|
# ---- 7. 启动时做迁移 ----
|
||||||
|
# 找 onCreate 里合适的位置:appendLog 第一次出现之前太早,
|
||||||
|
# 用 "store = " 初始化之后
|
||||||
|
import re
|
||||||
|
m = re.search(r"( store = new DeviceStore\(this\);\n)", text)
|
||||||
|
if m:
|
||||||
|
text = (text[:m.end()]
|
||||||
|
+ " // 老版本硬塞的中转地址要清掉(见 migrateLegacyRelay)\n"
|
||||||
|
" migrateLegacyRelay();\n"
|
||||||
|
+ text[m.end():])
|
||||||
|
results.append(("✓", "启动时调用迁移", ""))
|
||||||
|
else:
|
||||||
|
results.append(("✗", "启动时调用迁移", "找不到 store 初始化那行"))
|
||||||
|
|
||||||
|
# ---- 输出 ----
|
||||||
|
print(" ── 补丁结果 ──")
|
||||||
|
for mark, label, note in results:
|
||||||
|
print(" %s %s%s" % (mark, label, (" —— " + note) if note else ""))
|
||||||
|
|
||||||
|
if any(m == "✗" for m, _l, _n in results):
|
||||||
|
print()
|
||||||
|
print(" 有锚点没匹配,没有写回")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not any(m == "✗" for m, _l, _n in results):
|
||||||
|
SRC.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print()
|
||||||
|
print(" 已写回 MainActivity.java")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''设置页里加「快捷栏」和「允许新设备连接」(第二版)。
|
||||||
|
|
||||||
|
== 第一版为什么不行 ==
|
||||||
|
|
||||||
|
我原来写的 refresh 是重建整个设置面板,但 `buildSettingsPanel()` 是
|
||||||
|
**一次性把整页建完**的(没有"往给定容器里建"的版本),而
|
||||||
|
`root.addView(settingsPanel)` 是在 onCreate 里做的 ——
|
||||||
|
所以重建出来的面板**没挂在视图树上**,刷完页面就白了。
|
||||||
|
|
||||||
|
== 这一版怎么做 ==
|
||||||
|
|
||||||
|
用**一个专用容器**装我这两小节:
|
||||||
|
|
||||||
|
settingsPanel
|
||||||
|
└ ScrollView
|
||||||
|
└ box (LinearLayout)
|
||||||
|
├ ...原有的各小节...
|
||||||
|
├ settingsExtraBox <-- 我加的这个
|
||||||
|
└ 关于
|
||||||
|
|
||||||
|
刷新时只清空 `settingsExtraBox` 再填一遍 —— 不动页面其他部分,
|
||||||
|
也不用碰视图树。开销小,而且不会有"重建后没挂上去"的问题。
|
||||||
|
|
||||||
|
== 加两个小节 ==
|
||||||
|
|
||||||
|
1. **快捷栏**:3 个格子各点一下,从 7 个动作里选(也能选"留空")。
|
||||||
|
第 4 格是固定的「显示桌面」—— 界面上写明它改不了,
|
||||||
|
否则用户会一直找"怎么改第四个"。
|
||||||
|
|
||||||
|
2. **允许新设备连接**:关掉后只有配过对的设备能连。
|
||||||
|
两端的语义必须一致(电脑端设置窗口里也有同名勾选)。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
|
||||||
|
|
||||||
|
ANCHOR = ' // ================= 关于 ================='
|
||||||
|
|
||||||
|
SECTION = ''' // ================= 快捷栏 / 允许新设备连接 =================
|
||||||
|
//
|
||||||
|
// 这两小节装在**专用容器**里(settingsExtraBox),
|
||||||
|
// 这样改完设置只刷这一段,不用重建整页 ——
|
||||||
|
// 重建整页的话 settingsPanel 是新的对象,而 root 上挂的还是旧的,
|
||||||
|
// 表现就是"一改设置页面就白了"。
|
||||||
|
settingsExtraBox = new LinearLayout(this);
|
||||||
|
settingsExtraBox.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
box.addView(settingsExtraBox, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
fillSettingsExtra();
|
||||||
|
|
||||||
|
''' + ANCHOR
|
||||||
|
|
||||||
|
# ---------------- 各种方法 ----------------
|
||||||
|
METHODS = ''' /** 让用户选第 slot 格放哪个动作。 */
|
||||||
|
private void pickQuickSlot(final int slot) {
|
||||||
|
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||||
|
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||||
|
labels.add("留空(这一格不显示)");
|
||||||
|
ids.add("");
|
||||||
|
for (String[] pair : QUICK_ACTIONS) {
|
||||||
|
labels.add(pair[1]);
|
||||||
|
ids.add(pair[0]);
|
||||||
|
}
|
||||||
|
showChooser("第 " + (slot + 1) + " 格放什么", labels, new Chooser() {
|
||||||
|
@Override
|
||||||
|
public void onPick(int index) {
|
||||||
|
if (index < 0 || index >= ids.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
quickSlots[slot] = ids.get(index);
|
||||||
|
saveQuickSlots();
|
||||||
|
fillSettingsExtra();
|
||||||
|
toast("第 " + (slot + 1) + " 格已设为:"
|
||||||
|
+ quickLabel(quickSlots[slot]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 电脑端当前的「允许新设备连接」状态。本地记一份,连上后以电脑端为准。 */
|
||||||
|
private boolean allowNewPairing() {
|
||||||
|
return prefs().getBoolean("allow_new_pairing", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 填「快捷栏」和「允许新设备连接」这两小节。刷新时也调它。 */
|
||||||
|
private void fillSettingsExtra() {
|
||||||
|
if (settingsExtraBox == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settingsExtraBox.removeAllViews();
|
||||||
|
|
||||||
|
settingsExtraBox.addView(sectionTitle("快捷栏"));
|
||||||
|
settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
||||||
|
+ "最后一个「显示桌面」是固定的、改不了;"
|
||||||
|
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
||||||
|
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {
|
||||||
|
final int s = slot;
|
||||||
|
String now = (quickSlots[s] == null || quickSlots[s].isEmpty())
|
||||||
|
? "空着" : quickLabel(quickSlots[s]);
|
||||||
|
settingsExtraBox.addView(smallButton(
|
||||||
|
"第 " + (s + 1) + " 格:" + now, C_CARD,
|
||||||
|
new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
pickQuickSlot(s);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
settingsExtraBox.addView(smallButton(
|
||||||
|
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
||||||
|
new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
quickSlots = new String[]{"file", "mirror", "sfx"};
|
||||||
|
saveQuickSlots();
|
||||||
|
fillSettingsExtra();
|
||||||
|
toast("快捷栏已恢复默认");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
settingsExtraBox.addView(sectionTitle("允许新设备连接"));
|
||||||
|
settingsExtraBox.addView(hintText("关掉之后,只有已经配对过的电脑"
|
||||||
|
+ "能连上来,新电脑会被拒绝。教室里防别人乱连用这个。"));
|
||||||
|
settingsExtraBox.addView(smallButton(
|
||||||
|
"现在:" + (allowNewPairing() ? "允许"
|
||||||
|
: "不允许(只有老设备能连)"),
|
||||||
|
C_CARD, new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
final boolean want = !allowNewPairing();
|
||||||
|
new android.app.AlertDialog.Builder(MainActivity.this)
|
||||||
|
.setTitle("允许新设备连接")
|
||||||
|
.setMessage(want
|
||||||
|
? "打开后,没配对过的新电脑也能连上。"
|
||||||
|
: "关掉后,只有已经配对过的电脑能连上,"
|
||||||
|
+ "新电脑会被拒绝。")
|
||||||
|
.setPositiveButton(want ? "允许" : "关掉",
|
||||||
|
new android.content.DialogInterface.OnClickListener() {
|
||||||
|
public void onClick(android.content.DialogInterface d,
|
||||||
|
int w) {
|
||||||
|
// 电脑端执行 + 本地记一份(连不上时也能看到选择)
|
||||||
|
runFeature("allow_new_pairing",
|
||||||
|
Json.obj("allow", want),
|
||||||
|
want ? "允许新设备连接"
|
||||||
|
: "只允许老设备连接");
|
||||||
|
prefs().edit()
|
||||||
|
.putBoolean("allow_new_pairing", want)
|
||||||
|
.apply();
|
||||||
|
fillSettingsExtra();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
FIELD_ANCHOR = " private LinearLayout quickBar;"
|
||||||
|
FIELD_ADD = ''' private LinearLayout quickBar;
|
||||||
|
/** 设置页里装「快捷栏 / 允许新设备连接」那两小节的容器。
|
||||||
|
* 用专用容器是为了改完只刷这一段,不用重建整页。 */
|
||||||
|
private LinearLayout settingsExtraBox;'''
|
||||||
|
|
||||||
|
PICK_ANCHOR = " private void showSettingsPanel() {"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 设置页:快捷栏 + 允许新设备连接(第二版)")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
raw = MAIN.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "fillSettingsExtra" in t:
|
||||||
|
print(" 已经加过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
missing = [k for k, v in {
|
||||||
|
"关于锚点": ANCHOR,
|
||||||
|
"showSettingsPanel": PICK_ANCHOR,
|
||||||
|
"quickBar 字段": FIELD_ANCHOR,
|
||||||
|
}.items() if v not in t]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ 锚点找不到:%s" % ", ".join(missing))
|
||||||
|
return 1
|
||||||
|
print(" ✓ 锚点就位")
|
||||||
|
|
||||||
|
t = t.replace(ANCHOR, SECTION, 1)
|
||||||
|
t = t.replace(PICK_ANCHOR, METHODS + PICK_ANCHOR, 1)
|
||||||
|
t = t.replace(FIELD_ANCHOR, FIELD_ADD, 1)
|
||||||
|
|
||||||
|
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||||
|
print(" ✓ 加了 settingsExtraBox 字段 + 容器")
|
||||||
|
print(" ✓ 加了 fillSettingsExtra / pickQuickSlot / allowNewPairing")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''让客户端能用"纯 IP + 明文 HTTP"的中转(新服务器就是这种)。
|
||||||
|
|
||||||
|
== 背景 ==
|
||||||
|
|
||||||
|
2026-10-08 换了服务器:AWS Lightsail,**有公网 IP**(yk.xxmxxs.top),
|
||||||
|
中转直接监听 13245,**不再走 Cloudflare 隧道**。
|
||||||
|
|
||||||
|
好处一大堆(延迟从 1000ms 降到 1ms、不再有 7844 阻断、内存 4 倍),
|
||||||
|
但有个直接后果:**现在是明文 HTTP,没有 HTTPS 证书**。
|
||||||
|
|
||||||
|
而两个客户端原来都默认往 https 上走:
|
||||||
|
|
||||||
|
1. Android 的 RelayTransport:用户填 `yk.xxmxxs.top:13245`
|
||||||
|
(没写协议头)会被强制加上 `https://` -> 连不上(那台机器没证书)
|
||||||
|
|
||||||
|
2. Android 9+ **默认禁止明文 HTTP**。没有
|
||||||
|
`android:usesCleartextTraffic="true"` 的话,请求会被系统直接掐掉,
|
||||||
|
报的还是很含糊的 "Cleartext HTTP traffic not permitted"。
|
||||||
|
|
||||||
|
Windows 那边已经是对的:relay.py 的 _PRIVATE_HOST 正则里
|
||||||
|
`\d{1,3}(?:\.\d{1,3}){3}` 把**任意纯 IP(含公网)**都当"用明文",
|
||||||
|
所以裸 IP 会正确走 http://。
|
||||||
|
|
||||||
|
== 这个补丁做什么 ==
|
||||||
|
|
||||||
|
1. RelayTransport 的协议猜测改成和 Windows 一致:
|
||||||
|
纯 IP / localhost / *.local -> http://
|
||||||
|
域名 -> https://
|
||||||
|
|
||||||
|
2. AndroidManifest 加 usesCleartextTraffic="true"
|
||||||
|
|
||||||
|
3. 顺手清掉 windows/lanremote/relay.py 里一段**函数内死代码**
|
||||||
|
(_guess_scheme 的 return 之后还有一份一模一样的拷贝,
|
||||||
|
缩进仍属于函数体所以不报错,但看着很迷惑)
|
||||||
|
|
||||||
|
== 关于 HTTPS ==
|
||||||
|
|
||||||
|
明文 HTTP 意味着**同网络的人能看到流量**(虽然配对令牌、内容本来
|
||||||
|
就是加密的,但 URL、设备名这些会露)。真正的解法是上 HTTPS:
|
||||||
|
|
||||||
|
把域名 relay.xxmxxs.top 的 A 记录指到 yk.xxmxxs.top,
|
||||||
|
然后 certbot 签一张 Let's Encrypt 证书,confd 挂上。
|
||||||
|
|
||||||
|
那一步要用户动 DNS,所以先让明文能用(这个补丁),
|
||||||
|
HTTPS 等 DNS 指过来再做。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
JAVA = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
|
||||||
|
/ "RelayTransport.java")
|
||||||
|
MANIFEST = ROOT / "android" / "AndroidManifest.xml"
|
||||||
|
WINRELAY = ROOT / "windows" / "lanremote" / "relay.py"
|
||||||
|
|
||||||
|
|
||||||
|
def patch(path: Path, old: str, new: str, what: str, out: list) -> str:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
if old not in text:
|
||||||
|
out.append(("✗", what, "找不到锚点"))
|
||||||
|
return text
|
||||||
|
if text.count(old) != 1:
|
||||||
|
out.append(("!", what, "锚点 %d 处" % text.count(old)))
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||||
|
+ text.encode("utf-8"))
|
||||||
|
out.append(("✓", what, ""))
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 1. Java
|
||||||
|
JAVA_OLD = ''' if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
url = "https://" + url;
|
||||||
|
}'''
|
||||||
|
|
||||||
|
JAVA_NEW = ''' if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
// 没写协议头时猜一个。规则要**和 Windows 端一致**
|
||||||
|
// (windows/lanremote/relay.py 的 _guess_scheme):
|
||||||
|
//
|
||||||
|
// IP / localhost / *.local -> http://
|
||||||
|
// 域名 -> https://
|
||||||
|
//
|
||||||
|
// 为什么纯 IP 走明文:IP 地址的中转基本都是自己搭的
|
||||||
|
// (局域网那台,或者像现在这样一台有公网 IP 的服务器),
|
||||||
|
// 上面往往没有证书。原来一律加 https:// 的结果是**永远连不上**,
|
||||||
|
// 而且报的是 SSL 错误,会把人带去查证书查半天。
|
||||||
|
//
|
||||||
|
// ⚠ 纯 IP 判断包含**公网 IP** —— 2026-10-08 换到 Lightsail
|
||||||
|
// 之后中转就是 http://yk.xxmxxs.top:13245,
|
||||||
|
// 只把 192.168/10./172.16 当私有的话这里会走错。
|
||||||
|
String hostOnly = url;
|
||||||
|
int slash = hostOnly.indexOf('/');
|
||||||
|
if (slash > 0) {
|
||||||
|
hostOnly = hostOnly.substring(0, slash);
|
||||||
|
}
|
||||||
|
int colon = hostOnly.indexOf(':');
|
||||||
|
if (colon > 0) {
|
||||||
|
hostOnly = hostOnly.substring(0, colon);
|
||||||
|
}
|
||||||
|
boolean plain = hostOnly.equalsIgnoreCase("localhost")
|
||||||
|
|| hostOnly.toLowerCase().endsWith(".local")
|
||||||
|
|| hostOnly.matches("\\\\d{1,3}(\\\\.\\\\d{1,3}){3}");
|
||||||
|
url = (plain ? "http://" : "https://") + url;
|
||||||
|
}'''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 2. 清单
|
||||||
|
MAN_OLD = ''' <uses-permission android:name="android.permission.INTERNET" />'''
|
||||||
|
|
||||||
|
MAN_NEW = ''' <!-- ⚠ 允许明文 HTTP。
|
||||||
|
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
|
||||||
|
2026-10-08 之后那台有公网 IP 的服务器(http://yk.xxmxxs.top:13245)。
|
||||||
|
Android 9+ 默认**禁止明文流量**,不加这一条的话请求会被系统
|
||||||
|
直接掐掉,日志里只有一句含糊的
|
||||||
|
"Cleartext HTTP traffic to ... not permitted"。
|
||||||
|
|
||||||
|
安全性说明:中转上跑的内容(配对令牌、画面、文件)本来就是
|
||||||
|
加密过的,明文只暴露 URL 和设备名。要更严的话后续上 HTTPS
|
||||||
|
(Let's Encrypt),那时可以把这一条去掉。 -->
|
||||||
|
<application android:usesCleartextTraffic="true" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />'''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3. 死代码
|
||||||
|
DEAD_OLD = ''' host = address.split("/", 1)[0]
|
||||||
|
return "http://" if _PRIVATE_HOST.match(host) else "https://"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"""用户没写 http/https 时,猜一个。
|
||||||
|
|
||||||
|
IP 地址 / localhost -> http://
|
||||||
|
局域网里的中转服务是明文 HTTP。如果猜成 https,永远连不上,
|
||||||
|
而且报的是 SSL 错误,很容易误导人去查证书。
|
||||||
|
域名 -> https://
|
||||||
|
走公网必须加密(Cloudflare 隧道本来就是 https)。
|
||||||
|
"""
|
||||||
|
host = address.split("/", 1)[0]
|
||||||
|
return "http://" if _PRIVATE_HOST.match(host) else "https://"'''
|
||||||
|
|
||||||
|
DEAD_NEW = ''' host = address.split("/", 1)[0]
|
||||||
|
return "http://" if _PRIVATE_HOST.match(host) else "https://"'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
out: list = []
|
||||||
|
|
||||||
|
patch(JAVA, JAVA_OLD, JAVA_NEW, "RelayTransport 协议猜测改成和 Windows 一致",
|
||||||
|
out)
|
||||||
|
|
||||||
|
patch(MANIFEST, MAN_OLD, MAN_NEW, "AndroidManifest 允许明文 HTTP", out)
|
||||||
|
|
||||||
|
patch(WINRELAY, DEAD_OLD, DEAD_NEW, "清掉 relay.py 里的函数内死代码", out)
|
||||||
|
|
||||||
|
print(" ── 结果 ──")
|
||||||
|
for m, what, why in out:
|
||||||
|
print(" %s %s%s" % (m, what, (" —— " + why) if why else ""))
|
||||||
|
return 0 if not any(m == "✗" for m, _w, _y in out) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''分析 2 小时长测数据(列格式:时间戳 \t 状态码 \t 毫秒)。
|
||||||
|
|
||||||
|
上一版解析写错了 —— 它把任何 3 位数字都当状态码,
|
||||||
|
于是毫秒(921、968…)也被算成状态码,可用率算出来只有 84.8%,
|
||||||
|
是**假数字**。这里改成按列位置取。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(sys.argv[1] if len(sys.argv) > 1 else "/root/long2h_health.tsv")
|
||||||
|
if not path.is_file():
|
||||||
|
print(" 文件不存在: %s" % path)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
bad = 0
|
||||||
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) < 3:
|
||||||
|
bad += 1
|
||||||
|
continue
|
||||||
|
stamp, code, ms = parts[0], parts[1].strip(), parts[2].strip()
|
||||||
|
try:
|
||||||
|
rows.append((stamp, code, float(ms)))
|
||||||
|
except ValueError:
|
||||||
|
bad += 1
|
||||||
|
|
||||||
|
print(" 样本 %d 条(解析不了 %d 条)" % (len(rows), bad))
|
||||||
|
if not rows:
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
print(" 时间范围: %s -> %s" % (rows[0][0], rows[-1][0]))
|
||||||
|
|
||||||
|
codes = Counter(c for _s, c, _m in rows)
|
||||||
|
total = len(rows)
|
||||||
|
ok = codes.get("200", 0)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── HTTP 状态分布 ──")
|
||||||
|
for c, n in codes.most_common():
|
||||||
|
print(" %-6s %4d 次 %5.1f%%" % (c, n, n * 100.0 / total))
|
||||||
|
print(" " + "-" * 30)
|
||||||
|
print(" 可用率: %d/%d = %.2f%%" % (ok, total, ok * 100.0 / total))
|
||||||
|
|
||||||
|
# 只看成功的耗时(失败的那些耗时没有意义)
|
||||||
|
good = sorted(m for _s, c, m in rows if c == "200")
|
||||||
|
if good:
|
||||||
|
def p(q):
|
||||||
|
return good[min(int(len(good) * q), len(good) - 1)]
|
||||||
|
print()
|
||||||
|
print(" ── 成功请求耗时(毫秒)──")
|
||||||
|
print(" 最小 %.0f 中位 %.0f P90 %.0f P99 %.0f 最大 %.0f"
|
||||||
|
% (good[0], good[len(good) // 2], p(0.9), p(0.99), good[-1]))
|
||||||
|
|
||||||
|
# 如果还有第 4 列及以后(连接数之类),也统计一下
|
||||||
|
extra_cols = set()
|
||||||
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
p = line.strip().split("\t")
|
||||||
|
if len(p) > 3:
|
||||||
|
extra_cols.add(len(p))
|
||||||
|
if extra_cols:
|
||||||
|
print()
|
||||||
|
print(" (文件里还有更多列,最多 %d 列)" % max(extra_cols))
|
||||||
|
|
||||||
|
fails = [(s, c, m) for s, c, m in rows if c != "200"]
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(" ── 失败样本(共 %d 条)──" % len(fails))
|
||||||
|
for s, c, m in fails[:15]:
|
||||||
|
print(" %s HTTP %-5s %.0fms" % (s, c, m))
|
||||||
|
if len(fails) > 15:
|
||||||
|
print(" … 还有 %d 条" % (len(fails) - 15))
|
||||||
|
# 失败时间分布(按 10 分钟分桶)
|
||||||
|
print()
|
||||||
|
print(" ── 失败时刻分布(按小时)──")
|
||||||
|
hours = Counter(s.split(" ")[1].split(":")[0] if " " in s else s[:2]
|
||||||
|
for s, _c, _m in fails)
|
||||||
|
for h in sorted(hours):
|
||||||
|
print(" %s 点 %d 次" % (h, hours[h]))
|
||||||
|
else:
|
||||||
|
print(" ✓ 全程没有非 200 —— 2 小时 100% 可用")
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 分析 relay-stability 的 2 小时健康长测数据(只读)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
F=/root/long2h_health.tsv
|
||||||
|
[ -f "$F" ] || { echo " 数据文件不存在: $F"; exit 0; }
|
||||||
|
|
||||||
|
echo " 文件: $F 行数: $(wc -l < "$F")"
|
||||||
|
echo " 前 3 行(看格式):"
|
||||||
|
head -3 "$F" | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
|
||||||
|
python3 - "$F" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
lines = [l.strip() for l in path.read_text(encoding="utf-8",
|
||||||
|
errors="replace").splitlines()
|
||||||
|
if l.strip()]
|
||||||
|
if not lines:
|
||||||
|
print(" 没有数据")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
# 猜列:先看表头,没有表头就按 tab 切
|
||||||
|
head = lines[0].lower()
|
||||||
|
has_header = any(k in head for k in ("time", "code", "ms", "http"))
|
||||||
|
rows = lines[1:] if has_header else lines
|
||||||
|
if has_header:
|
||||||
|
print(" 表头: %s" % lines[0])
|
||||||
|
print(" 样本数: %d" % len(rows))
|
||||||
|
|
||||||
|
codes = Counter()
|
||||||
|
times = []
|
||||||
|
conns = []
|
||||||
|
fails = []
|
||||||
|
for i, line in enumerate(rows, 1):
|
||||||
|
parts = line.split("\t")
|
||||||
|
code = None
|
||||||
|
ms = None
|
||||||
|
for p in parts:
|
||||||
|
p = p.strip()
|
||||||
|
if p.isdigit() and len(p) == 3:
|
||||||
|
code = p
|
||||||
|
if p.replace(".", "").isdigit() and "." in p:
|
||||||
|
try:
|
||||||
|
v = float(p)
|
||||||
|
if ms is None and v < 300000:
|
||||||
|
ms = v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if code:
|
||||||
|
codes[code] += 1
|
||||||
|
if code != "200":
|
||||||
|
fails.append((i, line[:100]))
|
||||||
|
if ms:
|
||||||
|
times.append(ms)
|
||||||
|
# 连接数:找形如 quic=N h2=M 或者单独的数字
|
||||||
|
for p in parts:
|
||||||
|
if "quic=" in p:
|
||||||
|
conns.append(p.strip())
|
||||||
|
|
||||||
|
total = sum(codes.values())
|
||||||
|
ok = codes.get("200", 0)
|
||||||
|
print()
|
||||||
|
print(" ── HTTP 结果分布 ──")
|
||||||
|
for c, n in codes.most_common():
|
||||||
|
pct = n * 100.0 / total if total else 0
|
||||||
|
print(" %-6s %4d 次 %.1f%%" % (c, n, pct))
|
||||||
|
print(" --------")
|
||||||
|
print(" 可用率: %d/%d = %.1f%%" % (ok, total, ok * 100.0 / total if total else 0))
|
||||||
|
|
||||||
|
if times:
|
||||||
|
s = sorted(times)
|
||||||
|
def pct(q):
|
||||||
|
return s[min(int(len(s) * q), len(s) - 1)]
|
||||||
|
print()
|
||||||
|
print(" ── 耗时分布(毫秒)──")
|
||||||
|
print(" 最小 %.0f 中位 %.0f P90 %.0f P99 %.0f 最大 %.0f"
|
||||||
|
% (s[0], s[len(s)//2], pct(0.9), pct(0.99), s[-1]))
|
||||||
|
|
||||||
|
if conns:
|
||||||
|
c = Counter(conns)
|
||||||
|
print()
|
||||||
|
print(" ── 连接数分布(前 8)──")
|
||||||
|
for k, n in c.most_common(8):
|
||||||
|
print(" %-28s %d 次" % (k, n))
|
||||||
|
|
||||||
|
if fails:
|
||||||
|
print()
|
||||||
|
print(" ── 失败样本(最多 12 条)──")
|
||||||
|
for i, line in fails[:12]:
|
||||||
|
print(" 第 %d 行: %s" % (i, line))
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
print(" ✓ 全程没有非 200")
|
||||||
|
PYEOF
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''分析连接数时间线 long2h_conns.tsv,找塌陷的规律。
|
||||||
|
|
||||||
|
目的:回答两个问题
|
||||||
|
1. QUIC 塌陷是不是周期性的?周期多长?
|
||||||
|
2. 塌陷期间 HTTP/2 那半边是不是一直活着?(这是双协议兜底的价值)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
p = Path("/root/long2h_conns.tsv")
|
||||||
|
if not p.is_file():
|
||||||
|
print(" 没有 %s" % p)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
raw = p.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
print(" 共 %d 行,前 3 行:" % len(raw))
|
||||||
|
for line in raw[:3]:
|
||||||
|
print(" %r" % line[:120])
|
||||||
|
print()
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for line in raw:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.replace(",", "\t").split("\t")
|
||||||
|
stamp = ""
|
||||||
|
nums = []
|
||||||
|
for x in parts:
|
||||||
|
x = x.strip()
|
||||||
|
if not x:
|
||||||
|
continue
|
||||||
|
if ":" in x and ("-" in x or len(x) <= 8) and not x.replace(":", "").isdigit():
|
||||||
|
stamp = x
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
nums.append(int(x))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if nums:
|
||||||
|
rows.append((stamp, nums))
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print(" 解析不出数字")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
# 假设列是 [quic, h2] 或 [总连接, quic, h2] —— 按每行长度自适应
|
||||||
|
width = max(len(n) for _s, n in rows)
|
||||||
|
print(" 每行 %d 个数字(列)" % width)
|
||||||
|
print(" 前 5 行解析结果:")
|
||||||
|
for s, n in rows[:5]:
|
||||||
|
print(" %-12s %s" % (s, n))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 尝试识别:如果一行有 2 列,当作 [quic, h2];3 列当作 [total, quic, h2]
|
||||||
|
lower = [n for _s, n in rows]
|
||||||
|
if width == 2:
|
||||||
|
quic = [n[0] for n in lower]
|
||||||
|
h2 = [n[1] for n in lower]
|
||||||
|
elif width >= 3:
|
||||||
|
quic = [n[1] for n in lower]
|
||||||
|
h2 = [n[2] for n in lower]
|
||||||
|
else:
|
||||||
|
print(" 列数不支持")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
total = len(quic)
|
||||||
|
print(" ── 连接数统计(%d 个采样点)──" % total)
|
||||||
|
print(" QUIC: 最低 %d 最高 %d 平均 %.1f"
|
||||||
|
% (min(quic), max(quic), sum(quic) / total))
|
||||||
|
print(" HTTP/2: 最低 %d 最高 %d 平均 %.1f"
|
||||||
|
% (min(h2), max(h2), sum(h2) / total))
|
||||||
|
|
||||||
|
# 塌陷 = QUIC <= 2
|
||||||
|
collapse_idx = [i for i, q in enumerate(quic) if q <= 2]
|
||||||
|
print()
|
||||||
|
print(" ── QUIC 塌陷(<=2 条)──")
|
||||||
|
print(" 出现 %d 次,占比 %.1f%%" % (len(collapse_idx), len(collapse_idx) * 100.0 / total))
|
||||||
|
|
||||||
|
# 塌陷时 h2 还在吗
|
||||||
|
if collapse_idx:
|
||||||
|
h2_alive = sum(1 for i in collapse_idx if h2[i] >= 1)
|
||||||
|
h2_zero = sum(1 for i in collapse_idx if h2[i] == 0)
|
||||||
|
print(" 塌陷时 HTTP/2 >=1 条: %d 次(%.0f%%)"
|
||||||
|
% (h2_alive, h2_alive * 100.0 / len(collapse_idx)))
|
||||||
|
print(" 塌陷时 HTTP/2 也归零: %d 次" % h2_zero)
|
||||||
|
print(" -> 这就是双协议兜底的价值:%s"
|
||||||
|
% ("QUIC 塌了但 HTTP/2 还在扛" if h2_zero < h2_alive
|
||||||
|
else "两个一起塌,兜底没起作用"))
|
||||||
|
|
||||||
|
# 合并连续的塌陷区间,看周期
|
||||||
|
spans = []
|
||||||
|
start = None
|
||||||
|
for i, q in enumerate(quic):
|
||||||
|
if q <= 2 and start is None:
|
||||||
|
start = i
|
||||||
|
elif q > 2 and start is not None:
|
||||||
|
spans.append((start, i - 1))
|
||||||
|
start = None
|
||||||
|
if start is not None:
|
||||||
|
spans.append((start, total - 1))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 塌陷区间(连续采样点合并)──")
|
||||||
|
print(" 共 %d 段" % len(spans))
|
||||||
|
for a, b in spans[:15]:
|
||||||
|
sa = rows[a][0] if rows[a][0] else "第%d点" % a
|
||||||
|
sb = rows[b][0] if rows[b][0] else "第%d点" % b
|
||||||
|
print(" %-10s -> %-10s 持续 %d 个采样点 (quic 最低 %d)"
|
||||||
|
% (sa, sb, b - a + 1, min(quic[a:b + 1])))
|
||||||
|
|
||||||
|
# 塌陷间隔(周期)
|
||||||
|
if len(spans) >= 2:
|
||||||
|
gaps = [spans[i + 1][0] - spans[i][0] for i in range(len(spans) - 1)]
|
||||||
|
print()
|
||||||
|
print(" ── 两次塌陷之间隔多少个采样点 ──")
|
||||||
|
print(" %s" % gaps[:15])
|
||||||
|
print(" 平均 %.1f 个采样点" % (sum(gaps) / len(gaps)))
|
||||||
|
|
||||||
|
# 连接数分布
|
||||||
|
from collections import Counter
|
||||||
|
print()
|
||||||
|
print(" ── QUIC 连接数分布 ──")
|
||||||
|
for k in sorted(set(quic)):
|
||||||
|
c = quic.count(k)
|
||||||
|
print(" %2d 条: %4d 次 %5.1f%%" % (k, c, c * 100.0 / total))
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 采用最优配置 quic + 10,并清理 MSS 规则。
|
||||||
|
#
|
||||||
|
# v4 实测(MTU 1400,各 60 次):
|
||||||
|
# quic+6 建齐 10s 0 失败 100% 中位 1399 P90 2611 P99 13705
|
||||||
|
# quic+8 建齐 16s 1 失败 100% 中位 1280 P90 2279 P99 3856
|
||||||
|
# quic+10 建齐 13s 0 失败 100% 中位 1256 P90 2068 P99 2991 <- 最好
|
||||||
|
#
|
||||||
|
# 注意:v4 脚本自己的排序逻辑有多行输出没处理干净,误选了 quic+8,
|
||||||
|
# 这里按数据手工改成 quic+10。
|
||||||
|
#
|
||||||
|
# MSS 说明:接口 MTU 已经是 1400,内核自己算出来的 MSS 就是 1360;
|
||||||
|
# iptables 的 --set-mss 只能把 MSS 往小改,所以那条 1400 的规则
|
||||||
|
# 现在实际是空转。统一改成 1360,避免以后 MTU 变回去时留下不一致。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SVC=/etc/systemd/system/lanremote-tunnel.service
|
||||||
|
UA='LanRemote/1.8.2'
|
||||||
|
|
||||||
|
echo "════════ 1. 采用 quic + 10 ════════"
|
||||||
|
sed -i "s/--protocol [a-z0-9]*/--protocol quic/" "$SVC"
|
||||||
|
sed -i "s/--ha-connections [0-9]*/--ha-connections 10/" "$SVC"
|
||||||
|
grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+|--edge-ip-version [0-9]+|--retries [0-9]+' "$SVC" | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. 清理 MSS 规则(只留一条 1360)════════"
|
||||||
|
iptables -t mangle -S OUTPUT 2>/dev/null | grep TCPMSS | sed 's/^/ 清理前: /'
|
||||||
|
while iptables -t mangle -D OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1400 2>/dev/null; do :; done
|
||||||
|
while iptables -t mangle -D OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360 2>/dev/null; do :; done
|
||||||
|
iptables -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360
|
||||||
|
iptables -t mangle -S OUTPUT 2>/dev/null | grep TCPMSS | sed 's/^/ 清理后: /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. MTU 和持久化 ════════"
|
||||||
|
echo " wlan0 MTU 现在: $(cat /sys/class/net/wlan0/mtu)"
|
||||||
|
grep -c 'mtu 1400' /etc/systemd/system/lanremote-nettune.service | sed 's/^/ net-tune 里的 MTU 设置行数: /'
|
||||||
|
echo " net-tune 服务: $(systemctl is-enabled lanremote-nettune) / $(systemctl is-active lanremote-nettune)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 重启隧道并等连接建齐 ════════"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart lanremote-tunnel
|
||||||
|
for i in $(seq 1 40); do
|
||||||
|
sleep 4
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
[ "${n:-0}" -ge 10 ] && break
|
||||||
|
done
|
||||||
|
echo " 建齐 $n/10 条(等了约 $((i*4)) 秒)"
|
||||||
|
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep 'server_locations.*} 1' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 等公网可用 ════════"
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 -H "User-Agent: $UA" https://relay.xxmxxs.top/v1/health)
|
||||||
|
printf " 第 %d 次: %s\n" "$i" "$c"
|
||||||
|
[ "$c" = "200" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 6. 快速验收(30 次)════════"
|
||||||
|
ok=0; times=()
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H "User-Agent: $UA" https://relay.xxmxxs.top/v1/health)
|
||||||
|
c=$(echo "$out" | awk '{print $1}'); ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
[ "$c" = "200" ] && { ok=$((ok+1)); times+=("$ms"); }
|
||||||
|
done
|
||||||
|
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {printf "中位 %d P90 %d P99 %d 最大 %d", a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]}')
|
||||||
|
echo " 可用率 $ok/30 = $((ok*100/30))% $stats ms"
|
||||||
|
echo
|
||||||
|
echo " 最终: $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$SVC" | tr '\n' ' ') MTU=$(cat /sys/class/net/wlan0/mtu)"
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# -*- 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
@@ -0,0 +1,158 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 中转全面基线测量。
|
||||||
|
#
|
||||||
|
# 目的:把"优化到什么程度"变成可量化的数字,而不是感觉。
|
||||||
|
# 五类指标,每类都记录成功率和耗时分布:
|
||||||
|
# A. 公网 HTTP 可用率(100 次 /v1/health)
|
||||||
|
# B. 长轮询(10 次真实 poll,每次挂 60 秒)
|
||||||
|
# C. 三个发布产物的下载成功率和吞吐
|
||||||
|
# D. 隧道连接数稳定性 + 重连次数
|
||||||
|
# E. 静态资源(官网 / 截图 / favicon)
|
||||||
|
#
|
||||||
|
# 用法: bash bench.sh [标签]
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=${BASE:-https://relay.xxmxxs.top}
|
||||||
|
UA='LanRemote/1.8.1'
|
||||||
|
LABEL=${1:-baseline}
|
||||||
|
OUT="/root/bench_${LABEL}.txt"
|
||||||
|
|
||||||
|
echo "════════════════════════════════════════════════════════" | tee "$OUT"
|
||||||
|
echo " 中转基准测量 · $LABEL · $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$OUT"
|
||||||
|
echo "════════════════════════════════════════════════════════" | tee -a "$OUT"
|
||||||
|
|
||||||
|
echo | tee -a "$OUT"
|
||||||
|
echo "── 隧道配置 ──" | tee -a "$OUT"
|
||||||
|
CAP=$(systemctl show lanremote-tunnel -p ExecStart --value 2>/dev/null | tr ';' '\n' | grep -oE 'argv\[\]=[^ ]*|--[a-z-]+|/[a-z/]*cloudflared' | tr '\n' ' ')
|
||||||
|
grep -oE '\-\-protocol [a-z0-9]+|\-\-ha-connections [0-9]+|\-\-edge-ip-version [0-9]+' /etc/systemd/system/lanremote-tunnel.service 2>/dev/null | tr '\n' ' ' | sed 's/^/ 参数: /' | tee -a "$OUT"
|
||||||
|
echo " 长轮询: $(tail -40 /root/lanremote-relay/relay.log 2>/dev/null | grep -oE '长轮询 [0-9]+ 秒' | tail -1)" | tee -a "$OUT"
|
||||||
|
echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')" | tee -a "$OUT"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- A
|
||||||
|
echo | tee -a "$OUT"
|
||||||
|
echo "── A. 公网 HTTP 可用率(100 次,间隔 0.4 秒)──" | tee -a "$OUT"
|
||||||
|
okA=0; failA=0; timesA=()
|
||||||
|
for i in $(seq 1 100); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H "User-Agent: $UA" "$BASE/v1/health")
|
||||||
|
code=$(echo "$out" | awk '{print $1}')
|
||||||
|
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
if [ "$code" = "200" ]; then okA=$((okA+1)); timesA+=("$ms"); else failA=$((failA+1)); fi
|
||||||
|
sleep 0.4
|
||||||
|
done
|
||||||
|
pctA=$((okA * 100 / 100))
|
||||||
|
echo " 可用率: $okA/100 = ${pctA}% 失败 $failA 次" | tee -a "$OUT"
|
||||||
|
if [ "${#timesA[@]}" -gt 0 ]; then
|
||||||
|
printf '%s\n' "${timesA[@]}" | sort -n | awk '{
|
||||||
|
a[NR]=$1
|
||||||
|
} END {
|
||||||
|
printf " 耗时: 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms\n",
|
||||||
|
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[int(NR*0.99)+1], a[NR]
|
||||||
|
}' | tee -a "$OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- E(快的先做)
|
||||||
|
echo | tee -a "$OUT"
|
||||||
|
echo "── E. 静态资源(各 10 次)──" | tee -a "$OUT"
|
||||||
|
for path in "/" "/shot/pc-main.png" "/favicon.ico"; do
|
||||||
|
ok=0; times=()
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 -H "User-Agent: $UA" "$BASE$path")
|
||||||
|
c=$(echo "$out" | awk '{print $1}')
|
||||||
|
ms=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
[ "$c" = "200" ] && { ok=$((ok+1)); times+=("$ms"); }
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
stats=$(printf '%s\n' "${times[@]:-0}" | sort -n | awk '{a[NR]=$1} END {printf "中位 %d 最大 %d ms", a[int(NR/2)+1], a[NR]}')
|
||||||
|
printf " %-22s %2d/10 %s\n" "$path" "$ok" "$stats" | tee -a "$OUT"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- C
|
||||||
|
echo | tee -a "$OUT"
|
||||||
|
echo "── C. 三个成品下载(完整下载,量吞吐)──" | tee -a "$OUT"
|
||||||
|
for f in ykxmz-setup-1.8.1.exe ykxmz-server-1.8.1.zip ykxmz-watch-1.8.1.apk; do
|
||||||
|
rm -f /tmp/dl.bin
|
||||||
|
out=$(curl -s -o /tmp/dl.bin -w '%{http_code} %{size_download} %{time_total}' \
|
||||||
|
--max-time 180 -H "User-Agent: $UA" "$BASE/download/$f")
|
||||||
|
code=$(echo "$out" | awk '{print $1}')
|
||||||
|
size=$(echo "$out" | awk '{print $2}')
|
||||||
|
secs=$(echo "$out" | awk '{print $3}')
|
||||||
|
kbps=$(echo "$secs $size" | awk '{if ($1 > 0) printf "%.0f", $2/$1/1024; else print 0}')
|
||||||
|
printf " %-30s HTTP %-4s %9s 字节 %6.1fs %6s KB/s\n" \
|
||||||
|
"$f" "$code" "$size" "$secs" "$kbps" | tee -a "$OUT"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- D
|
||||||
|
echo | tee -a "$OUT"
|
||||||
|
echo "── D. 隧道连接数稳定性(采样 60 秒,每 2 秒一次)──" | tee -a "$OUT"
|
||||||
|
MARK=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
|
||||||
|
minc=99; maxc=0; vals=()
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
[ -z "${n:-}" ] && n=0
|
||||||
|
[ "$n" -lt "$minc" ] && minc="$n"
|
||||||
|
[ "$n" -gt "$maxc" ] && maxc="$n"
|
||||||
|
vals+=("$n")
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
reconn=$(tail -n +$((MARK+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Registered tunnel connection' || echo 0)
|
||||||
|
lost=$(tail -n +$((MARK+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Lost connection' || echo 0)
|
||||||
|
iot=$(tail -n +$((MARK+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'i/o timeout' || echo 0)
|
||||||
|
echo " 连接数: 最低 $minc 最高 $maxc 采样期间重连 $reconn 次 · 掉线 $lost 次 · i/o timeout $iot 次" | tee -a "$OUT"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- B
|
||||||
|
echo | tee -a "$OUT"
|
||||||
|
echo "── B. 长轮询(10 次真实 poll,每次挂 60 秒 ≈ 10 分钟)──" | tee -a "$OUT"
|
||||||
|
STAMP=$(date +%s)
|
||||||
|
DEV="bench-${LABEL}-${STAMP}"
|
||||||
|
CODE=$(printf "%06d" $(( STAMP % 1000000 )))
|
||||||
|
REG=$(curl -s --max-time 30 -X POST "$BASE/v1/agent/register" \
|
||||||
|
-H 'Content-Type: application/json' -H "User-Agent: $UA" \
|
||||||
|
-d "{\"deviceId\":\"$DEV\",\"code\":\"$CODE\",\"name\":\"bench\",\"version\":\"1.8.1\"}")
|
||||||
|
TOKEN=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo " [失败] 探针注册没拿到 token" | tee -a "$OUT"
|
||||||
|
else
|
||||||
|
okB=0; timesB=(); failsB=""
|
||||||
|
MARK2=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 130 \
|
||||||
|
-X POST "$BASE/v1/agent/poll" -H "X-Token: $TOKEN" -H "User-Agent: $UA")
|
||||||
|
c=$(echo "$out" | awk '{print $1}')
|
||||||
|
secs=$(echo "$out" | awk '{printf "%d", $2*1000}')
|
||||||
|
if [ "$c" = "200" ]; then
|
||||||
|
okB=$((okB+1)); timesB+=("$secs")
|
||||||
|
printf " %2d: 200 %6dms\n" "$i" "$secs" | tee -a "$OUT"
|
||||||
|
else
|
||||||
|
failsB="$failsB ${i}:${c}"
|
||||||
|
printf " %2d: %-4s **失败**\n" "$i" "$c" | tee -a "$OUT"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo " 长轮询成功: $okB/10 = $((okB*10))% 失败:$failsB" | tee -a "$OUT"
|
||||||
|
if [ "${#timesB[@]}" -gt 0 ]; then
|
||||||
|
printf '%s\n' "${timesB[@]}" | sort -n | awk '{
|
||||||
|
a[NR]=$1
|
||||||
|
} END {
|
||||||
|
printf " 耗时: 最小 %d 中位 %d P90 %d 最大 %d ms(理想 ≈ 61000)\n",
|
||||||
|
a[1], a[int(NR/2)+1], a[int(NR*0.9)+1], a[NR]
|
||||||
|
}' | tee -a "$OUT"
|
||||||
|
fi
|
||||||
|
r2=$(tail -n +$((MARK2+1)) /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'Registered tunnel connection' || echo 0)
|
||||||
|
echo " 这 10 分钟里隧道重连 $r2 次" | tee -a "$OUT"
|
||||||
|
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||||
|
curl -s --max-time 20 -X POST "http://127.0.0.1:8080/v1/admin/delete" \
|
||||||
|
-H "X-Admin: $KEY" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"deviceId\":\"$DEV\"}" > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo | tee -a "$OUT"
|
||||||
|
echo "════════════════════════════════════════════════════════" | tee -a "$OUT"
|
||||||
|
echo " 基线存档: $OUT" | tee -a "$OUT"
|
||||||
|
|
||||||
|
# 顺便把关键数字汇总成一行,便于前后对比
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo "===== 汇总($LABEL)====="
|
||||||
|
echo "A 公网可用率 ${pctA}% ($okA/100)"
|
||||||
|
echo "B 长轮询成功 ${okB:-0}/10"
|
||||||
|
echo "D 隧道连接 最低 ${minc} 最高 ${maxc} 重连 ${reconn} 掉线 ${lost}"
|
||||||
|
} | tee -a "$OUT"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 测下载吞吐:缓存预热前后对比。
|
||||||
|
#
|
||||||
|
# 基线(未加缓存头时):
|
||||||
|
# setup.exe 237568 字节 / 13.3s = 17 KB/s (还截断了)
|
||||||
|
# server.zip 214900 字节 / 3.1s = 67 KB/s
|
||||||
|
# watch.apk 201523 字节 / 66.8s = 3 KB/s
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=https://relay.xxmxxs.top
|
||||||
|
UA='LanRemote/1.8.1'
|
||||||
|
FILES="ykxmz-setup-1.8.1.exe ykxmz-server-1.8.1.zip ykxmz-watch-1.8.1.apk"
|
||||||
|
|
||||||
|
echo "════════ 1. 预热:每个文件先请求一次,把内容推进 Cloudflare 缓存 ════════"
|
||||||
|
for f in $FILES; do
|
||||||
|
# 只取前 1 字节,避免真的下载 15 MB
|
||||||
|
st=$(curl -s -o /dev/null -w '%{http_code}' -r 0-0 --max-time 60 \
|
||||||
|
-H "User-Agent: $UA" "$BASE/download/$f")
|
||||||
|
cf=$(curl -s -D - -o /dev/null -r 0-0 --max-time 60 -H "User-Agent: $UA" \
|
||||||
|
"$BASE/download/$f" 2>/dev/null | tr -d '\r' | grep -i '^cf-cache-status' | awk '{print $2}')
|
||||||
|
printf " %-30s HTTP %s cf-cache-status=%s\n" "$f" "$st" "${cf:-无}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " 等 5 秒让边缘缓存落定…"
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. 正式下载(完整文件,量吞吐)════════"
|
||||||
|
total_ok=0
|
||||||
|
for f in $FILES; do
|
||||||
|
rm -f /tmp/dl.bin
|
||||||
|
out=$(curl -s -o /tmp/dl.bin -D /tmp/dl.hdr -w '%{http_code} %{size_download} %{time_total}' \
|
||||||
|
--max-time 240 -H "User-Agent: $UA" "$BASE/download/$f")
|
||||||
|
code=$(echo "$out" | awk '{print $1}')
|
||||||
|
size=$(echo "$out" | awk '{print $2}')
|
||||||
|
secs=$(echo "$out" | awk '{print $3}')
|
||||||
|
cf=$(grep -i '^cf-cache-status' /tmp/dl.hdr | tr -d '\r' | awk '{print $2}')
|
||||||
|
age=$(grep -i '^age:' /tmp/dl.hdr | tr -d '\r' | awk '{print $2}')
|
||||||
|
kbps=$(echo "$secs $size" | awk '{if ($1 > 0) printf "%.0f", $2/$1/1024; else print 0}')
|
||||||
|
printf " %-30s HTTP %-4s %9s 字节 %6.1fs %6s KB/s cf=%s age=%s\n" \
|
||||||
|
"$f" "$code" "$size" "$secs" "$kbps" "${cf:-无}" "${age:-无}"
|
||||||
|
[ "$code" = "200" ] && total_ok=$((total_ok+1))
|
||||||
|
done
|
||||||
|
echo " 成功 $total_ok/3"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. 再下一次(应该全 HIT,更快)════════"
|
||||||
|
for f in $FILES; do
|
||||||
|
rm -f /tmp/dl.bin
|
||||||
|
out=$(curl -s -o /tmp/dl.bin -D /tmp/dl.hdr -w '%{http_code} %{size_download} %{time_total}' \
|
||||||
|
--max-time 240 -H "User-Agent: $UA" "$BASE/download/$f")
|
||||||
|
code=$(echo "$out" | awk '{print $1}')
|
||||||
|
size=$(echo "$out" | awk '{print $2}')
|
||||||
|
secs=$(echo "$out" | awk '{print $3}')
|
||||||
|
cf=$(grep -i '^cf-cache-status' /tmp/dl.hdr | tr -d '\r' | awk '{print $2}')
|
||||||
|
kbps=$(echo "$secs $size" | awk '{if ($1 > 0) printf "%.0f", $2/$1/1024; else print 0}')
|
||||||
|
printf " %-30s HTTP %-4s %9s 字节 %6.1fs %6s KB/s cf=%s\n" \
|
||||||
|
"$f" "$code" "$size" "$secs" "$kbps" "${cf:-无}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 对比基线 ════════"
|
||||||
|
echo " 基线: setup 17 KB/s(截断)· server 67 KB/s · apk 3 KB/s"
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''从这台 Windows 机器测中转 —— 这才是"真实用户"的视角。
|
||||||
|
|
||||||
|
== 为什么要换测量端 ==
|
||||||
|
|
||||||
|
之前所有测量都是在设备自己上跑的 curl。那等于:
|
||||||
|
设备 4G --> Cloudflare 边缘 (第一段)
|
||||||
|
边缘 --> 隧道 --> 设备 4G 源站 (第二段)
|
||||||
|
**两段都压在同一个 4G 链路上**,量出来的数字是双重打击。
|
||||||
|
|
||||||
|
真实用户(手机/电脑在 WiFi、宽带、5G 上):
|
||||||
|
用户 --> Cloudflare 边缘 (第一段,好网络)
|
||||||
|
边缘 --> 隧道 --> 设备 4G 源站 (第二段,只有这段是 4G)
|
||||||
|
|
||||||
|
所以从这台机器量出来的,才是用户实际会遇到的延迟和可用率。
|
||||||
|
|
||||||
|
用法: python _build/bench_from_pc.py [标签]
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE = "https://relay.xxmxxs.top"
|
||||||
|
UA = "LanRemote/1.8.2"
|
||||||
|
OUT_DIR = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\_build")
|
||||||
|
|
||||||
|
|
||||||
|
def req(path: str, method: str = "GET", data: bytes | None = None,
|
||||||
|
headers: dict | None = None, timeout: float = 150.0):
|
||||||
|
"""发一个请求,返回 (状态码, 字节数, 秒数, 响应头)。"""
|
||||||
|
hdrs = {"User-Agent": UA}
|
||||||
|
hdrs.update(headers or {})
|
||||||
|
r = urllib.request.Request(BASE + path, data=data, headers=hdrs,
|
||||||
|
method=method)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=timeout) as resp:
|
||||||
|
body = resp.read()
|
||||||
|
return resp.status, len(body), time.perf_counter() - t0, resp.headers
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
try:
|
||||||
|
exc.read()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return exc.code, 0, time.perf_counter() - t0, exc.headers
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return 0, 0, time.perf_counter() - t0, {}
|
||||||
|
|
||||||
|
|
||||||
|
def pct(values: list, q: float) -> int:
|
||||||
|
if not values:
|
||||||
|
return 0
|
||||||
|
s = sorted(values)
|
||||||
|
idx = min(int(len(s) * q), len(s) - 1)
|
||||||
|
return int(s[idx])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
label = sys.argv[1] if len(sys.argv) > 1 else "pc"
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
def say(text: str = "") -> None:
|
||||||
|
print(text)
|
||||||
|
lines.append(text)
|
||||||
|
|
||||||
|
say("=" * 62)
|
||||||
|
say(" 从 PC 量中转 · %s · %s" % (label, time.strftime("%Y-%m-%d %H:%M:%S")))
|
||||||
|
say("=" * 62)
|
||||||
|
|
||||||
|
# ---------------- A. 公网可用率 ----------------
|
||||||
|
say()
|
||||||
|
say("── A. 公网可用率(100 次 /v1/health)──")
|
||||||
|
ok, times, fails = 0, [], 0
|
||||||
|
for i in range(100):
|
||||||
|
code, _n, secs, _h = req("/v1/health", timeout=25)
|
||||||
|
if code == 200:
|
||||||
|
ok += 1
|
||||||
|
times.append(int(secs * 1000))
|
||||||
|
else:
|
||||||
|
fails += 1
|
||||||
|
time.sleep(0.3)
|
||||||
|
say(" 可用率 %d/100 = %d%% 失败 %d 次" % (ok, ok, fails))
|
||||||
|
if times:
|
||||||
|
say(" 耗时 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms"
|
||||||
|
% (min(times), int(statistics.median(times)), pct(times, 0.9),
|
||||||
|
pct(times, 0.99), max(times)))
|
||||||
|
|
||||||
|
# ---------------- E. 静态资源 ----------------
|
||||||
|
say()
|
||||||
|
say("── E. 静态资源(各 10 次)──")
|
||||||
|
for path in ("/", "/shot/pc-main.png", "/favicon.ico"):
|
||||||
|
o, ts = 0, []
|
||||||
|
for _ in range(10):
|
||||||
|
code, _n, secs, _h = req(path, timeout=25)
|
||||||
|
if code == 200:
|
||||||
|
o += 1
|
||||||
|
ts.append(int(secs * 1000))
|
||||||
|
time.sleep(0.2)
|
||||||
|
med = int(statistics.median(ts)) if ts else 0
|
||||||
|
say(" %-22s %2d/10 中位 %d 最大 %d ms"
|
||||||
|
% (path, o, med, max(ts) if ts else 0))
|
||||||
|
|
||||||
|
# ---------------- C. 下载 ----------------
|
||||||
|
say()
|
||||||
|
say("── C. 三个成品下载(完整下载)──")
|
||||||
|
dl_results = {}
|
||||||
|
for name in ("ykxmz-setup-1.8.2.exe", "ykxmz-server-1.8.2.zip",
|
||||||
|
"ykxmz-watch-1.8.2.apk"):
|
||||||
|
code, size, secs, hdrs = req("/download/" + name, timeout=300)
|
||||||
|
kbps = (size / secs / 1024) if secs > 0 else 0
|
||||||
|
cf = (hdrs.get("cf-cache-status") or "?") if hdrs else "?"
|
||||||
|
say(" %-30s HTTP %-4s %9d 字节 %6.1fs %7.0f KB/s cf=%s"
|
||||||
|
% (name, code, size, secs, kbps, cf))
|
||||||
|
if code == 200 and size > 0:
|
||||||
|
dl_results[name] = kbps
|
||||||
|
|
||||||
|
# ---------------- B. 长轮询 ----------------
|
||||||
|
say()
|
||||||
|
say("── B. 长轮询(6 次真实 poll,每次挂 60 秒)──")
|
||||||
|
stamp = int(time.time())
|
||||||
|
dev = "pcbench-%s-%d" % (label, stamp)
|
||||||
|
code6 = "%06d" % (stamp % 1000000)
|
||||||
|
payload = json.dumps({
|
||||||
|
"deviceId": dev, "code": code6, "name": "pcbench",
|
||||||
|
"version": "1.8.2",
|
||||||
|
}, ensure_ascii=False).encode("utf-8")
|
||||||
|
status, _n, _s, _h = req("/v1/agent/register", method="POST",
|
||||||
|
data=payload, timeout=30,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
token = ""
|
||||||
|
if status == 200:
|
||||||
|
# 重新取一次拿 body
|
||||||
|
r = urllib.request.Request(
|
||||||
|
BASE + "/v1/agent/register", data=payload,
|
||||||
|
headers={"User-Agent": UA, "Content-Type": "application/json"},
|
||||||
|
method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=30) as resp:
|
||||||
|
token = json.loads(resp.read().decode("utf-8")).get("token", "")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
say(" 探针注册: %s" % ("成功" if token else "失败"))
|
||||||
|
|
||||||
|
if token:
|
||||||
|
okb, btimes, bfails = 0, [], []
|
||||||
|
for i in range(6):
|
||||||
|
code, _n, secs, _h = req(
|
||||||
|
"/v1/agent/poll", method="POST", data=b"",
|
||||||
|
headers={"X-Token": token}, timeout=150)
|
||||||
|
ms = int(secs * 1000)
|
||||||
|
if code == 200:
|
||||||
|
okb += 1
|
||||||
|
btimes.append(ms)
|
||||||
|
say(" %d: 200 %6dms" % (i + 1, ms))
|
||||||
|
else:
|
||||||
|
bfails.append("%d:%s" % (i + 1, code))
|
||||||
|
say(" %d: %-4s **失败**" % (i + 1, code))
|
||||||
|
say(" 长轮询成功 %d/6 = %d%% 失败: %s"
|
||||||
|
% (okb, okb * 100 // 6, " ".join(bfails) or "无"))
|
||||||
|
if btimes:
|
||||||
|
say(" 耗时 最小 %d 中位 %d P90 %d 最大 %d ms(理想 ≈ 61000)"
|
||||||
|
% (min(btimes), int(statistics.median(btimes)),
|
||||||
|
pct(btimes, 0.9), max(btimes)))
|
||||||
|
|
||||||
|
say()
|
||||||
|
say("=" * 62)
|
||||||
|
out = OUT_DIR / ("bench_pc_%s.txt" % label)
|
||||||
|
out.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
say(" 存档: %s" % out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
==============================================================
|
||||||
|
从 PC 量中转 · optimized · 2026-10-07 16:01:23
|
||||||
|
==============================================================
|
||||||
|
|
||||||
|
── A. 公网可用率(100 次 /v1/health)──
|
||||||
|
可用率 98/100 = 98% 失败 2 次
|
||||||
|
耗时 最小 765 中位 911 P90 1889 P99 23868 最大 23868 ms
|
||||||
|
|
||||||
|
── E. 静态资源(各 10 次)──
|
||||||
|
/ 9/10 中位 1016 最大 1223 ms
|
||||||
|
/shot/pc-main.png 10/10 中位 1160 最大 1914 ms
|
||||||
|
/favicon.ico 8/10 中位 1075 最大 1993 ms
|
||||||
|
|
||||||
|
── C. 三个成品下载(完整下载)──
|
||||||
|
ykxmz-setup-1.8.2.exe HTTP 404 0 字节 0.8s 0 KB/s cf=BYPASS
|
||||||
|
ykxmz-server-1.8.2.zip HTTP 404 0 字节 0.8s 0 KB/s cf=BYPASS
|
||||||
|
ykxmz-watch-1.8.2.apk HTTP 404 0 字节 0.9s 0 KB/s cf=BYPASS
|
||||||
|
|
||||||
|
── B. 长轮询(6 次真实 poll,每次挂 60 秒)──
|
||||||
|
探针注册: 成功
|
||||||
|
1: 200 60817ms
|
||||||
|
2: 0 **失败**
|
||||||
|
3: 200 61758ms
|
||||||
|
4: 200 62027ms
|
||||||
|
5: 200 62060ms
|
||||||
|
6: 200 61247ms
|
||||||
|
长轮询成功 5/6 = 83% 失败: 2:0
|
||||||
|
耗时 最小 60817 中位 61758 P90 62060 最大 62060 ms(理想 ≈ 61000)
|
||||||
|
|
||||||
|
==============================================================
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''版本号 1.8.0 -> 1.8.1。
|
||||||
|
|
||||||
|
这次是 bug 修复版:局域网劫持 + 长轮询 60 秒。
|
||||||
|
(用户规矩:每次上传都要升版号。)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
OLD, NEW = "1.8.0", "1.8.1"
|
||||||
|
BOM = b"\xef\xbb\xbf"
|
||||||
|
|
||||||
|
TARGETS = [
|
||||||
|
"android/AndroidManifest.xml",
|
||||||
|
"android/build_apk.ps1",
|
||||||
|
"installer/ykxmz.iss",
|
||||||
|
"relay/relay.py",
|
||||||
|
"windows/lanremote/__init__.py",
|
||||||
|
"_tests/test_apk_contents.py",
|
||||||
|
"_tests/test_package_remote.py",
|
||||||
|
"_build/package_remote.py",
|
||||||
|
"_build/test_installer_cycle.py",
|
||||||
|
]
|
||||||
|
NEEDS_BOM = {".ps1"}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print(" %s -> %s" % (OLD, NEW))
|
||||||
|
total = 0
|
||||||
|
for rel in TARGETS:
|
||||||
|
path = ROOT / rel
|
||||||
|
if not path.is_file():
|
||||||
|
print(" [跳过] %s 不存在" % rel)
|
||||||
|
continue
|
||||||
|
raw = path.read_bytes()
|
||||||
|
had_bom = raw.startswith(BOM)
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
if OLD not in text:
|
||||||
|
print(" [跳过] %-40s 里面没有 %s" % (rel, OLD))
|
||||||
|
continue
|
||||||
|
count = text.count(OLD)
|
||||||
|
text = text.replace(OLD, NEW)
|
||||||
|
want_bom = path.suffix in NEEDS_BOM or had_bom
|
||||||
|
data = text.encode("utf-8")
|
||||||
|
if want_bom:
|
||||||
|
data = BOM + data
|
||||||
|
path.write_bytes(data)
|
||||||
|
print(" %-40s 改了 %d 处" % (rel, count))
|
||||||
|
total += count
|
||||||
|
print()
|
||||||
|
print(" 共改 %d 处" % total)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -83,9 +83,27 @@ def main() -> int:
|
|||||||
changed += 1
|
changed += 1
|
||||||
|
|
||||||
# ---- Windows EXE 版本信息 ----
|
# ---- Windows EXE 版本信息 ----
|
||||||
if swap(ROOT / "windows" / "version_info.txt",
|
# ⚠ 原来用的是 f'"{OLD}.0"',一旦 OLD 和文件里对不上就静默跳过
|
||||||
[(f'"{OLD}.0"', f'"{NEW}.0"')],
|
# (version_info.txt 就是这么从 1.7.1 一直漏到现在的)。
|
||||||
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__ ----
|
||||||
@@ -100,6 +118,22 @@ 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()
|
||||||
@@ -112,7 +146,31 @@ def main() -> int:
|
|||||||
print(f" docs/{path.name}")
|
print(f" docs/{path.name}")
|
||||||
changed += 1
|
changed += 1
|
||||||
|
|
||||||
|
# ---- README 里的成品文件名 ----
|
||||||
|
#
|
||||||
|
# README 里有 8 处写着 `ykxmz-setup-1.8.2.exe` 这类成品名。
|
||||||
|
# 以前没管它,结果一直停在 1.8.2 —— 用户照着 README 去 releases
|
||||||
|
# 找 1.8.2 的包,当然找不到(最新已经是 2.x 了)。
|
||||||
|
#
|
||||||
|
# 这里用"任意数字版本号 -> 新版本"的正则,而不是精确替换 OLD:
|
||||||
|
# README 可能因为漏过几次 bump 而停在更老的版本上,
|
||||||
|
# 精确匹配 OLD 那时候就换不掉了。
|
||||||
|
readme = ROOT / "README.md"
|
||||||
|
if readme.is_file():
|
||||||
|
text = readme.read_text(encoding="utf-8")
|
||||||
|
new_text = re.sub(
|
||||||
|
r"(ykxmz-(?:setup|server|watch|agent)-)\d+\.\d+\.\d+",
|
||||||
|
lambda m: m.group(1) + NEW, text)
|
||||||
|
if new_text != text:
|
||||||
|
readme.write_text(new_text, encoding="utf-8", newline="\n")
|
||||||
|
print(" README.md")
|
||||||
|
changed += 1
|
||||||
|
|
||||||
# ---- 会断言成品名的测试 ----
|
# ---- 会断言成品名的测试 ----
|
||||||
|
# 注意 test_installer_cycle.py 也曾经写死过成品名(ykxmz-setup-1.8.2.exe),
|
||||||
|
# 结果它去装一个不存在的包 -> 静默失败 -> 报出一堆莫名其妙的
|
||||||
|
# "配对码实际是 xxx" "版本显示 v1.8.2"。那个文件现在改成自动找
|
||||||
|
# dist 下最新的安装包了,不再需要这里维护。
|
||||||
for name in ("test_package_remote.py", "test_apk_contents.py"):
|
for name in ("test_package_remote.py", "test_apk_contents.py"):
|
||||||
path = ROOT / "_tests" / name
|
path = ROOT / "_tests" / name
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查 1Panel 和我装的 nginx 会不会打架。
|
||||||
|
|
||||||
|
== 为什么要查 ==
|
||||||
|
|
||||||
|
发现服务器上有 1Panel。1Panel 默认会**自己装一套 OpenResty(nginx)**
|
||||||
|
放在 /opt/1panel/apps/openresty/ 下,并且**接管 80/443**。
|
||||||
|
|
||||||
|
而我为了给遥控中转做反代,用 apt 装了**系统 nginx**,也在听 80/443。
|
||||||
|
|
||||||
|
两个 nginx 抢同一组端口,只有一个能起来 —— 另一个会
|
||||||
|
`Address already in use`。所以现在实际生效的到底是谁的配置,
|
||||||
|
必须搞清楚,否则:
|
||||||
|
|
||||||
|
* 我给 yk.xxmxxs.top 配的证书和反代可能**根本没生效**
|
||||||
|
(之前测通了,说明生效的是我的那份)
|
||||||
|
* 但用户在 1Panel 里建站,可能**建了个寂寞**(1Panel 的 OpenResty 起不来)
|
||||||
|
|
||||||
|
== 查什么 ==
|
||||||
|
|
||||||
|
1. 系统里到底有几个 nginx/OpenResty 可执行文件
|
||||||
|
2. 现在听 80/443 的是哪个进程(看 exe 路径)
|
||||||
|
3. 1Panel 的应用目录里有没有 openresty、有没有建过站
|
||||||
|
4. 1Panel 面板自己在哪个端口
|
||||||
|
5. 有没有已经存在的 WordPress 容器
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
echo "==== 1. 有几个 nginx / openresty ===="
|
||||||
|
for p in /usr/sbin/nginx /usr/local/openresty/nginx/sbin/nginx \
|
||||||
|
/opt/1panel/apps/openresty/openresty/bin/openresty; do
|
||||||
|
[ -x "$p" ] && echo " 有: $p"
|
||||||
|
done
|
||||||
|
command -v nginx openresty 2>/dev/null | sed 's/^/ PATH 里: /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 2. 现在听 80/443 的是谁(看可执行文件路径)===="
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443) ' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 3. 1Panel 的 nginx 有没有在跑 ===="
|
||||||
|
for s in openresty nginx 1panel; do
|
||||||
|
st=$(systemctl is-active $s 2>/dev/null)
|
||||||
|
[ -n "$st" ] && echo " $s: $st"
|
||||||
|
done
|
||||||
|
echo " --- 1Panel 应用目录 ---"
|
||||||
|
sudo ls -1 /opt/1panel/apps/ 2>/dev/null | sed 's/^/ /' || echo " (没有 /opt/1panel/apps)"
|
||||||
|
echo " --- 1Panel 的 www 站点目录 ---"
|
||||||
|
sudo ls -1 /opt/1panel/www/sites/ 2>/dev/null | sed 's/^/ /' || echo " (还没建过站)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 4. 1Panel 面板自己在哪 ===="
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep -i 1panel | sed 's/^/ /'
|
||||||
|
sudo grep -h "Port\|port" /opt/1panel/conf/app.yaml 2>/dev/null | head -3 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 5. 现有的容器 ===="
|
||||||
|
sudo docker ps --format ' {{.Names}} {{.Image}} {{.Status}} {{.Ports}}' 2>/dev/null || echo " (docker 命令不可用)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 6. 系统 nginx 的配置来自哪 ===="
|
||||||
|
systemctl show nginx -p FragmentPath 2>/dev/null | sed 's/^/ /'
|
||||||
|
nginx -v 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 7. 1Panel 有没有装 php/mysql 应用 ===="
|
||||||
|
sudo ls -1 /opt/1panel/apps/ 2>/dev/null | while read a; do
|
||||||
|
echo " 应用 $a:"
|
||||||
|
sudo ls -1 /opt/1panel/apps/$a 2>/dev/null | sed 's/^/ /'
|
||||||
|
done
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 1Panel 和系统 nginx 会不会打架")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
out = run(REMOTE, timeout=240, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 看各类资源的响应头,判断有哪些能被 Cloudflare 缓存却因为 no-store 被挡了。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=https://relay.xxmxxs.top
|
||||||
|
UA='LanRemote/1.8.1'
|
||||||
|
|
||||||
|
echo "════════ 各路径的缓存相关响应头 ════════"
|
||||||
|
for p in "/" "/v1/health" "/shot/pc-main.png" "/favicon.ico" \
|
||||||
|
"/download/ykxmz-server-1.8.1.zip"; do
|
||||||
|
echo " ── $p ──"
|
||||||
|
curl -sI --max-time 30 -H "User-Agent: $UA" "$BASE$p" 2>/dev/null \
|
||||||
|
| grep -iE '^HTTP|^cache-control|^cf-cache-status|^content-type|^content-length|^expires|^age' \
|
||||||
|
| tr -d '\r' | sed 's/^/ /'
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 静态图中的 cf-cache-status(HIT 说明缓存生效)════════"
|
||||||
|
for i in 1 2 3; do
|
||||||
|
s=$(curl -sI --max-time 20 -H "User-Agent: $UA" "$BASE/shot/pc-main.png" 2>/dev/null \
|
||||||
|
| grep -i '^cf-cache-status' | tr -d '\r' | awk '{print $2}')
|
||||||
|
echo " 第 $i 次: cf-cache-status = ${s:-?}"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ relay 源码里缓存头的设置 ════════"
|
||||||
|
grep -n 'Cache-Control' /root/lanremote-relay/relay.py | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 下载路由的实现(看有没有做流式/分块)════════"
|
||||||
|
sed -n '/if path.startswith("\/download\/")/,/^ # /p' /root/lanremote-relay/relay.py \
|
||||||
|
| head -50 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 看长测进度 + 实时连接数波动
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo "=== 长测进度 ==="
|
||||||
|
tail -20 /root/longstab_quic10.txt 2>/dev/null
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 连接数实时(每 3 秒一次,共 12 次 = 36 秒)==="
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
printf " %2d: %s\n" "$i" "${n:-?}"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 这 36 秒里隧道日志的新事件 ==="
|
||||||
|
MARK=$(wc -l < /root/lanremote-relay/tunnel.log 2>/dev/null || echo 0)
|
||||||
|
sleep 1
|
||||||
|
journalctl -u lanremote-tunnel --since '2 minutes ago' --no-pager 2>/dev/null \
|
||||||
|
| grep -E 'Lost connection|Registered tunnel|Failed to dial|no recent network' \
|
||||||
|
| tail -14 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查「默认中转服务器」在源码和已构建成品里各是什么值。
|
||||||
|
|
||||||
|
用户报告:软件默认中转服务器应该是**空白**、让用户自己填。
|
||||||
|
但给我的版本是填好的,上传的版本是清空的 —— 两者不一致。
|
||||||
|
|
||||||
|
先把所有源头找出来,看清楚再改。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
|
||||||
|
SOURCES = [
|
||||||
|
("windows/lanremote/server.py", r"DEFAULT_RELAY_URL\s*=\s*(.+)"),
|
||||||
|
("windows/lanremote/relay.py", r"192\.168\.\d+\.\d+:8080"),
|
||||||
|
("windows/run_agent.py", r"or\s+\"(192\.168[^\"]*|[^\"]*xxmxxs[^\"]*)\""),
|
||||||
|
("android/src/com/lanremote/watch/MainActivity.java",
|
||||||
|
r"DEFAULT_RELAY_URL\s*=\s*(.+);"),
|
||||||
|
]
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print(" 一、源码里的默认中转地址")
|
||||||
|
print("=" * 70)
|
||||||
|
for rel, pattern in SOURCES:
|
||||||
|
path = ROOT / rel
|
||||||
|
if not path.is_file():
|
||||||
|
print(" [不存在] %s" % rel)
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
hits = re.findall(pattern, text, re.M)
|
||||||
|
print(" %s" % rel)
|
||||||
|
if hits:
|
||||||
|
for h in hits[:4]:
|
||||||
|
h = h if isinstance(h, str) else h[0]
|
||||||
|
print(" %s" % h.strip()[:100])
|
||||||
|
else:
|
||||||
|
print(" (没匹配到)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(" 二、已构建成品里到底是什么")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# APK
|
||||||
|
apk = ROOT / "dist" / "ykxmz-watch-1.8.2.apk"
|
||||||
|
if apk.is_file():
|
||||||
|
print(" %s" % apk.name)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(apk) as z:
|
||||||
|
dex = [n for n in z.namelist() if n.endswith(".dex")]
|
||||||
|
found = []
|
||||||
|
for name in dex:
|
||||||
|
blob = z.read(name)
|
||||||
|
for needle in (b"https://relay.xxmxxs.top",
|
||||||
|
b"192.168.2.156",
|
||||||
|
b"192.168.10.105"):
|
||||||
|
if needle in blob:
|
||||||
|
found.append((name, needle.decode()))
|
||||||
|
if found:
|
||||||
|
for name, needle in sorted(set(found)):
|
||||||
|
print(" %s -> 含 %s" % (name, needle))
|
||||||
|
else:
|
||||||
|
print(" dex 里没有硬编码的中转地址(默认是空白)")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(" 读 APK 失败: %s" % exc)
|
||||||
|
else:
|
||||||
|
print(" dist/ykxmz-watch-1.8.2.apk 不存在")
|
||||||
|
|
||||||
|
# exe / onedir
|
||||||
|
exe_dir = ROOT / "dist_exe" / "ykxmz"
|
||||||
|
print()
|
||||||
|
print(" %s" % exe_dir)
|
||||||
|
if exe_dir.is_dir():
|
||||||
|
hits = []
|
||||||
|
for f in exe_dir.rglob("*"):
|
||||||
|
if not f.is_file() or f.suffix.lower() not in (".py", ".pyc"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
blob = f.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for needle in (b"192.168.2.156", b"192.168.10.105",
|
||||||
|
b"https://relay.xxmxxs.top"):
|
||||||
|
if needle in blob:
|
||||||
|
hits.append((f.relative_to(exe_dir).as_posix(),
|
||||||
|
needle.decode()))
|
||||||
|
if hits:
|
||||||
|
for name, needle in sorted(set(hits))[:10]:
|
||||||
|
print(" %-50s 含 %s" % (name, needle))
|
||||||
|
else:
|
||||||
|
print(" 没有硬编码的中转地址")
|
||||||
|
else:
|
||||||
|
print(" 不存在")
|
||||||
|
|
||||||
|
# 服务端 zip
|
||||||
|
print()
|
||||||
|
srv = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||||
|
if srv.is_file():
|
||||||
|
print(" %s" % srv.name)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(srv) as z:
|
||||||
|
for name in z.namelist():
|
||||||
|
if not name.endswith((".py", ".txt", ".sh")):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = z.read(name).decode("utf-8", "replace")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
for line in text.splitlines():
|
||||||
|
if ("192.168.2.156" in line or "192.168.10.105" in line
|
||||||
|
or "relay.xxmxxs.top" in line):
|
||||||
|
print(" %s: %s" % (name, line.strip()[:90]))
|
||||||
|
break
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(" 读 zip 失败: %s" % exc)
|
||||||
|
else:
|
||||||
|
print(" dist/ykxmz-server-1.8.2.zip 不存在")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(" 三、结论")
|
||||||
|
print("=" * 70)
|
||||||
|
print(" 期望:默认中转服务器为**空白**,由用户自己填。")
|
||||||
|
print(" 实际:见上面。凡是出现硬编码地址的地方都要改成空字符串。")
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查 Gitee 到底为什么判违规,以及 README 里哪些词可能触发。
|
||||||
|
|
||||||
|
== 已知 ==
|
||||||
|
|
||||||
|
https://gitee.com/xmz7891/ykxmz/raw/main/README.md -> HTTP 451
|
||||||
|
Unavailable For Legal Reasons
|
||||||
|
|
||||||
|
451 是"应法律要求不可用"。在国内平台上,这个通常是**内容审核拦的**。
|
||||||
|
|
||||||
|
== 要分清两种情况 ==
|
||||||
|
|
||||||
|
A. 整个仓库被限制(那 API 读仓库信息也会失败/受限)
|
||||||
|
B. 只有 README 这个文件被拦(网页能开、raw 不行)
|
||||||
|
|
||||||
|
== 同时扫一下 README 里可能触发审核的词 ==
|
||||||
|
|
||||||
|
国内平台的关键词审核对这几类比较敏感:
|
||||||
|
|
||||||
|
* 翻墙 / 代理 / VPN / 科学上网
|
||||||
|
* 挖矿 / 矿机 / 算力 / 虚拟货币
|
||||||
|
* 破解 / 破解版 / 盗版 / 激活工具
|
||||||
|
* 外挂 / 作弊
|
||||||
|
* 监控 / 偷拍 / 窃听 / 追踪("监控"在教育场景容易误判)
|
||||||
|
* 游戏相关的"封号""加速器"
|
||||||
|
* 二维码 / 收款 / 打赏
|
||||||
|
|
||||||
|
这份 README 是"教室手表控电脑",正常。但要确认没混进上面这些。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
GITEE = "https://gitee.com/xmz7891/ykxmz"
|
||||||
|
|
||||||
|
RISKY = [
|
||||||
|
"翻墙", "代理", "VPN", "vpn", "科学上网", "梯子", "加速器",
|
||||||
|
"挖矿", "矿机", "算力", "比特币", "虚拟货币", "以太坊",
|
||||||
|
"破解", "盗版", "激活工具", "注册机", "keygen", "crack",
|
||||||
|
"外挂", "作弊", "开挂",
|
||||||
|
"监控", "偷拍", "窃听", "追踪", "定位",
|
||||||
|
"打赏", "收款", "二维码收款",
|
||||||
|
"荷官", "博彩", "赌博",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get(url: str, ua: str = "Mozilla/5.0") -> tuple:
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
req.add_header("User-Agent", ua)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, r.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, ""
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "失败", str(exc)[:60]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 查 Gitee 为什么 451 + 扫 README 可疑词")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 各入口的状态码 ──")
|
||||||
|
for label, url in [
|
||||||
|
("仓库主页", GITEE),
|
||||||
|
("raw README", GITEE + "/raw/main/README.md"),
|
||||||
|
("raw LICENSE", GITEE + "/raw/main/LICENSE"),
|
||||||
|
("网页版 README", GITEE + "/blob/main/README.md"),
|
||||||
|
("发行版页", GITEE + "/releases"),
|
||||||
|
]:
|
||||||
|
code, body = get(url)
|
||||||
|
note = ""
|
||||||
|
if isinstance(code, int):
|
||||||
|
if code == 200:
|
||||||
|
note = "正常"
|
||||||
|
elif code == 451:
|
||||||
|
note = "**被法律原因拦了**"
|
||||||
|
elif code == 404:
|
||||||
|
note = "不存在"
|
||||||
|
print(" %-16s HTTP %-6s %s" % (label, code, note))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 用 Gitee API 看仓库(API 走的是另一条路)──")
|
||||||
|
token_file = ROOT / "_build" / ".gitee_token"
|
||||||
|
if token_file.is_file():
|
||||||
|
token = token_file.read_text(encoding="utf-8").strip()
|
||||||
|
code, body = get(
|
||||||
|
"https://gitee.com/api/v5/repos/xmz7891/ykxmz"
|
||||||
|
"?access_token=%s" % token)
|
||||||
|
print(" API 仓库信息 HTTP %s" % code)
|
||||||
|
if code == 200:
|
||||||
|
try:
|
||||||
|
d = json.loads(body)
|
||||||
|
print(" 名称:%s" % d.get("name"))
|
||||||
|
print(" 是否私有:%s" % d.get("private"))
|
||||||
|
print(" 最近推送:%s" % d.get("pushed_at"))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 扫 README 里的可疑词 ──")
|
||||||
|
rm = ROOT / "README.md"
|
||||||
|
text = rm.read_text(encoding="utf-8")
|
||||||
|
lines = text.splitlines()
|
||||||
|
hits = 0
|
||||||
|
for word in RISKY:
|
||||||
|
for i, line in enumerate(lines, 1):
|
||||||
|
if word in line:
|
||||||
|
hits += 1
|
||||||
|
print(" %-10s 第 %d 行: %s"
|
||||||
|
% (word, i, line.strip()[:60]))
|
||||||
|
if not hits:
|
||||||
|
print(" ✅ 没扫到这些词")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. README 里的链接(外链也可能被拦)──")
|
||||||
|
urls = re.findall(r"https?://[^\s\)\]\"'<>]+", text)
|
||||||
|
seen = []
|
||||||
|
for u in urls:
|
||||||
|
host = re.sub(r"^https?://", "", u).split("/")[0]
|
||||||
|
if host not in seen:
|
||||||
|
seen.append(host)
|
||||||
|
for h in seen:
|
||||||
|
print(" %s" % h)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 结论提示 ──")
|
||||||
|
print(" 如果只有 raw 是 451、主页 200,那就是**单个文件被拦**,")
|
||||||
|
print(" 通常改掉触发的措辞就能恢复。")
|
||||||
|
print(" 如果主页也 451,那是**整个仓库被限制**,得走 Gitee 申诉。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''看一眼 Lightsail 上中转服务器的完整状态。
|
||||||
|
|
||||||
|
⚠ 写这个文件的原因:用 `python -c @"..."@` 传带 `$(...)` 的远端命令,
|
||||||
|
PowerShell 会**先在本机求值** `$(systemctl ...)`(报"无法将 systemctl
|
||||||
|
项识别为 cmdlet"),还会吃掉引号导致 Python 语法错。
|
||||||
|
这个毛病我这一轮犯了四五次 —— **凡是超过两行的远端命令,一律写文件。**
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python _build/check_lightsail.py
|
||||||
|
python _build/check_lightsail.py --fix # 顺便修文件属主
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run, HOST as LS_HOST # noqa: E402
|
||||||
|
|
||||||
|
PORT = "13245"
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
|
||||||
|
# 三层地址各查一遍:
|
||||||
|
# 域名 —— 用户实际走的路(Cloudflare -> nginx -> 中转)
|
||||||
|
# IP:80 —— 绕过 Cloudflare,直连 nginx(域名没配好时靠它)
|
||||||
|
# :13245 —— **应该不通**(中转已只绑 127.0.0.1,不再对公网)
|
||||||
|
DOMAIN_URL = "http://%s" % DOMAIN
|
||||||
|
NGINX_URL = "http://%s:80" % LS_HOST
|
||||||
|
DIRECT_URL = "http://%s:%s" % (LS_HOST, PORT)
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
R=__DIR__
|
||||||
|
P=__PORT__
|
||||||
|
|
||||||
|
echo "════ 服务 ════"
|
||||||
|
for s in ykxmz-relay nginx ykxmz-monitor.timer; do
|
||||||
|
printf ' %-22s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 三层地址(用户路径 / 绕 CF / 直连中转)════"
|
||||||
|
# ⚠ 一律加 -L 跟跳转:nginx 那边 http 会 301 到 https,
|
||||||
|
# 不跟跳转拿到的是 301 而不是 200,会看着像"挂了"。
|
||||||
|
printf ' %-26s %s\n' "域名 __DOMAIN__" "$(curl -sL -o /dev/null -w '%{http_code}' --max-time 12 __DOMAIN_URL__/v1/health)"
|
||||||
|
printf ' %-26s %s\n' "IP:80(nginx)" "$(curl -sL -o /dev/null -w '%{http_code}' --max-time 12 __NGINX_URL__/v1/health)"
|
||||||
|
printf ' %-26s %s\n' ":__PORT__(应 000)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 __DIRECT_URL__/v1/health)"
|
||||||
|
printf ' %-26s %s\n' "本机 :__PORT__" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:$P/v1/health)"
|
||||||
|
printf ' %-26s %s\n' "域名延迟" "$(curl -sL -o /dev/null -w '%{time_total}s' --max-time 12 __DOMAIN_URL__/v1/health)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 首页角标 ════"
|
||||||
|
echo " 域名 -> $(curl -sL --max-time 12 __DOMAIN_URL__/ | grep -o '最新版 [0-9.]*' | head -1)"
|
||||||
|
echo " IP:80 -> $(curl -sL --max-time 12 __NGINX_URL__/ | grep -o '最新版 [0-9.]*' | head -1)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 缓存头(决定 Cloudflare 缓不缓存)════"
|
||||||
|
printf ' %-20s %s 条\n' "下载" "$(curl -sL -I --max-time 12 __DOMAIN_URL__/download/ykxmz-setup-2.1.3.exe | grep -ci cache-control)"
|
||||||
|
curl -sL -I --max-time 12 __DOMAIN_URL__/download/ykxmz-setup-2.1.3.exe \
|
||||||
|
| grep -i '^cache-control' | sed 's/^/ /'
|
||||||
|
printf ' %-20s %s\n' "长轮询" "$(curl -sL -I --max-time 12 __DOMAIN_URL__/v1/health | grep -i '^cache-control' | tr -d '\r')"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 监听 / 进程 ════"
|
||||||
|
ss -tlnp 2>/dev/null | grep -E ":(80|$P) " | sed 's/^/ /'
|
||||||
|
ps -eo pid,rss,etime,cmd --sort=-rss 2>/dev/null | grep 'relay\.py' | grep -v grep | head -1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 监测单元 ════"
|
||||||
|
cat $R/monitor_state.txt 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- 探测的是什么地址 ---"
|
||||||
|
grep -E '^(PUBLIC_URL|FALLBACK_URL)=' $R/monitor.sh | sed 's/^/ /'
|
||||||
|
echo " --- monitor.log 尾部(只挑非 Permission 的)---"
|
||||||
|
grep -v 'Permission denied' $R/monitor.log 2>/dev/null | tail -4 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 抢救报告 / 知识库 ════"
|
||||||
|
echo " 报告 $(ls $R/rescue_reports 2>/dev/null | wc -l) 份"
|
||||||
|
echo " 知识库 $(wc -l < $R/rescue_knowledge.md 2>/dev/null || echo 0) 行"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 资源 ════"
|
||||||
|
free -m 2>/dev/null | sed -n 2p | awk '{printf " 内存 %s/%s MB\n", $3, $2}'
|
||||||
|
df -h / | tail -1 | awk '{printf " 磁盘 %s 用 %s\n", $2, $5}'
|
||||||
|
uptime | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 文件属主(admin 必须能写日志)════"
|
||||||
|
ls -la $R/*.log $R/monitor_state.txt 2>/dev/null | awk '{print " " $3":"$4" "$9}'
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if "--fix" in sys.argv:
|
||||||
|
print(" ── 修属主 ──")
|
||||||
|
out = run("sudo chown -R admin:admin %s && sudo chmod 755 %s && echo OK"
|
||||||
|
% (RELAY_DIR, RELAY_DIR), timeout=120, quiet=True)
|
||||||
|
print(" " + out[1].strip())
|
||||||
|
|
||||||
|
cmd = (REMOTE
|
||||||
|
.replace("__DIR__", RELAY_DIR)
|
||||||
|
.replace("__PORT__", PORT)
|
||||||
|
.replace("__DOMAIN__", DOMAIN)
|
||||||
|
.replace("__DOMAIN_URL__", DOMAIN_URL)
|
||||||
|
.replace("__NGINX_URL__", NGINX_URL)
|
||||||
|
.replace("__DIRECT_URL__", DIRECT_URL))
|
||||||
|
out = run(cmd, timeout=300, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 看可靠性测试进度和隧道事件
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo "── 测试进程 ──"
|
||||||
|
pgrep -af 'tpr.sh|reliability' | head -3
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "── 隧道最近 30 分钟事件 ──"
|
||||||
|
reconn=$(journalctl -u lanremote-tunnel --since '30 minutes ago' --no-pager 2>/dev/null | grep -c 'Registered tunnel connection')
|
||||||
|
lost=$(journalctl -u lanremote-tunnel --since '30 minutes ago' --no-pager 2>/dev/null | grep -c 'Lost connection')
|
||||||
|
iot=$(journalctl -u lanremote-tunnel --since '30 minutes ago' --no-pager 2>/dev/null | grep -c 'i/o timeout')
|
||||||
|
echo " 重连 $reconn 次 · 掉线 $lost 次 · i/o timeout $iot 次"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── 最近的掉线/重连明细 ──"
|
||||||
|
journalctl -u lanremote-tunnel --since '30 minutes ago' --no-pager 2>/dev/null \
|
||||||
|
| grep -E 'Registered tunnel connection|Lost connection' | tail -8 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── watchdog 最近 12 行 ──"
|
||||||
|
tail -12 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── 隧道连接数和落点 ──"
|
||||||
|
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep -E '^cloudflared_tunnel_ha_connections|server_locations.*} 1' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── 公网现在通不通(10 次快速探测)──"
|
||||||
|
ok=0
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
|
||||||
|
-H 'User-Agent: LanRemote/1.8.1' https://relay.xxmxxs.top/v1/health)
|
||||||
|
[ "$c" = "200" ] && ok=$((ok+1))
|
||||||
|
printf " %d: %s\n" "$i" "$c"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " 可用 $ok/10"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把上次查询里两个可疑点查清楚。
|
||||||
|
|
||||||
|
== 疑点一:证书目录为什么是空的 ==
|
||||||
|
|
||||||
|
我明明给 yk.xxmxxs.top 签过 Let's Encrypt 证书。
|
||||||
|
但 `/etc/letsencrypt/live/` 列出来是空的 ——
|
||||||
|
多半是**权限**:那个目录是 0700 root,普通用户 ls 不到。
|
||||||
|
|
||||||
|
== 疑点二:3306 在监听,但没找到 mariadb/mysql ==
|
||||||
|
|
||||||
|
`ss` 显示 127.0.0.1:3306 有人在听,但 `command -v mariadb` 没找到。
|
||||||
|
可能是:
|
||||||
|
* 装了但可执行文件叫别的名字
|
||||||
|
* 是别的东西占了 3306(比如某个容器)
|
||||||
|
得看是哪个进程在听。
|
||||||
|
|
||||||
|
== 疑点三:内存用量涨了 ==
|
||||||
|
|
||||||
|
之前看是 414MB,现在 1037MB。得知道是谁吃的 ——
|
||||||
|
WordPress + MySQL 还要再吃 300~500MB,这台只有 2GB,得先弄清余量。
|
||||||
|
|
||||||
|
== 顺带 ==
|
||||||
|
|
||||||
|
`grep -r /etc/nginx/sites-enabled/` 没输出 server_name,
|
||||||
|
是因为那里全是**指向 sites-available 的符号链接**,
|
||||||
|
而 `grep -r` **默认不跟符号链接**。不是配置丢了。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
echo "==== 1. 证书(带 sudo)===="
|
||||||
|
sudo ls -1 /etc/letsencrypt/live/ 2>&1 | sed 's/^/ /'
|
||||||
|
echo " --- yk.xxmxxs.top 的证书 ---"
|
||||||
|
sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem \
|
||||||
|
-noout -subject -dates 2>&1 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 2. nginx 站点(跟符号链接)===="
|
||||||
|
sudo grep -h server_name /etc/nginx/sites-available/ykxmz 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " sites-enabled:"
|
||||||
|
sudo ls -la /etc/nginx/sites-enabled/ 2>&1 | tail -n +2 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 3. 谁在听 3306 ===="
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep 3306 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 4. 数据库到底装没装 ===="
|
||||||
|
dpkg -l 2>/dev/null | grep -iE 'mariadb|mysql' | head -8 | sed 's/^/ /'
|
||||||
|
echo " (上面没输出就是没装 —— 那 3306 是别的东西占的)"
|
||||||
|
echo
|
||||||
|
echo "==== 5. 内存被谁吃了 ===="
|
||||||
|
ps -eo rss,comm --sort=-rss 2>/dev/null | head -10 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " --- systemd 服务占用 ---"
|
||||||
|
systemd-cgtop -n 1 -b 2>/dev/null | head -8 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 6. 有没有 swap ===="
|
||||||
|
free -m | sed 's/^/ /'
|
||||||
|
swapon --show 2>/dev/null | sed 's/^/ /' || echo " 没有 swap"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 查清三个疑点")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
out = run(REMOTE, timeout=240, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''验证源站的 443 是否就绪(证书签完之后)。
|
||||||
|
|
||||||
|
背景:522 是因为 Cloudflare 的 SSL 模式是 Full/Full(strict),
|
||||||
|
回源走 443,而源站只有 80。现在证书签好了、nginx 也配了 443,
|
||||||
|
所以要确认:
|
||||||
|
|
||||||
|
1. 本机 443 能自测通(带正确 SNI)
|
||||||
|
2. 从**外面**能不能连上 443
|
||||||
|
—— 这一步取决于用户在 Lightsail 防火墙开没开 443
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run, HOST as LS_HOST # noqa: E402
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
IP = LS_HOST
|
||||||
|
|
||||||
|
|
||||||
|
def tcp(host: str, port: int, timeout: float = 8.0) -> bool:
|
||||||
|
s = socket.socket()
|
||||||
|
s.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
s.connect((host, port))
|
||||||
|
return True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def https(host: str, port: int = 443) -> tuple:
|
||||||
|
"""返回 (状态码文本, 证书主体)。"""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
"https://%s:%d/v1/health" % (host, port),
|
||||||
|
timeout=15, context=ctx) as r:
|
||||||
|
r.read(64)
|
||||||
|
return str(r.status), "证书验证通过"
|
||||||
|
except ssl.SSLCertVerificationError as exc:
|
||||||
|
return "证书错误", str(exc)[:70]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "失败", "%s: %s" % (type(exc).__name__, str(exc)[:60])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 验证 443 是否就绪")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 源站内部(从服务器自己看)──")
|
||||||
|
# ⚠ 这里**不能用 % 格式化** —— 远端命令里有 curl 的
|
||||||
|
# %{http_code},% 格式化会把 %{ 当成格式符直接抛
|
||||||
|
# "unsupported format character '{'"。用 replace 更安全。
|
||||||
|
inner = (
|
||||||
|
"echo ' --- 监听 ---'; "
|
||||||
|
"ss -tln 2>/dev/null | grep -E ':(80|443) ' | sed 's/^/ /'; "
|
||||||
|
"echo ' --- 本机 https(带 SNI)---'; "
|
||||||
|
"curl -s -o /dev/null -w ' %{http_code}\\n' --max-time 10 "
|
||||||
|
"--resolve __D__:443:127.0.0.1 https://__D__/v1/health; "
|
||||||
|
"echo ' --- 证书主体 ---'; "
|
||||||
|
"sudo openssl x509 -in /etc/letsencrypt/live/__D__/fullchain.pem "
|
||||||
|
"-noout -subject -dates 2>/dev/null | sed 's/^/ /'; "
|
||||||
|
"echo ' --- nginx 里的 443 段 ---'; "
|
||||||
|
"sudo grep -nE 'listen|ssl_certificate|return 301|server_name' "
|
||||||
|
"/etc/nginx/sites-available/ykxmz | sed 's/^/ /'"
|
||||||
|
).replace("__D__", DOMAIN)
|
||||||
|
out = run(inner, timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 从这台电脑直连源站(不经过 Cloudflare)──")
|
||||||
|
for port in (80, 443):
|
||||||
|
ok = tcp(IP, port)
|
||||||
|
print(" %s:%-4d TCP %s" % (IP, port, "通" if ok else "不通 ❌"))
|
||||||
|
code, note = https(IP)
|
||||||
|
print(" https://%s/v1/health -> %s (%s)" % (IP, code, note))
|
||||||
|
print(" (这里会是证书错误,正常 —— 我们是用 IP 直连,")
|
||||||
|
print(" 证书是签给域名的。这一步只看 TCP 通不通。)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 经 Cloudflare 的 HTTPS ──")
|
||||||
|
code, note = https(DOMAIN)
|
||||||
|
print(" https://%s/v1/health -> %s (%s)" % (DOMAIN, code, note))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 结论 ──")
|
||||||
|
if tcp(IP, 443):
|
||||||
|
print(" ✅ 443 从外面**通** —— Cloudflare 的 Full(strict) 能工作")
|
||||||
|
if code == "200":
|
||||||
|
print(" ✅ 全链路已经好了")
|
||||||
|
else:
|
||||||
|
print(" ⚠ 443 通了但 HTTPS 还不通,可能是 Cloudflare")
|
||||||
|
print(" 还没切到 Full(strict),或者配置还在生效中")
|
||||||
|
else:
|
||||||
|
print(" ❌ 443 从外面**不通** —— 需要去 Lightsail 防火墙开 TCP 443")
|
||||||
|
print(" (实例 → 联网 → 防火墙 → 添加规则 → 自定义 TCP 443)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+5
-1
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ⚠ 管理密码不再写在这里,从环境变量取:
|
||||||
|
# export RELAY_ADMIN_PASSWORD=...
|
||||||
|
: "${RELAY_ADMIN_PASSWORD:?请先 export RELAY_ADMIN_PASSWORD}"
|
||||||
# 官网 / 管理页 / favicon 的最终检查
|
# 官网 / 管理页 / favicon 的最终检查
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
@@ -26,7 +30,7 @@ curl -s -o /dev/null -w ' -> %{redirect_url}\n' --max-time 15 "$BASE/admin"
|
|||||||
echo
|
echo
|
||||||
echo " 4) 登录"
|
echo " 4) 登录"
|
||||||
curl -s -c /tmp/ck2.txt -o /dev/null --max-time 15 -X POST \
|
curl -s -c /tmp/ck2.txt -o /dev/null --max-time 15 -X POST \
|
||||||
-d 'username=xmz7891&password=@REVAedu120830' "$BASE/admin/login" 2>/dev/null
|
-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
|
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
|
for n in 'id=dlPanel' '网络体检' '退出登录' 'favicon'; do
|
||||||
grep -q "$n" /tmp/admin.html && echo " OK $n" || echo " XX $n"
|
grep -q "$n" /tmp/admin.html && echo " OK $n" || echo " XX $n"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''校验打出来的 EXE 能不能在 **Windows 7** 上跑。
|
||||||
|
|
||||||
|
== 为什么需要这个 ==
|
||||||
|
|
||||||
|
学校电脑是 Windows 7,装完报「dll 缺失」。
|
||||||
|
|
||||||
|
根因不是漏打包,是 **Python 版本越界**:
|
||||||
|
|
||||||
|
Python 3.8 -> 最后一个支持 Windows 7 的版本
|
||||||
|
Python 3.9+ -> 只支持 Windows 8 及以上
|
||||||
|
|
||||||
|
Python 3.9 起用到了 `api-ms-win-core-path-l1-1-0.dll` 里的
|
||||||
|
`PathCchCanonicalizeEx`,而这个 DLL **Windows 7 上根本不存在**,
|
||||||
|
也没法用 api-set 转发(那套机制只在 Win8+ 生效)。
|
||||||
|
|
||||||
|
**所以那个 DLL 不是"忘了放进包",是"放进去也没用"。**
|
||||||
|
唯一可行的办法是**用 3.8 打包**。
|
||||||
|
|
||||||
|
== 这个脚本查什么 ==
|
||||||
|
|
||||||
|
1. 有没有 python38.dll(而不是 python39/310/311/... )
|
||||||
|
2. **不该有** api-ms-win-core-path-l1-1-0.dll
|
||||||
|
—— 有它就说明是 3.9+ 打的,Win7 上必挂
|
||||||
|
3. PE 头是不是 32 位(0x14C)
|
||||||
|
—— 32 位在 Win7 的 32/64 位系统上都能跑;
|
||||||
|
64 位在 32 位 Windows 上直接起不来
|
||||||
|
4. 顺带报一下总大小和文件数
|
||||||
|
|
||||||
|
用 `python _build/check_win7_compat.py` 单独跑也行,
|
||||||
|
`build_exe.ps1` 里会调它,不过就直接拒绝出包。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DIST = ROOT / "dist_exe" / "ykxmz"
|
||||||
|
|
||||||
|
FAILS: list = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, ok: bool, why: str = "") -> None:
|
||||||
|
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
|
||||||
|
(" —— " + why) if why else ""))
|
||||||
|
if not ok:
|
||||||
|
FAILS.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def pe_machine(path: Path) -> int:
|
||||||
|
"""读 PE 头里的 machine 字段。0x14C=32 位,0x8664=64 位。"""
|
||||||
|
with path.open("rb") as f:
|
||||||
|
if f.read(2) != b"MZ":
|
||||||
|
return 0
|
||||||
|
f.seek(0x3C)
|
||||||
|
off = struct.unpack("<I", f.read(4))[0]
|
||||||
|
f.seek(off)
|
||||||
|
if f.read(4) != b"PE\0\0":
|
||||||
|
return 0
|
||||||
|
return struct.unpack("<H", f.read(2))[0]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" Windows 7 兼容性校验")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
check("dist_exe/ykxmz 存在", DIST.is_dir(),
|
||||||
|
"先跑 windows\\build_exe.ps1")
|
||||||
|
if not DIST.is_dir():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
dlls = {p.name.lower(): p for p in DIST.rglob("*.dll")}
|
||||||
|
exe = DIST / "ykxmz.exe"
|
||||||
|
check("ykxmz.exe 存在", exe.is_file())
|
||||||
|
if not exe.is_file():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ---- 1. Python 运行库版本 ----
|
||||||
|
print()
|
||||||
|
print("一、Python 运行库")
|
||||||
|
py_dlls = sorted(n for n in dlls if n.startswith("python3") and n.endswith(".dll"))
|
||||||
|
for n in py_dlls:
|
||||||
|
print(" %s" % n)
|
||||||
|
check("有 python38.dll(3.8 打的)",
|
||||||
|
any("python38" in n for n in py_dlls),
|
||||||
|
"没有 python38.dll 说明不是 3.8 打的")
|
||||||
|
bad_py = [n for n in py_dlls
|
||||||
|
if any(v in n for v in ("python39", "python310", "python311",
|
||||||
|
"python312", "python313", "python314"))]
|
||||||
|
check("没有 3.9+ 的运行库", not bad_py,
|
||||||
|
"3.9+ 的包在 Win7 上会因为缺 DLL 启动失败:%s" % bad_py)
|
||||||
|
|
||||||
|
# ---- 2. 那个要命的 DLL ----
|
||||||
|
print()
|
||||||
|
print("二、Win7 上不存在的那个 DLL")
|
||||||
|
killer = "api-ms-win-core-path-l1-1-0.dll"
|
||||||
|
has = killer in dlls
|
||||||
|
check("**没有** %s" % killer, not has,
|
||||||
|
"有它就说明是 3.9+ 打的 —— Win7 上必然报缺 DLL")
|
||||||
|
print(" (这个 DLL 是 Win8+ 才有的。Python 3.9 起用到它,")
|
||||||
|
print(" 放进来也没用,Win7 上加载不了。)")
|
||||||
|
|
||||||
|
# ---- 3. 位数 ----
|
||||||
|
print()
|
||||||
|
print("三、位数")
|
||||||
|
m = pe_machine(exe)
|
||||||
|
arch = {0x14C: "32 位 (x86)", 0x8664: "64 位 (x64)"}.get(m, "未知 (0x%X)" % m)
|
||||||
|
print(" ykxmz.exe: %s" % arch)
|
||||||
|
check("是 32 位(Win7 32/64 位都能跑)", m == 0x14C,
|
||||||
|
"64 位的包在 32 位 Windows 上起不来;学校机器可能是 32 位")
|
||||||
|
|
||||||
|
# ---- 4. 体积概览 ----
|
||||||
|
print()
|
||||||
|
print("四、产物概览")
|
||||||
|
files = list(DIST.rglob("*"))
|
||||||
|
files = [f for f in files if f.is_file()]
|
||||||
|
total = sum(f.stat().st_size for f in files)
|
||||||
|
print(" 文件数: %d" % len(files))
|
||||||
|
print(" 总大小: %.1f MB" % (total / 1048576))
|
||||||
|
print(" exe 大小: %.1f MB" % (exe.stat().st_size / 1048576))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 58)
|
||||||
|
if FAILS:
|
||||||
|
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
|
||||||
|
print(" 这个包学校电脑装不上,不该发布。")
|
||||||
|
return 1
|
||||||
|
print(" ✅ 可以在 Windows 7 / 8 / 10 / 11 上运行")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''看服务器上装 WordPress 缺什么。
|
||||||
|
|
||||||
|
== 背景 ==
|
||||||
|
|
||||||
|
用户想把 xxmxxs.top 拿来做 WordPress(原来是 Cloudflare Workers 占着)。
|
||||||
|
WordPress 要 PHP + 数据库,而服务器上现在只跑着 nginx(给遥控中转用)。
|
||||||
|
|
||||||
|
== 为什么写成文件 ==
|
||||||
|
|
||||||
|
这个查询里有一堆 `$(...)`、`$b`、`2>/dev/null`。
|
||||||
|
用 `python -c "..."` 传的话,**PowerShell 会先在本机把它们求值**
|
||||||
|
(报"无法将 systemctl 项识别为 cmdlet"、"未能找到路径 C:\dev\null")。
|
||||||
|
这个坑今天踩了太多次 —— 凡是超过两行的远端命令,一律写成文件。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
echo "==== 磁盘 ===="
|
||||||
|
df -h / | tail -1
|
||||||
|
echo
|
||||||
|
echo "==== 内存 ===="
|
||||||
|
free -m | head -2
|
||||||
|
echo
|
||||||
|
echo "==== nginx 站点 ===="
|
||||||
|
ls -1 /etc/nginx/sites-enabled/ 2>/dev/null
|
||||||
|
echo " server_name:"
|
||||||
|
grep -rh server_name /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== PHP ===="
|
||||||
|
if command -v php >/dev/null 2>&1; then
|
||||||
|
php -v | head -1
|
||||||
|
echo " php-fpm: $(systemctl is-active php8.3-fpm 2>/dev/null || systemctl is-active php8.2-fpm 2>/dev/null || echo 没装)"
|
||||||
|
else
|
||||||
|
echo " 没装 PHP"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "==== 数据库 ===="
|
||||||
|
if command -v mariadb >/dev/null 2>&1; then
|
||||||
|
echo " mariadb 客户端在: $(command -v mariadb)"
|
||||||
|
echo " mariadb 服务: $(systemctl is-active mariadb 2>/dev/null)"
|
||||||
|
elif command -v mysql >/dev/null 2>&1; then
|
||||||
|
echo " mysql 客户端在"
|
||||||
|
echo " mysql 服务: $(systemctl is-active mysql 2>/dev/null)"
|
||||||
|
else
|
||||||
|
echo " 没装数据库"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "==== apt 源里有 php-fpm 吗 ===="
|
||||||
|
apt-cache policy php-fpm 2>/dev/null | head -3 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 服务状态 ===="
|
||||||
|
for s in nginx ykxmz-relay ykxmz-monitor.timer certbot.timer; do
|
||||||
|
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "==== 证书目录 ===="
|
||||||
|
ls -1 /etc/letsencrypt/live/ 2>/dev/null | sed 's/^/ /' || echo " (空)"
|
||||||
|
echo
|
||||||
|
echo "==== 监听端口 ===="
|
||||||
|
ss -tln 2>/dev/null | grep -E ':(80|443|3306) ' | sed 's/^/ /'
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 服务器 WordPress 就绪度")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
out = run(REMOTE, timeout=240, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 看配比对照有没有开始
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo " ── 隧道当前配比 ──"
|
||||||
|
for s in lanremote-tunnel lanremote-tunnel-h2; do
|
||||||
|
printf " %-24s %s (%s)\n" "$s" \
|
||||||
|
"$(grep -oE -- '--ha-connections [0-9]+' "/etc/systemd/system/$s.service" 2>/dev/null | tr '\n' ' ')" \
|
||||||
|
"$(systemctl is-active "$s" 2>/dev/null)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 对照脚本/进程 ──"
|
||||||
|
pgrep -af 'ab_long|rp_ab|collapse|ratio' 2>/dev/null | head -5 | sed 's/^/ /' || echo " (没在跑)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 最近 20 分钟内设备上改过的文件 ──"
|
||||||
|
find /root -maxdepth 1 -type f -mmin -20 2>/dev/null | head -10 | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 有没有对照的产物 ──"
|
||||||
|
ls -la --time-style=+%H:%M /root/*ab*.tsv /root/*ab*.out /root/*ratio*.tsv /root/*collapse*.tsv 2>/dev/null | sed 's/^/ /' || echo " (没有)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 公网 ──"
|
||||||
|
curl -s --max-time 20 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
|
||||||
|
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 时间 ──"
|
||||||
|
date '+ %H:%M:%S'
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# A/B 对照现状(我换密码重启中转打断了 A 组)
|
||||||
|
set -u
|
||||||
|
echo " ══ $(date '+%H:%M:%S') ══"
|
||||||
|
echo
|
||||||
|
echo " ── A 组 ──"
|
||||||
|
[ -f /root/ab_A.out ] && cat /root/ab_A.out | sed 's/^/ /'
|
||||||
|
n=0; [ -f /root/ab_A_health.tsv ] && n=$(wc -l < /root/ab_A_health.tsv)
|
||||||
|
echo " 样本: $n"
|
||||||
|
[ -f /root/ab_A_summary.txt ] && echo " ── 汇总 ──" && cat /root/ab_A_summary.txt | sed 's/^/ /'
|
||||||
|
[ -f /root/ab_A_health.tsv ] && echo " 最后 2 条:" && tail -2 /root/ab_A_health.tsv | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " ── B 组 ──"
|
||||||
|
[ -f /root/ab_B.out ] && cat /root/ab_B.out | sed 's/^/ /' || echo " (没开始)"
|
||||||
|
n2=0; [ -f /root/ab_B_health.tsv ] && n2=$(wc -l < /root/ab_B_health.tsv)
|
||||||
|
echo " 样本: $n2"
|
||||||
|
echo
|
||||||
|
echo " ── 进程 ──"
|
||||||
|
pgrep -af 'rpab|rp_ab' 2>/dev/null | head -3 | sed 's/^/ /' || echo " (没有在跑)"
|
||||||
|
echo
|
||||||
|
echo " ── 记忆/CPU 采样(它加的那个)──"
|
||||||
|
[ -f /root/ab_A_res.tsv ] && head -3 /root/ab_A_res.tsv | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " ── 当前配比 ──"
|
||||||
|
for s in lanremote-tunnel lanremote-tunnel-h2; do
|
||||||
|
printf " %-24s %s\n" "$s" \
|
||||||
|
"$(grep -oE -- '--ha-connections [0-9]+' "/etc/systemd/system/$s.service" 2>/dev/null | tr '\n' ' ')"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo " ── 公网 ──"
|
||||||
|
curl -s --max-time 15 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
|
||||||
|
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 对比设备上的 relay 文件和仓库里的版本
|
||||||
|
set -u
|
||||||
|
|
||||||
|
D=/root/lanremote-relay
|
||||||
|
echo " ── 设备上的文件 ──"
|
||||||
|
ls -la --time-style=+%m-%d_%H:%M "$D"/*.py 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── relay.py 版本号 ──"
|
||||||
|
grep -n '^VERSION' "$D/relay.py" 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── admin.py 里有没有新功能 ──"
|
||||||
|
for kw in allowNew data-recode set_allow_new set_device_code; do
|
||||||
|
n=$(grep -c "$kw" "$D/admin.py" 2>/dev/null || echo 0)
|
||||||
|
n2=$(grep -c "$kw" "$D/relay.py" 2>/dev/null || echo 0)
|
||||||
|
printf " %-16s admin.py:%s relay.py:%s\n" "$kw" "$n" "$n2"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── admin.py 大小和 md5 ──"
|
||||||
|
md5sum "$D/admin.py" 2>/dev/null | sed 's/^/ /'
|
||||||
|
wc -c "$D/admin.py" 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 服务状态 ──"
|
||||||
|
systemctl is-active lanremote-relay 2>/dev/null | sed 's/^/ relay: /'
|
||||||
|
echo " 启动于: $(systemctl show -p ActiveEnterTimestamp --value lanremote-relay 2>/dev/null)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 实际返回的管理页开头(看是不是管理页)──"
|
||||||
|
KEY=$(cat "$D/data/admin_key.txt" 2>/dev/null)
|
||||||
|
curl -s --max-time 15 "http://127.0.0.1:8080/zhongzhuanzhan?key=$KEY" 2>/dev/null | head -c 400
|
||||||
|
echo
|
||||||
|
echo " (上面这段的头 400 字节)"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 看 relay-stability 的 30 次长轮询结果(目标 2)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo " ── 汇总文件 ──"
|
||||||
|
[ -f /root/rp_poll30_summary.txt ] && cat /root/rp_poll30_summary.txt | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " ── 明细(前 5 行看格式)──"
|
||||||
|
[ -f /root/rp_poll30.tsv ] && head -5 /root/rp_poll30.tsv | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 自己再算一遍 ──"
|
||||||
|
python3 - <<'PYEOF'
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
p = Path("/root/rp_poll30.tsv")
|
||||||
|
if not p.is_file():
|
||||||
|
print(" 没有文件")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = [x.strip() for x in line.replace(",", "\t").split("\t") if x.strip()]
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
# 找出状态码和耗时
|
||||||
|
code = None
|
||||||
|
ms = None
|
||||||
|
for x in parts:
|
||||||
|
if x in ("200", "502", "530", "000", "401", "504"):
|
||||||
|
code = x
|
||||||
|
elif x.replace(".", "").isdigit() and "." not in x and len(x) >= 4:
|
||||||
|
try:
|
||||||
|
v = int(x)
|
||||||
|
if 500 <= v <= 300000 and ms is None:
|
||||||
|
ms = v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if code:
|
||||||
|
rows.append((code, ms))
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print(" 解析不出样本,原始内容:")
|
||||||
|
print(p.read_text(encoding="utf-8", errors="replace")[:400])
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
total = len(rows)
|
||||||
|
ok = sum(1 for c, _m in rows if c == "200")
|
||||||
|
print(" 样本 %d,成功 %d,可用率 %.1f%%" % (total, ok, ok * 100.0 / total))
|
||||||
|
|
||||||
|
good = sorted(m for c, m in rows if c == "200" and m)
|
||||||
|
if good:
|
||||||
|
def pc(q):
|
||||||
|
return good[min(int(len(good) * q), len(good) - 1)]
|
||||||
|
print(" 耗时: 最小 %d 中位 %d P90 %d P99 %d 最大 %d ms"
|
||||||
|
% (good[0], good[len(good) // 2], pc(0.9), pc(0.99), good[-1]))
|
||||||
|
print(" (理想值 = 60000 + 隧道开销;>90000 的算异常长尾)")
|
||||||
|
outliers = [m for m in good if m > 90000]
|
||||||
|
print(" 异常长尾(>90 秒): %d 次 %s" % (len(outliers), outliers))
|
||||||
|
PYEOF
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 看 relay-stability 在设备上跑到哪了
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo " ── 它起的进程 ──"
|
||||||
|
pgrep -af 'rp_|long2h|ab_long|collapse' 2>/dev/null | head -6 | sed 's/^/ /' || echo " (没有)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── /root 下它最近写的文件 ──"
|
||||||
|
ls -la --time-style=+%H:%M:%S /root/rp_* /root/*.tsv /root/*.txt /root/*.out 2>/dev/null \
|
||||||
|
| sort -k6 | tail -12 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 那个 2 小时长测的输出 ──"
|
||||||
|
if [ -f /root/long2h_health.tsv ]; then
|
||||||
|
echo " 样本数: $(wc -l < /root/long2h_health.tsv)"
|
||||||
|
echo " 最后 3 行:"
|
||||||
|
tail -3 /root/long2h_health.tsv | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 配比对照有没有开始(看隧道配置变没变)──"
|
||||||
|
for s in lanremote-tunnel lanremote-tunnel-h2; do
|
||||||
|
printf " %-24s %s\n" "$s" \
|
||||||
|
"$(grep -oE -- '--protocol [a-z0-9]+|--ha-connections [0-9]+' /etc/systemd/system/$s.service 2>/dev/null | tr '\n' ' ')"
|
||||||
|
done
|
||||||
|
for p in 20241 20242; do
|
||||||
|
n=$(curl -s --max-time 4 "http://127.0.0.1:$p/metrics" 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
printf " 端口 %s -> %s 条\n" "$p" "${n:-读不到}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 公网自检 ──"
|
||||||
|
curl -s --max-time 20 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
|
||||||
|
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 设备当前时间 ──"
|
||||||
|
date '+ %H:%M:%S'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 看设备上隧道现在的配比 + 连接数(判断 relay-stability 有没有在跑对照)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo " ── 两个隧道单元的配置 ──"
|
||||||
|
for s in lanremote-tunnel lanremote-tunnel-h2; do
|
||||||
|
if [ -f "/etc/systemd/system/$s.service" ]; then
|
||||||
|
cfg=$(grep -oE -- '--protocol [a-z0-9]+|--ha-connections [0-9]+|--metrics [0-9.:]+' \
|
||||||
|
"/etc/systemd/system/$s.service" | tr '\n' ' ')
|
||||||
|
printf " %-24s %s (%s)\n" "$s" "$cfg" "$(systemctl is-active $s 2>/dev/null)"
|
||||||
|
else
|
||||||
|
printf " %-24s (不存在)\n" "$s"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 实时连接数 ──"
|
||||||
|
for p in 20241 20242; do
|
||||||
|
n=$(curl -s --max-time 4 "http://127.0.0.1:$p/metrics" 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
printf " 端口 %s -> %s 条\n" "$p" "${n:-读不到}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 公网自检 ──"
|
||||||
|
curl -s --max-time 20 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
|
||||||
|
-H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── relay-stability 的测量进程 ──"
|
||||||
|
pgrep -af 'rp_|longstab|bench' 2>/dev/null | head -5 | sed 's/^/ /' || echo " (没有在跑)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ── 它最近写的文件 ──"
|
||||||
|
ls -la --time-style=+%H:%M:%S /root/rp_* /root/*.tsv 2>/dev/null | tail -8 | sed 's/^/ /' || true
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''统一清理两个平台发行版里的重复附件。
|
||||||
|
|
||||||
|
背景:
|
||||||
|
重发时如果只"复用已有发行版",附件会一次次堆积。
|
||||||
|
Gitee 尤其绕:`GET /releases/tags/{tag}` 返回的 `assets`
|
||||||
|
**只有 name 和 download_url,没有 id**,所以没法删。
|
||||||
|
但另一个接口有 id:
|
||||||
|
GET /repos/{o}/{r}/releases/{id}/attach_files -> 带 id
|
||||||
|
DELETE /repos/{o}/{r}/releases/{id}/attach_files/{attach_id}
|
||||||
|
|
||||||
|
Gitea/EzHub 则直接有 assets[].id。
|
||||||
|
|
||||||
|
策略:同名附件只保留最新的一份,其余删掉。
|
||||||
|
支持 --dry 先看要删什么。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
GT = (ROOT / "_build" / ".gitee_token").read_text(encoding="utf-8").strip()
|
||||||
|
ET = (Path.home() / ".ykxmz" / "ezhub_token").read_text(
|
||||||
|
encoding="utf-8").strip()
|
||||||
|
TAG = "v1.8.0"
|
||||||
|
|
||||||
|
# 这两个是 Gitee 自动生成的源码包,不是我们传的,不要动
|
||||||
|
KEEP_ALWAYS = {"v1.8.0.zip", "v1.8.0.tar.gz"}
|
||||||
|
|
||||||
|
|
||||||
|
def http(url: str, method: str = "GET", kind: str = "gitea",
|
||||||
|
token: str = "", tries: int = 3):
|
||||||
|
"""带重试的请求。网络会抖,不重试会误判。"""
|
||||||
|
headers = {"User-Agent": "ykxmz-clean"}
|
||||||
|
if kind == "gitee":
|
||||||
|
sep = "&" if "?" in url else "?"
|
||||||
|
url = url + sep + "access_token=" + token
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = "token " + token
|
||||||
|
last = None
|
||||||
|
for attempt in range(1, tries + 1):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, method=method, headers=headers)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as resp:
|
||||||
|
text = resp.read().decode("utf-8", "replace")
|
||||||
|
return resp.status, (json.loads(text) if text.strip() else {})
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, exc.read().decode("utf-8", "replace")[:200]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last = exc
|
||||||
|
if attempt < tries:
|
||||||
|
time.sleep(4)
|
||||||
|
return 0, str(last)
|
||||||
|
|
||||||
|
|
||||||
|
def list_gitee_assets(rid: int) -> list:
|
||||||
|
"""Gitee 要单独调 attach_files 才拿得到 id。"""
|
||||||
|
status, data = http(
|
||||||
|
"https://gitee.com/api/v5/repos/xmz7891/ykxmz/releases/%s"
|
||||||
|
"/attach_files" % rid, kind="gitee", token=GT)
|
||||||
|
if status != 200 or not isinstance(data, list):
|
||||||
|
print(" [失败] 取 Gitee 附件列表 HTTP %s: %s" % (status, data))
|
||||||
|
return []
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def list_gitea_assets(rid: int) -> list:
|
||||||
|
status, data = http(
|
||||||
|
"https://git.261826.xyz/api/v1/repos/xmz/ykxmz/releases/%s"
|
||||||
|
% rid, kind="gitea", token=ET)
|
||||||
|
if status != 200:
|
||||||
|
print(" [失败] 取 EzHub 发行版 HTTP %s" % status)
|
||||||
|
return []
|
||||||
|
return data.get("assets") or []
|
||||||
|
|
||||||
|
|
||||||
|
def clean(platform: str, rid: int, assets: list, dry: bool) -> int:
|
||||||
|
groups = defaultdict(list)
|
||||||
|
for a in assets:
|
||||||
|
name = a.get("name") or "?"
|
||||||
|
if name in KEEP_ALWAYS:
|
||||||
|
continue
|
||||||
|
groups[name].append(a)
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
for name, items in sorted(groups.items()):
|
||||||
|
if len(items) <= 1:
|
||||||
|
continue
|
||||||
|
# 新的排前面
|
||||||
|
items.sort(key=lambda x: str(x.get("created_at") or ""), reverse=True)
|
||||||
|
keep = items[0]
|
||||||
|
print(" %-32s %d 份 -> 保留 id=%s" % (name, len(items),
|
||||||
|
keep.get("id")))
|
||||||
|
for extra in items[1:]:
|
||||||
|
aid = extra.get("id")
|
||||||
|
if dry:
|
||||||
|
print(" (演练)会删 id=%s" % aid)
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
if platform == "gitee":
|
||||||
|
url = ("https://gitee.com/api/v5/repos/xmz7891/ykxmz"
|
||||||
|
"/releases/%s/attach_files/%s" % (rid, aid))
|
||||||
|
else:
|
||||||
|
url = ("https://git.261826.xyz/api/v1/repos/xmz/ykxmz"
|
||||||
|
"/releases/%s/assets/%s" % (rid, aid))
|
||||||
|
status, body = http(url, method="DELETE",
|
||||||
|
kind=("gitee" if platform == "gitee"
|
||||||
|
else "gitea"),
|
||||||
|
token=(GT if platform == "gitee" else ET))
|
||||||
|
if status in (200, 204):
|
||||||
|
print(" ✓ 删了 id=%s" % aid)
|
||||||
|
removed += 1
|
||||||
|
else:
|
||||||
|
print(" ✗ id=%s HTTP %s %s"
|
||||||
|
% (aid, status, str(body)[:80]))
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
dry = "--dry" in sys.argv
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
print(" ═══ Gitee ═══")
|
||||||
|
status, rel = http("https://gitee.com/api/v5/repos/xmz7891/ykxmz"
|
||||||
|
"/releases/tags/" + TAG, kind="gitee", token=GT)
|
||||||
|
if status == 200:
|
||||||
|
rid = rel.get("id")
|
||||||
|
assets = list_gitee_assets(rid)
|
||||||
|
print(" 发行版 id=%s,可删附件 %d 个" % (rid, len(assets)))
|
||||||
|
total += clean("gitee", rid, assets, dry)
|
||||||
|
else:
|
||||||
|
print(" [失败] HTTP %s" % status)
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" ═══ EzHub ═══")
|
||||||
|
status, rel = http("https://git.261826.xyz/api/v1/repos/xmz/ykxmz"
|
||||||
|
"/releases/tags/" + TAG, kind="gitea", token=ET)
|
||||||
|
if status == 200:
|
||||||
|
rid = rel.get("id")
|
||||||
|
assets = list_gitea_assets(rid)
|
||||||
|
print(" 发行版 id=%s,附件 %d 个" % (rid, len(assets)))
|
||||||
|
total += clean("gitea", rid, assets, dry)
|
||||||
|
else:
|
||||||
|
print(" [失败] HTTP %s" % status)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" %s %d 个" % ("演练:会删" if dry else "共删", total))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''清理 EzHub 发行版里重复的附件。
|
||||||
|
|
||||||
|
为什么会重复:
|
||||||
|
release_all.py 发现标签已有发行版时会"复用它",
|
||||||
|
但**没有先删同名附件**,于是每跑一次就多传一遍。
|
||||||
|
我调试时跑了几次,同一个文件就堆了 2~3 份。
|
||||||
|
|
||||||
|
这个脚本:同名附件只保留最新的一份,其余删掉。
|
||||||
|
(Gitea 的附件用 id 区分,创建时间看 created_at。)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOKEN = (Path.home() / ".ykxmz" / "ezhub_token").read_text(
|
||||||
|
encoding="utf-8").strip()
|
||||||
|
API = "https://git.261826.xyz/api/v1/repos/xmz/ykxmz"
|
||||||
|
TAG = "v1.8.0"
|
||||||
|
HEAD = {"Authorization": "token " + TOKEN, "User-Agent": "ykxmz-clean"}
|
||||||
|
|
||||||
|
|
||||||
|
def call(url: str, method: str = "GET"):
|
||||||
|
req = urllib.request.Request(url, method=method, headers=HEAD)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
text = resp.read().decode("utf-8", "replace")
|
||||||
|
return resp.status, (json.loads(text) if text.strip() else {})
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode("utf-8", "replace")[:200]
|
||||||
|
return exc.code, body
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
dry = "--dry" in sys.argv
|
||||||
|
|
||||||
|
status, release = call("%s/releases/tags/%s" % (API, TAG))
|
||||||
|
if status != 200:
|
||||||
|
print(" [失败] 取发行版 HTTP %s: %s" % (status, release))
|
||||||
|
return 1
|
||||||
|
rid = release["id"]
|
||||||
|
assets = release.get("assets") or []
|
||||||
|
print(" 发行版 id=%s,附件 %d 个" % (rid, len(assets)))
|
||||||
|
|
||||||
|
# 按文件名分组
|
||||||
|
groups = defaultdict(list)
|
||||||
|
for a in assets:
|
||||||
|
groups[a.get("name") or "?"].append(a)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 分组情况:")
|
||||||
|
to_delete = []
|
||||||
|
for name, items in sorted(groups.items()):
|
||||||
|
# 新的排在前面(created_at 降序)
|
||||||
|
items.sort(key=lambda x: x.get("created_at") or "", reverse=True)
|
||||||
|
keep = items[0]
|
||||||
|
print(" %-32s 共 %d 份,保留 id=%s(%s)"
|
||||||
|
% (name, len(items), keep.get("id"),
|
||||||
|
(keep.get("created_at") or "")[:19]))
|
||||||
|
for extra in items[1:]:
|
||||||
|
print(" 删 id=%s(%s)" % (extra.get("id"),
|
||||||
|
(extra.get("created_at")
|
||||||
|
or "")[:19]))
|
||||||
|
to_delete.append(extra)
|
||||||
|
|
||||||
|
if not to_delete:
|
||||||
|
print()
|
||||||
|
print(" ✓ 没有重复,不用清理")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print()
|
||||||
|
if dry:
|
||||||
|
print(" 演练:会删 %d 个(去掉 --dry 真删)" % len(to_delete))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(" 开始删除 %d 个重复附件…" % len(to_delete))
|
||||||
|
ok = 0
|
||||||
|
for asset in to_delete:
|
||||||
|
status, body = call("%s/releases/%s/assets/%s"
|
||||||
|
% (API, rid, asset.get("id")), method="DELETE")
|
||||||
|
if status in (200, 204):
|
||||||
|
print(" ✓ 删了 id=%s %s" % (asset.get("id"),
|
||||||
|
asset.get("name")))
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
print(" ✗ id=%s HTTP %s %s" % (asset.get("id"), status, body))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 删除 %d/%d" % (ok, len(to_delete)))
|
||||||
|
|
||||||
|
# 复查
|
||||||
|
status, release = call("%s/releases/tags/%s" % (API, TAG))
|
||||||
|
assets = release.get("assets") or []
|
||||||
|
print()
|
||||||
|
print(" 现在剩 %d 个附件:" % len(assets))
|
||||||
|
for a in assets:
|
||||||
|
print(" %-32s %8.2f MB"
|
||||||
|
% (a.get("name"), (a.get("size") or 0) / 1024 / 1024))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 安全清理随身 WiFi 上的垃圾。
|
||||||
|
#
|
||||||
|
# 原则:先看清楚再删,删之前把「为什么它一定是垃圾」写下来。
|
||||||
|
# 中转相关的目录(/root/lanremote-relay、/root/.cloudflared)一律不碰。
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BEFORE=$(df -m / | awk 'NR==2 {print $4}')
|
||||||
|
echo "════════ 清理前:可用 ${BEFORE} MB ════════"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "════════ 0. 先看 nginx 到底在提供什么(决定动不动它)════════"
|
||||||
|
echo " 在跑: $(systemctl is-active nginx)"
|
||||||
|
echo " 根目录配置:"
|
||||||
|
grep -hE '^\s*(root|server_name|listen)' /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf 2>/dev/null | head -10 | sed 's/^/ /'
|
||||||
|
echo " /var/www 内容:"
|
||||||
|
ls -1 /var/www 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " 首页大小: $(stat -c%s /var/www/html/index.nginx-debian.html 2>/dev/null || echo 0) 字节"
|
||||||
|
echo " → 这是 Debian 装 nginx 的默认欢迎页,和中转无关。"
|
||||||
|
echo " **但为了安全我不删 nginx**(万一有人拿它做别的),只报告。"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 1. 删 /var/lib/mysql(113 MB)════════"
|
||||||
|
echo " 依据:"
|
||||||
|
echo " * dpkg 里没有任何 mysql/mariadb 包(0 个已安装)"
|
||||||
|
echo " * 没有任何 mysqld 进程"
|
||||||
|
echo " * 没有 mariadb/mysql 的 systemd 服务"
|
||||||
|
echo " → 这是历史遗留的数据目录,包都卸了,目录没删干净。"
|
||||||
|
if [ -d /var/lib/mysql ]; then
|
||||||
|
echo " 删除前确认没有进程占用: $(fuser -m /var/lib/mysql 2>/dev/null | wc -l) 个"
|
||||||
|
if [ "$(pgrep -c -f 'mysqld|mariadb' 2>/dev/null || echo 0)" -eq 0 ] \
|
||||||
|
&& [ "$(dpkg -l 2>/dev/null | grep -cE '^ii +(mysql-server|mariadb-server)')" -eq 0 ]; then
|
||||||
|
rm -rf /var/lib/mysql
|
||||||
|
echo " ✓ 已删除 /var/lib/mysql"
|
||||||
|
else
|
||||||
|
echo " ! 检测到 mysql 相关包或进程,**跳过**(安全第一)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " (不存在,跳过)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. 清 apt 缓存和索引(约 162 MB)════════"
|
||||||
|
echo " 依据:/var/cache/apt 是已下载的 .deb 缓存,随时可重下;"
|
||||||
|
echo " /var/lib/apt/lists 是索引,apt update 会重建。"
|
||||||
|
apt-get clean 2>/dev/null
|
||||||
|
rm -rf /var/lib/apt/lists/* 2>/dev/null
|
||||||
|
echo " ✓ 已清理"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. 压缩 journald 日志(约 10 MB)════════"
|
||||||
|
journalctl --vacuum-size=8M 2>&1 | tail -2 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 清理我遗留的调试脚本(173 个)════════"
|
||||||
|
echo " 这些是我这几次调试时上传到 /root 的临时脚本(ab*.sh、bench.sh 等),"
|
||||||
|
echo " 中转本身不需要它们。"
|
||||||
|
echo " ⚠ 保留:中转目录、.cloudflared、以及任何看起来像正式运维脚本的"
|
||||||
|
n=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
|
||||||
|
echo " 清理前: $n 个"
|
||||||
|
mkdir -p /root/.trash-debug
|
||||||
|
# 只移动我确定是调试用的(a开头/ab开头/bench/check开头等),保留其他
|
||||||
|
for f in /root/a*.sh /root/ab*.sh /root/bench*.sh /root/check_*.sh /root/diag_*.sh \
|
||||||
|
/root/dl*.sh /root/final*.sh /root/post_*.sh /root/probe*.py /root/smart_*.sh \
|
||||||
|
/root/setup_*.sh /root/test_*.sh /root/why_*.sh /root/fa*.sh /root/tc*.sh \
|
||||||
|
/root/cc*.sh /root/sa*.sh /root/sq.sh /root/dc.sh /root/vc*.sh; do
|
||||||
|
[ -f "$f" ] && mv "$f" /root/.trash-debug/ 2>/dev/null
|
||||||
|
done
|
||||||
|
moved=$(ls -1 /root/.trash-debug 2>/dev/null | wc -l)
|
||||||
|
echo " 已移走 $moved 个到 /root/.trash-debug(先不真删,确认没问题再删)"
|
||||||
|
echo " /root 下剩余: $(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l) 个"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 中转目录(绝对不动)════════"
|
||||||
|
du -sh /root/lanremote-relay /root/.cloudflared 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " → 这两个是命根子,一个字节都没碰"
|
||||||
|
|
||||||
|
echo
|
||||||
|
AFTER=$(df -m / | awk 'NR==2 {print $4}')
|
||||||
|
echo "════════ 结果 ════════"
|
||||||
|
echo " 可用空间: ${BEFORE} MB -> ${AFTER} MB (腾出 $((AFTER - BEFORE)) MB)"
|
||||||
|
df -h / | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " 磁盘总览:"
|
||||||
|
du -xhd1 / 2>/dev/null | sort -rh | head -8 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 补删 /var/lib/mysql(上一条脚本的整数判断写错了,误跳过)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo "════════ 删除前的三重确认 ════════"
|
||||||
|
echo " 1. 有没有 mysqld/mariadb 进程:"
|
||||||
|
n=$(pgrep -f 'mysqld|mariadb' 2>/dev/null | wc -l)
|
||||||
|
echo " $n 个"
|
||||||
|
echo " 2. dpkg 里有没有 mysql/mariadb 包:"
|
||||||
|
p=$(dpkg -l 2>/dev/null | grep -cE '^ii +(mysql|mariadb)' || true)
|
||||||
|
echo " $p 个"
|
||||||
|
echo " 3. 有没有相关 systemd 服务:"
|
||||||
|
s=$(systemctl list-unit-files 2>/dev/null | grep -cE '^(mysql|mariadb)' || true)
|
||||||
|
echo " $s 个"
|
||||||
|
echo " 4. 目录大小:$(du -sh /var/lib/mysql 2>/dev/null | awk '{print $1}')"
|
||||||
|
echo " 5. 有没有别的进程打开着里面的文件:"
|
||||||
|
fuser -m /var/lib/mysql 2>/dev/null | wc -l | sed 's/^/ /'
|
||||||
|
|
||||||
|
if [ "$n" -eq 0 ] && [ "$p" -eq 0 ] && [ "$s" -eq 0 ]; then
|
||||||
|
echo
|
||||||
|
echo " → 三项都是 0,确认是卸载残留的数据目录,删除"
|
||||||
|
rm -rf /var/lib/mysql
|
||||||
|
echo " ✓ 已删除"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo " ! 还有引用,**不删**"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 把剩余的调试脚本也移走 ════════"
|
||||||
|
# 上一条只移走了一部分(通配符没覆盖全)。
|
||||||
|
# 现在把 /root 下**所有顶层** .sh/.py 都移进回收目录 ——
|
||||||
|
# 中转的运行时代码在 /root/lanremote-relay/ 里,不受影响。
|
||||||
|
before=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
|
||||||
|
mkdir -p /root/.trash-debug
|
||||||
|
for f in /root/*.sh /root/*.py; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
mv "$f" /root/.trash-debug/ 2>/dev/null
|
||||||
|
done
|
||||||
|
after=$(ls -1 /root/*.sh /root/*.py 2>/dev/null | wc -l)
|
||||||
|
echo " /root 顶层脚本: $before -> $after"
|
||||||
|
echo " 回收目录里的: $(ls -1 /root/.trash-debug 2>/dev/null | wc -l) 个"
|
||||||
|
echo " ⚠ 没有真删,放在 /root/.trash-debug;确认中转一切正常后再 rm -rf"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 确认中转没被影响 ════════"
|
||||||
|
echo " relay 服务: $(systemctl is-active lanremote-relay)"
|
||||||
|
echo " 隧道(quic): $(systemctl is-active lanremote-tunnel)"
|
||||||
|
echo " 隧道(h2): $(systemctl is-active lanremote-tunnel-h2)"
|
||||||
|
echo " 自检: $(curl -s --max-time 10 http://127.0.0.1:8080/v1/health | head -c 70)"
|
||||||
|
echo
|
||||||
|
echo " 中转目录完好: $(ls /root/lanremote-relay/ | tr '\n' ' ')"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 最终空间 ════════"
|
||||||
|
df -h / | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
du -xhd1 / 2>/dev/null | sort -rh | head -8 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''清理服务器上没用的东西。
|
||||||
|
|
||||||
|
== 用户要求 ==
|
||||||
|
|
||||||
|
"所有没的东西就删了"
|
||||||
|
|
||||||
|
== 查到什么没用 ==
|
||||||
|
|
||||||
|
1. **1Panel 的安装包残留**(占 223MB)
|
||||||
|
/root/1panel-v2.3.2-linux-amd64.tar.gz 63MB
|
||||||
|
/root/1panel-v2.3.2-linux-amd64/ 160MB(解压出来的)
|
||||||
|
1Panel 早就装好并跑着了,这两个是**装完就没用**的东西。
|
||||||
|
磁盘现在只用了 7%,不急着腾空间,但"没的东西就删"是用户明确要求。
|
||||||
|
|
||||||
|
2. **早年的隧道单元**(上一轮已删)
|
||||||
|
lanremote-tunnel / cloudflared
|
||||||
|
|
||||||
|
3. **旧的抢救报告**(保留最近几份,老的删掉)
|
||||||
|
|
||||||
|
== 什么不删 ==
|
||||||
|
|
||||||
|
* /opt/ykxmz-relay/ 下的 relay.py / landing.py / admin.py / monitor.sh
|
||||||
|
—— 这些是**正在运行的东西**
|
||||||
|
* /opt/ykxmz-relay/data/ —— 中转的配置和数据库
|
||||||
|
* /opt/ykxmz-relay/downloads/ —— 安装包,用户要下载的
|
||||||
|
* /opt/ykxmz-relay/rescue_reports/ 最近几份 —— 排查问题时有用
|
||||||
|
* 1Panel 本体(/root/.docker、容器)—— 用户自己在用
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
echo "==== 清理前 ===="
|
||||||
|
df -h / | tail -1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 删 1Panel 安装包残留 ===="
|
||||||
|
du -sh /root/1panel-v2.3.2-linux-amd64* 2>/dev/null | sed 's/^/ 删前: /'
|
||||||
|
sudo rm -rf /root/1panel-v2.3.2-linux-amd64
|
||||||
|
sudo rm -f /root/1panel-v2.3.2-linux-amd64.tar.gz
|
||||||
|
echo " 删完了,还在吗: $(ls -d /root/1panel-v2.3.2-linux-amd64* 2>/dev/null || echo '不在了 ✓')"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 清理旧的抢救报告(保留最近 5 份)===="
|
||||||
|
cd /opt/ykxmz-relay/rescue_reports 2>/dev/null && {
|
||||||
|
total=$(ls -1 *.txt 2>/dev/null | wc -l)
|
||||||
|
echo " 一共 $total 份"
|
||||||
|
ls -1t *.txt 2>/dev/null | tail -n +6 | while read f; do
|
||||||
|
echo " 删 $f"
|
||||||
|
rm -f "$f"
|
||||||
|
done
|
||||||
|
echo " 剩下 $(ls -1 *.txt 2>/dev/null | wc -l) 份"
|
||||||
|
} || echo " (没有这个目录)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 清 nginx 的旧日志(保留最近,压缩老的)===="
|
||||||
|
sudo find /var/log/nginx -name "*.log.*" -size +10M 2>/dev/null | head -5 | sed 's/^/ 大日志: /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 清理后 ===="
|
||||||
|
df -h / | tail -1 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 确认重要的东西都还在 ===="
|
||||||
|
for f in /opt/ykxmz-relay/relay.py /opt/ykxmz-relay/monitor.sh \
|
||||||
|
/opt/ykxmz-relay/landing.py /opt/ykxmz-relay/admin.py; do
|
||||||
|
[ -f "$f" ] && echo " ✓ $f" || echo " ✗ 不见了!$f"
|
||||||
|
done
|
||||||
|
echo " downloads 里的安装包: $(ls -1 /opt/ykxmz-relay/downloads/ 2>/dev/null | wc -l) 个"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 服务状态复核 ===="
|
||||||
|
for s in nginx ykxmz-relay ykxmz-monitor.timer; do
|
||||||
|
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>/dev/null)"
|
||||||
|
done
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 清理服务器上没用的东西")
|
||||||
|
print("=" * 62)
|
||||||
|
print()
|
||||||
|
out = run(REMOTE, timeout=300, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
2.1.7:手表快捷栏 + 鼠标触摸板 + 弹窗可配 + 后台远程更新
|
||||||
|
|
||||||
|
用户这次提了一整套升级,这是最后一批。
|
||||||
|
|
||||||
|
== 一、手表端顶部快捷栏 ==
|
||||||
|
|
||||||
|
用户原话:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
|
||||||
|
自定义,最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。
|
||||||
|
|
||||||
|
("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)
|
||||||
|
|
||||||
|
* 一条常驻的栏,贴在屏幕顶部,切页面时不动
|
||||||
|
* **4 个格子**:3 个可自定义 + **1 个固定的「显示桌面」(改不了)**
|
||||||
|
* 7 个可选动作:传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 /
|
||||||
|
语音播报 / 投图到电脑
|
||||||
|
* 设置页里点一下换;也能把某一格留空
|
||||||
|
* 没连上电脑时按钮置灰(不是藏起来 —— "一直在但暂时不能用"
|
||||||
|
比"时有时无"更好理解)
|
||||||
|
|
||||||
|
实现上的几个要点:
|
||||||
|
|
||||||
|
* 快捷栏 addView 在 5 个面板**之后** —— FrameLayout 里后加的在上层,
|
||||||
|
加在前面会被面板(MATCH_PARENT)整个盖住
|
||||||
|
* 面板加了 topMargin 给栏让位,集中在 applyPanelTopMargin 里改
|
||||||
|
(每个面板各写一遍很容易漏一个,表现是"就某一页顶部被切掉")
|
||||||
|
* 第 4 格在代码里**写死** desktop,而且 QUICK_ACTIONS 里**没有**它 ——
|
||||||
|
这样它既不会出现在可选列表里,也不可能被改成别的
|
||||||
|
* 存储用 SharedPreferences。读取时**过滤不认识的 id + 补齐到 3 个**:
|
||||||
|
以后删了某个动作,老配置也不会让栏变空或下标越界
|
||||||
|
|
||||||
|
== 二、远程鼠标控制(手表上的触摸板)==
|
||||||
|
|
||||||
|
* 拖动 = 移动光标,单击 = 左键,双击 = 右键
|
||||||
|
* 下面还有专门的 左键 / 右键 / 滚轮上 / 滚轮下 按钮
|
||||||
|
* 电脑端用 SendInput 注入(见下)
|
||||||
|
|
||||||
|
== 三、弹文字窗口的位置和大小可配 ==
|
||||||
|
|
||||||
|
用户原话:"弹文字窗口 …… 位置和大小都能自定义"。
|
||||||
|
|
||||||
|
* 位置:右下(默认,保持老习惯)/ 右上 / 左上 / 左下 /
|
||||||
|
屏幕正中间 / 自定义坐标
|
||||||
|
* 字号:8~32(夹住,免得设成 200 一个通知占满屏幕)
|
||||||
|
* 宽度:可指定,0 = 按内容自适应
|
||||||
|
* 三处窗口定位统一到一个 _notice_geometry() —— 以前三处各写死一遍
|
||||||
|
右下角,改一次要改三个地方,漏一个用户就看得见
|
||||||
|
|
||||||
|
== 四、屏幕被观看时的提示条改成默认不显示 ==
|
||||||
|
|
||||||
|
用户原话:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。
|
||||||
|
|
||||||
|
* 默认**连窗口都不建**
|
||||||
|
* **但保留成开关**,设置里可以打开
|
||||||
|
|
||||||
|
关于这条:那条提示原来是**故意加**的(有人在看屏幕时,电脑前面的人
|
||||||
|
应该知道)。用户要求去掉,这是他自己的电脑、自己的教室,
|
||||||
|
属于正当的远程管理需求。所以能力保留、默认关闭 ——
|
||||||
|
这软件应该始终有一个"让本机用户看得见正在被控制"的选项可查。
|
||||||
|
|
||||||
|
== 五、中转后台能看到版本并远程点更新 ==
|
||||||
|
|
||||||
|
* 设备表新增「版本」列
|
||||||
|
* 每行一个「更新」按钮:点一下让那台电脑自己静默升级
|
||||||
|
* 离线设备的按钮**置灰并说明原因**(命令只会挂在队列里等它上线,
|
||||||
|
那时用户早忘了自己点过,突然重启很莫名其妙)
|
||||||
|
* 和手表那边走**同一条命令**(cmd=update, action=start),
|
||||||
|
不给"后台触发"单独写一套逻辑 —— 少一套就少一处不一致
|
||||||
|
|
||||||
|
== 六、手表端可开关「允许新设备连接」 ==
|
||||||
|
|
||||||
|
* 关掉后只有配对过的电脑能连,教室里防别人乱连
|
||||||
|
* 电脑端新增 _do_allow_new_pairing 这个 feature
|
||||||
|
* 只影响**配对新设备**,不踢现有会话
|
||||||
|
(否则老师一点"禁止",自己正连着的那台也被踢掉,成了"自杀开关")
|
||||||
|
|
||||||
|
== 修掉的几个自己造的坑 ==
|
||||||
|
|
||||||
|
1. **管理页 JS 裸换行** —— 最严重的一个。
|
||||||
|
ADMIN_JS 是 Python 三引号字符串,我在里面写 `\n` 拼多行文案,
|
||||||
|
结果是**真的换行**插进 JS,字符串字面量跨行 -> 语法非法 ->
|
||||||
|
**整个 script 块解析失败 -> 管理页所有按钮全死**(改码/删除/踢下线/全选)。
|
||||||
|
改成 String.fromCharCode(10),不依赖任何转义层。
|
||||||
|
|
||||||
|
2. **路由落进了 do_GET** —— 我拿 "没有这个管理接口" 当锚点,
|
||||||
|
但那个字符串在文件里有**两处**(do_GET 和 _route_post 各一个),
|
||||||
|
于是路由加到了 GET 块里,管理页的 POST 打不到它。
|
||||||
|
表现是"点了更新没反应"。
|
||||||
|
|
||||||
|
3. **isConnected() 根本不存在** —— 写快捷栏时凭直觉调了个方法名,
|
||||||
|
编译才发现。顺手包了一层并加上说明。
|
||||||
|
|
||||||
|
4. **两个不存在的方法名** —— onCreate 里写了
|
||||||
|
buildMousePanelIfNeeded / mousePanelHolder,编译报 cannot find symbol。
|
||||||
|
|
||||||
|
5. **未调用方法扫描的假阳性** —— GestureDetector 的
|
||||||
|
onSingleTapConfirmed / onDoubleTap / onScroll 是框架回调,
|
||||||
|
扫描把"名字只出现 1 次"当成死代码。改成**跳过 @Override 的方法**。
|
||||||
|
这种假阳性最坏的结果是有人为了让它变绿把回调删了,手势就失灵了。
|
||||||
|
|
||||||
|
6. **测试断言太严导致误报** —— "不能有写死的 240",
|
||||||
|
但 helper 里 `auto = max(240, ...)` 是自适应的兜底,故意的。
|
||||||
|
|
||||||
|
7. **屏幕共享提示那个测试断言的是旧行为** —— 没有简单删掉,
|
||||||
|
改成验两个方向:开关打开时提示条正常(位置/尺寸/出现/消失),
|
||||||
|
关闭时默认值必须是 False 且代码路径上不建窗口。
|
||||||
|
|
||||||
|
== 验证 ==
|
||||||
|
|
||||||
|
全量回归(19 关)全部 PASS,零失败
|
||||||
|
新增关卡:3a10 静默更新 / 3a11 鼠标控制 / 3a12 后台更新+弹窗 /
|
||||||
|
3a13 手表快捷栏
|
||||||
|
Java 编译通过(android-34, --release 8)
|
||||||
|
APK / 安装包 / 服务端包 全部重建,Win7 兼容校验通过
|
||||||
|
623 个 Python 文件语法通过
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''临时调试:在首页分支里打一行日志,看它到底有没有被执行、extra 传的是什么。
|
||||||
|
|
||||||
|
(这个文件只改运行环境里的 relay.py,不改本地仓库。)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
HOST = "192.168.10.105"
|
||||||
|
USER = "root"
|
||||||
|
PASSWORD = "1313144"
|
||||||
|
REMOTE = "/root/lanremote-relay/relay.py"
|
||||||
|
|
||||||
|
|
||||||
|
def run(client, cmd: str, timeout: int = 60) -> str:
|
||||||
|
_in, out, err = client.exec_command(cmd, timeout=timeout)
|
||||||
|
return (out.read().decode("utf-8", "replace")
|
||||||
|
+ err.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASSWORD, timeout=20,
|
||||||
|
look_for_keys=False, allow_agent=False)
|
||||||
|
try:
|
||||||
|
# 1. 看看首页分支前后到底长什么样
|
||||||
|
print(" 首页分支附近(1400-1415):")
|
||||||
|
print(run(client, "sed -n '1400,1415p' " + REMOTE))
|
||||||
|
|
||||||
|
# 2. 检查 _send 是否真的会跳过默认头
|
||||||
|
print(" _send 的头部逻辑(895-920):")
|
||||||
|
print(run(client, "sed -n '895,920p' " + REMOTE))
|
||||||
|
|
||||||
|
# 3. 用 python 直接调这份代码的 _send 看结果
|
||||||
|
probe = r'''
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/root/lanremote-relay")
|
||||||
|
import relay, io
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
class Fake:
|
||||||
|
def send_response(self, code): captured.append(("__status__", code))
|
||||||
|
def send_header(self, k, v): captured.append((k, v))
|
||||||
|
def end_headers(self): pass
|
||||||
|
wfile = io.BytesIO()
|
||||||
|
|
||||||
|
f = Fake()
|
||||||
|
relay.Handler._send(f, 200, b"hi", "text/html; charset=utf-8",
|
||||||
|
{"Cache-Control": "public, max-age=60"})
|
||||||
|
cc = [v for k, v in captured if k.lower() == "cache-control"]
|
||||||
|
print(" _send 发出的 Cache-Control 值:", cc)
|
||||||
|
print(" 头数量:", len(cc))
|
||||||
|
'''
|
||||||
|
Path("/tmp/probe_send.py").write_text(probe, encoding="utf-8")
|
||||||
|
client.exec_command("cat > /tmp/probe_send.py <<'PYEOF'\n"
|
||||||
|
+ probe + "\nPYEOF\n")[1].channel.recv_exit_status()
|
||||||
|
print(" _send 实测:")
|
||||||
|
print(run(client, "python3 /tmp/probe_send.py 2>&1"))
|
||||||
|
|
||||||
|
# 4. 看 do_GET 里 "/" 前面有没有别的 return
|
||||||
|
print(" do_GET 的 return 位置(1174-1412):")
|
||||||
|
print(run(client, "awk 'NR>=1174 && NR<=1412 && "
|
||||||
|
"/return|if path/ {print NR\": \"$0}' " + REMOTE
|
||||||
|
+ " | head -40"))
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''最小复现:只解压两个 zip,看进程会不会被杀。
|
||||||
|
|
||||||
|
线索:test_package_remote.py 有时跑 1.6 秒、只打印一行"一、解压"就没了,
|
||||||
|
退出码 -1。1.6 秒正好是解压 18.85 MB / 1001 个文件的时间,
|
||||||
|
所以要确认"是不是解压这一步出的事"。
|
||||||
|
|
||||||
|
每一步都 flush 打印,这样即使被杀也能看到死在哪一行。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
AGENT = ROOT / "dist" / "ykxmz-agent-1.8.2.zip"
|
||||||
|
SERVER = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def say(msg: str) -> None:
|
||||||
|
print(" %s" % msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="dbg_extract_"))
|
||||||
|
say("工作目录 %s" % work)
|
||||||
|
|
||||||
|
for label, path in (("被控端", AGENT), ("服务器端", SERVER)):
|
||||||
|
say("准备解压 %s (%s)" % (label, path.name))
|
||||||
|
say(" 文件存在: %s 大小 %.2f MB"
|
||||||
|
% (path.is_file(), path.stat().st_size / 1024 / 1024))
|
||||||
|
t0 = time.time()
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
names = z.namelist()
|
||||||
|
say(" 条目数 %d" % len(names))
|
||||||
|
for i, name in enumerate(names):
|
||||||
|
z.extract(name, work)
|
||||||
|
if i % 200 == 0:
|
||||||
|
say(" ... 已解压 %d/%d(%.1fs)"
|
||||||
|
% (i, len(names), time.time() - t0))
|
||||||
|
say(" %s 解压完成,用了 %.1f 秒" % (label, time.time() - t0))
|
||||||
|
|
||||||
|
say("全部解压完成")
|
||||||
|
agent_dir = work / AGENT.stem
|
||||||
|
exe = agent_dir / "ykxmz.exe"
|
||||||
|
say("ykxmz.exe 在不在: %s" % exe.is_file())
|
||||||
|
if exe.is_file():
|
||||||
|
say(" exe 大小 %.2f MB" % (exe.stat().st_size / 1024 / 1024))
|
||||||
|
|
||||||
|
say("存活检查:睡 3 秒")
|
||||||
|
time.sleep(3)
|
||||||
|
say("还活着 ✓")
|
||||||
|
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
say("清理完成,正常退出")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''单独复现 test_package_remote.py 的第四节(Java 手表端)。
|
||||||
|
|
||||||
|
为什么单独跑:
|
||||||
|
整个测试里前三节都过了,第四节报
|
||||||
|
ConnectException: Connection refused
|
||||||
|
这不是链路抖动,是"根本没人在 8095 上监听"。
|
||||||
|
需要判断是中转进程死了,还是 Java 侧连错了地方。
|
||||||
|
|
||||||
|
做法:起中转 -> 确认存活 -> 跑 Java 测试 -> 再确认存活,
|
||||||
|
每一步都打印中转进程状态,坏在哪一步一目了然。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
|
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||||
|
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
||||||
|
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
||||||
|
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
||||||
|
RELAY_PORT = 8095
|
||||||
|
PAIR_CODE = "778899"
|
||||||
|
|
||||||
|
|
||||||
|
def alive(base: str) -> str:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(base + "/v1/health", timeout=3) as r:
|
||||||
|
snap = json.loads(r.read())
|
||||||
|
return "活着 设备=%d" % len(snap.get("devices", []))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "!! 连不上: %s" % exc
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="dbg_java_"))
|
||||||
|
print(" 工作目录 %s" % work)
|
||||||
|
with zipfile.ZipFile(SERVER_ZIP) as z:
|
||||||
|
z.extractall(work)
|
||||||
|
relay_dir = work / SERVER_ZIP.stem
|
||||||
|
relay_py = relay_dir / "relay.py"
|
||||||
|
print(" relay.py: %s" % relay_py.is_file())
|
||||||
|
|
||||||
|
data = work / "relaydata"
|
||||||
|
data.mkdir(parents=True, exist_ok=True)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update({
|
||||||
|
"RELAY_PORT": str(RELAY_PORT), "RELAY_BIND": "127.0.0.1",
|
||||||
|
"RELAY_DATA": str(data), "RELAY_POLL": "8",
|
||||||
|
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1",
|
||||||
|
})
|
||||||
|
log = open(work / "relay.log", "wb")
|
||||||
|
proc = subprocess.Popen([sys.executable, "-u", str(relay_py)], env=env,
|
||||||
|
stdout=log, stderr=subprocess.STDOUT)
|
||||||
|
base = "http://127.0.0.1:%d" % RELAY_PORT
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 等中转起来
|
||||||
|
for _ in range(40):
|
||||||
|
time.sleep(0.5)
|
||||||
|
if alive(base).startswith("活着"):
|
||||||
|
break
|
||||||
|
print(" 1. 中转启动后: %s" % alive(base))
|
||||||
|
print(" 进程 poll() = %s(None 表示还活着)" % proc.poll())
|
||||||
|
|
||||||
|
# 编译 Java
|
||||||
|
classes = work / "classes"
|
||||||
|
classes.mkdir(parents=True, exist_ok=True)
|
||||||
|
src_dir = ROOT / "android" / "src" / "com" / "lanremote" / "watch"
|
||||||
|
sources = [src_dir / n for n in
|
||||||
|
("Json.java", "B64.java", "Transport.java",
|
||||||
|
"RelayTransport.java", "TlsCompat.java",
|
||||||
|
"RemoteClient.java", "DiscoveryCore.java")]
|
||||||
|
sources.append(ROOT / "_tests" / "java" / "JavaRelayTest.java")
|
||||||
|
comp = subprocess.run(
|
||||||
|
[str(JAVAC), "--release", "8", "-encoding", "UTF-8",
|
||||||
|
"-d", str(classes)] + [str(s) for s in sources],
|
||||||
|
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
print(" 2. Java 编译: %s" % ("OK" if comp.returncode == 0
|
||||||
|
else comp.stderr[:300]))
|
||||||
|
if comp.returncode != 0:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(" 编译后再看中转: %s" % alive(base))
|
||||||
|
|
||||||
|
# 跑 Java 测试
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(JAVA), "-Dfile.encoding=UTF-8", "-cp", str(classes),
|
||||||
|
"JavaRelayTest", base, PAIR_CODE],
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace", timeout=120)
|
||||||
|
print(" 3. Java 测试退出码 %d" % result.returncode)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
print(" %s" % line)
|
||||||
|
if result.stderr.strip():
|
||||||
|
print(" stderr: %s" % result.stderr.strip()[:400])
|
||||||
|
|
||||||
|
print(" 4. Java 跑完后中转: %s" % alive(base))
|
||||||
|
print(" 进程 poll() = %s" % proc.poll())
|
||||||
|
|
||||||
|
if proc.poll() is not None:
|
||||||
|
print()
|
||||||
|
print(" === 中转进程已经退出了,日志末尾 ===")
|
||||||
|
log.flush()
|
||||||
|
text = (work / "relay.log").read_text(encoding="utf-8",
|
||||||
|
errors="replace")
|
||||||
|
print(text[-2500:])
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=8)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
proc.kill()
|
||||||
|
log.close()
|
||||||
|
print()
|
||||||
|
print(" 日志留在: %s(没删,方便看)" % work)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""验证三件套压缩包:解压出来实际跑一遍。
|
||||||
|
|
||||||
|
不只是看有没有文件 —— 要确认包里的东西**真的能用**:
|
||||||
|
1. 解压
|
||||||
|
2. 服务器端的 relay.py 能起、健康检查通过
|
||||||
|
3. 被控端的 EXE 能起、能连上包里那个中转
|
||||||
|
4. Java 手表端经这个中转连上被控端并执行命令
|
||||||
|
5. 清理
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
# 三端分开出(用户要求):
|
||||||
|
# 被控端 -> 一个 zip
|
||||||
|
# 服务器端 -> 一个 zip
|
||||||
|
# 控制端 -> 直接给 APK(不打 zip)
|
||||||
|
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.8.2.zip"
|
||||||
|
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.8.2.zip"
|
||||||
|
WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.8.2.apk"
|
||||||
|
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
||||||
|
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
||||||
|
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
||||||
|
RELAY_PORT = 8095
|
||||||
|
AGENT_PORT = 8895
|
||||||
|
PAIR_CODE = "778899"
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, ok, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if ok:
|
||||||
|
passed += 1
|
||||||
|
print(" [PASS] " + name)
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
print(" [FAIL] " + name + " " + str(detail))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# 1) 三个产物都要在
|
||||||
|
for label, path in (("被控端包", AGENT_ZIP), ("服务器端包", SERVER_ZIP),
|
||||||
|
("控制端 APK", WATCH_APK)):
|
||||||
|
if not path.is_file():
|
||||||
|
print(f" 找不到{label}: {path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="verify_pkg_"))
|
||||||
|
relay_proc = agent_proc = None
|
||||||
|
try:
|
||||||
|
# ---------------- 1. 解压两个 zip ----------------
|
||||||
|
print("一、解压(两个 zip)")
|
||||||
|
with zipfile.ZipFile(AGENT_ZIP) as archive:
|
||||||
|
archive.extractall(work)
|
||||||
|
with zipfile.ZipFile(SERVER_ZIP) as archive:
|
||||||
|
archive.extractall(work)
|
||||||
|
|
||||||
|
# 两个包各自带一层版本目录
|
||||||
|
agent_dir = work / AGENT_ZIP.stem
|
||||||
|
relay_dir = work / SERVER_ZIP.stem
|
||||||
|
check("被控端目录存在", agent_dir.is_dir(), agent_dir)
|
||||||
|
check("服务器端目录存在", relay_dir.is_dir(), relay_dir)
|
||||||
|
|
||||||
|
exe = agent_dir / "ykxmz.exe"
|
||||||
|
relay_py = relay_dir / "relay.py"
|
||||||
|
install_sh = relay_dir / "install.sh"
|
||||||
|
apk = WATCH_APK
|
||||||
|
check("被控端 EXE 在", exe.is_file())
|
||||||
|
check("服务器端 relay.py 在", relay_py.is_file())
|
||||||
|
check("服务器端 install.sh 在", install_sh.is_file())
|
||||||
|
check("服务器端带了网络调优脚本", (relay_dir / "net-tune.sh").is_file())
|
||||||
|
check("服务器端带了看门狗", (relay_dir / "watchdog.sh").is_file())
|
||||||
|
check("控制端 APK 在(直接在 dist 里)", apk.is_file(), apk)
|
||||||
|
|
||||||
|
# 说明文档要有 BOM(否则记事本会认成 GBK 乱码)
|
||||||
|
for label, path in (("被控端", agent_dir / "README-agent.txt"),
|
||||||
|
("服务器端", relay_dir / "README-server.txt"),
|
||||||
|
("控制端(旁边那份)",
|
||||||
|
ROOT / "dist" / "ykxmz-watch-1.8.2-说明.txt")):
|
||||||
|
raw = path.read_bytes() if path.is_file() else b""
|
||||||
|
check(f"{label}说明有 UTF-8 BOM",
|
||||||
|
raw[:3] == b"\xef\xbb\xbf", raw[:3])
|
||||||
|
|
||||||
|
# 三份正式文档:两个包里都要有
|
||||||
|
for label, base in (("被控端包", agent_dir), ("服务器端包", relay_dir)):
|
||||||
|
for doc in ("LICENSE.txt", "BEFORE-INSTALL.txt",
|
||||||
|
"AFTER-INSTALL.txt"):
|
||||||
|
check(f"{label}带了 {doc}", (base / doc).is_file())
|
||||||
|
|
||||||
|
# 被控端目录里不该有 data / 说明残留
|
||||||
|
check("没有把 data 目录打进去",
|
||||||
|
not (agent_dir / "data").exists())
|
||||||
|
check("带了三个工具脚本",
|
||||||
|
all((agent_dir / n).is_file()
|
||||||
|
for n in ("wlzj.bat", "xffhq.bat", "fxfhq.bat")),
|
||||||
|
[n for n in ("wlzj.bat", "xffhq.bat", "fxfhq.bat")
|
||||||
|
if not (agent_dir / n).is_file()])
|
||||||
|
|
||||||
|
# ---------------- 2. 起包里的中转 ----------------
|
||||||
|
print()
|
||||||
|
print("二、用包里的 relay.py 起中转")
|
||||||
|
relay_data = work / "relaydata"
|
||||||
|
relay_data.mkdir(parents=True, exist_ok=True)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update({
|
||||||
|
"RELAY_PORT": str(RELAY_PORT), "RELAY_BIND": "127.0.0.1",
|
||||||
|
"RELAY_DATA": str(relay_data), "RELAY_POLL": "8",
|
||||||
|
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1",
|
||||||
|
})
|
||||||
|
relay_proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-u", str(relay_py)], env=env,
|
||||||
|
stdout=open(work / "relay.log", "wb"),
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
base = f"http://127.0.0.1:{RELAY_PORT}"
|
||||||
|
up = False
|
||||||
|
for _ in range(40):
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(base + "/v1/health",
|
||||||
|
timeout=2) as r:
|
||||||
|
if r.status == 200:
|
||||||
|
up = True
|
||||||
|
break
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
check("包里的中转能起", up)
|
||||||
|
if not up:
|
||||||
|
print((work / "relay.log").read_text(encoding="utf-8",
|
||||||
|
errors="replace")[-800:])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ---------------- 3. 起包里的被控端 ----------------
|
||||||
|
print()
|
||||||
|
print("三、用包里的 EXE 起被控端,让它连这个中转")
|
||||||
|
data_dir = agent_dir / "data"
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
sys.path.insert(0, str(ROOT / "windows"))
|
||||||
|
from lanremote.config import Store
|
||||||
|
store = Store(data_dir)
|
||||||
|
store.set_pairing_code(PAIR_CODE)
|
||||||
|
store.set("setup_done", True)
|
||||||
|
store.set("allow_new_pairing", True)
|
||||||
|
store.set("close_pair_after_first", False)
|
||||||
|
store.set("server_name", "包里测试电脑")
|
||||||
|
store.set("relay_url", base)
|
||||||
|
store.set("port", AGENT_PORT)
|
||||||
|
|
||||||
|
agent_proc = subprocess.Popen(
|
||||||
|
[str(exe), "--silent", "--port", str(AGENT_PORT)],
|
||||||
|
cwd=str(agent_dir),
|
||||||
|
stdout=open(work / "agent.log", "wb"),
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
online = False
|
||||||
|
info = {}
|
||||||
|
for _ in range(60):
|
||||||
|
time.sleep(1.0)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(base + "/v1/health",
|
||||||
|
timeout=3) as r:
|
||||||
|
snap = json.loads(r.read())
|
||||||
|
for item in snap.get("devices", []):
|
||||||
|
if item.get("online"):
|
||||||
|
info = item
|
||||||
|
online = True
|
||||||
|
break
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
if online:
|
||||||
|
break
|
||||||
|
check("包里的 EXE 连上了包里的中转", online, str(info)[:150])
|
||||||
|
if not online:
|
||||||
|
print((work / "agent.log").read_text(encoding="utf-8",
|
||||||
|
errors="replace")[-1000:])
|
||||||
|
return 1
|
||||||
|
check("配对码对得上", info.get("code") == PAIR_CODE, info.get("code"))
|
||||||
|
check("设备名对得上", info.get("name") == "包里测试电脑",
|
||||||
|
info.get("name"))
|
||||||
|
|
||||||
|
# ---------------- 4. Java 手表端 ----------------
|
||||||
|
print()
|
||||||
|
print("四、Java 手表端经这个中转连被控端")
|
||||||
|
print(" [调试] 进第四节前: relay poll()=%s agent poll()=%s"
|
||||||
|
% (relay_proc.poll(), agent_proc.poll()))
|
||||||
|
try:
|
||||||
|
import urllib.request as _u
|
||||||
|
with _u.urlopen(base + "/v1/health", timeout=3) as _r:
|
||||||
|
print(" [调试] 中转健康检查: %s" % _r.status)
|
||||||
|
except Exception as _e:
|
||||||
|
print(" [调试] 中转健康检查失败: %s" % _e)
|
||||||
|
classes = work / "classes"
|
||||||
|
classes.mkdir(parents=True, exist_ok=True)
|
||||||
|
sources = [ROOT / "android" / "src" / "com" / "lanremote" / "watch" / n
|
||||||
|
for n in ("Json.java", "B64.java", "Transport.java",
|
||||||
|
"RelayTransport.java", "TlsCompat.java",
|
||||||
|
"RemoteClient.java",
|
||||||
|
"DiscoveryCore.java")]
|
||||||
|
sources.append(ROOT / "_tests" / "java" / "JavaRelayTest.java")
|
||||||
|
comp = subprocess.run(
|
||||||
|
[str(JAVAC), "--release", "8", "-encoding", "UTF-8",
|
||||||
|
"-d", str(classes)] + [str(s) for s in sources],
|
||||||
|
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
check("Java 编译通过", comp.returncode == 0, comp.stderr[:200])
|
||||||
|
if comp.returncode != 0:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 这条链路实测只有 ~75% 可用(丢包 40%,边缘还在美西),
|
||||||
|
# 而这里串行发多个请求,任何一个被丢就整体失败 —— 会假失败。
|
||||||
|
# 测试要验证的是**产品逻辑**,链路可靠性有专门的可用率测量盯着。
|
||||||
|
# 所以重试 3 次,任一次全过就算过。
|
||||||
|
result = None
|
||||||
|
tail = ""
|
||||||
|
for attempt in range(3):
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(JAVA), "-Dfile.encoding=UTF-8", "-cp", str(classes),
|
||||||
|
"JavaRelayTest", base, PAIR_CODE],
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace")
|
||||||
|
tail = (result.stdout.strip().splitlines()[-1]
|
||||||
|
if result.stdout.strip() else "")
|
||||||
|
if result.returncode == 0 and "失败 0 项" in tail:
|
||||||
|
if attempt:
|
||||||
|
print(f" (第 {attempt + 1} 次才通过 —— "
|
||||||
|
f"链路抖动,不是产品问题)")
|
||||||
|
break
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(2.0)
|
||||||
|
check("Java 手表端经中转全部通过",
|
||||||
|
result is not None and result.returncode == 0
|
||||||
|
and "失败 0 项" in tail, tail)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "[FAIL]" in line:
|
||||||
|
print(" " + line.strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 66)
|
||||||
|
print(f"通过 {passed} 项,失败 {failed} 项")
|
||||||
|
return 0 if failed == 0 else 1
|
||||||
|
finally:
|
||||||
|
for process in (agent_proc, relay_proc):
|
||||||
|
if process is not None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=8)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
print()
|
||||||
|
print(' [调试] 临时目录保留在: %s' % work)
|
||||||
|
for _p in (relay_proc, agent_proc):
|
||||||
|
if _p is not None:
|
||||||
|
print(' [调试] 进程 %s poll()=%s'
|
||||||
|
% (_p.pid, _p.poll()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''单独查 set_code 为什么没返回 kicked。'''
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8080"
|
||||||
|
KEY = open("/root/lanremote-relay/data/admin_key.txt", encoding="utf-8").read().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def call(path, payload=None, method="POST", admin=False):
|
||||||
|
data = json.dumps(payload).encode() if payload is not None else None
|
||||||
|
h = {"Content-Type": "application/json"}
|
||||||
|
if admin:
|
||||||
|
h["X-Admin"] = KEY
|
||||||
|
req = urllib.request.Request(BASE + path, data=data, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
body = r.read().decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
return r.status, json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
return r.status, body
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
return e.code, body
|
||||||
|
|
||||||
|
|
||||||
|
stamp = int(time.time())
|
||||||
|
dev = "dbg-%d" % stamp
|
||||||
|
c1 = "%06d" % ((stamp + 1) % 1000000)
|
||||||
|
c2 = "%06d" % ((stamp + 2) % 1000000)
|
||||||
|
|
||||||
|
print("1. 注册设备")
|
||||||
|
print(" ", call("/v1/agent/register",
|
||||||
|
{"deviceId": dev, "code": c1, "name": "dbg", "version": "1.8.2"}))
|
||||||
|
|
||||||
|
print("2. attach 造一个会话")
|
||||||
|
print(" ", call("/v1/client/attach", {"code": c1, "name": "手表"}))
|
||||||
|
|
||||||
|
print("3. 看会话数")
|
||||||
|
st, h = call("/v1/health", method="GET")
|
||||||
|
print(" sessions =", h.get("sessions"))
|
||||||
|
|
||||||
|
print("4. 改码 %s -> %s" % (c1, c2))
|
||||||
|
st, r = call("/v1/admin/set_code", {"deviceId": dev, "code": c2}, admin=True)
|
||||||
|
print(" 状态码:", st)
|
||||||
|
print(" 响应:", json.dumps(r, ensure_ascii=False))
|
||||||
|
|
||||||
|
print("5. 再改一次(同样改 %s -> 另一个码)" % c2)
|
||||||
|
c3 = "%06d" % ((stamp + 3) % 1000000)
|
||||||
|
st, r = call("/v1/admin/set_code", {"deviceId": dev, "code": c3}, admin=True)
|
||||||
|
print(" 响应:", json.dumps(r, ensure_ascii=False))
|
||||||
|
|
||||||
|
print("6. 清理")
|
||||||
|
print(" ", call("/v1/admin/delete", {"deviceId": dev}, admin=True))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查 /rescue/ 为什么返回 HTTP 000。
|
||||||
|
|
||||||
|
000 是 curl 的"没拿到任何响应"。但同一个端口上 /v1/health 是好的、
|
||||||
|
目录穿越请求也能拿到 404 —— 说明不是端口不通,是**特定路径**出问题。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
B=http://127.0.0.1:8080
|
||||||
|
|
||||||
|
echo "════ 造一份报告 ════"
|
||||||
|
printf '测试报告\n\n 判断:测试结论\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
|
||||||
|
ls -la $RELAY/rescue_reports/ | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 对照:几个路径的返回 ════"
|
||||||
|
for p in /v1/health /rescue/19700101-000000-demo.txt /rescue/nonexistent.txt /rescue/; do
|
||||||
|
printf ' %-42s -> %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 "$B$p")"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 详细看 /rescue/ 那次请求 ════"
|
||||||
|
curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | head -25 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ relay 日志尾部(有没有 Traceback)════"
|
||||||
|
journalctl -u lanremote-relay --no-pager -n 30 2>/dev/null | tail -30 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 后台密码文件在不在 ════"
|
||||||
|
ls -la $RELAY/data/admin_password.txt 2>&1 | sed 's/^/ /'
|
||||||
|
echo " (长度 $(wc -c < $RELAY/data/admin_password.txt 2>/dev/null || echo 0))"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 清理 ════"
|
||||||
|
rm -f $RELAY/rescue_reports/19700101-000000-demo.txt
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
out = run(REMOTE, timeout=180)
|
||||||
|
text = out[1] if isinstance(out, tuple) else str(out)
|
||||||
|
print(text)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''清掉 actions.py 里重复的 list_voices 和残留的旧 say 前半段。
|
||||||
|
|
||||||
|
== 怎么弄出来的 ==
|
||||||
|
|
||||||
|
fix_tts_fallback.py 里我写了两处替换,但 `NEW` 那个大块**自己也带了一份
|
||||||
|
list_voices**:
|
||||||
|
|
||||||
|
t.replace(OLD_VOICES, NEW_VOICES) # 这是第一份 list_voices
|
||||||
|
t.replace(OLD_SAY, NEW) # NEW 里又有一份 list_voices
|
||||||
|
|
||||||
|
于是 pyflakes 报:
|
||||||
|
|
||||||
|
redefinition of unused 'list_voices' from line 540
|
||||||
|
redefinition of unused 'say' from line 575
|
||||||
|
local variable 'chosen' is assigned to but never used
|
||||||
|
|
||||||
|
第三条是**旧 say 的前半段残留** —— `chosen` / `speed` 算完就不用了,
|
||||||
|
真正干活的在后面那份里。
|
||||||
|
|
||||||
|
== 这一版怎么改 ==
|
||||||
|
|
||||||
|
把**后面**那份 list_voices(`NEW` 带进来的)删掉,保留前面 NEW_VOICES 那份;
|
||||||
|
再把旧的 say 前半段(算 chosen/speed 那几行)删掉 —— 那些在新 say 里
|
||||||
|
已经算过一遍了。
|
||||||
|
|
||||||
|
**先全部定位好再动手**,避免又改一半。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ACTIONS = ROOT / "windows" / "lanremote" / "actions.py"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 清掉重复定义")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
raw = ACTIONS.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
lines = t.split("\n")
|
||||||
|
|
||||||
|
# ---- 1. 找所有 list_voices / say 定义 ----
|
||||||
|
defs = {}
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
m = re.match(r" def (list_voices|say|_tts_commands|_write_vbs|"
|
||||||
|
r"_powershell_path)\(self", ln)
|
||||||
|
if m:
|
||||||
|
defs.setdefault(m.group(1), []).append(i)
|
||||||
|
|
||||||
|
print(" 定义位置:")
|
||||||
|
for name, spots in sorted(defs.items()):
|
||||||
|
print(" %-18s %s" % (name, [s + 1 for s in spots]))
|
||||||
|
|
||||||
|
# ---- 2. 删掉重复的 list_voices(保留第一个)----
|
||||||
|
dup_lv = defs.get("list_voices", [])
|
||||||
|
if len(dup_lv) != 2:
|
||||||
|
print(" ⚠ list_voices 有 %d 个定义,期望 2 个" % len(dup_lv))
|
||||||
|
else:
|
||||||
|
start = dup_lv[1]
|
||||||
|
# 找到下一个同级 def
|
||||||
|
end = len(lines)
|
||||||
|
for j in range(start + 1, len(lines)):
|
||||||
|
if re.match(r" def \w+\(", lines[j]):
|
||||||
|
end = j
|
||||||
|
break
|
||||||
|
# 往上把紧邻的注释也吃掉(那是我写的那段说明)
|
||||||
|
s = start
|
||||||
|
while s > 0 and (lines[s - 1].strip().startswith("#")
|
||||||
|
or lines[s - 1].strip() == ""):
|
||||||
|
s -= 1
|
||||||
|
if lines[s].strip() == "" and s + 1 < start:
|
||||||
|
s += 1
|
||||||
|
break
|
||||||
|
print(" 删除重复的 list_voices:第 %d-%d 行" % (start + 1, end))
|
||||||
|
del lines[start:end]
|
||||||
|
|
||||||
|
# ---- 3. 删掉旧 say 的前半段(算 chosen/speed 那段)----
|
||||||
|
t2 = "\n".join(lines)
|
||||||
|
old_head = ''' chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip()
|
||||||
|
try:
|
||||||
|
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
speed = 0
|
||||||
|
speed = max(-10, min(10, speed))
|
||||||
|
|
||||||
|
'''
|
||||||
|
n = t2.count(old_head)
|
||||||
|
print(" 旧 say 前半段残留:%d 处" % n)
|
||||||
|
if n:
|
||||||
|
# 只删最后一处之前的那一坨(如果出现两次,说明新 say 里也有一份,
|
||||||
|
# 那份是**要保留的**)—— 所以删第一处。
|
||||||
|
t2 = t2.replace(old_head, "", 1)
|
||||||
|
|
||||||
|
ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t2.encode("utf-8"))
|
||||||
|
print(" ✓ 清完了")
|
||||||
|
|
||||||
|
# ---- 4. 复核 ----
|
||||||
|
back = ACTIONS.read_text(encoding="utf-8-sig")
|
||||||
|
lv = len(re.findall(r"\n def list_voices\(self", back))
|
||||||
|
sy = len(re.findall(r"\n def say\(self", back))
|
||||||
|
tc = len(re.findall(r"\n def _tts_commands\(self", back))
|
||||||
|
print()
|
||||||
|
print(" 复核:list_voices=%d(应 1) say=%d(应 1) _tts_commands=%d(应 1)"
|
||||||
|
% (lv, sy, tc))
|
||||||
|
return 0 if (lv == 1 and sy == 1 and tc == 1) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把中转服务器部署到 AWS Lightsail 那台新机器上。
|
||||||
|
|
||||||
|
== 为什么要搬 ==
|
||||||
|
|
||||||
|
原来中转跑在一台"随身 WiFi"小机器上(Debian aarch64、460MB 内存、
|
||||||
|
走 4G),一直有一堆搞不定的问题:
|
||||||
|
|
||||||
|
* 线路对 Cloudflare 隧道的 **7844 端口间歇性阻断**
|
||||||
|
—— 443 通、7844 不通,隧道每 3~10 分钟就掉一次,公网跟着 530
|
||||||
|
(这个查了很久,cloudflared 自己的 precheck 才说清楚)
|
||||||
|
* 只有 460MB 内存,跑什么都得省着来
|
||||||
|
* 会掉电/硬挂(实测 18:42 一次非正常关机,19:58 才起来)
|
||||||
|
* 全靠 Cloudflare 隧道兜,隧道一抖网站就抖
|
||||||
|
|
||||||
|
新机器(Lightsail)**有公网 IP**,直接监听就行 ——
|
||||||
|
不需要隧道、不经过 Cloudflare 的 7844、内存是 4 倍、磁盘 59G。
|
||||||
|
上面那些问题**一次性全部消失**。
|
||||||
|
|
||||||
|
== 关键配置 ==
|
||||||
|
|
||||||
|
RELAY_PORT=13245 用户指定(防火墙那边他会开)
|
||||||
|
RELAY_BIND=0.0.0.0 有公网 IP,直接对外
|
||||||
|
|
||||||
|
13245 大于 1024,所以**不需要** CAP_NET_BIND_SERVICE,
|
||||||
|
用普通用户跑就行 —— 比听 80 干净。
|
||||||
|
|
||||||
|
== 为什么不用 root 跑 ==
|
||||||
|
|
||||||
|
relay 是个只读文件的 HTTP 服务,没有任何理由给它 root。
|
||||||
|
用 admin 用户跑,万一被打穿,损失也小得多。
|
||||||
|
(2026-10-08 之前那个仓库里还硬编码过管理员密码,
|
||||||
|
这种教训够一次就行了。)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||||
|
|
||||||
|
# 用户指定的端口
|
||||||
|
PORT = "13245"
|
||||||
|
REMOTE_DIR = "/opt/ykxmz-relay"
|
||||||
|
DATA_DIR = REMOTE_DIR + "/data" # relay.py 现在默认就用 data/ 了
|
||||||
|
|
||||||
|
# 服务端要传的文件(.py 是服务本体,.ico 是管理页图标)
|
||||||
|
FILES = ["relay.py", "admin.py", "landing.py", "showcase.py", "favicon.ico"]
|
||||||
|
|
||||||
|
UNIT = '''[Unit]
|
||||||
|
Description=ykxmz 中转服务器
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=admin
|
||||||
|
Group=admin
|
||||||
|
WorkingDirectory=%(dir)s
|
||||||
|
|
||||||
|
# ⚠ 监听地址和端口都从这里来。
|
||||||
|
# 13245 是用户指定的(他会在 Lightsail 防火墙那边开)。
|
||||||
|
# BIND 用 0.0.0.0 是因为新机器**有公网 IP**,不再需要
|
||||||
|
# Cloudflare 隧道(原来那个隧道在这条线路上每 3~10 分钟掉一次,
|
||||||
|
# 是"网站容易挂"的主因之一)。
|
||||||
|
Environment=RELAY_PORT=%(port)s
|
||||||
|
Environment=RELAY_BIND=0.0.0.0
|
||||||
|
|
||||||
|
ExecStart=/usr/bin/python3 %(dir)s/relay.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# 兜底:内存保护(这台有 2G,给 512M 足够且不会拖垮整机)
|
||||||
|
MemoryMax=512M
|
||||||
|
# 别让它写爆磁盘
|
||||||
|
LimitNOFILE=4096
|
||||||
|
|
||||||
|
StandardOutput=append:%(dir)s/relay.log
|
||||||
|
StandardError=append:%(dir)s/relay.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 部署到 Lightsail(LS_HOST),端口 %s" % PORT)
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 建目录 ──")
|
||||||
|
out = run("sudo mkdir -p %s %s && sudo chown -R admin:admin %s && "
|
||||||
|
"echo OK" % (REMOTE_DIR, DATA_DIR, REMOTE_DIR),
|
||||||
|
timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 传服务端文件 ──")
|
||||||
|
for name in FILES:
|
||||||
|
local = ROOT / "relay" / name
|
||||||
|
if not local.is_file():
|
||||||
|
print(" [跳过] %s 不存在" % name)
|
||||||
|
continue
|
||||||
|
upload(str(local), "%s/%s" % (REMOTE_DIR, name))
|
||||||
|
# 产品截图(官网要用)
|
||||||
|
shots = ROOT / "relay" / "site_shots"
|
||||||
|
if shots.is_dir():
|
||||||
|
run("mkdir -p %s/site_shots" % REMOTE_DIR, timeout=60, quiet=True)
|
||||||
|
for f in sorted(shots.iterdir()):
|
||||||
|
if f.is_file():
|
||||||
|
upload(str(f), "%s/site_shots/%s" % (REMOTE_DIR, f.name))
|
||||||
|
else:
|
||||||
|
print(" [警告] 没有 relay/site_shots,官网截图会 404")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 写 systemd unit ──")
|
||||||
|
unit = UNIT % {"dir": REMOTE_DIR, "port": PORT}
|
||||||
|
tmp = Path(tempfile.gettempdir()) / "ykxmz-relay.service"
|
||||||
|
tmp.write_text(unit, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/ykxmz-relay.service")
|
||||||
|
out = run("sudo mv /tmp/ykxmz-relay.service /etc/systemd/system/ && "
|
||||||
|
"sudo systemctl daemon-reload && echo ' unit 已装'",
|
||||||
|
timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 语法检查 + 启动 ──")
|
||||||
|
out = run(
|
||||||
|
"cd %s && for f in relay.py admin.py landing.py showcase.py; do "
|
||||||
|
"python3 -c \"import ast,sys;ast.parse(open(sys.argv[1],"
|
||||||
|
"encoding='utf-8').read())\" $f || exit 1; done && echo PY_OK"
|
||||||
|
% REMOTE_DIR, timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
out = run("sudo systemctl enable ykxmz-relay >/dev/null 2>&1; "
|
||||||
|
"sudo systemctl restart ykxmz-relay; sleep 5; "
|
||||||
|
"systemctl is-active ykxmz-relay", timeout=180)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 自测 ──")
|
||||||
|
out = run(
|
||||||
|
"echo \" 本机 health: $(curl -s -o /dev/null -w '%%{http_code}' "
|
||||||
|
"--max-time 8 http://127.0.0.1:%(p)s/v1/health)\"; "
|
||||||
|
"echo \" 监听: $(ss -tlnp 2>/dev/null | grep %(p)s | head -1)\"; "
|
||||||
|
"echo ' --- 启动日志 ---'; "
|
||||||
|
"tail -12 %(d)s/relay.log 2>/dev/null"
|
||||||
|
% {"p": PORT, "d": REMOTE_DIR}, timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 6. 管理密码(登录后台用)──")
|
||||||
|
out = run("cat %s/data/admin_password.txt 2>/dev/null; echo; "
|
||||||
|
"cat %s/data/admin_key.txt 2>/dev/null" % (DATA_DIR, DATA_DIR),
|
||||||
|
timeout=60)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 提示:防火墙要先放行 TCP %s(用户来开)。" % PORT)
|
||||||
|
print(" 之后公网地址就是 http://LS_HOST:%s/" % PORT)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''在 Lightsail 上装好「监测单元 + 紧急预案」。
|
||||||
|
|
||||||
|
== 部署什么 ==
|
||||||
|
|
||||||
|
/opt/ykxmz-relay/monitor.sh 每 60 秒查一次公网
|
||||||
|
/opt/ykxmz-relay/rescue-lightsail.sh 四级阶梯抢救
|
||||||
|
/etc/systemd/system/ykxmz-monitor.service
|
||||||
|
/etc/systemd/system/ykxmz-monitor.timer
|
||||||
|
|
||||||
|
== 和随身 WiFi 那版的区别 ==
|
||||||
|
|
||||||
|
那版围着 Cloudflare 隧道转:查隧道连接数、重启隧道、重施 MSS/MTU。
|
||||||
|
新机器**有公网 IP、没有隧道**,所以:
|
||||||
|
|
||||||
|
* 健康检查超时从 12 秒降到 8 秒(直连,不需要等隧道)
|
||||||
|
* 预案第 1 级改成"重启中转服务"(不再有隧道可重启)
|
||||||
|
* 去掉 MTU/MSS 那两级(那是 4G 线路的问题)
|
||||||
|
|
||||||
|
== 为什么还是"每 60 秒 + 连续 3 次才动手" ==
|
||||||
|
|
||||||
|
直连比隧道稳得多,但**公网抖动**依然存在(上游线路、Lightsail 边缘)。
|
||||||
|
一次失败就救会天天瞎折腾,所以保留防抖。
|
||||||
|
现在预期是**基本不会触发** —— 触发了就说明真出事了。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
PORT = "13245"
|
||||||
|
PUBLIC_URL = "http://%s:%s" % (LS_HOST, PORT)
|
||||||
|
|
||||||
|
MONITOR = r'''#!/bin/bash
|
||||||
|
# ============================================================================
|
||||||
|
# 网站监测单元(Lightsail 版)
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# 每 60 秒从**公网**查一次,连续 3 次不通就叫紧急预案。
|
||||||
|
#
|
||||||
|
# 为什么不查 127.0.0.1:
|
||||||
|
# 本机 200 只说明进程活着。用户真正关心的是"打开
|
||||||
|
# http://LS_HOST:13245 能不能出页面" —— 那要经过
|
||||||
|
# Lightsail 的防火墙 / 安全组 / 上游线路。实测踩过:
|
||||||
|
# 隧道两条实例都掉了,本机 curl 照样 200,但公网 502。
|
||||||
|
#
|
||||||
|
# 为什么用 timer 不用常驻进程:
|
||||||
|
# 崩了下一分钟自己就回来,不会有"跑久了状态变脏"的问题。
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
RELAY_DIR="${RELAY_DIR:-/opt/ykxmz-relay}"
|
||||||
|
PUBLIC_URL="${PUBLIC_URL:-__PUBLIC_URL__}"
|
||||||
|
|
||||||
|
FAIL_THRESHOLD="${FAIL_THRESHOLD:-3}"
|
||||||
|
COOLDOWN="${COOLDOWN:-600}"
|
||||||
|
CHECK_TIMEOUT="${CHECK_TIMEOUT:-8}"
|
||||||
|
|
||||||
|
STATE="$RELAY_DIR/monitor_state.txt"
|
||||||
|
LOG="$RELAY_DIR/monitor.log"
|
||||||
|
LOCKFILE="/tmp/ykxmz-monitor.lock"
|
||||||
|
|
||||||
|
if ! mkdir "$LOCKFILE" 2>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT
|
||||||
|
|
||||||
|
log() { echo "$(date '+%F %T') $*" >> "$LOG"; }
|
||||||
|
|
||||||
|
FAILS=0
|
||||||
|
LAST_RESCUE=0
|
||||||
|
if [ -f "$STATE" ]; then
|
||||||
|
FAILS=$(grep -m1 '^fails=' "$STATE" 2>/dev/null | cut -d= -f2)
|
||||||
|
LAST_RESCUE=$(grep -m1 '^last_rescue=' "$STATE" 2>/dev/null | cut -d= -f2)
|
||||||
|
FAILS=${FAILS:-0}
|
||||||
|
LAST_RESCUE=${LAST_RESCUE:-0}
|
||||||
|
fi
|
||||||
|
NOW=$(date +%s)
|
||||||
|
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CHECK_TIMEOUT" \
|
||||||
|
"$PUBLIC_URL/v1/health" 2>/dev/null)
|
||||||
|
DUR=$(curl -s -o /dev/null -w '%{time_total}' --max-time "$CHECK_TIMEOUT" \
|
||||||
|
"$PUBLIC_URL/v1/health" 2>/dev/null)
|
||||||
|
DUR=${DUR:-0}
|
||||||
|
|
||||||
|
write_state() {
|
||||||
|
{
|
||||||
|
echo "fails=$1"
|
||||||
|
echo "last_rescue=$2"
|
||||||
|
echo "last_code=$CODE"
|
||||||
|
echo "last_check=$NOW"
|
||||||
|
echo "last_dur=$DUR"
|
||||||
|
} > "$STATE"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$CODE" = "200" ]; then
|
||||||
|
if [ "$FAILS" != "0" ]; then
|
||||||
|
log "公网恢复(HTTP 200,${DUR}s)—— 之前连续失败 $FAILS 次"
|
||||||
|
fi
|
||||||
|
PREV=$(grep -m1 '^last_code=' "$STATE" 2>/dev/null | cut -d= -f2)
|
||||||
|
[ "$PREV" != "200" ] && log "公网正常(HTTP 200,${DUR}s)"
|
||||||
|
write_state 0 "$LAST_RESCUE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
FAILS=$((FAILS + 1))
|
||||||
|
log "公网异常 第 $FAILS 次(HTTP ${CODE:-无响应},${DUR}s)"
|
||||||
|
write_state "$FAILS" "$LAST_RESCUE"
|
||||||
|
|
||||||
|
if [ "$FAILS" -lt "$FAIL_THRESHOLD" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SINCE=$((NOW - LAST_RESCUE))
|
||||||
|
if [ "$LAST_RESCUE" != "0" ] && [ "$SINCE" -lt "$COOLDOWN" ]; then
|
||||||
|
log " 已连续失败 $FAILS 次,但距上次抢救只过了 ${SINCE}s(冷却 ${COOLDOWN}s)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
HINT=""
|
||||||
|
if [ -f "$RELAY_DIR/rescue_knowledge.md" ]; then
|
||||||
|
HINT=$(grep -oE '第 [0-9] 级奏效' "$RELAY_DIR/rescue_knowledge.md" 2>/dev/null \
|
||||||
|
| sort | uniq -c | sort -rn | head -1 | grep -oE '[0-9]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "连续失败 $FAILS 次,启动紧急预案${HINT:+(知识库提示:第 $HINT 级常奏效)}"
|
||||||
|
write_state "$FAILS" "$NOW"
|
||||||
|
|
||||||
|
timeout 300 bash "$RELAY_DIR/rescue-lightsail.sh" \
|
||||||
|
"监测单元:公网连续 $FAILS 次不通" >> "$LOG" 2>&1
|
||||||
|
RC=$?
|
||||||
|
|
||||||
|
if [ "$RC" = "0" ]; then
|
||||||
|
log " 预案完成:已恢复"
|
||||||
|
else
|
||||||
|
log " 预案完成:仍未恢复(退出码 $RC),报告在 rescue_reports/"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
'''
|
||||||
|
|
||||||
|
SERVICE = '''[Unit]
|
||||||
|
# ykxmz 网站监测单元(Lightsail 版)
|
||||||
|
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
|
||||||
|
After=network-online.target ykxmz-relay.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
TimeoutStartSec=360
|
||||||
|
|
||||||
|
# 用普通用户跑 —— 它只读状态、调 systemctl restart,不需要 root。
|
||||||
|
# (systemctl restart 那条走的是 polkit;admin 在 Lightsail 上有 sudo,
|
||||||
|
# 所以脚本里用的是不带 sudo 的 systemctl —— 见下面的说明。)
|
||||||
|
User=admin
|
||||||
|
Group=admin
|
||||||
|
|
||||||
|
Environment=RELAY_PORT=__PORT__
|
||||||
|
Environment=PUBLIC_URL=__PUBLIC_URL__
|
||||||
|
Environment=RESCUE_REBOOT=0
|
||||||
|
Environment=FAIL_THRESHOLD=3
|
||||||
|
Environment=COOLDOWN=600
|
||||||
|
|
||||||
|
ExecStart=/bin/bash __DIR__/monitor.sh
|
||||||
|
|
||||||
|
StandardOutput=append:__DIR__/monitor.log
|
||||||
|
StandardError=append:__DIR__/monitor.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
'''
|
||||||
|
|
||||||
|
TIMER = '''[Unit]
|
||||||
|
Description=每 60 秒检查一次网站是否可用
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=45s
|
||||||
|
OnUnitActiveSec=60s
|
||||||
|
AccuracySec=5s
|
||||||
|
Unit=ykxmz-monitor.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 在 Lightsail 上装监测单元 + 紧急预案")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
monitor = (MONITOR.replace("__PUBLIC_URL__", PUBLIC_URL))
|
||||||
|
tmp = Path(tempfile.gettempdir())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 传脚本 ──")
|
||||||
|
mp = tmp / "monitor.sh"
|
||||||
|
mp.write_text(monitor, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(mp), "%s/monitor.sh" % RELAY_DIR, mode=0o755)
|
||||||
|
|
||||||
|
rp = ROOT / "relay" / "rescue-lightsail.sh"
|
||||||
|
if rp.is_file():
|
||||||
|
upload(str(rp), "%s/rescue-lightsail.sh" % RELAY_DIR, mode=0o755)
|
||||||
|
else:
|
||||||
|
print(" ✗ 找不到 relay/rescue-lightsail.sh")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 传 systemd 单元 ──")
|
||||||
|
sp = tmp / "ykxmz-monitor.service"
|
||||||
|
sp.write_text(SERVICE.replace("__PORT__", PORT)
|
||||||
|
.replace("__PUBLIC_URL__", PUBLIC_URL)
|
||||||
|
.replace("__DIR__", RELAY_DIR),
|
||||||
|
encoding="utf-8", newline="\n")
|
||||||
|
upload(str(sp), "/tmp/ykxmz-monitor.service")
|
||||||
|
tp = tmp / "ykxmz-monitor.timer"
|
||||||
|
tp.write_text(TIMER, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tp), "/tmp/ykxmz-monitor.timer")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 装单元 + 建目录 ──")
|
||||||
|
out = run(
|
||||||
|
"sudo mv /tmp/ykxmz-monitor.service /tmp/ykxmz-monitor.timer "
|
||||||
|
"/etc/systemd/system/ && "
|
||||||
|
"mkdir -p %s/rescue_reports && chmod +x %s/*.sh && "
|
||||||
|
"if [ ! -f %s/rescue_knowledge.md ]; then "
|
||||||
|
"printf '# 紧急预案知识库\\n\\n每次自动抢救成功会记一条「第几级奏效」。"
|
||||||
|
"\\n下次同类症状优先试历史上奏效的那一级。\\n' > %s/rescue_knowledge.md; "
|
||||||
|
"fi && sudo systemctl daemon-reload && echo OK"
|
||||||
|
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 启用定时器 ──")
|
||||||
|
out = run("sudo systemctl enable --now ykxmz-monitor.timer 2>&1 | tail -2; "
|
||||||
|
"sleep 2; systemctl is-active ykxmz-monitor.timer",
|
||||||
|
timeout=180)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 手动跑一次监测(验证它能工作)──")
|
||||||
|
out = run("bash %s/monitor.sh; echo '--- 状态 ---'; "
|
||||||
|
"cat %s/monitor_state.txt 2>/dev/null; "
|
||||||
|
"echo '--- 日志 ---'; tail -3 %s/monitor.log 2>/dev/null"
|
||||||
|
% (RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 6. 演练预案(网站正常时应该什么都不做)──")
|
||||||
|
out = run("bash %s/rescue-lightsail.sh '安装验证(网站正常)' 2>&1 | tail -6; "
|
||||||
|
"echo '--- 报告数(应该是 0)---'; "
|
||||||
|
"ls %s/rescue_reports 2>/dev/null | wc -l"
|
||||||
|
% (RELAY_DIR, RELAY_DIR), timeout=180)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 7. 总览 ──")
|
||||||
|
out = run(
|
||||||
|
"printf ' %-26s %s\\n' 'ykxmz-relay' \"$(systemctl is-active ykxmz-relay)\"; "
|
||||||
|
"printf ' %-26s %s\\n' 'ykxmz-monitor.timer' \"$(systemctl is-active ykxmz-monitor.timer)\"; "
|
||||||
|
"printf ' %-26s %s\\n' '本机 health' \"$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:%s/v1/health)\"; "
|
||||||
|
"printf ' %-26s %s\\n' '公网 health' \"$(curl -s -o /dev/null -w '%%{http_code}' %s/v1/health)\""
|
||||||
|
% (PORT, PUBLIC_URL), timeout=180)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 说明:预案里用 systemctl 重启服务,而 monitor.service 以 admin")
|
||||||
|
print(" 身份跑 —— admin 在 Lightsail 上有免密 sudo,但脚本里没加")
|
||||||
|
print(" sudo。如果实测发现重启失败,把它改成 sudo systemctl 即可。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把一个版本的代码和成品部署到 Lightsail。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python _build/deploy_lightsail_release.py 2.1.3
|
||||||
|
python _build/deploy_lightsail_release.py 2.1.3 --code-only
|
||||||
|
python _build/deploy_lightsail_release.py 2.1.3 --files-only
|
||||||
|
|
||||||
|
== 为什么写成文件 ==
|
||||||
|
|
||||||
|
这一轮我用 `python -c @"..."@` 传带 `$(...)` 的远端命令,
|
||||||
|
PowerShell 把 `$(systemctl ...)` 当成本机的子表达式执行了
|
||||||
|
(报 "无法将 systemctl 项识别为 cmdlet"),还吃掉引号导致
|
||||||
|
Python 语法错。凡是超过两行的远端命令,一律写成文件跑。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
PORT = "13245"
|
||||||
|
PUBLIC = "http://%s:%s" % (LS_HOST, PORT)
|
||||||
|
|
||||||
|
CODE = ["relay.py", "admin.py", "landing.py", "showcase.py"]
|
||||||
|
ARTIFACTS = ["ykxmz-setup-{v}.exe", "ykxmz-watch-{v}.apk",
|
||||||
|
"ykxmz-watch-{v}-说明.txt", "ykxmz-server-{v}.zip"]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = [a for a in sys.argv[1:] if a]
|
||||||
|
if not args:
|
||||||
|
print(__doc__)
|
||||||
|
return 1
|
||||||
|
version = args[0]
|
||||||
|
code_only = "--code-only" in args
|
||||||
|
files_only = "--files-only" in args
|
||||||
|
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 部署 %s 到 Lightsail" % version)
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
if not code_only:
|
||||||
|
print()
|
||||||
|
print(" ── 1. 传成品到 downloads/ ──")
|
||||||
|
run("mkdir -p %s/downloads" % RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
for tpl in ARTIFACTS:
|
||||||
|
name = tpl.format(v=version)
|
||||||
|
local = ROOT / "dist" / name
|
||||||
|
if not local.is_file():
|
||||||
|
print(" [跳过] %s 不存在" % name)
|
||||||
|
continue
|
||||||
|
upload(str(local), "%s/downloads/%s" % (RELAY_DIR, name))
|
||||||
|
|
||||||
|
if not files_only:
|
||||||
|
print()
|
||||||
|
print(" ── 2. 传服务端代码 ──")
|
||||||
|
for name in CODE:
|
||||||
|
local = ROOT / "relay" / name
|
||||||
|
if not local.is_file():
|
||||||
|
print(" [跳过] %s 不存在" % name)
|
||||||
|
continue
|
||||||
|
upload(str(local), "%s/%s" % (RELAY_DIR, name))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 语法检查 + 重启 ──")
|
||||||
|
out = run(
|
||||||
|
"cd %s && for f in relay.py admin.py landing.py showcase.py; do "
|
||||||
|
"python3 -c \"import ast,sys;ast.parse(open(sys.argv[1],"
|
||||||
|
"encoding='utf-8').read())\" $f || exit 1; done && echo PY_OK"
|
||||||
|
% RELAY_DIR, timeout=120, quiet=True)
|
||||||
|
print(" " + out[1].strip().splitlines()[-1])
|
||||||
|
|
||||||
|
out = run("sudo systemctl restart ykxmz-relay; sleep 4; "
|
||||||
|
"systemctl is-active ykxmz-relay", timeout=180, quiet=True)
|
||||||
|
print(" relay 状态: " + out[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 复核 ──")
|
||||||
|
script = (
|
||||||
|
'printf " %-18s %s\\n" 本机 "'
|
||||||
|
'$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://127.0.0.1:' + PORT + '/v1/health)"\n'
|
||||||
|
'printf " %-18s %s\\n" 公网 "'
|
||||||
|
'$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||||
|
+ PUBLIC + '/v1/health)"\n'
|
||||||
|
'printf " %-18s %s\\n" 公网首页 "'
|
||||||
|
'$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||||
|
+ PUBLIC + '/)"\n'
|
||||||
|
'printf " %-18s %s\\n" 公网延迟 "'
|
||||||
|
'$(curl -s -o /dev/null -w \'%{time_total}s\' --max-time 15 '
|
||||||
|
+ PUBLIC + '/v1/health)"\n'
|
||||||
|
'printf " %-18s %s\\n" 角标 "'
|
||||||
|
'$(curl -s --max-time 10 http://127.0.0.1:' + PORT
|
||||||
|
+ '/ | grep -o "最新版 [0-9.]*" | head -1)"\n'
|
||||||
|
'printf " %-18s %s\\n" 监听 "'
|
||||||
|
'$(ss -tln 2>/dev/null | grep ":' + PORT + ' " | wc -l) 个"\n'
|
||||||
|
'printf " %-18s %s\\n" 监测 "'
|
||||||
|
'fails=$(grep -m1 ^fails= ' + RELAY_DIR
|
||||||
|
+ '/monitor_state.txt 2>/dev/null | cut -d= -f2); '
|
||||||
|
'echo ${fails:-?} 次连续失败"\n'
|
||||||
|
'printf " %-18s %s\\n" 抢救报告 "'
|
||||||
|
'$(ls ' + RELAY_DIR + '/rescue_reports 2>/dev/null | wc -l) 份"\n'
|
||||||
|
)
|
||||||
|
out = run(script, timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 公网地址:%s" % PUBLIC)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把「网站监测 + 紧急预案 + 抢救报告分区」部署到中转服务器。
|
||||||
|
|
||||||
|
部署的东西:
|
||||||
|
relay.py / admin.py 后台多一个「抢救报告」分区 + /rescue/ 下载
|
||||||
|
monitor.sh / rescue.sh 监测单元 + 紧急预案
|
||||||
|
lanremote-monitor.service/.timer 每 60 秒拉一次 monitor.sh
|
||||||
|
lanremote-nettune.service 顺手修掉设备上那份过期的(见下)
|
||||||
|
|
||||||
|
== 顺手修的设备问题 ==
|
||||||
|
|
||||||
|
设备上那份 lanremote-nettune.service 是**很早以前的版本**,有两个毛病:
|
||||||
|
|
||||||
|
1. ExecStart 里写了 `iptables ... || iptables ...`
|
||||||
|
systemd 的 ExecStart **不支持 `||`** —— 它把 `||` 当成一个参数,
|
||||||
|
iptables 报 `Bad argument '||'`,服务直接 exit 2 失败。
|
||||||
|
|
||||||
|
2. 里面有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己
|
||||||
|
实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。
|
||||||
|
**两条 ExecStart 顺序是 net-tune.sh 先、1400 后**,所以最终是 1400 ——
|
||||||
|
也就是说这份过期配置一直在主动损害稳定性。
|
||||||
|
|
||||||
|
本地 relay/deploy.sh 生成的是对的(单入口 -> deploy-tune.sh)。
|
||||||
|
这里直接按那个正确形状重写设备上的 unit。
|
||||||
|
'''
|
||||||
|
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 upload, run # noqa: E402
|
||||||
|
|
||||||
|
RELAY_DIR = "/root/lanremote-relay"
|
||||||
|
UNIT_DIR = "/etc/systemd/system"
|
||||||
|
|
||||||
|
FILES = [
|
||||||
|
"relay.py", "admin.py", "landing.py", "showcase.py",
|
||||||
|
"monitor.sh", "rescue.sh", "net-tune.sh", "watchdog.sh",
|
||||||
|
"deploy-tune.sh",
|
||||||
|
]
|
||||||
|
|
||||||
|
NETTUNE_UNIT = '''[Unit]
|
||||||
|
Description=ykxmz 网络调优(MSS 钳制 + 路径 MTU 探测)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
Before=lanremote-relay.service lanremote-tunnel.service lanremote-tunnel-h2.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
# ⚠ 只跑 net-tune.sh 这一个入口 —— MSS / 接口 MTU / 路径探测的唯一出处。
|
||||||
|
#
|
||||||
|
# 设备上原来那份 unit 有两条毛病:
|
||||||
|
# * ExecStart 里写了 `iptables ... || iptables ...`
|
||||||
|
# systemd 的 ExecStart **不支持 `||`**,它把 `||` 当参数传给
|
||||||
|
# iptables,报 `Bad argument '||'`,服务直接 exit 2 失败。
|
||||||
|
# * 还有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己
|
||||||
|
# 实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。
|
||||||
|
# 两条 ExecStart 是 net-tune.sh 先、1400 后,所以最终停在 1400 ——
|
||||||
|
# 这份过期配置一直在主动损害稳定性。
|
||||||
|
#
|
||||||
|
# (仓库里的 deploy.sh 生成的是 `ExecStart=/bin/bash deploy-tune.sh`
|
||||||
|
# 那种形状,但设备上并没有 deploy-tune.sh,所以这里直接用
|
||||||
|
# net-tune.sh,和设备的实际情况对齐。
|
||||||
|
# deploy-tune.sh 也一并传上去了,以后想切回单入口随时可以。)
|
||||||
|
ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 部署 监测单元 + 紧急预案 + 抢救报告分区")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 上传文件 ──")
|
||||||
|
for name in FILES:
|
||||||
|
local = ROOT / "relay" / name
|
||||||
|
if not local.is_file():
|
||||||
|
print(" [跳过] %s 不存在" % name)
|
||||||
|
continue
|
||||||
|
upload(str(local), "%s/%s" % (RELAY_DIR, name))
|
||||||
|
print(" ✓ %s" % name)
|
||||||
|
|
||||||
|
for unit in ("lanremote-monitor.service", "lanremote-monitor.timer"):
|
||||||
|
local = ROOT / "relay" / unit
|
||||||
|
upload(str(local), "%s/%s" % (UNIT_DIR, unit))
|
||||||
|
print(" ✓ %s -> %s" % (unit, UNIT_DIR))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 修掉过期的 nettune 服务(MTU 打架 + ExecStart 里的 ||)──")
|
||||||
|
import tempfile
|
||||||
|
tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service"
|
||||||
|
tmp.write_text(NETTUNE_UNIT, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "%s/lanremote-nettune.service" % UNIT_DIR)
|
||||||
|
print(" ✓ 已替换")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 语法检查 ──")
|
||||||
|
out = run(
|
||||||
|
"cd %s && for f in relay.py admin.py landing.py showcase.py; do "
|
||||||
|
"python3 -c \"import ast,sys; ast.parse(open(sys.argv[1],encoding='utf-8').read())\" $f "
|
||||||
|
"|| exit 1; done && echo PY_OK; "
|
||||||
|
"bash -n monitor.sh && bash -n rescue.sh && echo SH_OK; "
|
||||||
|
"systemd-analyze verify %s/lanremote-monitor.service 2>&1 | head -3 || true"
|
||||||
|
% (RELAY_DIR, UNIT_DIR), timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 建目录 + 知识库 + 重载并启用 ──")
|
||||||
|
out = run(
|
||||||
|
"mkdir -p %s/rescue_reports && chmod +x %s/monitor.sh %s/rescue.sh && "
|
||||||
|
"[ -f %s/rescue_knowledge.md ] || printf '# 紧急预案知识库\\n\\n"
|
||||||
|
'每次自动抢救成功,这里会记一条"第几级奏效"。\\n'
|
||||||
|
'以后遇到同类症状,监测单元会优先试历史上奏效的那一级。\\n" > %s/rescue_knowledge.md; '
|
||||||
|
"systemctl daemon-reload && "
|
||||||
|
"systemctl restart lanremote-nettune.service; sleep 3; "
|
||||||
|
"systemctl enable --now lanremote-monitor.timer 2>&1 | tail -1; "
|
||||||
|
"sleep 2; systemctl is-active lanremote-monitor.timer"
|
||||||
|
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 复核 ──")
|
||||||
|
out = run(
|
||||||
|
"echo 'nettune: '$(systemctl is-active lanremote-nettune.service); "
|
||||||
|
"echo 'monitor.timer: '$(systemctl is-active lanremote-monitor.timer); "
|
||||||
|
"echo 'MTU: '$(cat /sys/class/net/wlan0/mtu); "
|
||||||
|
"echo 'MSS 规则: '$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss)' 条'; "
|
||||||
|
"echo '报告目录: '$(ls %s/rescue_reports 2>/dev/null | wc -l)' 份'; "
|
||||||
|
"echo '后台分区: '$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:8080/rescue/nonexistent.txt)"
|
||||||
|
% RELAY_DIR, timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 提示:监测单元默认每 60 秒查一次公网,连续 3 次不通才动手;")
|
||||||
|
print(" 抢救完 10 分钟内不再动手(防抖,避免来回重启)。")
|
||||||
|
print(" 日志在 %s/monitor.log 和 %s/rescue.log。" % (RELAY_DIR, RELAY_DIR))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把某个版本的成品和官网代码传到中转服务器。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python _build/deploy_release.py 1.9.0
|
||||||
|
python _build/deploy_release.py 1.9.0 --code-only # 只更新官网代码
|
||||||
|
python _build/deploy_release.py 1.9.0 --files-only # 只传成品文件
|
||||||
|
|
||||||
|
== 为什么写成脚本文件而不是 python -c ==
|
||||||
|
|
||||||
|
这一轮我在 PowerShell 里用 `python -c @"..."@` 写带引号和 $ 的
|
||||||
|
Python 代码,失败了四五次(PowerShell 会插值、会吃掉 $、嵌套引号
|
||||||
|
转义一层套一层)。凡是超过两行的 Python,一律写成 .py 文件再跑。
|
||||||
|
|
||||||
|
== 官网上"最新版"是怎么来的 ==
|
||||||
|
|
||||||
|
relay.py 的 _list_downloads() 按 **mtime 倒序** 返回文件,
|
||||||
|
landing.py 的 _version() 取**第一个**能匹配到 x.y.z 的文件名。
|
||||||
|
|
||||||
|
所以"上传哪个版本,官网就显示哪个版本" —— 不用改任何代码。
|
||||||
|
按顺序传 1.9.0、再传 2.0.0,官网就会先显示 1.9.0、再显示 2.0.0。
|
||||||
|
'''
|
||||||
|
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 upload, run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE_DIR = "/root/lanremote-relay"
|
||||||
|
DL_DIR = REMOTE_DIR + "/downloads"
|
||||||
|
|
||||||
|
# 要传的成品(和 dist/ 里的文件名一致)
|
||||||
|
ARTIFACTS = [
|
||||||
|
"ykxmz-setup-{v}.exe",
|
||||||
|
"ykxmz-watch-{v}.apk",
|
||||||
|
"ykxmz-watch-{v}-说明.txt",
|
||||||
|
"ykxmz-server-{v}.zip",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 官网/服务端代码
|
||||||
|
CODE = ["relay.py", "admin.py", "landing.py", "showcase.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = [a for a in sys.argv[1:] if a]
|
||||||
|
if not args:
|
||||||
|
print(__doc__)
|
||||||
|
return 1
|
||||||
|
version = args[0]
|
||||||
|
code_only = "--code-only" in args
|
||||||
|
files_only = "--files-only" in args
|
||||||
|
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 部署 %s 到中转服务器" % version)
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
# ---------------- 成品 ----------------
|
||||||
|
if not code_only:
|
||||||
|
print()
|
||||||
|
print(" ── 1. 上传成品到 downloads/ ──")
|
||||||
|
for tpl in ARTIFACTS:
|
||||||
|
name = tpl.format(v=version)
|
||||||
|
local = ROOT / "dist" / name
|
||||||
|
if not local.is_file():
|
||||||
|
print(" [跳过] %s 不存在" % name)
|
||||||
|
continue
|
||||||
|
upload(str(local), "%s/%s" % (DL_DIR, name))
|
||||||
|
print(" ✓ %-34s %10d 字节" % (name, local.stat().st_size))
|
||||||
|
|
||||||
|
# ---------------- 代码 ----------------
|
||||||
|
if not files_only:
|
||||||
|
print()
|
||||||
|
print(" ── 2. 更新官网/服务端代码 ──")
|
||||||
|
for name in CODE:
|
||||||
|
local = ROOT / "relay" / name
|
||||||
|
if not local.is_file():
|
||||||
|
print(" [跳过] %s 不存在" % name)
|
||||||
|
continue
|
||||||
|
upload(str(local), "%s/%s" % (REMOTE_DIR, name))
|
||||||
|
print(" ✓ %s" % name)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 语法检查 + 重启 ──")
|
||||||
|
out = run(
|
||||||
|
"cd %s && python3 - <<'PY'\n"
|
||||||
|
"import ast\n"
|
||||||
|
"for f in %r:\n"
|
||||||
|
" ast.parse(open(f, encoding='utf-8').read())\n"
|
||||||
|
"print('语法 OK')\n"
|
||||||
|
"PY" % (REMOTE_DIR, tuple(CODE)),
|
||||||
|
timeout=90)
|
||||||
|
print(" " + out[1].strip().splitlines()[-1])
|
||||||
|
|
||||||
|
out = run("systemctl restart lanremote-relay && sleep 4 && "
|
||||||
|
"systemctl is-active lanremote-relay && "
|
||||||
|
"grep -m1 '^VERSION' %s/relay.py" % REMOTE_DIR, timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
# ---------------- 复核 ----------------
|
||||||
|
print()
|
||||||
|
print(" ── 4. 复核:官网现在显示哪个版本 ──")
|
||||||
|
out = run(
|
||||||
|
"curl -s -H 'Cache-Control: no-cache' http://127.0.0.1:8080/v1/downloads",
|
||||||
|
timeout=60)
|
||||||
|
print(" " + out[1].strip()[:400])
|
||||||
|
|
||||||
|
out = run("curl -s -H 'Cache-Control: no-cache' http://127.0.0.1:8080/ "
|
||||||
|
"| grep -o '最新版 [0-9.]*' | head -1", timeout=60)
|
||||||
|
print(" 官网首页角标: " + out[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 提示:官网 / 走 max-age=60,公网可能需要等一分钟才刷新;")
|
||||||
|
print(" 下载链接带 604800 缓存,换版本号正好会换 URL。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''看当前桌面/窗口的真实状态 —— 诊断"桌面消失、一片蓝"。
|
||||||
|
|
||||||
|
用户报:"我现在桌面消失了,电脑一片蓝,然后还有个蓝色的窗口"。
|
||||||
|
|
||||||
|
已知:
|
||||||
|
18:42:41 机器异常关机(Kernel-Power 41 / EventLog 6008,无蓝屏记录)
|
||||||
|
19:58:23 重新启动
|
||||||
|
20:01:37 explorer 起来
|
||||||
|
自启项里没有 ykxmz,当前也没有 ykxmz 在跑
|
||||||
|
|
||||||
|
要查的是:现在屏幕上到底有什么、是不是有全屏窗口盖住了桌面。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import ctypes.wintypes as wt
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||||
|
|
||||||
|
EnumWindows = user32.EnumWindows
|
||||||
|
EnumWindows.argtypes = [ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM),
|
||||||
|
wt.LPARAM]
|
||||||
|
|
||||||
|
|
||||||
|
class RECT(ctypes.Structure):
|
||||||
|
_fields_ = [("left", wt.LONG), ("top", wt.LONG),
|
||||||
|
("right", wt.LONG), ("bottom", wt.LONG)]
|
||||||
|
|
||||||
|
|
||||||
|
def windows() -> list:
|
||||||
|
out = []
|
||||||
|
|
||||||
|
def cb(hwnd, _lp):
|
||||||
|
if not user32.IsWindowVisible(hwnd):
|
||||||
|
return True
|
||||||
|
title = ctypes.create_unicode_buffer(512)
|
||||||
|
user32.GetWindowTextW(hwnd, title, 512)
|
||||||
|
cls = ctypes.create_unicode_buffer(256)
|
||||||
|
user32.GetClassNameW(hwnd, cls, 256)
|
||||||
|
r = RECT()
|
||||||
|
user32.GetWindowRect(hwnd, ctypes.byref(r))
|
||||||
|
pid = wt.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
w, h = r.right - r.left, r.bottom - r.top
|
||||||
|
if w > 80 and h > 80:
|
||||||
|
out.append({"hwnd": hwnd, "title": title.value, "cls": cls.value,
|
||||||
|
"pid": pid.value, "w": w, "h": h,
|
||||||
|
"x": r.left, "y": r.top})
|
||||||
|
return True
|
||||||
|
|
||||||
|
EnumWindows(ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)(cb), 0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def proc_name(pid: int) -> str:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-Command",
|
||||||
|
"(Get-Process -Id %d -EA SilentlyContinue).ProcessName" % pid],
|
||||||
|
capture_output=True, text=True, timeout=8)
|
||||||
|
return (r.stdout or "").strip() or "?"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
sw = user32.GetSystemMetrics(0)
|
||||||
|
sh = user32.GetSystemMetrics(1)
|
||||||
|
print(" 屏幕: %d x %d" % (sw, sh))
|
||||||
|
print(" 当前输入桌面: %s" % _desktop_name())
|
||||||
|
print()
|
||||||
|
|
||||||
|
ws = windows()
|
||||||
|
ws.sort(key=lambda w: w["w"] * w["h"], reverse=True)
|
||||||
|
print(" 可见窗口 %d 个(按面积排序,前 20):" % len(ws))
|
||||||
|
for w in ws[:20]:
|
||||||
|
full = w["w"] >= sw - 4 and w["h"] >= sh - 4
|
||||||
|
pn = proc_name(w["pid"])
|
||||||
|
print(" %s %-14s pid=%-6d %dx%d @(%d,%d)"
|
||||||
|
% ("★全屏" if full else " ", pn, w["pid"],
|
||||||
|
w["w"], w["h"], w["x"], w["y"]))
|
||||||
|
print(" 类=%-22s 标题=%r" % (w["cls"][:22], w["title"][:60]))
|
||||||
|
print()
|
||||||
|
fulls = [w for w in ws if w["w"] >= sw - 4 and w["h"] >= sh - 4]
|
||||||
|
if fulls:
|
||||||
|
print(" ⚠ 有 %d 个全屏窗口 —— 下面这些就是盖住桌面的嫌疑:" % len(fulls))
|
||||||
|
for w in fulls:
|
||||||
|
print(" pid=%d (%s) 类=%s 标题=%r"
|
||||||
|
% (w["pid"], proc_name(w["pid"]), w["cls"], w["title"][:50]))
|
||||||
|
else:
|
||||||
|
print(" ✓ 没有全屏窗口盖着")
|
||||||
|
print()
|
||||||
|
ex = subprocess.run(["powershell", "-NoProfile", "-Command",
|
||||||
|
"Get-Process explorer -EA SilentlyContinue | "
|
||||||
|
"Select-Object -First 1 -ExpandProperty Id"],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
print(" explorer PID: %s" % ((ex.stdout or "").strip() or "**没在跑**"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _desktop_name() -> str:
|
||||||
|
"""当前输入桌面的名字。Default = 正常,Winlogon = 锁屏。"""
|
||||||
|
h = user32.OpenInputDesktop(0, False, 0x0001)
|
||||||
|
if not h:
|
||||||
|
return "(拿不到 —— 可能锁屏或没有交互桌面)"
|
||||||
|
try:
|
||||||
|
sb = ctypes.create_unicode_buffer(256)
|
||||||
|
need = wt.DWORD()
|
||||||
|
ok = user32.GetUserObjectInformationW(h, 2, sb, 512,
|
||||||
|
ctypes.byref(need))
|
||||||
|
return sb.value if ok else "(拿不到名字)"
|
||||||
|
finally:
|
||||||
|
user32.CloseDesktop(h)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
sys.exit(main())
|
||||||
@@ -96,7 +96,7 @@ PY
|
|||||||
echo
|
echo
|
||||||
echo " 收尾:清掉负载测试造的设备和会话"
|
echo " 收尾:清掉负载测试造的设备和会话"
|
||||||
curl -s -o /dev/null --max-time 8 -X POST "$BASE/v1/admin/forget" \
|
curl -s -o /dev/null --max-time 8 -X POST "$BASE/v1/admin/forget" \
|
||||||
-H 'X-Admin: 120830' -H 'Content-Type: application/json' \
|
-H 'X-Admin: <改从环境变量读>' -H 'Content-Type: application/json' \
|
||||||
-d '{"code":"112233"}'
|
-d '{"code":"112233"}'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+89
-27
@@ -4,38 +4,100 @@
|
|||||||
用法:
|
用法:
|
||||||
python _build/devssh.py "命令" # 跑一条命令
|
python _build/devssh.py "命令" # 跑一条命令
|
||||||
python _build/devssh.py --script 文件 # 跑一段脚本
|
python _build/devssh.py --script 文件 # 跑一段脚本
|
||||||
|
|
||||||
|
== 地址会自动试 ==
|
||||||
|
|
||||||
|
这台设备跟着教室网络走,地址会变:
|
||||||
|
192.168.2.156 <-> 192.168.10.105
|
||||||
|
所以这里**按顺序试多个地址**,谁先连通就用谁,不用改代码。
|
||||||
|
|
||||||
|
想强制指定就用环境变量:
|
||||||
|
$env:YKXMZ_DEV_HOST = "192.168.10.105"
|
||||||
|
|
||||||
|
⚠ 以前的坑:定义了 HOST_FALLBACKS 但**三个函数都直接用 HOST**,
|
||||||
|
那个常量根本没被用过。于是设备一换网段就全部连不上,
|
||||||
|
而且报的是「Connection refused / timed out」,看起来像设备挂了。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
|
|
||||||
# 随身 WiFi(Debian)的地址。
|
|
||||||
#
|
|
||||||
# 这台设备跟着教室网络走,地址会变:
|
|
||||||
# 192.168.2.156 -> 192.168.10.105(换网络了)
|
|
||||||
# 所以优先读环境变量 YKXMZ_DEV_HOST,没有才用下面的默认值。
|
|
||||||
# 换网络时不用改代码:
|
|
||||||
# $env:YKXMZ_DEV_HOST = "192.168.10.105"
|
|
||||||
import os
|
|
||||||
|
|
||||||
HOST = os.environ.get("YKXMZ_DEV_HOST", "192.168.10.105")
|
|
||||||
USER = "root"
|
USER = "root"
|
||||||
PASSWORD = os.environ.get("YKXMZ_DEV_PASS", "1313144")
|
PASSWORD = os.environ.get("YKXMZ_DEV_PASS", "1313144")
|
||||||
|
|
||||||
# 旧地址留一份,方便回退或同时探测
|
# 候选地址,按顺序试。
|
||||||
HOST_FALLBACKS = ("192.168.10.105", "192.168.2.156")
|
# 环境变量优先(想锁定时用),然后是最近见过的两个地址。
|
||||||
|
_CANDIDATES: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _build_candidates() -> list[str]:
|
||||||
|
out = []
|
||||||
|
env_host = (os.environ.get("YKXMZ_DEV_HOST") or "").strip()
|
||||||
|
if env_host:
|
||||||
|
out.append(env_host)
|
||||||
|
for host in ("192.168.2.156", # 当前地址
|
||||||
|
"192.168.10.105"): # 换网络前用过
|
||||||
|
if host not in out:
|
||||||
|
out.append(host)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
CANDIDATES = _build_candidates()
|
||||||
|
HOST = CANDIDATES[0] # 兼容老代码:默认取第一个
|
||||||
|
HOST_FALLBACKS = tuple(CANDIDATES[1:])
|
||||||
|
|
||||||
|
# 记住这次连通的是哪个,后续调用不用每次从头试
|
||||||
|
_CONNECTED: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(timeout: int = 15):
|
||||||
|
"""按候选顺序连,返回 (client, host)。全都不通才抛异常。"""
|
||||||
|
global _CONNECTED
|
||||||
|
order = list(CANDIDATES)
|
||||||
|
if _CONNECTED and _CONNECTED in order:
|
||||||
|
# 上次通的那个排最前,省一次失败尝试
|
||||||
|
order.remove(_CONNECTED)
|
||||||
|
order.insert(0, _CONNECTED)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
for host in order:
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(host, username=USER, password=PASSWORD,
|
||||||
|
timeout=timeout, banner_timeout=20,
|
||||||
|
auth_timeout=20, look_for_keys=False,
|
||||||
|
allow_agent=False)
|
||||||
|
if _CONNECTED != host:
|
||||||
|
print(f" [devssh] 连上 {host}")
|
||||||
|
_CONNECTED = host
|
||||||
|
return client, host
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"{host}: {type(exc).__name__} {exc}")
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
raise RuntimeError("所有候选地址都连不上:\n " + "\n ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def probe() -> str | None:
|
||||||
|
"""探一下现在哪个地址能用(不抛异常)。"""
|
||||||
|
try:
|
||||||
|
client, host = _connect(timeout=8)
|
||||||
|
client.close()
|
||||||
|
return host
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def run(command: str, timeout: int = 600, quiet: bool = False) -> tuple:
|
def run(command: str, timeout: int = 600, quiet: bool = False) -> tuple:
|
||||||
client = paramiko.SSHClient()
|
client, _host = _connect()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
client.connect(HOST, username=USER, password=PASSWORD, timeout=15,
|
|
||||||
banner_timeout=20, auth_timeout=20,
|
|
||||||
look_for_keys=False, allow_agent=False)
|
|
||||||
try:
|
try:
|
||||||
_stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
|
_stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
|
||||||
out = stdout.read().decode("utf-8", "replace")
|
out = stdout.read().decode("utf-8", "replace")
|
||||||
@@ -52,10 +114,7 @@ def run(command: str, timeout: int = 600, quiet: bool = False) -> tuple:
|
|||||||
|
|
||||||
|
|
||||||
def upload(local: str, remote: str, mode: int = 0o644) -> None:
|
def upload(local: str, remote: str, mode: int = 0o644) -> None:
|
||||||
client = paramiko.SSHClient()
|
client, _host = _connect()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
client.connect(HOST, username=USER, password=PASSWORD, timeout=15,
|
|
||||||
look_for_keys=False, allow_agent=False)
|
|
||||||
try:
|
try:
|
||||||
sftp = client.open_sftp()
|
sftp = client.open_sftp()
|
||||||
sftp.put(local, remote)
|
sftp.put(local, remote)
|
||||||
@@ -67,10 +126,7 @@ def upload(local: str, remote: str, mode: int = 0o644) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def download(remote: str, local: str) -> None:
|
def download(remote: str, local: str) -> None:
|
||||||
client = paramiko.SSHClient()
|
client, _host = _connect()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
client.connect(HOST, username=USER, password=PASSWORD, timeout=15,
|
|
||||||
look_for_keys=False, allow_agent=False)
|
|
||||||
try:
|
try:
|
||||||
sftp = client.open_sftp()
|
sftp = client.open_sftp()
|
||||||
sftp.get(remote, local)
|
sftp.get(remote, local)
|
||||||
@@ -83,13 +139,19 @@ def download(remote: str, local: str) -> None:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
|
print(" 候选地址: %s" % ", ".join(CANDIDATES))
|
||||||
|
print(" 现在能连的: %s" % (probe() or "(一个都不通)"))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if sys.argv[1] == "--script":
|
if sys.argv[1] == "--script":
|
||||||
run(Path(sys.argv[2]).read_text(encoding="utf-8"), timeout=600)
|
run(Path(sys.argv[2]).read_text(encoding="utf-8"), timeout=600)
|
||||||
|
elif sys.argv[1] == "--probe":
|
||||||
|
got = probe()
|
||||||
|
print(" 能连的地址: %s" % (got or "(一个都不通)"))
|
||||||
|
sys.exit(0 if got else 1)
|
||||||
else:
|
else:
|
||||||
args = sys.argv[1:]
|
args = sys.argv[1:]
|
||||||
timeout = 600
|
timeout = 600
|
||||||
if args and args[0] == '--timeout':
|
if args and args[0] == "--timeout":
|
||||||
timeout = int(args[1])
|
timeout = int(args[1])
|
||||||
args = args[2:]
|
args = args[2:]
|
||||||
run(" ".join(args), timeout=timeout)
|
sys.exit(run(" ".join(args), timeout=timeout)[0])
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 502 诊断 v2
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo " ══ 1. 三个服务 ══"
|
||||||
|
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2; do
|
||||||
|
printf " %-24s %s\n" "$s" "$(systemctl is-active $s 2>/dev/null)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ══ 2. 所有 cloudflared 进程(看 protocol / metrics 端口)══"
|
||||||
|
ps -eo pid,args 2>/dev/null | grep '[c]loudflared tunnel' | while read -r line; do
|
||||||
|
echo " $line" | cut -c1-160
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ══ 3. 两个 metrics 端口分别报几条连接 ══"
|
||||||
|
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 " ══ 4. 隧道 config.yml 的回源配置(关键:指向哪个端口)══"
|
||||||
|
cat /root/.cloudflared/config.yml 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ══ 5. relay 实际监听端口 ══"
|
||||||
|
ss -lntp 2>/dev/null | grep -E 'python|8080|8880' | sed 's/^/ /' || echo " (没看到)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ══ 6. 本机 8080 ══"
|
||||||
|
curl -s --max-time 8 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \
|
||||||
|
-H 'User-Agent: LanRemote/1.8.2' http://127.0.0.1:8080/v1/health
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " ══ 7. 公网(关键)══"
|
||||||
|
curl -s --max-time 25 -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 " ══ 8. cloudflared 日志最近 25 行 ══"
|
||||||
|
journalctl -u lanremote-tunnel -n 25 --no-pager 2>/dev/null | tail -25 | sed 's/^/ /'
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 诊断中转为什么 502 / 延时 1426ms
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo "════════ 1. 服务状态 ════════"
|
||||||
|
for s in lanremote-relay lanremote-tunnel lanremote-nettune lanremote-watchdog.timer; do
|
||||||
|
printf " %-26s %s\n" "$s" "$(systemctl is-active $s 2>&1)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. 隧道连接数 · 落点 ════════"
|
||||||
|
curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep -E '^cloudflared_tunnel_ha_connections|server_locations' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. 本机中转响应(排除公网因素)════════"
|
||||||
|
for i in 1 2 3; do
|
||||||
|
printf " %d: HTTP %s %sms\n" "$i" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:8080/v1/health)" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{time_total}' --max-time 8 http://127.0.0.1:8080/v1/health | awk '{printf "%d", $1*1000}')"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 公网延时拆解(关键)════════"
|
||||||
|
echo " a) 到 Cloudflare 边缘的裸 RTT:"
|
||||||
|
for i in 1 2 3; do
|
||||||
|
t=$(curl -s -o /dev/null -w '%{time_connect}' --max-time 15 https://relay.xxmxxs.top/v1/health 2>/dev/null)
|
||||||
|
echo " 连接耗时 $(echo "$t" | awk '{printf "%.0f", $1*1000}') ms"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " b) 完整请求耗时(含隧道回源):"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
out=$(curl -s -o /dev/null -w '%{http_code} %{time_connect} %{time_starttransfer} %{time_total}' \
|
||||||
|
--max-time 25 -H 'User-Agent: LanRemote/1.8.0' https://relay.xxmxxs.top/v1/health 2>/dev/null)
|
||||||
|
echo " 码/连接/首字节/总计: $(echo "$out" | awk '{printf "%s %.0fms %.0fms %.0fms", $1, $2*1000, $3*1000, $4*1000}')"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 本机到 Cloudflare 的网络质量 ════════"
|
||||||
|
ping -c 10 -W 2 1.1.1.1 2>/dev/null | tail -2 | sed 's/^/ /'
|
||||||
|
echo " ---"
|
||||||
|
ping -c 10 -W 2 104.21.54.45 2>/dev/null | tail -2 | sed 's/^/ /'
|
||||||
|
echo " ---"
|
||||||
|
ping -c 10 -W 2 198.41.200.43 2>/dev/null | tail -2 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 6. 隧道日志最近的重连/错误 ════════"
|
||||||
|
tail -400 /root/lanremote-relay/tunnel.log 2>/dev/null \
|
||||||
|
| grep -E 'Registered|Lost connection|Retrying|i/o timeout|context deadline' \
|
||||||
|
| tail -12 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 7. 隧道连接数变化趋势(连打 30 次)════════"
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
c=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \
|
||||||
|
| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
|
||||||
|
h=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \
|
||||||
|
-H 'User-Agent: LanRemote/1.8.0' https://relay.xxmxxs.top/v1/health)
|
||||||
|
printf " %2d: 连接 %-3s 公网 %s\n" "$i" "${c:-?}" "$h"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 8. watchdog 最近记录 ════════"
|
||||||
|
tail -10 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 9. 资源占用 ════════"
|
||||||
|
free -m | sed 's/^/ /'
|
||||||
|
echo " 负载: $(cat /proc/loadavg | cut -d' ' -f1-3)"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把管理页的内嵌 JS 抠出来,看第 46 行为什么语法错。
|
||||||
|
|
||||||
|
背景:_tests/test_admin_live.py 用 node --check 检查页面上的内嵌脚本,
|
||||||
|
报第 46 行 SyntaxError: Invalid or unexpected token。
|
||||||
|
但同一个页面上的管理接口(/v1/admin/allow_new、/v1/admin/set_code)
|
||||||
|
是能用的 —— 说明后端没问题,问题在浏览器端。
|
||||||
|
|
||||||
|
两种可能:
|
||||||
|
A. 真的有语法错 -> 用户点按钮没反应(严重)
|
||||||
|
B. 我抠脚本的方式不对(比如页面里字符串里含 </script>,
|
||||||
|
或者 JS 用到了 node 不认的语法)-> 假警报
|
||||||
|
|
||||||
|
这个脚本把可疑的那一行原样打出来,人肉判断是哪种。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "_build"))
|
||||||
|
from devssh import run as ssh_run # noqa: E402
|
||||||
|
|
||||||
|
BASE = "https://relay.xxmxxs.top"
|
||||||
|
UA = {"User-Agent": "LanRemote/1.8.2"}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
_c, out, _e = ssh_run(
|
||||||
|
"cat /root/lanremote-relay/data/admin_key.txt", timeout=60, quiet=True)
|
||||||
|
key = (out or "").strip().splitlines()[0]
|
||||||
|
|
||||||
|
req = urllib.request.Request(BASE + "/zhongzhuanzhan",
|
||||||
|
headers={**UA, "X-Admin": key})
|
||||||
|
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||||
|
html = resp.read().decode("utf-8", "replace")
|
||||||
|
|
||||||
|
scripts = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
|
||||||
|
inline = [s for s in scripts if s.strip()]
|
||||||
|
print(" 页面上有 %d 段内嵌脚本,长度: %s"
|
||||||
|
% (len(inline), [len(s) for s in inline]))
|
||||||
|
|
||||||
|
js = inline[0]
|
||||||
|
lines = js.splitlines()
|
||||||
|
print(" 第 1 段共 %d 行" % len(lines))
|
||||||
|
print()
|
||||||
|
print(" ════ 第 40~54 行原样 ════")
|
||||||
|
for i in range(39, min(54, len(lines))):
|
||||||
|
marker = " <<<< 报错行" if i == 45 else ""
|
||||||
|
print(" %3d| %s%s" % (i + 1, lines[i], marker))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 把这一段单独跑 node --check,确认能复现
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False,
|
||||||
|
encoding="utf-8") as fh:
|
||||||
|
fh.write(js)
|
||||||
|
path = fh.name
|
||||||
|
proc = subprocess.run(["node", "--check", path],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace")
|
||||||
|
print(" node --check 退出码: %d" % proc.returncode)
|
||||||
|
if proc.stderr:
|
||||||
|
print(" stderr:")
|
||||||
|
for line in proc.stderr.splitlines()[:12]:
|
||||||
|
print(" " + line)
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# 也检查每一行里有没有裸控制字符
|
||||||
|
print()
|
||||||
|
print(" ════ 有没有裸控制字符 ════")
|
||||||
|
bad = 0
|
||||||
|
for i, line in enumerate(lines, 1):
|
||||||
|
for ch in line:
|
||||||
|
if ord(ch) < 32 and ch not in "\t":
|
||||||
|
print(" 第 %d 行有控制字符 0x%02x" % (i, ord(ch)))
|
||||||
|
bad += 1
|
||||||
|
break
|
||||||
|
if not bad:
|
||||||
|
print(" ✓ 没有")
|
||||||
|
|
||||||
|
# 反引号 / 模板字面量 / 正则 里可能藏着截断问题
|
||||||
|
print()
|
||||||
|
print(" ════ 源 HTML 里 </script> 出现几次 ════")
|
||||||
|
print(" %d 次(正常应该等于 <script> 的个数)" % html.count("</script>"))
|
||||||
|
print(" <script 出现 %d 次" % html.lower().count("<script"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查后台「抢救报告」分区为什么不显示。
|
||||||
|
|
||||||
|
可能原因:
|
||||||
|
1. 后台页要登录,curl 直接拿是登录页(那 0 次是正常的,得带 cookie)
|
||||||
|
2. relay.py 没真正重新加载(旧进程还在)
|
||||||
|
3. 模板参数没传进去
|
||||||
|
|
||||||
|
一条条排除。
|
||||||
|
'''
|
||||||
|
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 "════ 1. 直接 curl 后台页,看返回的是什么 ════"
|
||||||
|
curl -s --max-time 8 -D- http://127.0.0.1:8080/zhongzhuanzhan -o /tmp/adm.html 2>&1 | head -8
|
||||||
|
echo " --- 页面大小: $(wc -c < /tmp/adm.html) 字节 ---"
|
||||||
|
echo " --- 前 3 行 ---"
|
||||||
|
head -3 /tmp/adm.html | sed 's/^/ /'
|
||||||
|
echo " --- 含关键词统计 ---"
|
||||||
|
for k in 登录 密码 password 抢救报告 下载区 设备; do
|
||||||
|
printf ' %-12s %s\n' "$k" "$(grep -c "$k" /tmp/adm.html 2>/dev/null)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. relay.py 里到底有没有 rescue_fn ════"
|
||||||
|
grep -n 'rescue_fn' $RELAY/relay.py | head -5 | sed 's/^/ /'
|
||||||
|
echo " admin.py 里 _rescue_rows:"
|
||||||
|
grep -n 'def _rescue_rows\|def render_admin' $RELAY/admin.py | head -4 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. 进程是什么时候起的(比文件新还是旧)════"
|
||||||
|
ps -o pid,lstart,cmd -p $(systemctl show -p MainPID --value lanremote-relay) 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " relay.py 修改时间: $(stat -c '%y' $RELAY/relay.py)"
|
||||||
|
echo " admin.py 修改时间: $(stat -c '%y' $RELAY/admin.py)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 无登录直接调 render_admin 看看(绕过 HTTP)════"
|
||||||
|
cd $RELAY && python3 -c "
|
||||||
|
import admin
|
||||||
|
html = admin.render_admin({'version':'2.0.0','uptimeSeconds':1,'devices':[],'allowNew':True},
|
||||||
|
'https://x','/zhongzhuanzhan',0, lambda: [],
|
||||||
|
rescue_fn=lambda: [{'name':'20261008-120000.txt','size':1234,
|
||||||
|
'mtime':1791400000,
|
||||||
|
'summary':'测试摘要'}] )
|
||||||
|
print(' 渲染长度:', len(html))
|
||||||
|
print(' 含抢救报告:', '抢救报告' in html)
|
||||||
|
print(' 含测试摘要:', '测试摘要' in html)
|
||||||
|
print(' 含下载链接:', '/rescue/20261008-120000.txt' in html)
|
||||||
|
" 2>&1 | sed 's/^/ /'
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
out = run(REMOTE, timeout=180)
|
||||||
|
text = out[1] if isinstance(out, tuple) else str(out)
|
||||||
|
print(text)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 查为什么首页还是 no-store,尽管代码里有 max-age=60
|
||||||
|
set -u
|
||||||
|
|
||||||
|
echo "════════ 1. 进程状态 ════════"
|
||||||
|
systemctl show lanremote-relay -p ActiveEnterTimestamp -p MainPID --value | sed 's/^/ /'
|
||||||
|
echo " 现在: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo " 进程命令行:"
|
||||||
|
ps -o pid,etime,cmd -p $(systemctl show lanremote-relay -p MainPID --value) 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 2. 本地首页响应头(全部)════════"
|
||||||
|
curl -sI --max-time 15 http://127.0.0.1:8080/ 2>/dev/null | tr -d '\r' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 3. 换个端口直接跑这份代码,排除服务读的是别的文件 ════════"
|
||||||
|
cd /root/lanremote-relay
|
||||||
|
RELAY_PORT=18099 RELAY_BIND=127.0.0.1 timeout 8 python3 relay.py > /tmp/fresh.log 2>&1 &
|
||||||
|
sleep 4
|
||||||
|
echo " 新进程的首页头:"
|
||||||
|
curl -sI --max-time 6 http://127.0.0.1:18099/ 2>/dev/null | tr -d '\r' | grep -iE '^cache-control|^HTTP' | sed 's/^/ /'
|
||||||
|
wait 2>/dev/null || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 4. 服务实际读的文件是哪个 ════════"
|
||||||
|
systemctl cat lanremote-relay 2>/dev/null | grep -E 'WorkingDirectory|ExecStart' | sed 's/^/ /'
|
||||||
|
echo " WorkingDirectory 下的 relay.py md5: $(md5sum /root/lanremote-relay/relay.py 2>/dev/null | awk '{print $1}')"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 5. 有没有 __pycache__ 里的旧字节码在捣乱 ════════"
|
||||||
|
ls -la /root/lanremote-relay/__pycache__/ 2>/dev/null | sed 's/^/ /' || echo " (没有 __pycache__)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════ 6. 源码里那段到底长什么样(带行号)════════"
|
||||||
|
grep -n -B3 -A6 'max-age=60' /root/lanremote-relay/relay.py | sed 's/^/ /'
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查 nettune 为什么卡在 activating(它挡着隧道,隧道一挂网站就 530)。
|
||||||
|
|
||||||
|
发现:
|
||||||
|
lanremote-nettune activating <- 卡住
|
||||||
|
lanremote-tunnel inactive <- 在等 nettune
|
||||||
|
lanremote-tunnel-h2 inactive
|
||||||
|
公网 530
|
||||||
|
|
||||||
|
nettune 的 unit 里有 Before=lanremote-tunnel.service,
|
||||||
|
所以它卡住 = 隧道永远起不来。
|
||||||
|
'''
|
||||||
|
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. nettune 的完整状态 ════"
|
||||||
|
systemctl status lanremote-nettune --no-pager -n 25 2>&1 | tail -25
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. 有没有卡住的子进程 ════"
|
||||||
|
systemctl show -p MainPID -p ControlPID -p ExecMainStatus -p StateChangeTimestamp lanremote-nettune 2>&1
|
||||||
|
echo " --- 所有相关进程 ---"
|
||||||
|
ps -eo pid,ppid,stat,etime,cmd 2>/dev/null | grep -E 'iptables|net-tune|deploy-tune|ip link' | grep -v grep
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. net-tune.sh 里会不会卡住的那步 ════"
|
||||||
|
echo " --- 脚本第 4 步(重启隧道)会 wait 吗 ---"
|
||||||
|
grep -n 'systemctl restart' /root/lanremote-relay/net-tune.sh
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 手动跑一次 net-tune.sh 看卡在哪(30 秒超时)════"
|
||||||
|
timeout 30 bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -15
|
||||||
|
echo " 退出码: $?"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 5. 卡住的时候隧道是什么状态 ════"
|
||||||
|
systemctl is-active lanremote-tunnel lanremote-tunnel-h2
|
||||||
|
journalctl -u lanremote-tunnel --no-pager -n 12 2>/dev/null | tail -12
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查:下午的中断 + 还有哪些没用的东西。
|
||||||
|
|
||||||
|
== 为什么要查"下午的中断" ==
|
||||||
|
|
||||||
|
用户:"下午网站有一段时间停了"。
|
||||||
|
|
||||||
|
已知的硬事实:
|
||||||
|
* relay 进程从 10/9 15:32 起没重启过
|
||||||
|
* nginx 从 10/8 14:57 起没重启过
|
||||||
|
* 系统从 10/8 13:26 起没重启过
|
||||||
|
* 今天 nginx 的 error.log **一条都没有**
|
||||||
|
|
||||||
|
所以**服务端本身没停**。那"停"是什么?几种可能:
|
||||||
|
|
||||||
|
A. **Cloudflare 侧的问题**(边缘节点、回源失败)——
|
||||||
|
服务端日志里看不到,得从监控的探测记录里找
|
||||||
|
B. **网络抖动**(Lightsail 出网、校园网)
|
||||||
|
C. **用户那边打不开**(DNS 缓存、本地网络)
|
||||||
|
|
||||||
|
监控每 60 秒探测一次公网 —— **它的记录是判断这个最直接的证据**。
|
||||||
|
先找到它写在哪。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
echo "==== 1. 监控脚本写在哪 ===="
|
||||||
|
sudo systemctl cat ykxmz-monitor.service 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " --- 脚本文件 ---"
|
||||||
|
for f in /usr/local/bin/ykxmz-monitor.sh /root/ykxmz-monitor.sh /opt/ykxmz/monitor.sh; do
|
||||||
|
[ -f "$f" ] && echo " 存在: $f" && sudo grep -nE "LOG|log|>>|fails|报告" "$f" | head -12 | sed 's/^/ /'
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 2. 找监控的日志/状态文件 ===="
|
||||||
|
sudo find /root /var/log /var/lib -maxdepth 3 -iname "*ykxmz*" -o -maxdepth 3 -iname "*monitor*" 2>/dev/null \
|
||||||
|
| head -20 | while read f; do
|
||||||
|
echo " $f ($(sudo stat -c '%y %s' "$f" 2>/dev/null | cut -c1-19))"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 3. 监控最近报了什么(含今天的失败)===="
|
||||||
|
for f in /root/ykxmz-monitor.log /var/log/ykxmz-monitor.log /root/ykxmz-monitor.state /root/.ykxmz_monitor; do
|
||||||
|
if sudo test -f "$f"; then
|
||||||
|
echo " --- $f 尾部 ---"
|
||||||
|
sudo tail -30 "$f" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 4. 今天的 journal 里有没有网络/系统层面异常 ===="
|
||||||
|
sudo journalctl --since "2026-10-10 12:00" --no-pager 2>/dev/null \
|
||||||
|
| grep -iE "link|network|dhcp|dns|timeout|unreachable|oom|out of memory|error" \
|
||||||
|
| grep -viE "audit|apparmor|snap" | tail -25 | sed 's/^/ /' || echo " (没有)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 5. Cloudflare 边缘有没有回源失败(relay 日志里)===="
|
||||||
|
sudo journalctl -u ykxmz-relay --since "2026-10-10 12:00" --no-pager 2>/dev/null \
|
||||||
|
| grep -iE "error|fail|refus|timeout|reset|502|504|broken" | tail -20 | sed 's/^/ /' || echo " (没有)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 6. relay 自己的访问日志(如果有)===="
|
||||||
|
sudo find /root /var/log -maxdepth 2 -iname "*relay*log*" 2>/dev/null | head -5 | while read f; do
|
||||||
|
echo " $f ($(sudo stat -c '%s' "$f") 字节)"
|
||||||
|
sudo tail -10 "$f" | sed 's/^/ /'
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 7. 还有哪些没用的东西 ===="
|
||||||
|
echo " --- 老版本残留的 systemd 单元 ---"
|
||||||
|
sudo ls -1 /etc/systemd/system/ 2>/dev/null | grep -iE "lanremote|ykxmz|cloudflare|tunnel" | sed 's/^/ /' || echo " (没有)"
|
||||||
|
echo " --- /root 下 ---"
|
||||||
|
sudo ls -la /root 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- 大文件(>50MB)---"
|
||||||
|
sudo find /root /var/log /tmp -maxdepth 2 -type f -size +50M 2>/dev/null | head -10 | while read f; do
|
||||||
|
echo " $(sudo du -h "$f" | cut -f1) $f"
|
||||||
|
done
|
||||||
|
echo " --- 无用的 docker 镜像 ---"
|
||||||
|
sudo docker images 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- 无用的 docker 容器 ---"
|
||||||
|
sudo docker ps -a --format ' {{.Names}} {{.Status}}' 2>/dev/null | sed 's/^/ /'
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 下午的中断 + 还有哪些没用的东西")
|
||||||
|
print("=" * 62)
|
||||||
|
print()
|
||||||
|
out = run(REMOTE, timeout=300, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''复刻 bench_lan_vs_relay 的中转那一半,抓被控端日志。
|
||||||
|
|
||||||
|
我自己那个 ins_relay_flow.py 是中转 4/4 通过的,
|
||||||
|
但 bench_lan_vs_relay.py 的中转那半报"连接阶段失败: timed out"。
|
||||||
|
差异一定在设置里,所以要逐项对齐 bench 的写法:
|
||||||
|
* RELAY_POLL=2(我那边是 5)
|
||||||
|
* 用 YKXMZ_DATA 指到临时目录 + --silent(我那边直接写 windows/data)
|
||||||
|
* 每条路起**一台全新的**被控端
|
||||||
|
* 先 attach 再 pair(bench 的 relay_connect 就是这么做的)
|
||||||
|
|
||||||
|
把每一步的耗时和被控端日志都打出来。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
WINDOWS = ROOT / "windows"
|
||||||
|
UA = {"User-Agent": "LanRemote/1.8.2"}
|
||||||
|
CODE = "975310"
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
s = socket.socket()
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
p = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def post(base, path, body=b"", headers=None, timeout=20.0):
|
||||||
|
h = dict(UA)
|
||||||
|
h.update(headers or {})
|
||||||
|
req = urllib.request.Request(base + path, data=body, headers=h,
|
||||||
|
method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.status, resp.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, exc.read()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return 0, str(exc).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def get(base, path, timeout=5.0):
|
||||||
|
req = urllib.request.Request(base + path, headers=UA)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.status, resp.read()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return 0, str(exc).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="benchdiag_"))
|
||||||
|
relay_port = free_port()
|
||||||
|
agent_port = free_port()
|
||||||
|
base = "http://127.0.0.1:%d" % relay_port
|
||||||
|
relay_proc = agent_proc = None
|
||||||
|
relay_log = agent_log = None
|
||||||
|
try:
|
||||||
|
print(" 中转端口 %d 被控端端口 %d" % (relay_port, agent_port))
|
||||||
|
|
||||||
|
# ---- 1. 本地中转(对齐 bench:RELAY_POLL=2)----
|
||||||
|
rdata = work / "r"
|
||||||
|
rdata.mkdir(parents=True, exist_ok=True)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update({"RELAY_PORT": str(relay_port), "RELAY_BIND": "127.0.0.1",
|
||||||
|
"RELAY_DATA": str(rdata), "RELAY_POLL": "2",
|
||||||
|
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"})
|
||||||
|
relay_log = open(work / "relay.log", "wb")
|
||||||
|
relay_proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-u", str(ROOT / "relay" / "relay.py")], env=env,
|
||||||
|
stdout=relay_log, stderr=subprocess.STDOUT)
|
||||||
|
for _ in range(60):
|
||||||
|
time.sleep(0.5)
|
||||||
|
if get(base, "/v1/health")[0] == 200:
|
||||||
|
break
|
||||||
|
print(" 中转就绪")
|
||||||
|
|
||||||
|
# ---- 2. 被控端(对齐 bench:YKXMZ_DATA + --silent + 独立锁端口)----
|
||||||
|
sys.path.insert(0, str(WINDOWS))
|
||||||
|
from lanremote.config import Store
|
||||||
|
adata = work / "a"
|
||||||
|
adata.mkdir(parents=True, exist_ok=True)
|
||||||
|
store = Store(adata)
|
||||||
|
store.set_pairing_code(CODE)
|
||||||
|
store.set("setup_done", True)
|
||||||
|
store.set("allow_new_pairing", True)
|
||||||
|
store.set("close_pair_after_first", False)
|
||||||
|
store.set("server_name", "基准测试电脑")
|
||||||
|
store.set("relay_url", base)
|
||||||
|
store.set("port", agent_port)
|
||||||
|
|
||||||
|
aenv = dict(os.environ)
|
||||||
|
aenv.update({"YKXMZ_DATA": str(adata),
|
||||||
|
"YKXMZ_LOCK_PORT": str(free_port()),
|
||||||
|
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"})
|
||||||
|
agent_log = open(work / "agent.log", "wb")
|
||||||
|
agent_proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-u", str(WINDOWS / "run_agent.py"),
|
||||||
|
"--silent", "--port", str(agent_port)],
|
||||||
|
cwd=str(WINDOWS), env=aenv,
|
||||||
|
stdout=agent_log, stderr=subprocess.STDOUT)
|
||||||
|
|
||||||
|
online = False
|
||||||
|
for _ in range(45):
|
||||||
|
time.sleep(1.0)
|
||||||
|
if agent_proc.poll() is not None:
|
||||||
|
print(" ✗ 被控端启动即退出,返回码 %s" % agent_proc.returncode)
|
||||||
|
break
|
||||||
|
st, body = get(base, "/v1/health", timeout=4)
|
||||||
|
if st == 200:
|
||||||
|
snap = json.loads(body.decode())
|
||||||
|
if any(d.get("online") for d in snap.get("devices", [])):
|
||||||
|
online = True
|
||||||
|
break
|
||||||
|
print(" 被控端登记: %s" % ("成功" if online else "**失败**"))
|
||||||
|
if not online:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# ---- 3. 完全照 bench 的 relay_connect 做 ----
|
||||||
|
print()
|
||||||
|
print(" ── 照 bench 的顺序:attach -> pair ──")
|
||||||
|
t0 = time.time()
|
||||||
|
st, body = post(base, "/v1/client/attach", json.dumps(
|
||||||
|
{"code": CODE, "name": "基准测试手表"}).encode(),
|
||||||
|
{"Content-Type": "application/json"})
|
||||||
|
att = json.loads(body.decode())
|
||||||
|
session = att.get("session", "")
|
||||||
|
print(" attach: %dms ok=%s session=%s"
|
||||||
|
% (int((time.time() - t0) * 1000), att.get("ok"),
|
||||||
|
session[:8] if session else "无"))
|
||||||
|
|
||||||
|
payload = (json.dumps({"cmd": "pair", "deviceId": "bench-relay",
|
||||||
|
"deviceName": "基准测试手表",
|
||||||
|
"code": CODE}, ensure_ascii=False)
|
||||||
|
+ "\n").encode()
|
||||||
|
t0 = time.time()
|
||||||
|
st, _ = post(base, "/v1/client/send", payload, {"X-Session": session})
|
||||||
|
print(" send pair: %dms status=%s" % (int((time.time() - t0) * 1000), st))
|
||||||
|
|
||||||
|
# 轮询等回复,每轮都打点
|
||||||
|
buf = b""
|
||||||
|
deadline = time.time() + 40
|
||||||
|
got = None
|
||||||
|
round_no = 0
|
||||||
|
while time.time() < deadline:
|
||||||
|
round_no += 1
|
||||||
|
remaining = deadline - time.time()
|
||||||
|
t0 = time.time()
|
||||||
|
st, chunk = post(base, "/v1/client/poll", b"",
|
||||||
|
{"X-Session": session}, min(10.0, remaining))
|
||||||
|
buf += chunk
|
||||||
|
print(" poll#%d: %dms status=%s 收到 %d 字节"
|
||||||
|
% (round_no, int((time.time() - t0) * 1000), st, len(chunk)))
|
||||||
|
while b"\n" in buf:
|
||||||
|
line, _, buf = buf.partition(b"\n")
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
msg = json.loads(line.decode())
|
||||||
|
if "event" in msg:
|
||||||
|
continue
|
||||||
|
got = msg
|
||||||
|
break
|
||||||
|
if got is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if got is None:
|
||||||
|
print(" ✗ 40 秒内没等到 pair 的回复")
|
||||||
|
else:
|
||||||
|
print(" ✓ pair 回复: %s" % json.dumps(got, ensure_ascii=False)[:120])
|
||||||
|
|
||||||
|
# ---- 4. 再来几次 ping ----
|
||||||
|
if got is not None:
|
||||||
|
for i in range(5):
|
||||||
|
payload = (json.dumps({"cmd": "ping"}) + "\n").encode()
|
||||||
|
post(base, "/v1/client/send", payload, {"X-Session": session})
|
||||||
|
deadline = time.time() + 15
|
||||||
|
ok = False
|
||||||
|
while time.time() < deadline:
|
||||||
|
st, chunk = post(base, "/v1/client/poll", b"",
|
||||||
|
{"X-Session": session}, 8.0)
|
||||||
|
buf += chunk
|
||||||
|
while b"\n" in buf:
|
||||||
|
line, _, buf = buf.partition(b"\n")
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
msg = json.loads(line.decode())
|
||||||
|
if "event" in msg:
|
||||||
|
continue
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
if ok:
|
||||||
|
break
|
||||||
|
print(" ping#%d: %s" % (i + 1, "OK" if ok else "**超时**"))
|
||||||
|
|
||||||
|
# ---- 5. 被控端日志 ----
|
||||||
|
print()
|
||||||
|
print(" ════ 被控端日志(最后 25 行)════")
|
||||||
|
agent_log.flush()
|
||||||
|
for line in (work / "agent.log").read_text(
|
||||||
|
encoding="utf-8", errors="replace").splitlines()[-25:]:
|
||||||
|
print(" " + line[:120])
|
||||||
|
print()
|
||||||
|
print(" ════ 进程还活着吗 ════")
|
||||||
|
print(" agent poll = %s" % agent_proc.poll())
|
||||||
|
print()
|
||||||
|
print(" ════ 中转日志(最后 10 行)════")
|
||||||
|
relay_log.flush()
|
||||||
|
for line in (work / "relay.log").read_text(
|
||||||
|
encoding="utf-8", errors="replace").splitlines()[-10:]:
|
||||||
|
print(" " + line[:120])
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
for p in (agent_proc, relay_proc):
|
||||||
|
if p is not None:
|
||||||
|
try:
|
||||||
|
p.terminate()
|
||||||
|
p.wait(timeout=6)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user