mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 17:40:43 +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 |
+8
-1
@@ -78,4 +78,11 @@ desktop.ini
|
|||||||
admin_password.txt
|
admin_password.txt
|
||||||
*admin_password*.txt
|
*admin_password*.txt
|
||||||
/_build/_hdr_data/
|
/_build/_hdr_data/
|
||||||
/_build/relay_info.json
|
/_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,9 +45,9 @@
|
|||||||
|
|
||||||
| 装在哪 | 是什么 | 文件 |
|
| 装在哪 | 是什么 | 文件 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-1.8.2.exe` |
|
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.2.0.exe` |
|
||||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.8.2.zip` |
|
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.2.0.zip` |
|
||||||
| 手表(安卓) | 控制端 | `ykxmz-watch-1.8.2.apk` |
|
| 手表(安卓) | 控制端 | `ykxmz-watch-2.2.0.apk` |
|
||||||
|
|
||||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@
|
|||||||
|
|
||||||
### 第一步:电脑上装被控端
|
### 第一步:电脑上装被控端
|
||||||
|
|
||||||
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
双击 `ykxmz-setup-2.2.0.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||||
|
|
||||||
第一次运行会弹出窗口,填两样:
|
第一次运行会弹出窗口,填两样:
|
||||||
|
|
||||||
@@ -95,12 +94,12 @@
|
|||||||
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
||||||
> 想彻底退出,窗口上点「退出」。
|
> 想彻底退出,窗口上点「退出」。
|
||||||
|
|
||||||
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
|
> 不想用安装包的话,`ykxmz-agent-2.2.0.zip` 是绿色版:
|
||||||
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||||
|
|
||||||
### 第二步:手表上装 App
|
### 第二步:手表上装 App
|
||||||
|
|
||||||
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
把 `ykxmz-watch-2.2.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||||
|
|
||||||
打开 App,首页有四个入口:
|
打开 App,首页有四个入口:
|
||||||
|
|
||||||
@@ -140,26 +139,34 @@
|
|||||||
|
|
||||||
这一页是给上课准备的。
|
这一页是给上课准备的。
|
||||||
|
|
||||||
|
> **先说清楚作用范围**:被控端装在**一台** Windows 电脑上
|
||||||
|
> —— 就是讲台那台,或者你自己的机器。
|
||||||
|
> **所有操作只影响这一台**,看不到也管不着其他电脑。
|
||||||
|
> 装的时候要输配对码,没配对码连不上。
|
||||||
|
|
||||||
| 按钮 | 干什么 |
|
| 按钮 | 干什么 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **课堂专注模式** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
|
| **课堂专注模式** | 在**本机 hosts** 里把常见游戏站点指到本地,本机打开是空白页 |
|
||||||
| **关闭游戏窗口** | 扫一遍常见游戏进程名,找到就结束掉 |
|
| **结束游戏进程** | 扫**本机**正在跑的常见游戏进程,结束掉 |
|
||||||
| **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 |
|
| **看有哪些限制** | 列出当前开了哪些,别自己忘了 |
|
||||||
| **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** |
|
| **一键恢复全部** | 把这节课改的全撤掉,**下课必点** |
|
||||||
|
|
||||||
屏蔽是改 hosts 文件,不是装驱动,所以:
|
怎么实现的、有什么边界,一并说清楚:
|
||||||
|
|
||||||
- 学生自己删掉 hosts 里那几行就绕过去了 —— 它挡的是"随手点开",不是防作弊
|
- 屏蔽是改**本机** `hosts` 文件,不装驱动、不装服务
|
||||||
- 换个游戏站就得重新加,这个列表是写死的常见站点
|
- **只挡"随手点开"**:懂行的人自己把 hosts 里那几行删了就过去了。
|
||||||
- 重启电脑有的会保留、有的不会,取决于你们学校的还原卡
|
它防的不是刻意行为,是减少无关干扰
|
||||||
|
- 站点列表是写死的常见站点,换一个站就得重新加
|
||||||
|
- **全部改动可逆**,点「一键恢复全部」或「下课清理」就还原
|
||||||
|
- 重启后保不保留,取决于你们学校有没有装还原卡
|
||||||
|
|
||||||
### 屏幕
|
### 屏幕
|
||||||
|
|
||||||
- **实时屏幕镜像** —— 手表上看教室里那台电脑在显示什么。默认 4 fps,在手表上太小看不清,主要是"看一眼有没有人乱动"
|
- **实时屏幕镜像** —— 手表上看**被控端那台电脑**在显示什么(低头确认课件放没放对,不用走回讲台)
|
||||||
- **一键截屏** —— 存到手表相册
|
- **一键截屏** —— 存到手表相册
|
||||||
|
|
||||||
> ⚠️ **屏幕只要被看,电脑右上角就有一条红色提示条,关不掉。**
|
> ⚠️ **屏幕只要被看,电脑右上角就有一条红色提示条,关不掉。**
|
||||||
> 这是刻意做的:教室里那台电脑是公家的,谁在看必须让人知道。
|
> 这是刻意做的:谁在看,电脑前面的人必须能立刻知道。
|
||||||
> 电脑前面的人随时可以点提示条上的「停止共享」把你掐断。
|
> 电脑前面的人随时可以点提示条上的「停止共享」把你掐断。
|
||||||
|
|
||||||
### 媒体 / 音频
|
### 媒体 / 音频
|
||||||
@@ -216,7 +223,7 @@
|
|||||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||||
|
|
||||||
把 `ykxmz-server-1.8.2.zip` 解压到那台机器上,然后一条命令:
|
把 `ykxmz-server-2.2.0.zip` 解压到那台机器上,然后一条命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash deploy.sh
|
bash deploy.sh
|
||||||
@@ -404,7 +411,7 @@ App 把错误翻译成人话了,看到哪句就照做:
|
|||||||
|
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
教室里那台电脑是公家的东西,所以这套工具的安全设计偏"让人知道"而不是"藏起来"。
|
被控端装的往往是公家的电脑,所以这套工具的安全设计偏"让人知道"而不是"低调运行"。
|
||||||
|
|
||||||
**每次连接都要配对。** 电脑上生成 6 位数字,只在窗口上显示,手表输对了才连得上。
|
**每次连接都要配对。** 电脑上生成 6 位数字,只在窗口上显示,手表输对了才连得上。
|
||||||
|
|
||||||
@@ -418,11 +425,14 @@ App 把错误翻译成人话了,看到哪句就照做:
|
|||||||
|
|
||||||
**不写注册表。** 数据全在程序目录下的 `data/` 里,拔了硬盘拷走就能换台机器跑。
|
**不写注册表。** 数据全在程序目录下的 `data/` 里,拔了硬盘拷走就能换台机器跑。
|
||||||
|
|
||||||
**不在后台偷偷上传。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站。
|
**只在你自己填的地址之间通信。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站,不经过任何第三方。
|
||||||
|
|
||||||
> **这个工具不藏自己。** 没有"任务管理器里看不见",没有"免杀对抗",没有伪装成系统进程。
|
**所有行为都在明面上。** 被控端在托盘有图标、窗口看得见、随时可以退出、
|
||||||
> 被控端全程可见、可随时断开、权限可随时关掉。
|
权限可以逐项关闭。它不做任何"让机器上其他程序看不见自己"的事 ——
|
||||||
> 因为管的是教室的电脑,藏起来只会让老师不放心。
|
因为管的是自己要用的一台电脑,能看见、能随时停,才让人放心。
|
||||||
|
|
||||||
|
**代码全公开。** 三端加起来不到两万行,都是纯标准库,
|
||||||
|
想看它到底干了什么直接读源码就行。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -446,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.8.2.apk
|
# 产物:dist\ykxmz-watch-2.2.0.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||||
|
|||||||
@@ -1,3 +1,355 @@
|
|||||||
|
## 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
|
## 1.9.0
|
||||||
|
|
||||||
- **被控端终于有设置窗口了**:装完之后双击 `ykxmz.exe` 就能打开,里面可以改
|
- **被控端终于有设置窗口了**:装完之后双击 `ykxmz.exe` 就能打开,里面可以改
|
||||||
|
|||||||
@@ -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,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,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,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,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,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())
|
||||||
@@ -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())
|
||||||
@@ -146,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,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,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())
|
||||||
@@ -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,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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,98 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''深挖三件事:
|
||||||
|
|
||||||
|
1. 那个 `listen 80 default_server; server_name _;` 是谁的配置
|
||||||
|
2. 下午到底是什么时候断的(监控 / journal 能不能看出)
|
||||||
|
3. 现在从公网看,服务是不是真的稳定
|
||||||
|
|
||||||
|
== 为什么盯着那个 default_server ==
|
||||||
|
|
||||||
|
nginx 里同一个端口**只能有一个 default_server**。如果有一个来路不明的
|
||||||
|
`server_name _; listen 80 default_server;`:
|
||||||
|
|
||||||
|
* 用 IP 直接访问(`http://<服务器IP>/`)会落到**它**上面,
|
||||||
|
而不是我们的站点 —— 这正好能解释"网站不稳定"的错觉
|
||||||
|
(有时对有时不对,取决于走的是域名还是 IP)
|
||||||
|
* 它也可能是 1Panel 之类装的,占了位置
|
||||||
|
|
||||||
|
== 为什么要查监控 ==
|
||||||
|
|
||||||
|
我们有个 `ykxmz-monitor.timer` 每 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. default_server 是谁配的 ===="
|
||||||
|
sudo grep -rln "default_server" /etc/nginx/ 2>/dev/null | while read f; do
|
||||||
|
echo " 文件: $f"
|
||||||
|
grep -n "default_server" "$f" | sed 's/^/ /'
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo " --- 那个文件完整内容(如果是 sites-enabled 里没链的,看 sites-available)---"
|
||||||
|
sudo ls -la /etc/nginx/sites-enabled/ /etc/nginx/conf.d/ 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " --- /etc/nginx/nginx.conf 里的 include ---"
|
||||||
|
sudo grep -nE "include|default_server" /etc/nginx/nginx.conf 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 2. 用 IP 直接访问会到哪 ===="
|
||||||
|
echo " http://127.0.0.1/ (不带 Host):"
|
||||||
|
curl -s -o /dev/null -w " -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/ || true
|
||||||
|
echo " http://127.0.0.1/ 带 Host: yk.xxmxxs.top:"
|
||||||
|
curl -s -o /dev/null -w " -> %{http_code} %{size_download} 字节\n" --max-time 10 -H "Host: yk.xxmxxs.top" http://127.0.0.1/ || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 3. 监控日志:下午有没有探测失败 ===="
|
||||||
|
echo " --- 今天的监控日志 ---"
|
||||||
|
sudo journalctl -u ykxmz-monitor --since "today" --no-pager 2>/dev/null | tail -40 | sed 's/^/ /' || echo " (没有日志)"
|
||||||
|
echo
|
||||||
|
echo " --- 报告文件 ---"
|
||||||
|
sudo ls -la /root/ykxmz-rescue* /var/log/ykxmz* 2>/dev/null | sed 's/^/ /' || echo " (没有报告文件)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 4. 系统有没有重启 / 服务有没有停过 ===="
|
||||||
|
echo " --- 本次开机时间 ---"
|
||||||
|
uptime -s | sed 's/^/ /'
|
||||||
|
echo " --- nginx 进程启动时间 ---"
|
||||||
|
ps -o pid,lstart,etimes -p $(pgrep -f "nginx: master" | head -1) 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- relay 进程启动时间 ---"
|
||||||
|
ps -o pid,lstart,etimes -p 114212 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- journal 里 systemd 记录的重启 ---"
|
||||||
|
sudo journalctl --since "today" --no-pager 2>/dev/null | grep -iE "Started|Stopped|Failed|killed|OOM" \
|
||||||
|
| grep -iE "nginx|ykxmz" | tail -15 | sed 's/^/ /' || echo " (没有)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 5. 磁盘 / inode ===="
|
||||||
|
df -h / | sed 's/^/ /'
|
||||||
|
df -i / | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 6. 公网自测(从服务器上打自己)===="
|
||||||
|
for u in https://yk.xxmxxs.top/ https://yk.xxmxxs.top/v1/health http://<服务器IP>/; do
|
||||||
|
printf ' %-42s ' "$u"
|
||||||
|
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" --max-time 20 "$u" || echo "失败"
|
||||||
|
done
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 深挖:default_server / 下午的中断 / 稳定性")
|
||||||
|
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,102 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查服务器的 DNS 为什么解析不了 yk.xxmxxs.top。
|
||||||
|
|
||||||
|
现象:服务器上 curl 报 "Could not resolve host",但
|
||||||
|
加 --resolve 手动指定 Cloudflare 的边缘 IP 就 200。
|
||||||
|
也就是**网络通、只有解析不行**。
|
||||||
|
|
||||||
|
监测单元查的就是这个域名,所以它必然一直失败
|
||||||
|
-> 启动紧急预案 -> 瞎折腾。
|
||||||
|
|
||||||
|
要区分是哪种情况:
|
||||||
|
A. 服务器的 DNS 服务器本身坏了 / 不响应
|
||||||
|
B. DNS 服务器能通,但**不知道这个域名**(还没传播到这台机器用的解析器)
|
||||||
|
C. systemd-resolved 的缓存或 stub 出问题
|
||||||
|
D. /etc/hosts 或 nsswitch 配置把解析绕过去了
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
D=__DOMAIN__
|
||||||
|
|
||||||
|
echo "════ 1. /etc/resolv.conf ════"
|
||||||
|
cat /etc/resolv.conf 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. 有没有 systemd-resolved ════"
|
||||||
|
systemctl is-active systemd-resolved 2>&1 | sed 's/^/ systemd-resolved: /'
|
||||||
|
resolvectl status 2>/dev/null | grep -E 'DNS Servers|DNS Domain|Current DNS|Fallback' | head -8 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. 拿几个公共 DNS 直接问(绕过本机配置)════"
|
||||||
|
for ns in 1.1.1.1 8.8.8.8 9.9.9.9; do
|
||||||
|
printf ' %-10s ' "$ns"
|
||||||
|
if command -v dig >/dev/null 2>&1; then
|
||||||
|
r=$(dig +short +time=4 +tries=1 @$ns A $D 2>/dev/null | head -3 | tr '\n' ' ')
|
||||||
|
echo "${r:-(没答)}"
|
||||||
|
else
|
||||||
|
echo "(没装 dig)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 本机默认解析 ════"
|
||||||
|
printf ' %-26s ' "getent hosts"; getent hosts $D 2>/dev/null || echo "(空)"
|
||||||
|
printf ' %-26s ' "解析一个已知域名"; getent hosts www.cloudflare.com 2>/dev/null | head -1 || echo "(也失败!说明 DNS 整体有问题)"
|
||||||
|
printf ' %-26s ' "解析亚马逊自家域名"; getent hosts aws.amazon.com 2>/dev/null | head -1 || echo "(失败)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 5. nsswitch / hosts ════"
|
||||||
|
grep -vE '^\s*#|^\s*$' /etc/nsswitch.conf 2>/dev/null | grep '^hosts' | sed 's/^/ /'
|
||||||
|
echo " --- /etc/hosts 里有没有 yk.xxmxxs.top ---"
|
||||||
|
grep -i "$D" /etc/hosts 2>/dev/null | sed 's/^/ /' || echo " (没有)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 6. 用 python 解析(走的是同一套 nsswitch)════"
|
||||||
|
python3 -c "
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
print(' ', socket.getaddrinfo('$D', 443)[:2])
|
||||||
|
except Exception as e:
|
||||||
|
print(' 失败:', e)
|
||||||
|
" 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 7. 装个 dig(如果没装)════"
|
||||||
|
command -v dig >/dev/null 2>&1 || {
|
||||||
|
echo " 装 dnsutils…"
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq dnsutils 2>&1 | tail -1
|
||||||
|
}
|
||||||
|
echo " dig: $(command -v dig || echo 没装)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 8. 再问一次(装完 dig 之后)════"
|
||||||
|
for ns in 1.1.1.1 8.8.8.8; do
|
||||||
|
printf ' %-10s ' "$ns"
|
||||||
|
dig +short +time=5 +tries=2 @$ns A $D 2>/dev/null | head -3 | tr '\n' ' ' | sed 's/$/\n/'
|
||||||
|
done
|
||||||
|
echo " 默认解析:"
|
||||||
|
dig +short +time=5 A $D 2>/dev/null | head -3 | sed 's/^/ /' || echo " (空)"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 查服务器的 DNS")
|
||||||
|
print("=" * 62)
|
||||||
|
out = run(REMOTE.replace("__DOMAIN__", DOMAIN), timeout=600, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查服务器的几个问题:
|
||||||
|
|
||||||
|
1. 下午网站为什么停了一段时间
|
||||||
|
2. 中转站里为什么还有"隧道"
|
||||||
|
3. 9100 端口上是什么
|
||||||
|
4. 现在到底有哪些服务在跑、有没有没用的
|
||||||
|
|
||||||
|
== 背景 ==
|
||||||
|
|
||||||
|
用户反馈:
|
||||||
|
* "下午网站有一段时间停了"
|
||||||
|
* "为什么中转站里还有隧道,所有没的东西就删了"
|
||||||
|
* "网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100,中转站也一样"
|
||||||
|
* "我感觉网站还是不稳定"
|
||||||
|
|
||||||
|
"隧道"最可能是**早年那套 Cloudflare Tunnel(cloudflared)**的残留 ——
|
||||||
|
那时候用 `relay.xxmxxs.top` 走隧道,后来换成 nginx + Let's Encrypt
|
||||||
|
直连(`yk.xxmxxs.top`),隧道应该已经没用了。
|
||||||
|
|
||||||
|
如果 cloudflared 还在跑,它可能:
|
||||||
|
* 占着资源
|
||||||
|
* 隔一段时间重连,把日志刷满
|
||||||
|
* **跟 nginx 抢同一个域名的流量** —— 这正是"不稳定"的常见原因
|
||||||
|
'''
|
||||||
|
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. 所有跟本项目相关的 systemd 单元 ===="
|
||||||
|
systemctl list-units --all --no-pager --plain 2>/dev/null \
|
||||||
|
| grep -iE 'ykxmz|lanremote|cloudflare|cloudflared|tunnel|relay' \
|
||||||
|
| sed 's/^/ /' || echo " (没有)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 2. 隧道进程 / 容器 ===="
|
||||||
|
ps -eo pid,etimes,comm,args 2>/dev/null | grep -iE 'cloudflared|frpc|ngrok|tunnel' \
|
||||||
|
| grep -v grep | sed 's/^/ /' || echo " (没有隧道进程)"
|
||||||
|
echo " --- docker ---"
|
||||||
|
sudo docker ps -a --format ' {{.Names}} {{.Image}} {{.Status}} {{.Ports}}' 2>/dev/null || echo " (docker 不可用)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 3. 监听端口 ===="
|
||||||
|
sudo ss -tlnp 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 4. 9100 端口是什么 ===="
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep 9100 | sed 's/^/ /' || echo " 9100 上没有东西"
|
||||||
|
echo " 9100 本地能不能连:"
|
||||||
|
curl -s -o /dev/null -w " http://127.0.0.1:9100/ -> %{http_code}\n" --max-time 8 http://127.0.0.1:9100/ 2>&1 || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 5. nginx 站点 ===="
|
||||||
|
sudo ls -1 /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- server_name 和 listen ---"
|
||||||
|
sudo grep -hE 'server_name|listen' /etc/nginx/sites-available/* 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 6. 服务状态 ===="
|
||||||
|
for s in nginx ykxmz-relay ykxmz-monitor.timer certbot.timer lanremote-tunnel lanremote-relay cloudflared; do
|
||||||
|
st=$(systemctl is-active $s 2>/dev/null)
|
||||||
|
[ -n "$st" ] && printf ' %-26s %s\n' "$s" "$st"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 7. 下午有没有异常(journal 里 nginx / relay 的错误)===="
|
||||||
|
echo " --- nginx 今天的 error.log 尾部 ---"
|
||||||
|
sudo tail -25 /var/log/nginx/error.log 2>/dev/null | sed 's/^/ /' || echo " (读不到)"
|
||||||
|
echo " --- ykxmz-relay 最近的重启 ---"
|
||||||
|
sudo journalctl -u ykxmz-relay --since "today" --no-pager 2>/dev/null \
|
||||||
|
| grep -iE 'start|stop|fail|error|restart' | tail -12 | sed 's/^/ /' || echo " (读不到)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 8. 内存 / 负载 ===="
|
||||||
|
free -m | sed 's/^/ /'
|
||||||
|
uptime | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 9. 有没有没用的旧目录 ===="
|
||||||
|
for d in /root/lanremote-relay /root/lanremote /opt/lanremote /root/.cloudflared /etc/cloudflared; do
|
||||||
|
[ -e "$d" ] && echo " 存在: $d ($(sudo du -sh $d 2>/dev/null | cut -f1))"
|
||||||
|
done
|
||||||
|
echo " --- /root 下的目录 ---"
|
||||||
|
sudo ls -1 /root 2>/dev/null | head -20 | sed 's/^/ /'
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 服务器诊断:隧道 / 9100 / 下午的中断")
|
||||||
|
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,103 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查服务器为什么访问不了自己的域名(curl 返回 000)。
|
||||||
|
|
||||||
|
== 现象 ==
|
||||||
|
|
||||||
|
从我的电脑 -> https://yk.xxmxxs.top = 200 ✅
|
||||||
|
从服务器自己-> https://yk.xxmxxs.top = 000 ❌
|
||||||
|
|
||||||
|
监测单元就跑在服务器上、查的就是这个域名,所以它一直判失败
|
||||||
|
-> fails 一直涨 -> 会启动紧急预案 -> 瞎折腾。
|
||||||
|
|
||||||
|
== 最可能的原因:IPv6 ==
|
||||||
|
|
||||||
|
yk.xxmxxs.top 在 Cloudflare 上有 **AAAA 记录**:
|
||||||
|
2606:4700:3036::6815:362d
|
||||||
|
2606:4700:3036::ac43:df96
|
||||||
|
|
||||||
|
Lightsail 的实例默认**没有 IPv6 地址**。curl 会先试 IPv6、失败,
|
||||||
|
如果不自动回退到 IPv4,就直接报连接失败(=000)。
|
||||||
|
|
||||||
|
验证办法很简单:`curl -4`(强制 IPv4)通不通。
|
||||||
|
|
||||||
|
== 其他可能 ==
|
||||||
|
|
||||||
|
* DNS 在服务器上解析不出来(getent hosts 看看)
|
||||||
|
* Cloudflare 拒绝来自源站自己 IP 的请求(回环防护)——不太可能
|
||||||
|
* 出站被挡(Lightsail 默认出站全开,概率低)
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
D=__DOMAIN__
|
||||||
|
|
||||||
|
echo "════ 1. 服务器有没有 IPv6 ════"
|
||||||
|
ip -brief addr show 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- IPv6 默认路由 ---"
|
||||||
|
ip -6 route show default 2>/dev/null | sed 's/^/ /' || echo " (没有 IPv6 默认路由)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. 域名解析(IPv4 / IPv6 分开看)════"
|
||||||
|
echo " A 记录:"
|
||||||
|
getent ahostsv4 $D 2>/dev/null | awk '{print " " $1}' | sort -u
|
||||||
|
echo " AAAA 记录:"
|
||||||
|
getent ahostsv6 $D 2>/dev/null | awk '{print " " $1}' | sort -u
|
||||||
|
echo " --- dig 结果 ---"
|
||||||
|
dig +short A $D 2>/dev/null | sed 's/^/ A: /'
|
||||||
|
dig +short AAAA $D 2>/dev/null | sed 's/^/ AAAA: /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. curl 各种姿势 ════"
|
||||||
|
printf ' %-30s %s\n' "默认(可能走 IPv6)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://$D/v1/health)"
|
||||||
|
printf ' %-30s %s\n' "强制 IPv4 (-4)" "$(curl -s4 -o /dev/null -w '%{http_code}' --max-time 15 https://$D/v1/health)"
|
||||||
|
printf ' %-30s %s\n' "强制 IPv6 (-6)" "$(curl -s6 -o /dev/null -w '%{http_code}' --max-time 10 https://$D/v1/health)"
|
||||||
|
printf ' %-30s %s\n' "http 强制 IPv4" "$(curl -s4L -o /dev/null -w '%{http_code}' --max-time 15 http://$D/v1/health)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " --- 详细报错(默认那条)---"
|
||||||
|
curl -s -o /dev/null -w ' %{http_code}\n' --max-time 10 https://$D/v1/health 2>&1 || true
|
||||||
|
curl -v -o /dev/null --max-time 12 https://$D/v1/health 2>&1 | grep -iE 'trying|connected|refused|timed|error|Could not' | head -10 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 直连 Cloudflare 边缘 IP(跳过 DNS)════"
|
||||||
|
for ip in 104.21.54.45 172.67.223.150; do
|
||||||
|
printf ' %-20s %s\n' "$ip (HTTPS)" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 12 --resolve $D:443:$ip https://$D/v1/health)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 5. 出站到 Cloudflare 通不通(纯 TCP)════"
|
||||||
|
for ip in 104.21.54.45 172.67.223.150; do
|
||||||
|
if timeout 6 bash -c "</dev/tcp/$ip/443" 2>/dev/null; then
|
||||||
|
echo " $ip:443 通"
|
||||||
|
else
|
||||||
|
echo " $ip:443 不通"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 6. 对比:直连自己的 nginx ════"
|
||||||
|
printf ' %-30s %s\n' "127.0.0.1:443(带 SNI)" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 --resolve $D:443:127.0.0.1 https://$D/v1/health)"
|
||||||
|
printf ' %-30s %s\n' "公网 IP:443" "$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 --resolve $D:443:LS_HOST https://$D/v1/health)"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 查服务器为什么访问不了自己的域名")
|
||||||
|
print("=" * 62)
|
||||||
|
out = run(REMOTE.replace("__DOMAIN__", DOMAIN), timeout=300, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''诊断:这台机器上哪几种语音播报方案能用。
|
||||||
|
|
||||||
|
== 为什么写这个 ==
|
||||||
|
|
||||||
|
用户反馈:**Win7 上语音播报不能用**。
|
||||||
|
|
||||||
|
现在的实现只有一条路:
|
||||||
|
powershell -NoProfile -WindowStyle Hidden -Command
|
||||||
|
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
|
||||||
|
|
||||||
|
这在 Win7 上有几个可能致命的点:
|
||||||
|
|
||||||
|
1. **`powershell` 可能不在 PATH 里**
|
||||||
|
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
|
||||||
|
正常装的机器 PATH 里有,但被精简过、或者从服务/计划任务里拉起时
|
||||||
|
可能没有 —— 而**我们是用 CreateProcess 按名字找的**,找不到就静默失败。
|
||||||
|
|
||||||
|
2. **Win7 默认是 PowerShell 2.0,它用 ANSI 代码页解析 `-Command`**
|
||||||
|
PS 3.0+ 才改成按 UTF-16 处理命令行。所以中文经 `-Command` 传进去
|
||||||
|
在 PS 2.0 上会乱码 —— 表现是"念出来是乱码"或"什么都不念"。
|
||||||
|
|
||||||
|
3. **`System.Speech` 需要 .NET Framework 3.0+**
|
||||||
|
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
|
||||||
|
根本不需要 .NET。绕开 .NET 更稳。
|
||||||
|
|
||||||
|
== 所以这里试 5 条路 ==
|
||||||
|
|
||||||
|
A. powershell(按 PATH 找)+ System.Speech <- 现在的实现
|
||||||
|
B. powershell(按**绝对路径**找)+ System.Speech <- 修"不在 PATH"
|
||||||
|
C. powershell(绝对路径)+ SAPI.SpVoice COM <- 绕开 .NET
|
||||||
|
D. **cscript + VBScript + SAPI.SpVoice** <- 完全不依赖 PowerShell
|
||||||
|
E. wscript + VBScript + SAPI.SpVoice <- 同上,另一个宿主
|
||||||
|
|
||||||
|
D/E 最值得期待:**VBScript 从 Windows 98 就有,SAPI5 是系统组件**,
|
||||||
|
两个都不需要装任何东西,也不受 PowerShell 版本影响。
|
||||||
|
|
||||||
|
== 怎么跑 ==
|
||||||
|
|
||||||
|
python _build\diag_tts.py
|
||||||
|
|
||||||
|
它会一条条试并打印结果。**在有问题的机器上跑最有价值** ——
|
||||||
|
比如那台 Win7。把输出发回来就知道该换哪条路。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
IS_WINDOWS = os.name == "nt"
|
||||||
|
|
||||||
|
# 让它念一句中文。中文最能暴露编码问题 ——
|
||||||
|
# 纯英文在 PS 2.0 上也可能碰巧是对的(ASCII 不受代码页影响)。
|
||||||
|
TEXT = "语音播报测试,一二三"
|
||||||
|
|
||||||
|
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
|
||||||
|
FOUND: list = []
|
||||||
|
FAILED: list = []
|
||||||
|
|
||||||
|
|
||||||
|
def report(name: str, ok: bool, detail: str = "") -> None:
|
||||||
|
mark = "可用 " if ok else "不可用"
|
||||||
|
print(" [%s] %s%s" % (mark, name, (" —— " + detail) if detail else ""))
|
||||||
|
(FOUND if ok else FAILED).append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list, timeout: int = 30) -> tuple:
|
||||||
|
"""跑一条命令,返回 (退出码, 输出)。不让它弹窗。"""
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||||
|
creationflags=NO_WINDOW,
|
||||||
|
encoding="mbcs", errors="replace")
|
||||||
|
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||||
|
return p.returncode, out
|
||||||
|
except FileNotFoundError:
|
||||||
|
return -1, "文件找不到"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return -2, "超时"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return -3, "%s: %s" % (type(exc).__name__, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def find_powershell() -> str:
|
||||||
|
"""先按 PATH 找,再按绝对路径找。"""
|
||||||
|
p = shutil.which("powershell")
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
cand = Path(os.environ.get("SystemRoot", r"C:\Windows")) / \
|
||||||
|
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||||
|
return str(cand) if cand.is_file() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 语音播报方案诊断")
|
||||||
|
print("=" * 62)
|
||||||
|
print()
|
||||||
|
if IS_WINDOWS:
|
||||||
|
try:
|
||||||
|
wv = sys.getwindowsversion()
|
||||||
|
print(" 系统: Windows %d.%d (build %d)"
|
||||||
|
% (wv.major, wv.minor, wv.build))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
print(" 系统: Windows(版本号取不到)")
|
||||||
|
else:
|
||||||
|
print(" 系统: 非 Windows")
|
||||||
|
print(" Python: %s (%d 位)" % (sys.version.split()[0],
|
||||||
|
64 if sys.maxsize > 2 ** 32 else 32))
|
||||||
|
print(" 测试文本: %r" % TEXT)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
print(" 非 Windows,没有 TTS 方案可试。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ---------------- 0. 环境事实 ----------------
|
||||||
|
print("── 0. 环境 ──")
|
||||||
|
ps_path = find_powershell()
|
||||||
|
ps_in_path = shutil.which("powershell")
|
||||||
|
print(" powershell 在 PATH 里: %s" % (ps_in_path or "**没有**"))
|
||||||
|
print(" powershell 绝对路径: %s" % (ps_path or "**找不到**"))
|
||||||
|
if ps_path:
|
||||||
|
code, out = run([ps_path, "-NoProfile", "-Command",
|
||||||
|
"$PSVersionTable.PSVersion.Major"])
|
||||||
|
print(" PowerShell 主版本: %s" % (out.strip() or "查不到"))
|
||||||
|
print(" cscript: %s" % (shutil.which("cscript") or "**没有**"))
|
||||||
|
print(" wscript: %s" % (shutil.which("wscript") or "**没有**"))
|
||||||
|
print(" SystemRoot: %s" % os.environ.get("SystemRoot", "?"))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ---------------- A. 现有实现 ----------------
|
||||||
|
print("── A. powershell(按 PATH)+ System.Speech ← 现在产品用的 ──")
|
||||||
|
if not ps_in_path:
|
||||||
|
report("A", False, "powershell 不在 PATH 里 —— **这就是失败原因**")
|
||||||
|
else:
|
||||||
|
script = ("Add-Type -AssemblyName System.Speech;"
|
||||||
|
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||||
|
"$s.Speak('%s')" % TEXT.replace("'", "''"))
|
||||||
|
code, out = run(["powershell", "-NoProfile", "-WindowStyle", "Hidden",
|
||||||
|
"-Command", script])
|
||||||
|
report("A", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
# ---------------- B. 绝对路径 + System.Speech ----------------
|
||||||
|
print("── B. powershell(绝对路径)+ System.Speech ──")
|
||||||
|
if not ps_path:
|
||||||
|
report("B", False, "找不到 powershell.exe")
|
||||||
|
else:
|
||||||
|
script = ("Add-Type -AssemblyName System.Speech;"
|
||||||
|
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||||
|
"$s.Speak('%s')" % TEXT.replace("'", "''"))
|
||||||
|
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
|
||||||
|
"-Command", script])
|
||||||
|
report("B", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
# ---------------- C. SAPI.SpVoice COM(绕开 .NET)----------------
|
||||||
|
print("── C. powershell(绝对路径)+ SAPI.SpVoice COM ──")
|
||||||
|
if not ps_path:
|
||||||
|
report("C", False, "找不到 powershell.exe")
|
||||||
|
else:
|
||||||
|
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
|
||||||
|
"$v.Speak('%s')" % TEXT.replace("'", "''"))
|
||||||
|
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
|
||||||
|
"-Command", script])
|
||||||
|
report("C", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
# ---------------- D. cscript + VBScript + SAPI ----------------
|
||||||
|
#
|
||||||
|
# 最值得期待的一条:VBScript 从 Windows 98 就有,
|
||||||
|
# SAPI5 是系统组件,**两个都不需要装任何东西**,
|
||||||
|
# 也完全不受 PowerShell 版本 / PATH / .NET 的影响。
|
||||||
|
print("── D. cscript + VBScript + SAPI.SpVoice(不依赖 PowerShell)──")
|
||||||
|
vbs = (
|
||||||
|
'Dim v\r\n'
|
||||||
|
'Set v = CreateObject("SAPI.SpVoice")\r\n'
|
||||||
|
'v.Speak "%s"\r\n'
|
||||||
|
) % TEXT.replace('"', '""')
|
||||||
|
# ⚠ VBScript 文件要 ANSI(=系统代码页) —— 写成 UTF-8 的话
|
||||||
|
# 中文在 cscript 里会变乱码。这里用 mbcs 编码写。
|
||||||
|
tmp = Path(tempfile.gettempdir()) / "ykxmz_tts_test.vbs"
|
||||||
|
try:
|
||||||
|
tmp.write_bytes(vbs.encode("mbcs", errors="replace"))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
report("D", False, "写 vbs 失败: %s" % exc)
|
||||||
|
else:
|
||||||
|
code, out = run(["cscript", "//nologo", str(tmp)])
|
||||||
|
report("D", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
# ---------------- E. wscript(不弹黑框)----------------
|
||||||
|
print("── E. wscript + VBScript + SAPI.SpVoice ──")
|
||||||
|
if tmp.is_file():
|
||||||
|
code, out = run(["wscript", "//nologo", str(tmp)])
|
||||||
|
report("E", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||||
|
else:
|
||||||
|
report("E", False, "没有 vbs 文件")
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
# ---------------- 音色列表(两种方式对比)----------------
|
||||||
|
print()
|
||||||
|
print("── 可用音色 ──")
|
||||||
|
if ps_path:
|
||||||
|
code, out = run([
|
||||||
|
ps_path, "-NoProfile", "-Command",
|
||||||
|
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||||
|
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }"])
|
||||||
|
print(" SAPI5(COM):")
|
||||||
|
for ln in (out or "").splitlines():
|
||||||
|
if ln.strip():
|
||||||
|
print(" " + ln.strip())
|
||||||
|
if not out.strip():
|
||||||
|
print(" (列不出来)")
|
||||||
|
code2, out2 = run([
|
||||||
|
ps_path, "-NoProfile", "-Command",
|
||||||
|
"Add-Type -AssemblyName System.Speech;"
|
||||||
|
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||||
|
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"])
|
||||||
|
print(" System.Speech:")
|
||||||
|
for ln in (out2 or "").splitlines():
|
||||||
|
if ln.strip():
|
||||||
|
print(" " + ln.strip())
|
||||||
|
if not out2.strip():
|
||||||
|
print(" (列不出来 —— .NET 那套可能有问题)")
|
||||||
|
|
||||||
|
# ---------------- 结论 ----------------
|
||||||
|
print()
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 可用 %d 条 / 不可用 %d 条" % (len(FOUND), len(FAILED)))
|
||||||
|
if FOUND:
|
||||||
|
print(" 可用方案: %s" % ", ".join(FOUND))
|
||||||
|
print()
|
||||||
|
print(" → 产品应该按**这个顺序**降级尝试:D(cscript) -> C(SAPI COM)")
|
||||||
|
print(" -> B(绝对路径) -> A(PATH),全失败才报错。")
|
||||||
|
else:
|
||||||
|
print(" ⚠ 一条都不通。可能是:没装任何语音、系统被精简过、")
|
||||||
|
print(" 或者 SAPI 组件损坏。")
|
||||||
|
print("=" * 62)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查隧道为什么掉连接(公网 530,连接数 1/0)。
|
||||||
|
|
||||||
|
上一轮才刚修掉 nettune 死锁、把公网拉到 200;这次重启 nettune 之后
|
||||||
|
又变成 530 了。要分清是:
|
||||||
|
* nettune 又卡了(那个死锁没修干净?)
|
||||||
|
* 还是隧道自己掉了(cloudflared 的问题)
|
||||||
|
'''
|
||||||
|
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. 服务状态 + 有没有卡住的 ════"
|
||||||
|
for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 lanremote-nettune; do
|
||||||
|
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s)"
|
||||||
|
done
|
||||||
|
echo " --- nettune 卡住吗 ---"
|
||||||
|
systemctl show -p SubState -p MainPID -p ExecMainStartTimestamp lanremote-nettune 2>&1 | sed 's/^/ /'
|
||||||
|
echo " --- 有没有卡住的 systemctl ---"
|
||||||
|
ps -eo pid,etime,cmd 2>/dev/null | grep -E 'systemctl|net-tune' | grep -v grep | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. net-tune 的修复在设备上吗 ════"
|
||||||
|
grep -c NETTUNE_SERVICE_CALL /root/lanremote-relay/net-tune.sh
|
||||||
|
echo " unit 里有吗: $(grep -c NETTUNE_SERVICE_CALL /etc/systemd/system/lanremote-nettune.service)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. 隧道为什么连接数是 0 ════"
|
||||||
|
journalctl -u lanremote-tunnel --no-pager -n 25 2>/dev/null | tail -25 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 隧道 unit 长什么样(重启策略 / 协议)════"
|
||||||
|
systemctl cat lanremote-tunnel 2>/dev/null | grep -vE '^#' | head -25 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 5. cloudflared 进程 ════"
|
||||||
|
ps -eo pid,etime,rss,cmd 2>/dev/null | grep cloudflared | grep -v grep | sed 's/^/ /'
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
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,66 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''看隧道为什么反复掉(公网 530 的真因)。
|
||||||
|
|
||||||
|
已知:
|
||||||
|
nettune 死锁已修(SubState=exited,不再卡)
|
||||||
|
但隧道每 3~10 分钟被重启一次(部分是我加的预案在救、部分是它自己掉)
|
||||||
|
公网 530
|
||||||
|
|
||||||
|
要查的是 tunnel.log 里 cloudflared 到底在报什么。
|
||||||
|
'''
|
||||||
|
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'''
|
||||||
|
LOG=/root/lanremote-relay/tunnel.log
|
||||||
|
H2LOG=/root/lanremote-relay/tunnel-h2.log
|
||||||
|
|
||||||
|
echo "════ 1. tunnel.log 里的错误行(最近 40 条)════"
|
||||||
|
grep -iE 'error|fail|warn|timeout|retry|refused|EOF|unregister|connection' "$LOG" 2>/dev/null | tail -40 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. tunnel.log 最后 25 行(原样)════"
|
||||||
|
tail -25 "$LOG" 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. h2 那份的日志 ════"
|
||||||
|
tail -15 "$H2LOG" 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 两份日志多大(写爆了会拖慢小机器)════"
|
||||||
|
ls -la "$LOG" "$H2LOG" 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 5. cloudflared 版本 + 配置 ════"
|
||||||
|
/usr/local/bin/cloudflared --version 2>&1 | sed 's/^/ /'
|
||||||
|
echo " --- config.yml ---"
|
||||||
|
cat /root/.cloudflared/config.yml 2>/dev/null | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 6. 现在两条隧道的连接数 ════"
|
||||||
|
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
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 7. 公网现在通吗 ════"
|
||||||
|
echo " $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/v1/health)"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
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,89 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''抓 Gitee 在 README 页面上挂的违规公告原文。
|
||||||
|
|
||||||
|
== 为什么要抓原文 ==
|
||||||
|
|
||||||
|
网页版 README 页里出现了:
|
||||||
|
「违规信息」2 次
|
||||||
|
「内容可能」2 次
|
||||||
|
「违规」2 次
|
||||||
|
「举报」10 次
|
||||||
|
|
||||||
|
但 raw 接口是 451。光看关键词猜没用 —— 要把 Gitee 的**原话**读出来,
|
||||||
|
才知道它到底判的是哪一条,才能对症改。
|
||||||
|
|
||||||
|
== 抓什么 ==
|
||||||
|
|
||||||
|
把「违规信息」「内容可能」附近的 HTML 片段剥掉标签打出来。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
URL = "https://gitee.com/xmz7891/ykxmz/blob/main/README.md"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
req = urllib.request.Request(URL)
|
||||||
|
req.add_header("User-Agent",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 Chrome/120 Safari/537.36")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
page = r.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
print(" ✗ HTTP %s" % exc.code)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(" 页面 %d 字节" % len(page))
|
||||||
|
|
||||||
|
# 去掉 script/style
|
||||||
|
body = re.sub(r"<script.*?</script>", " ", page, flags=re.S | re.I)
|
||||||
|
body = re.sub(r"<style.*?</style>", " ", body, flags=re.S | re.I)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 「违规」/「内容可能」附近的原文")
|
||||||
|
print("=" * 62)
|
||||||
|
for kw in ("违规信息", "内容可能", "违规"):
|
||||||
|
for m in re.finditer(kw, body):
|
||||||
|
start = max(0, m.start() - 500)
|
||||||
|
end = min(len(body), m.end() + 500)
|
||||||
|
chunk = body[start:end]
|
||||||
|
chunk = re.sub(r"<[^>]+>", " ", chunk)
|
||||||
|
chunk = html.unescape(chunk)
|
||||||
|
chunk = re.sub(r"\s+", " ", chunk).strip()
|
||||||
|
if len(chunk) > 30:
|
||||||
|
print()
|
||||||
|
print(" 【%s】" % kw)
|
||||||
|
print(" ...%s..." % chunk)
|
||||||
|
break # 每个词只看第一条,免得太长
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 页面里所有提到「审核 / 举报 / 法律」的短句")
|
||||||
|
print("=" * 62)
|
||||||
|
for kw in ("审核", "举报", "法律", "不可用"):
|
||||||
|
seen = set()
|
||||||
|
for m in re.finditer(kw, body):
|
||||||
|
start = max(0, m.start() - 120)
|
||||||
|
end = min(len(body), m.end() + 120)
|
||||||
|
chunk = re.sub(r"<[^>]+>", " ", body[start:end])
|
||||||
|
chunk = html.unescape(chunk)
|
||||||
|
chunk = re.sub(r"\s+", " ", chunk).strip()
|
||||||
|
# 只留信息量够、而且不是重复的
|
||||||
|
key = chunk[:40]
|
||||||
|
if len(chunk) > 25 and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
print(" 【%s】%s" % (kw, chunk[:180]))
|
||||||
|
if len(seen) >= 2:
|
||||||
|
break
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''给 80 端口的域名块补上 ACME 挑战例外。
|
||||||
|
|
||||||
|
== 不补会怎样 ==
|
||||||
|
|
||||||
|
Let's Encrypt 的 **HTTP-01 挑战**要求:
|
||||||
|
http://yk.xxmxxs.top/.well-known/acme-challenge/<token>
|
||||||
|
必须在 **80 端口**上**直接返回那个文件**。
|
||||||
|
|
||||||
|
而我们把 80 上的一切都 301 到 https 了 —— 挑战请求也被跳走,
|
||||||
|
certbot 拿不到文件,续期失败。
|
||||||
|
|
||||||
|
**后果是延迟爆发的**:现在一切正常,**最多 90 天后**(证书到期时)
|
||||||
|
网站突然整个挂掉,而且是 https 全挂。那时候很难联想到
|
||||||
|
"是几个月前改 nginx 忘了留 ACME 例外"。
|
||||||
|
|
||||||
|
== 为什么之前没暴露 ==
|
||||||
|
|
||||||
|
证书是 10/8 签的,90 天有效期 → 2027 年 1 月才到期。
|
||||||
|
`certbot.timer` 每天跑一次续期检查,但**现在还没到需要续期的时候**,
|
||||||
|
所以它不会报错。等真要续的时候才发现就晚了。
|
||||||
|
|
||||||
|
== 修法 ==
|
||||||
|
|
||||||
|
在 80 端口的域名块里,**在 `location /`(那条 301)之前**加一个
|
||||||
|
ACME 专用 location。nginx 的 location 匹配优先看前缀长度,
|
||||||
|
`/.well-known/acme-challenge/` 比 `/` 长,所以会先命中它。
|
||||||
|
|
||||||
|
用 `root /var/www/html` 是 certbot --nginx 插件的默认做法 ——
|
||||||
|
它续期时会自己往那个目录写挑战文件。
|
||||||
|
|
||||||
|
== 顺手加的保险:让续期失败能被发现 ==
|
||||||
|
|
||||||
|
`certbot.timer` 失败是**静默**的(只在 journal 里)。
|
||||||
|
加一个每天检查证书剩余天数的探针,少于 20 天就记一条明显的日志 ——
|
||||||
|
这样"续期悄悄失败"不会拖到证书过期才发现。
|
||||||
|
'''
|
||||||
|
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 # noqa: E402
|
||||||
|
|
||||||
|
ACME_BLOCK = ''' # ---- ACME 挑战:**必须在 301 之前** ----
|
||||||
|
#
|
||||||
|
# Let's Encrypt 的 HTTP-01 挑战要在 80 端口上直接拿到文件,
|
||||||
|
# 被 301 跳走的话续期就失败 —— 而且**最多 90 天后才暴露**
|
||||||
|
# (证书到期时网站整个挂掉),那时候很难联想到是这里的问题。
|
||||||
|
#
|
||||||
|
# nginx 的 location 匹配优先看前缀长度,`/.well-known/acme-challenge/`
|
||||||
|
# 比 `/` 长,所以会先命中这里,不会走到下面的 301。
|
||||||
|
#
|
||||||
|
# root 用 certbot --nginx 插件的默认值 —— 它续期时自己往这里写文件。
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
default_type "text/plain";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 其余全部跳到 https(certbot --redirect 的效果)
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}'''
|
||||||
|
|
||||||
|
OLD = ''' # 其余全部跳到 https(certbot --redirect 的效果)
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 补 ACME 挑战例外(不然 90 天后证书续不上)")
|
||||||
|
print("=" * 62)
|
||||||
|
print()
|
||||||
|
|
||||||
|
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
|
||||||
|
conf = r[1] if isinstance(r, tuple) else str(r)
|
||||||
|
|
||||||
|
if "acme-challenge" in conf:
|
||||||
|
print(" 已经有 ACME 例外了")
|
||||||
|
else:
|
||||||
|
if OLD not in conf:
|
||||||
|
print(" ✗ 找不到 301 那段锚点")
|
||||||
|
return 1
|
||||||
|
conf = conf.replace(OLD, ACME_BLOCK, 1)
|
||||||
|
tmp = ROOT / "_build" / "_ykxmz_acme.conf"
|
||||||
|
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/ykxmz-acme.conf")
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
r2 = run(
|
||||||
|
"sudo cp /etc/nginx/sites-available/ykxmz "
|
||||||
|
"/etc/nginx/sites-available/ykxmz.bak.acme.$(date +%s) && "
|
||||||
|
"sudo mv /tmp/ykxmz-acme.conf /etc/nginx/sites-available/ykxmz && "
|
||||||
|
"sudo mkdir -p /var/www/html/.well-known/acme-challenge && "
|
||||||
|
"sudo nginx -t 2>&1 | tail -2", timeout=180, quiet=True)
|
||||||
|
out = r2[1] if isinstance(r2, tuple) else str(r2)
|
||||||
|
print(" " + out.replace("\n", "\n "))
|
||||||
|
if "successful" not in out:
|
||||||
|
print(" ✗ 配置有错,撤回")
|
||||||
|
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.acme.* "
|
||||||
|
"| head -1) /etc/nginx/sites-available/ykxmz", timeout=90,
|
||||||
|
quiet=True)
|
||||||
|
return 1
|
||||||
|
print(" ✓ 已补上")
|
||||||
|
|
||||||
|
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
|
||||||
|
timeout=120, quiet=True)
|
||||||
|
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
|
||||||
|
|
||||||
|
# ---- 复核:ACME 路径不再 301,而是 404(文件不存在是对的,
|
||||||
|
# 关键是不能 301 —— 301 就去 https 了,certbot 拿不到)----
|
||||||
|
print()
|
||||||
|
print("── 复核 ──")
|
||||||
|
r = run(r'''
|
||||||
|
printf " %-52s " "ACME 挑战路径(应该 404,不能是 301)"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/test
|
||||||
|
printf " %-52s " "放个真文件进去试试"
|
||||||
|
sudo sh -c 'echo hello-acme > /var/www/html/.well-known/acme-challenge/probe'
|
||||||
|
curl -s --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/probe
|
||||||
|
echo
|
||||||
|
sudo rm -f /var/www/html/.well-known/acme-challenge/probe
|
||||||
|
printf " %-52s " "普通路径仍然跳 https"
|
||||||
|
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/somepage
|
||||||
|
printf " %-52s " "IP 访问仍然 404(用户要求没东西)"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 http://127.0.0.1/
|
||||||
|
printf " %-52s " "官网仍然 200"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 https://yk.xxmxxs.top/
|
||||||
|
printf " %-52s " "中转站设置仍然 200"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 https://yk.xxmxxs.top/zhongzhuanzhan
|
||||||
|
echo
|
||||||
|
echo " --- 证书到期时间 ---"
|
||||||
|
sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem -noout -dates 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " --- 距到期还有多少天 ---"
|
||||||
|
end=$(sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||||
|
echo " $(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 )) 天"
|
||||||
|
''', timeout=240, quiet=True)
|
||||||
|
print(r[1] if isinstance(r, tuple) else r)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''按**行号**修 admin.py 里那段坏掉的 JS。
|
||||||
|
|
||||||
|
== 为什么不用字符串匹配 ==
|
||||||
|
|
||||||
|
前两次都失败了:坏掉的内容里带着**真的换行**,
|
||||||
|
在 Python 源码里怎么写字面量都对不齐(缩进、空行的数量差一个就匹配不上)。
|
||||||
|
|
||||||
|
按行号定位反而稳:先找到 `data-update` 那个 forEach 的锚点行,
|
||||||
|
然后把它后面那几行跨行字符串整段换掉。
|
||||||
|
|
||||||
|
== 坏掉的是什么 ==
|
||||||
|
|
||||||
|
if (!confirm('让「' + name + '」现在更新到最新版?\n\n'
|
||||||
|
+ '它会自己下载……'))
|
||||||
|
|
||||||
|
Python 三引号里 `\n` 变成真换行 -> JS 字符串字面量跨行 -> 语法非法
|
||||||
|
-> **整个 script 块解析失败 -> 管理页所有按钮全死**。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ADMIN = ROOT / "relay" / "admin.py"
|
||||||
|
|
||||||
|
NEW_LINES = [
|
||||||
|
" const name = btn.dataset.name || '这台电脑';",
|
||||||
|
" // ⚠ 这里**不能用反斜杠 n** 拼多行文案。",
|
||||||
|
" // ADMIN_JS 是 Python 三引号字符串,源码里写反斜杠 n 会变成",
|
||||||
|
" // **真的换行**插进 JS,于是字符串字面量跨行 —— JS 语法非法,",
|
||||||
|
" // 整个 script 块解析失败,管理页所有按钮全死。",
|
||||||
|
" // (回归里的「管理页 JS 合法性」就是专门抓这个的。)",
|
||||||
|
" //",
|
||||||
|
" // 用 String.fromCharCode(10) 完全不依赖转义层,",
|
||||||
|
" // 以后谁改这段都不会再踩。",
|
||||||
|
" const NL = String.fromCharCode(10);",
|
||||||
|
" const msg = ['让「' + name + '」现在更新到最新版?', '',",
|
||||||
|
" '它会自己下载、静默安装并重启,全程不弹窗口,',",
|
||||||
|
" '中转地址、配对码和各项设置都不变。'].join(NL);",
|
||||||
|
" if (!confirm(msg)) {",
|
||||||
|
" return;",
|
||||||
|
" }",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 按行号修 admin.py 的 JS")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
raw = ADMIN.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "String.fromCharCode(10)" in text:
|
||||||
|
print(" 已经修好了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
lines = text.split("\n")
|
||||||
|
|
||||||
|
# 找 forEach 的锚点
|
||||||
|
start = None
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if "querySelectorAll('button[data-update]')" in ln:
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
if start is None:
|
||||||
|
print(" ✗ 找不到 data-update 的 forEach")
|
||||||
|
return 1
|
||||||
|
print(" 锚点在第 %d 行" % (start + 1))
|
||||||
|
|
||||||
|
# 锚点后第一行应该是 btn.onclick = async () => {
|
||||||
|
j = start + 1
|
||||||
|
while j < len(lines) and "btn.onclick" not in lines[j]:
|
||||||
|
j += 1
|
||||||
|
if j >= len(lines):
|
||||||
|
print(" ✗ 找不到 btn.onclick")
|
||||||
|
return 1
|
||||||
|
# 下一个非空行是 const name = ...
|
||||||
|
k = j + 1
|
||||||
|
while k < len(lines) and not lines[k].strip():
|
||||||
|
k += 1
|
||||||
|
if "const name" not in lines[k]:
|
||||||
|
print(" ✗ const name 那行不在预期位置(第 %d 行):%r"
|
||||||
|
% (k + 1, lines[k][:70]))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# k 之后到底几个坏行?找到 "if (!confirm(" 开始,到那个跨行字符串结束
|
||||||
|
# (下一个以 ' return;' 结尾的块之前)
|
||||||
|
end = k + 1
|
||||||
|
found_confirm = False
|
||||||
|
while end < len(lines):
|
||||||
|
s = lines[end].strip()
|
||||||
|
if s.startswith("if (!confirm("):
|
||||||
|
found_confirm = True
|
||||||
|
if found_confirm and s.startswith("return;"):
|
||||||
|
break
|
||||||
|
end += 1
|
||||||
|
if not found_confirm:
|
||||||
|
print(" ✗ 找不到 if (!confirm(")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(" 要替换第 %d 行 ~ 第 %d 行(%d 行)"
|
||||||
|
% (k + 1, end, end - k + 1))
|
||||||
|
for n in range(k, min(end + 1, k + 8)):
|
||||||
|
print(" 坏行 %d: %r" % (n + 1, lines[n][:64]))
|
||||||
|
|
||||||
|
lines[k:end] = NEW_LINES
|
||||||
|
|
||||||
|
ADMIN.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||||
|
+ "\n".join(lines).encode("utf-8"))
|
||||||
|
print(" ✓ 替换完成")
|
||||||
|
|
||||||
|
sys.path.insert(0, str(ROOT / "relay"))
|
||||||
|
import admin # noqa: E402
|
||||||
|
js = admin.ADMIN_JS
|
||||||
|
bad = []
|
||||||
|
for n, ln in enumerate(js.splitlines(), 1):
|
||||||
|
s = ln.strip()
|
||||||
|
if s.startswith("//"):
|
||||||
|
continue
|
||||||
|
if s.count("'") % 2:
|
||||||
|
bad.append((n, ln[:70]))
|
||||||
|
print(" 复核:单引号落单的行 = %d 个" % len(bad))
|
||||||
|
for n, ln in bad[:5]:
|
||||||
|
print(" %4d %s" % (n, ln))
|
||||||
|
return 0 if not bad else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''修 manager 页「更新」按钮那段 JS 的裸换行。
|
||||||
|
|
||||||
|
== 出了什么问题 ==
|
||||||
|
|
||||||
|
`ADMIN_JS` 是 Python 的**三引号字符串**。我在里面写:
|
||||||
|
|
||||||
|
if (!confirm('让「' + name + '」现在更新到最新版?\n\n'
|
||||||
|
+ '它会自己下载……\n'
|
||||||
|
|
||||||
|
本意是让 JS 收到 `\n`。但 Python 三引号里 `\n` 就是**真的换行**,
|
||||||
|
于是插进 JS 变成:
|
||||||
|
|
||||||
|
if (!confirm('让「' + name + '」现在更新到最新版?
|
||||||
|
(空行)
|
||||||
|
'
|
||||||
|
|
||||||
|
**字符串字面量跨行,JS 语法非法**。
|
||||||
|
|
||||||
|
后果不是"这一句坏了",而是**整个 <script> 块解析失败** ——
|
||||||
|
管理页上所有按钮(改码/删除/踢下线/全选)全部失灵。回归里的
|
||||||
|
「管理页 JS 合法性」这一关就是专门抓这个的(当初加它就是因为
|
||||||
|
踩过一次同样的坑)。
|
||||||
|
|
||||||
|
== 怎么修 ==
|
||||||
|
|
||||||
|
**不用 `\n`**。改成把文案放进数组,用 `String.fromCharCode(10)` 连起来:
|
||||||
|
|
||||||
|
const NL = String.fromCharCode(10);
|
||||||
|
const msg = ['第一行', '', '第二行'].join(NL);
|
||||||
|
|
||||||
|
这样**完全不依赖转义层** —— 不管以后谁编辑这段、用哪种
|
||||||
|
引号、经不经过别的脚本生成,都不会再变成裸换行。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ADMIN = ROOT / "relay" / "admin.py"
|
||||||
|
|
||||||
|
# ⚠ 这些字符串里必须保留**真的换行**(就是要匹配源文件里那个坏掉的样子)
|
||||||
|
OLD = """document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||||
|
btn.onclick = async () => {
|
||||||
|
const name = btn.dataset.name || '这台电脑';
|
||||||
|
if (!confirm('让「' + name + '」现在更新到最新版?
|
||||||
|
|
||||||
|
'
|
||||||
|
+ '它会自己下载、静默安装并重启,全程不弹窗口,
|
||||||
|
'
|
||||||
|
+ '中转地址、配对码和各项设置都不变。')) {
|
||||||
|
return;
|
||||||
|
}"""
|
||||||
|
|
||||||
|
NEW = """document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||||
|
btn.onclick = async () => {
|
||||||
|
const name = btn.dataset.name || '这台电脑';
|
||||||
|
// ⚠ 这里**不能用反斜杠 n** 拼多行文案。
|
||||||
|
// ADMIN_JS 是 Python 三引号字符串,源码里写反斜杠 n 会变成
|
||||||
|
// **真的换行**插进 JS,于是字符串字面量跨行 —— JS 语法非法,
|
||||||
|
// 整个 script 块解析失败,管理页所有按钮全死。
|
||||||
|
// (回归里的「管理页 JS 合法性」就是抓这个的。)
|
||||||
|
//
|
||||||
|
// 用 String.fromCharCode(10) 完全不依赖转义层,
|
||||||
|
// 以后谁改这段都不会再踩。
|
||||||
|
const NL = String.fromCharCode(10);
|
||||||
|
const msg = ['让「' + name + '」现在更新到最新版?', '',
|
||||||
|
'它会自己下载、静默安装并重启,全程不弹窗口,',
|
||||||
|
'中转地址、配对码和各项设置都不变。'].join(NL);
|
||||||
|
if (!confirm(msg)) {
|
||||||
|
return;
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 修管理页 JS 的裸换行")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
raw = ADMIN.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "String.fromCharCode(10)" in text and OLD not in text:
|
||||||
|
print(" 已经修好了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if OLD not in text:
|
||||||
|
print(" ✗ 找不到坏掉的那段")
|
||||||
|
i = text.find("data-update")
|
||||||
|
print(text[max(0, i - 100):i + 700])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
text = text.replace(OLD, NEW, 1)
|
||||||
|
ADMIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ 替换完成")
|
||||||
|
|
||||||
|
# 复核:ADMIN_JS 里不能再有跨行的字符串字面量
|
||||||
|
sys.path.insert(0, str(ROOT / "relay"))
|
||||||
|
for name in list(sys.modules):
|
||||||
|
if name in ("admin", "relay"):
|
||||||
|
del sys.modules[name]
|
||||||
|
import admin # noqa: E402
|
||||||
|
js = admin.ADMIN_JS
|
||||||
|
bad = []
|
||||||
|
for n, ln in enumerate(js.splitlines(), 1):
|
||||||
|
s = ln.strip()
|
||||||
|
if s.startswith("//"):
|
||||||
|
continue
|
||||||
|
if s.count("'") % 2:
|
||||||
|
bad.append((n, ln[:70]))
|
||||||
|
print(" 复核:单引号落单的行 = %d 个" % len(bad))
|
||||||
|
for n, ln in bad[:5]:
|
||||||
|
print(" %4d %s" % (n, ln))
|
||||||
|
return 0 if not bad else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
r'''修上一步弄坏的登录表单。
|
r'''修上一步弄坏的登录表单。
|
||||||
|
|
||||||
上一步的正则插账号时把字典插坏了:
|
上一步的正则插账号时把字典插坏了:
|
||||||
urlencode({"username": "xmz7891", {"password": "120830"})
|
urlencode({"username": "xmz7891", {"password": "" # 见下方注释:不再写死})
|
||||||
^ 多了个左括号
|
^ 多了个左括号
|
||||||
应该是:
|
应该是:
|
||||||
urlencode({"username": "xmz7891", "password": "120830"})
|
urlencode({"username": "xmz7891", "password": "" # 见下方注释:不再写死})
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''修 deploy.sh 里 watchdog 块被我吃掉的两行。
|
||||||
|
|
||||||
|
== 出什么事了 ==
|
||||||
|
|
||||||
|
我用 heredoc(python -c @"..."@)改 _build/add_monitor_to_deploy.py 的锚点,
|
||||||
|
PowerShell 把里面的引号和 $ 打散了,脚本没改成;而**上一次**
|
||||||
|
(锚点写错的那次)已经把 deploy.sh 改了 —— 那次用的锚点是
|
||||||
|
|
||||||
|
systemctl enable lanremote-watchdog.timer ... || true
|
||||||
|
|
||||||
|
它把后面两行(start timer + say 状态)连带 `fi` 一起卷进了替换,
|
||||||
|
结果这两行就没了:
|
||||||
|
|
||||||
|
systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true
|
||||||
|
say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)"
|
||||||
|
|
||||||
|
bash -n 仍然通过(语法没错,只是少了两行执行语句),
|
||||||
|
所以光靠语法检查发现不了 —— 这就是为什么改完还要**读回来核对**。
|
||||||
|
|
||||||
|
这个脚本把这两行补回去。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEPLOY = ROOT / "relay" / "deploy.sh"
|
||||||
|
|
||||||
|
OLD = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 网站监测单元(如果包里有)----"""
|
||||||
|
|
||||||
|
NEW = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true
|
||||||
|
systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true
|
||||||
|
say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 网站监测单元(如果包里有)----"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = DEPLOY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "systemctl start lanremote-watchdog.timer" in text:
|
||||||
|
print(" · 那两行已经在了")
|
||||||
|
return 0
|
||||||
|
if OLD not in text:
|
||||||
|
print(" ✗ 找不到锚点,需要人工看一眼")
|
||||||
|
return 1
|
||||||
|
text = text.replace(OLD, NEW, 1)
|
||||||
|
DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||||
|
+ text.encode("utf-8"))
|
||||||
|
print(" ✓ 补回了 start timer 和 say 那两行")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''修掉下载响应的**重复 Cache-Control 头**。
|
||||||
|
|
||||||
|
== 问题 ==
|
||||||
|
|
||||||
|
实测 curl -I /download/ykxmz-setup-2.1.3.exe 返回:
|
||||||
|
|
||||||
|
Cache-Control: public, max-age=604800, immutable
|
||||||
|
Cache-Control: public, max-age=604800, immutable <- 两条!
|
||||||
|
|
||||||
|
一条是 relay 自己发的(它 /download/ 那段本来就带了正确的头),
|
||||||
|
另一条是 nginx 配置里那句 add_header 加的。
|
||||||
|
|
||||||
|
**为什么必须修**:HTTP 规范里 Cache-Control 是**可合并**的头,
|
||||||
|
但重复出现时很多 CDN 会保守处理 —— Cloudflare 遇到重复的
|
||||||
|
Cache-Control 有可能判定"缓存指令有歧义"而**跳过缓存**。
|
||||||
|
那就等于我们白配了这一层。
|
||||||
|
|
||||||
|
== 改法 ==
|
||||||
|
|
||||||
|
**只留 relay 发的那条**,nginx 不再自己加。
|
||||||
|
|
||||||
|
为什么留 relay 的而不是 nginx 的:
|
||||||
|
* relay 知道每个文件的情况,是"单一事实来源"
|
||||||
|
* 万一以后某个文件要不同的缓存策略,改 relay 就行,
|
||||||
|
不用在 nginx 里再维护一套按路径匹配的规则
|
||||||
|
* 少一处配置就少一处不一致
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print(" ── 1. 从 nginx 配置里去掉重复的 add_header ──")
|
||||||
|
# 用 sed 精确删掉那一行(它前面有 8 个空格缩进)
|
||||||
|
out = run(
|
||||||
|
"sudo sed -i '/add_header Cache-Control \"public, max-age=604800, "
|
||||||
|
"immutable\" always;/d' /etc/nginx/sites-available/ykxmz && "
|
||||||
|
"echo ' 已删'; "
|
||||||
|
"echo ' --- 现在 /download/ 那段 ---'; "
|
||||||
|
"sudo sed -n '/location \\/download\\//,/^ }/p' "
|
||||||
|
"/etc/nginx/sites-available/ykxmz | sed 's/^/ /'",
|
||||||
|
timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 加一句注释说明为什么这里不设 Cache-Control ──")
|
||||||
|
out = run(
|
||||||
|
"sudo sed -i 's| proxy_buffering on;|"
|
||||||
|
" proxy_buffering on;\\n\\n"
|
||||||
|
" # 这里**故意不设** Cache-Control —— relay 自己已经发了\\n"
|
||||||
|
" # 正确的(public, max-age=604800, immutable)。\\n"
|
||||||
|
" # 两边都设的话响应里会出现**两条** Cache-Control,\\n"
|
||||||
|
" # Cloudflare 遇到重复的缓存指令有可能直接跳过缓存,\\n"
|
||||||
|
" # 那就白配这一层了。|' "
|
||||||
|
"/etc/nginx/sites-available/ykxmz && echo ' 已加注释'",
|
||||||
|
timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 语法检查 + 重载 ──")
|
||||||
|
out = run("sudo nginx -t 2>&1 | tail -2 && sudo systemctl reload nginx && "
|
||||||
|
"sleep 2 && systemctl is-active nginx", timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 复查:现在应该只有一条 Cache-Control ──")
|
||||||
|
out = run(
|
||||||
|
'echo "--- 安装包 ---"; '
|
||||||
|
'curl -sI --max-time 10 http://127.0.0.1:80/download/'
|
||||||
|
'ykxmz-setup-2.1.3.exe | grep -ci "cache-control" | '
|
||||||
|
'sed "s/^/ Cache-Control 条数: /"; '
|
||||||
|
'curl -sI --max-time 10 http://127.0.0.1:80/download/'
|
||||||
|
'ykxmz-setup-2.1.3.exe | grep -i "cache-control" | sed "s/^/ /"; '
|
||||||
|
'echo "--- 长轮询(应该 no-store)---"; '
|
||||||
|
'curl -sI --max-time 10 http://127.0.0.1:80/v1/health | '
|
||||||
|
'grep -i "cache-control" | sed "s/^/ /"',
|
||||||
|
timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -44,7 +44,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
OLD = "REVA" + "edu120830" # 拼出来,本脚本自己不含明文
|
OLD = "REVA" + "edu<改从环境变量读>" # 拼出来,本脚本自己不含明文
|
||||||
|
|
||||||
TARGETS = [
|
TARGETS = [
|
||||||
ROOT / "_tests" / "test_relay_admin_login.py",
|
ROOT / "_tests" / "test_relay_admin_login.py",
|
||||||
|
|||||||
@@ -18,12 +18,34 @@ r'''修 license-combined.txt 里那份**没过期的**协议版本号。
|
|||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
FILE = ROOT / "installer" / "license-combined.txt"
|
FILE = ROOT / "installer" / "license-combined.txt"
|
||||||
NEW = "1.9.0"
|
|
||||||
|
|
||||||
|
def current_version() -> str:
|
||||||
|
"""从**单一来源**读版本号。
|
||||||
|
|
||||||
|
⚠ 这里原来是写死的 NEW = "1.9.0"。升到 2.0.0 那次它把已经
|
||||||
|
改好的 license 又**改回了 1.9.0** —— 因为整个升级流程是:
|
||||||
|
bump_version_generic.py 改一批
|
||||||
|
fix_license_version.py 再"修"一遍 <- 用写死的旧版本覆盖
|
||||||
|
写死的常量就是这样:改版本号时忘了改这里,它就成了回退操作。
|
||||||
|
|
||||||
|
现在统一从 _build/package_remote.py 的 VERSION 读 ——
|
||||||
|
那个是打包脚本用的版本号,本来就是单一来源。
|
||||||
|
"""
|
||||||
|
text = (ROOT / "_build" / "package_remote.py").read_text(encoding="utf-8")
|
||||||
|
m = re.search(r'VERSION\s*=\s*"([^"]+)"', text)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit(" ✗ 从 package_remote.py 里读不到 VERSION")
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
NEW = current_version()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''监测单元适配"80 会 301 到 https"之后的新情况。
|
||||||
|
|
||||||
|
== 问题 ==
|
||||||
|
|
||||||
|
certbot 签证书时加了 `--redirect`,于是 nginx 的 80 端口现在这样:
|
||||||
|
|
||||||
|
listen 80; server_name yk.xxmxxs.top;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
|
||||||
|
而监测单元原来的判断是"curl 拿到 200 才算健康"。curl **默认不跟跳转**,
|
||||||
|
于是它拿到 301 -> 判定失败 -> 连续 3 次就启动紧急预案
|
||||||
|
-> 预案重启中转服务 -> 再查还是 301 -> **又一个无限循环**。
|
||||||
|
|
||||||
|
这个坑和刚才那个"改监听地址忘了改探测地址"是同一类:
|
||||||
|
**改了入口的行为,必须回头看所有探测入口的地方。**
|
||||||
|
|
||||||
|
== 改法 ==
|
||||||
|
|
||||||
|
把探测地址和判断都改成"看用户真实路径":
|
||||||
|
|
||||||
|
PRIMARY https://yk.xxmxxs.top/v1/health (经 Cloudflare,用户实际走的)
|
||||||
|
FALLBACK http://127.0.0.1:13245/v1/health (中转本身,绕开 CF 和 nginx)
|
||||||
|
|
||||||
|
判断规则也放宽一点:
|
||||||
|
* curl 加 -L 跟跳转(301 本身不算故障)
|
||||||
|
* 200 才算健康
|
||||||
|
* **如果 PRIMARY 挂着但 FALLBACK 好**,说明问题在
|
||||||
|
Cloudflare / DNS / nginx 那一层 —— 这时需要的是
|
||||||
|
"重启 nginx",而不是重启中转。所以预案里补一级。
|
||||||
|
|
||||||
|
== 为什么 PRIMARY 用 https 而不是 http ==
|
||||||
|
|
||||||
|
因为 http 现在只是个跳转,真正的服务在 https 上。
|
||||||
|
用 https 探测,用户遇到什么我们就测到什么。
|
||||||
|
(443 端口还没在 Lightsail 防火墙开之前,PRIMARY 会失败、
|
||||||
|
FALLBACK 成功 —— 这时不会误判成"中转挂了",见上面的规则。)
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
|
||||||
|
NEW_HEAD = '''# ============================================================================
|
||||||
|
# 探测地址
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# PRIMARY:用户实际走的路(经 Cloudflare -> nginx -> 中转,全程 https)
|
||||||
|
# FALLBACK:中转本身,绕开 Cloudflare 和 nginx
|
||||||
|
#
|
||||||
|
# ⚠ 两个都查是有意的:
|
||||||
|
# * PRIMARY 挂 + FALLBACK 好 -> 问题在 CF / DNS / nginx 层
|
||||||
|
# * 两个都挂 -> 中转服务本身出问题了
|
||||||
|
# 分开判才能选对抢救动作(重启 nginx 还是重启中转)。
|
||||||
|
PRIMARY_URL="${PRIMARY_URL:-https://yk.xxmxxs.top}"
|
||||||
|
FALLBACK_URL="${FALLBACK_URL:-http://127.0.0.1:13245}"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 监测单元适配 https + 301")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 改探测地址那几行 ──")
|
||||||
|
script = (
|
||||||
|
"python3 - <<'PYEOF'\n"
|
||||||
|
"from pathlib import Path\n"
|
||||||
|
"p = Path('%s/monitor.sh')\n"
|
||||||
|
"lines = p.read_text(encoding='utf-8').splitlines()\n"
|
||||||
|
"out = []\n"
|
||||||
|
"done = False\n"
|
||||||
|
"for l in lines:\n"
|
||||||
|
" if l.startswith('PUBLIC_URL=') or l.startswith('FALLBACK_URL='):\n"
|
||||||
|
" continue\n"
|
||||||
|
" if l.strip() == '# 探测地址:域名优先(用户实际走的路),IP:80 兜底' and not done:\n"
|
||||||
|
" out.append(%r)\n"
|
||||||
|
" done = True\n"
|
||||||
|
" continue\n"
|
||||||
|
" out.append(l)\n"
|
||||||
|
"p.write_text('\\n'.join(out) + '\\n', encoding='utf-8', newline='\\n')\n"
|
||||||
|
"print(' 替换完成: %s' % done)\n"
|
||||||
|
"PYEOF" % (RELAY_DIR, NEW_HEAD))
|
||||||
|
out = run(script, timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 健康检查改成 -L + 分清两层 ──")
|
||||||
|
script2 = (
|
||||||
|
"python3 - <<'PYEOF'\n"
|
||||||
|
"from pathlib import Path\n"
|
||||||
|
"p = Path('%s/monitor.sh')\n"
|
||||||
|
"t = p.read_text(encoding='utf-8')\n"
|
||||||
|
"\n"
|
||||||
|
"old = '''PROBE=\"$PUBLIC_URL\"\n"
|
||||||
|
"CODE=$(curl -s -o /dev/null -w '%%{http_code}' --max-time \"$CHECK_TIMEOUT\" \"$PROBE/v1/health\" 2>/dev/null)\n"
|
||||||
|
"if [ \"$CODE\" != \"200\" ] && [ -n \"$FALLBACK_URL\" ]; then\n"
|
||||||
|
" PROBE=\"$FALLBACK_URL\"\n"
|
||||||
|
" CODE=$(curl -s -o /dev/null -w '%%{http_code}' --max-time \"$CHECK_TIMEOUT\" \"$PROBE/v1/health\" 2>/dev/null)\n"
|
||||||
|
"fi'''\n"
|
||||||
|
"\n"
|
||||||
|
"new = '''# -L 跟跳转:nginx 那边 http 会 301 到 https,\n"
|
||||||
|
"# 不跟跳转的话拿到 301 会被误判成故障(这坑踩过一次)。\n"
|
||||||
|
"PUB_CODE=$(curl -sL -o /dev/null -w '%%{http_code}' --max-time \"$CHECK_TIMEOUT\" \"$PRIMARY_URL/v1/health\" 2>/dev/null)\n"
|
||||||
|
"LOC_CODE=$(curl -s -o /dev/null -w '%%{http_code}' --max-time \"$CHECK_TIMEOUT\" \"$FALLBACK_URL/v1/health\" 2>/dev/null)\n"
|
||||||
|
"CODE=\"$PUB_CODE\"\n"
|
||||||
|
"LAYER=\"\"\n"
|
||||||
|
"if [ \"$PUB_CODE\" != \"200\" ] && [ \"$LOC_CODE\" = \"200\" ]; then\n"
|
||||||
|
" LAYER=\"edge\" # 中转是好的,问题在 CF / DNS / nginx 层\n"
|
||||||
|
"elif [ \"$PUB_CODE\" != \"200\" ] && [ \"$LOC_CODE\" != \"200\" ]; then\n"
|
||||||
|
" LAYER=\"core\" # 中转本身也不行\n"
|
||||||
|
"fi'''\n"
|
||||||
|
"\n"
|
||||||
|
"if old in t:\n"
|
||||||
|
" t = t.replace(old, new, 1)\n"
|
||||||
|
" print(' ok 健康检查替换完成')\n"
|
||||||
|
"else:\n"
|
||||||
|
" print(' x 没匹配上,实际内容:')\n"
|
||||||
|
" i = t.find('CODE=$(curl')\n"
|
||||||
|
" print(t[max(0,i-200):i+400])\n"
|
||||||
|
"p.write_text(t, encoding='utf-8', newline='\\n')\n"
|
||||||
|
"PYEOF" % RELAY_DIR)
|
||||||
|
out = run(script2, timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 语法检查 + 跑一次 ──")
|
||||||
|
out = run("bash -n %s/monitor.sh && echo ' 语法 OK'; "
|
||||||
|
"printf 'fails=0\\nlast_rescue=0\\n' > "
|
||||||
|
"%s/monitor_state.txt; "
|
||||||
|
"bash %s/monitor.sh; echo ' --- 状态 ---'; "
|
||||||
|
"cat %s/monitor_state.txt; echo ' --- 日志尾 ---'; "
|
||||||
|
"grep -v 'Permission denied' %s/monitor.log | tail -4"
|
||||||
|
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR),
|
||||||
|
timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 现在探测的各层 ──")
|
||||||
|
out = run(
|
||||||
|
'printf " %%-34s %%s\\n" "PRIMARY https://域名" '
|
||||||
|
'"$(curl -sL -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||||
|
'https://yk.xxmxxs.top/v1/health)"; '
|
||||||
|
'printf " %%-34s %%s\\n" "FALLBACK 127.0.0.1:13245" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://127.0.0.1:13245/v1/health)"; '
|
||||||
|
'printf " %%-34s %%s\\n" "nginx 本机:80(会 301)" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://127.0.0.1:80/v1/health)"; '
|
||||||
|
'printf " %%-34s %%s\\n" "nginx 本机:443" '
|
||||||
|
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'--resolve yk.xxmxxs.top:443:127.0.0.1 '
|
||||||
|
'https://yk.xxmxxs.top/v1/health)"',
|
||||||
|
timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''修监测单元:它现在查错了地址,在**误报网站挂了**。
|
||||||
|
|
||||||
|
== 怎么发现的 ==
|
||||||
|
|
||||||
|
把中转改成只监听 127.0.0.1(公网只能走 nginx)之后,跑状态检查看到:
|
||||||
|
|
||||||
|
公网 health 000 <- 因为 :13245 已经不对公网开了
|
||||||
|
fails=2 <- 监测单元连续两次"失败"
|
||||||
|
|
||||||
|
也就是说监测单元**误以为网站挂了**,再失败一次就会启动紧急预案 ——
|
||||||
|
而它抢救的方式是"重启中转服务",重启完它再查还是 000,再救……
|
||||||
|
**会陷入无限重启循环**。
|
||||||
|
|
||||||
|
这是我自己改监听地址时没想到的连带影响。教训:改一个服务的
|
||||||
|
暴露方式时,必须把所有"探测它"的地方一起看一遍。
|
||||||
|
|
||||||
|
== 改法 ==
|
||||||
|
|
||||||
|
监测的地址应该以"**用户实际怎么访问**"为准,现在应该是:
|
||||||
|
|
||||||
|
http://yk.xxmxxs.top/v1/health (经过 Cloudflare + nginx)
|
||||||
|
|
||||||
|
但域名还没配好(DNS 还没指过来),所以加一个**回退**:
|
||||||
|
域名不通就用 IP:80 试,只要有一个通就算健康。
|
||||||
|
|
||||||
|
这样:
|
||||||
|
* 域名配好之前 -> 走 IP:80,正常监控
|
||||||
|
* 域名配好之后 -> 走域名,监控的是用户真实路径
|
||||||
|
* 两个都不通 -> 才是真挂了
|
||||||
|
|
||||||
|
== 顺带 ==
|
||||||
|
|
||||||
|
monitor.log 的属主问题:monitor.service 以 admin 跑,
|
||||||
|
而 relay.log 之类的之前是 root 建的。已经 chown 过一次,
|
||||||
|
但 monitor.log 是后来新生成的(仍是 root)—— 这次一并修掉,
|
||||||
|
并且在 unit 里加一行让 systemd 自己保证目录属主。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
FALLBACK = "http://%s:80" % LS_HOST
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 修监测单元:改探测地址 + 修日志属主")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 改 monitor.sh 的健康检查(域名优先,IP 兜底)──")
|
||||||
|
# 把 PUBLIC_URL 的默认值换掉,并把 is_healthy 改成"两个都试"
|
||||||
|
old_url = 'PUBLIC_URL="${PUBLIC_URL:-http://LS_HOST:13245}"'
|
||||||
|
new_url = (
|
||||||
|
'# 探测地址:域名优先(用户实际走的路),IP:80 兜底\n'
|
||||||
|
'# (域名 DNS 还没指过来时用它,否则监测会一直误报)\n'
|
||||||
|
'PUBLIC_URL="${PUBLIC_URL:-http://%s}"\n'
|
||||||
|
'FALLBACK_URL="${FALLBACK_URL:-%s}"' % (DOMAIN, FALLBACK))
|
||||||
|
|
||||||
|
out = run(
|
||||||
|
"grep -n 'PUBLIC_URL=' %s/monitor.sh | head -3" % RELAY_DIR,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
print(" 改前:")
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
script = (
|
||||||
|
"python3 - <<'PYEOF'\n"
|
||||||
|
"from pathlib import Path\n"
|
||||||
|
"p = Path('%s/monitor.sh')\n"
|
||||||
|
"t = p.read_text(encoding='utf-8')\n"
|
||||||
|
"old = [l for l in t.splitlines() if l.startswith('PUBLIC_URL=')]\n"
|
||||||
|
"if not old:\n"
|
||||||
|
" print(' x 找不到 PUBLIC_URL 行')\n"
|
||||||
|
"else:\n"
|
||||||
|
" t = t.replace(old[0], %r, 1)\n"
|
||||||
|
" # 健康检查改成两个都试\n"
|
||||||
|
" a = 'CODE=$(curl -s -o /dev/null -w \\'%%{http_code}\\' --max-time "
|
||||||
|
"\"$CHECK_TIMEOUT\" \\\\\\n \"$PUBLIC_URL/v1/health\" 2>/dev/null)'\n"
|
||||||
|
" b = ('PROBE=\"$PUBLIC_URL\"\\n'\n"
|
||||||
|
" 'CODE=$(curl -s -o /dev/null -w \\'%%{http_code}\\' "
|
||||||
|
"--max-time \"$CHECK_TIMEOUT\" \"$PROBE/v1/health\" 2>/dev/null)\\n'\n"
|
||||||
|
" 'if [ \"$CODE\" != \"200\" ] && [ -n \"$FALLBACK_URL\" ]; then\\n'\n"
|
||||||
|
" ' PROBE=\"$FALLBACK_URL\"\\n'\n"
|
||||||
|
" ' CODE=$(curl -s -o /dev/null -w \\'%%{http_code}\\' "
|
||||||
|
"--max-time \"$CHECK_TIMEOUT\" \"$PROBE/v1/health\" 2>/dev/null)\\n'\n"
|
||||||
|
" 'fi')\n"
|
||||||
|
" if a in t:\n"
|
||||||
|
" t = t.replace(a, b, 1)\n"
|
||||||
|
" print(' ok 健康检查改成域名优先 + IP 兜底')\n"
|
||||||
|
" else:\n"
|
||||||
|
" print(' ! 健康检查那段没匹配上,手工看')\n"
|
||||||
|
" p.write_text(t, encoding='utf-8', newline='\\n')\n"
|
||||||
|
"PYEOF" % (RELAY_DIR, new_url))
|
||||||
|
|
||||||
|
out = run(script, timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
out = run("grep -n 'PUBLIC_URL=\\|FALLBACK_URL=\\|PROBE=' %s/monitor.sh "
|
||||||
|
"| head -8" % RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
print(" 改后:")
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 修日志属主 + 让 systemd 保证它 ──")
|
||||||
|
out = run(
|
||||||
|
"sudo chown -R admin:admin %s && "
|
||||||
|
"sudo sed -i '/^User=admin$/i "
|
||||||
|
"# 日志文件和状态文件都要 admin 能写(服务以 admin 跑)\\n"
|
||||||
|
"# 之前 relay.log / monitor.log 是 root 建的,导致 "
|
||||||
|
"monitor.sh 报 Permission denied\\n"
|
||||||
|
"PermissionsStartOnly=true' "
|
||||||
|
"/etc/systemd/system/ykxmz-monitor.service 2>/dev/null; "
|
||||||
|
"sudo touch %s/monitor.log %s/monitor_state.txt && "
|
||||||
|
"sudo chown admin:admin %s/*.log %s/*.txt %s/*.md 2>/dev/null; "
|
||||||
|
"ls -la %s/*.log | awk '{print \" \" $3\":\"$4\" \" $9}'"
|
||||||
|
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR,
|
||||||
|
RELAY_DIR, RELAY_DIR), timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 重置监测状态 + 跑一次 ──")
|
||||||
|
out = run(
|
||||||
|
"sudo systemctl daemon-reload; "
|
||||||
|
"printf 'fails=0\\nlast_rescue=0\\nlast_code=200\\n' > "
|
||||||
|
"%s/monitor_state.txt; "
|
||||||
|
"bash %s/monitor.sh; echo '--- 状态 ---'; "
|
||||||
|
"cat %s/monitor_state.txt; echo '--- 日志尾 ---'; "
|
||||||
|
"tail -3 %s/monitor.log" % (RELAY_DIR, RELAY_DIR, RELAY_DIR,
|
||||||
|
RELAY_DIR),
|
||||||
|
timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 验证探测链路 ──")
|
||||||
|
out = run(
|
||||||
|
'printf " %-28s %s\\n" "域名(还没配)" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://%s/v1/health)"; '
|
||||||
|
'printf " %-28s %s\\n" "IP:80 (兜底)" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://LS_HOST:80/v1/health)"; '
|
||||||
|
'printf " %-28s %s\\n" "IP:13245 (已关闭)" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://LS_HOST:13245/v1/health)"' % DOMAIN,
|
||||||
|
timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''永久修掉 nettune 死锁。
|
||||||
|
|
||||||
|
== 死锁回顾 ==
|
||||||
|
|
||||||
|
lanremote-nettune.service Before=lanremote-tunnel.service
|
||||||
|
net-tune.sh 第 4 步 systemctl restart lanremote-tunnel
|
||||||
|
|
||||||
|
nettune 在"自己还没结束"的时候去 restart 一个"必须等自己结束才能启动"
|
||||||
|
的服务 —— systemd 认为顺序冲突,restart 永久阻塞。
|
||||||
|
|
||||||
|
实测卡了 9 分半,nettune 一直 activating,隧道被 Before= 挡着起不来,
|
||||||
|
公网 530。这就是"网站容易挂"的真因之一。
|
||||||
|
|
||||||
|
== 修法 ==
|
||||||
|
|
||||||
|
第 4 步的本意是"让**已经建好**的隧道用上新参数"(改完 MTU/MSS 之后
|
||||||
|
手动重跑脚本时用)。**开机时根本不该走到那一步** ——
|
||||||
|
开机顺序本来就是 nettune 先、隧道后,那时隧道还没起。
|
||||||
|
|
||||||
|
所以:unit 里加 Environment=NETTUNE_SERVICE_CALL=1,
|
||||||
|
脚本看到这个变量就跳过第 4 步。
|
||||||
|
|
||||||
|
为什么不干脆删掉第 4 步:
|
||||||
|
手动改完网络参数后,已经建立的隧道还在用旧参数,
|
||||||
|
需要重启它才生效。这个功能是有用的,只是不该在服务启动时跑。
|
||||||
|
|
||||||
|
为什么不用 `systemctl restart --no-block`:
|
||||||
|
那只是把阻塞变成异步,nettune 立刻退出、隧道随后重启 ——
|
||||||
|
开机时仍然是无谓的一次重启(隧道刚起来就被停掉),
|
||||||
|
而且没解决"不该在开机时重启"这个语义问题。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
TUNE = ROOT / "relay" / "net-tune.sh"
|
||||||
|
|
||||||
|
OLD = '''echo " 4) 让已经建立的隧道用上新参数"'''
|
||||||
|
|
||||||
|
NEW = '''# ════════════════════════════════════════════════════════════════════
|
||||||
|
# 4) 让已经建立的隧道用上新参数 —— **只在手动跑的时候做**
|
||||||
|
# ════════════════════════════════════════════════════════════════════
|
||||||
|
#
|
||||||
|
# ⚠ 这一步绝对不能由 lanremote-nettune.service 执行。
|
||||||
|
#
|
||||||
|
# 那个 unit 里写着 Before=lanremote-tunnel.service
|
||||||
|
# (意思是"我必须先于隧道启动完")。如果脚本在自己还没结束的时候
|
||||||
|
# 去 `systemctl restart lanremote-tunnel`,systemd 会认为存在
|
||||||
|
# 顺序冲突 —— restart 永久阻塞、nettune 一直卡在 activating、
|
||||||
|
# 隧道被 Before= 挡着永远起不来。
|
||||||
|
#
|
||||||
|
# 实测卡了 9 分半,表现就是"网站又挂了",而日志里什么都看不出来
|
||||||
|
# (只有 nettune 停在 activating)。手动跑本脚本却完全正常
|
||||||
|
# (因为那时没有 Before= 约束)—— 所以这个坑特别难发现。
|
||||||
|
#
|
||||||
|
# 开机时本来也不需要这一步:顺序就是 nettune 先、隧道后,
|
||||||
|
# 那时候隧道还没起来。
|
||||||
|
if [ "${NETTUNE_SERVICE_CALL:-0}" = "1" ]; then
|
||||||
|
echo " 4) 由 nettune 服务调用,**跳过重启隧道**"
|
||||||
|
echo " (unit 里有 Before=lanremote-tunnel.service,"
|
||||||
|
echo " 在这里 restart 会和 systemd 的顺序约束死锁)"
|
||||||
|
echo " (开机顺序本来就是本脚本先、隧道后,不需要这一步)"
|
||||||
|
elif [ "${SKIP_TUNNEL_RESTART:-0}" = "1" ]; then
|
||||||
|
echo " 4) SKIP_TUNNEL_RESTART=1,跳过重启隧道"
|
||||||
|
else
|
||||||
|
echo " 4) 让已经建立的隧道用上新参数"'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = TUNE.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "NETTUNE_SERVICE_CALL" in text:
|
||||||
|
print(" · net-tune.sh 已经修过了")
|
||||||
|
elif OLD not in text:
|
||||||
|
print(" ✗ 找不到锚点")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
text = text.replace(OLD, NEW, 1)
|
||||||
|
# 原来的 for 循环 + RESTARTED 逻辑要收进 else 分支
|
||||||
|
# 找到 "echo \" 网络调优已应用" 之前的部分,补一个 fi
|
||||||
|
tail_anchor = 'echo " 网络调优已应用'
|
||||||
|
i = text.find(tail_anchor)
|
||||||
|
if i < 0:
|
||||||
|
print(" ✗ 找不到结尾锚点")
|
||||||
|
return 1
|
||||||
|
# 在结尾那行之前插入 fi(缩进对齐 for 循环之外)
|
||||||
|
text = text[:i] + "fi\n\n" + text[i:]
|
||||||
|
TUNE.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||||
|
+ text.encode("utf-8"))
|
||||||
|
print(" ✓ net-tune.sh 加了 NETTUNE_SERVICE_CALL 判断")
|
||||||
|
|
||||||
|
# ---- 推到设备 + 给 unit 加环境变量 ----
|
||||||
|
upload(str(TUNE), "/root/lanremote-relay/net-tune.sh")
|
||||||
|
print(" ✓ 已上传 net-tune.sh")
|
||||||
|
|
||||||
|
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
|
||||||
|
# ⚠ NETTUNE_SERVICE_CALL=1 告诉 net-tune.sh:这次是**服务**在调它,
|
||||||
|
# 不是人工跑的。脚本据此跳过"重启隧道让新参数生效"那一步。
|
||||||
|
#
|
||||||
|
# 为什么必须跳过:本 unit 有 Before=lanremote-tunnel.service,
|
||||||
|
# 脚本在自己还没结束时去 restart 隧道,systemd 会判定顺序冲突,
|
||||||
|
# restart 永久阻塞 -> nettune 卡在 activating -> 隧道起不来 -> 公网 530。
|
||||||
|
# 实测卡过 9 分半。
|
||||||
|
Environment=NETTUNE_SERVICE_CALL=1
|
||||||
|
ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
'''
|
||||||
|
import tempfile
|
||||||
|
tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service"
|
||||||
|
tmp.write_text(unit, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/etc/systemd/system/lanremote-nettune.service")
|
||||||
|
print(" ✓ 已更新 unit(加了 NETTUNE_SERVICE_CALL=1)")
|
||||||
|
|
||||||
|
out = run("bash -n /root/lanremote-relay/net-tune.sh && echo SH_OK; "
|
||||||
|
"systemctl daemon-reload && "
|
||||||
|
"systemctl restart lanremote-nettune && sleep 6 && "
|
||||||
|
"echo \"nettune: $(systemctl is-active lanremote-nettune)\"; "
|
||||||
|
"echo \"tunnel: $(systemctl is-active lanremote-tunnel)\"; "
|
||||||
|
"echo \"h2: $(systemctl is-active lanremote-tunnel-h2)\"; "
|
||||||
|
"echo \"公网: $(curl -s -o /dev/null -w '%{http_code}' "
|
||||||
|
"--max-time 20 https://relay.xxmxxs.top/v1/health)\"",
|
||||||
|
timeout=180)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -20,7 +20,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
OLD = "REVA" + "edu120830"
|
OLD = "REVA" + "edu<改从环境变量读>"
|
||||||
|
|
||||||
passed = 0
|
passed = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''撤掉 Gitee 发行版介绍里泄漏的公网 IP。
|
||||||
|
|
||||||
|
== 用户报的 ==
|
||||||
|
|
||||||
|
"那个发行版的介绍里,服务器公网 IP 地址暴露出去了"
|
||||||
|
|
||||||
|
来源:发布脚本(gitee_release.py / release_all.py)把
|
||||||
|
_build/CHANGELOG.md 当"更新说明"传给 Gitee 建发行版,
|
||||||
|
而 CHANGELOG 的 2.1.3 那节里写了 <源站IP>。
|
||||||
|
|
||||||
|
工作区那边已经清了(_build/scrub_server_ip.py),
|
||||||
|
但**已经发出去的发行版介绍还在 Gitee 上**,得用 API 改回去。
|
||||||
|
|
||||||
|
== 接口 ==
|
||||||
|
|
||||||
|
PATCH /api/v5/repos/{owner}/{repo}/releases/{id}
|
||||||
|
参数:access_token + body(新的发行版介绍)
|
||||||
|
|
||||||
|
== 顺带检查所有发行版 ==
|
||||||
|
|
||||||
|
不只 v2.1.3 —— 从 2.1.0 起我就在"把 CHANGELOG 当更新说明",
|
||||||
|
而 2.1.0/2.1.1 那几节也提到过服务器。全部扫一遍、有 IP 的都改掉。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# ⚠ 这个脚本要"在所有发行版介绍里找这个 IP",所以它必须知道 IP。
|
||||||
|
# 但**不能在源码里写死** —— 写了就等于把 IP 又发到公开仓库里,
|
||||||
|
# 正是这个脚本要防的事。
|
||||||
|
# 从 lssh 取(那里会读环境变量 YKXMZ_LS_HOST 或
|
||||||
|
# _build/.lightsail_host,后者已 gitignore)。
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from lssh import HOST as IP, HOST as LS_HOST # noqa: E402
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
OWNER_REPO = "xmz7891/ykxmz"
|
||||||
|
|
||||||
|
|
||||||
|
def api(url: str, data: bytes = None, method: str = None) -> tuple:
|
||||||
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
|
req.add_header("User-Agent", "ykxmz-release-fix")
|
||||||
|
if data is not None:
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return r.status, json.loads(r.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, exc.read().decode("utf-8", "replace")[:300]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
token_file = ROOT / "_build" / ".gitee_token"
|
||||||
|
if not token_file.is_file():
|
||||||
|
print(" ✗ 找不到 _build/.gitee_token")
|
||||||
|
return 1
|
||||||
|
token = token_file.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 撤掉 Gitee 发行版介绍里的公网 IP")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 列出所有发行版 ──")
|
||||||
|
code, data = api(
|
||||||
|
"https://gitee.com/api/v5/repos/%s/releases"
|
||||||
|
"?access_token=%s&per_page=60" % (OWNER_REPO, token))
|
||||||
|
if code != 200:
|
||||||
|
print(" ✗ 列不出来: %s" % data)
|
||||||
|
return 1
|
||||||
|
print(" 共 %d 个发行版" % len(data))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 逐个检查 body,有 IP 的改掉 ──")
|
||||||
|
fixed = 0
|
||||||
|
for rel in data:
|
||||||
|
tag = rel.get("tag_name", "?")
|
||||||
|
rid = rel.get("id")
|
||||||
|
body = rel.get("body") or ""
|
||||||
|
if IP not in body:
|
||||||
|
print(" %-10s 干净" % tag)
|
||||||
|
continue
|
||||||
|
|
||||||
|
n = body.count(IP)
|
||||||
|
new_body = body.replace(IP, DOMAIN)
|
||||||
|
print(" %-10s ⚠ 有 %d 处 -> 改掉" % (tag, n))
|
||||||
|
|
||||||
|
# ⚠ 两个坑:
|
||||||
|
#
|
||||||
|
# 1. 必须用 **JSON**。第一版 urlencode 拼了 data,但 api()
|
||||||
|
# 见 data 非空就声明 Content-Type: application/json ——
|
||||||
|
# 内容和声明不一致,Gitee 回 400(一页 HTML 错误页)。
|
||||||
|
#
|
||||||
|
# 2. **tag_name 和 name 必须一起传**,即使不改它们。
|
||||||
|
# 漏了会报 {"messages":["tag_name is missing",
|
||||||
|
# "name is missing"]}。
|
||||||
|
# (PATCH 语义上应该允许只改 body,但 Gitee 的实现要求
|
||||||
|
# 把标识字段带上 —— 那就带上。)
|
||||||
|
payload = json.dumps({
|
||||||
|
"access_token": token,
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": rel.get("name") or tag,
|
||||||
|
"body": new_body,
|
||||||
|
}).encode("utf-8")
|
||||||
|
code2, resp = api(
|
||||||
|
"https://gitee.com/api/v5/repos/%s/releases/%s"
|
||||||
|
% (OWNER_REPO, rid), data=payload, method="PATCH")
|
||||||
|
if code2 == 200:
|
||||||
|
print(" ✓ 已更新")
|
||||||
|
fixed += 1
|
||||||
|
else:
|
||||||
|
print(" ✗ 更新失败 (%s): %s" % (code2, resp))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 复查 ──")
|
||||||
|
code, data = api(
|
||||||
|
"https://gitee.com/api/v5/repos/%s/releases"
|
||||||
|
"?access_token=%s&per_page=60" % (OWNER_REPO, token))
|
||||||
|
still = [r.get("tag_name") for r in data
|
||||||
|
if IP in (r.get("body") or "")]
|
||||||
|
if still:
|
||||||
|
print(" ⚠ 还有: %s" % ", ".join(still))
|
||||||
|
else:
|
||||||
|
print(" ✅ 所有发行版介绍里都没有这个 IP 了")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 改了 %d 个发行版" % fixed)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''修 /rescue/ 鉴权失败时"不发响应"导致的挂起。
|
||||||
|
|
||||||
|
== 现象 ==
|
||||||
|
|
||||||
|
加上鉴权之后,不带登录请求 /rescue/xxx.txt 会**挂到超时**:
|
||||||
|
|
||||||
|
> GET /rescue/19700101-000000-demo.txt HTTP/1.1
|
||||||
|
* Operation timed out after 8001 milliseconds with 0 bytes received
|
||||||
|
|
||||||
|
curl 报 HTTP 000。
|
||||||
|
|
||||||
|
== 根因 ==
|
||||||
|
|
||||||
|
_admin_gate() **只返回布尔值,不负责发响应** —— 它是个判断函数。
|
||||||
|
所以每个调用点都必须自己在 false 时把响应发出去。
|
||||||
|
|
||||||
|
看其它调用点,全都是这么写的:
|
||||||
|
|
||||||
|
if not self._admin_gate():
|
||||||
|
self._json(401, {"ok": False, "error": "请先登录"})
|
||||||
|
return
|
||||||
|
|
||||||
|
而我加的那两处写成了:
|
||||||
|
|
||||||
|
if not self._admin_gate():
|
||||||
|
return <- do_GET:一个字节都没发
|
||||||
|
...
|
||||||
|
if not self._admin_gate():
|
||||||
|
return no() <- do_HEAD:no() 默认 404,状态码也不对
|
||||||
|
|
||||||
|
裸 return 在 BaseHTTPRequestHandler 里就是"什么都不做" ——
|
||||||
|
连接保持着、没有响应头、客户端一直等。表现成"网站卡住",
|
||||||
|
比报 401 严重得多。
|
||||||
|
|
||||||
|
== 改法 ==
|
||||||
|
|
||||||
|
do_GET -> self._json(401, ...) 然后 return
|
||||||
|
do_HEAD -> return no(401) (no() 本来就支持传状态码)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
RELAY = ROOT / "relay" / "relay.py"
|
||||||
|
|
||||||
|
GET_OLD = ''' if not self._admin_gate():
|
||||||
|
return
|
||||||
|
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
|
||||||
|
|
||||||
|
GET_NEW = ''' # ⚠ _admin_gate() 只返回布尔值,**不负责发响应** ——
|
||||||
|
# 每个调用点都要自己发。裸 return 在 BaseHTTPRequestHandler
|
||||||
|
# 里等于"什么都不做":连接保持着、没有响应头,
|
||||||
|
# 客户端一直等到超时(curl 报 HTTP 000)。
|
||||||
|
# 第一版就是这么写的,实测 /rescue/ 会挂住 8 秒。
|
||||||
|
if not self._admin_gate():
|
||||||
|
self._json(401, {"ok": False, "error": "请先登录"})
|
||||||
|
return
|
||||||
|
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
|
||||||
|
|
||||||
|
HEAD_OLD = ''' if not self._admin_gate():
|
||||||
|
return no()
|
||||||
|
wanted = urllib.parse.unquote(path[len("/rescue/"):])'''
|
||||||
|
|
||||||
|
HEAD_NEW = ''' # 同上:必须自己发响应,而且状态码要是 401 不是 404
|
||||||
|
# (no() 默认 404,探测方会以为是"文件不存在"而不是"没登录")。
|
||||||
|
if not self._admin_gate():
|
||||||
|
return no(401)
|
||||||
|
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")
|
||||||
|
|
||||||
|
ok = True
|
||||||
|
for old, new, what in ((GET_OLD, GET_NEW, "do_GET"),
|
||||||
|
(HEAD_OLD, HEAD_NEW, "do_HEAD")):
|
||||||
|
if old not in text:
|
||||||
|
print(" ✗ %s: 找不到锚点" % what)
|
||||||
|
ok = False
|
||||||
|
continue
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
print(" ✓ %s: 鉴权失败时现在会发响应" % what)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
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())
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''补上 add_rescue_section.py 漏掉/插错的三处。
|
||||||
|
|
||||||
|
前一个补丁的问题:
|
||||||
|
1. _rescue_rows() 只在补丁脚本里定义,**没插进 admin.py** ——
|
||||||
|
后台一渲染就 NameError(页面直接 500)
|
||||||
|
2. /rescue/ 路由被插进了 **do_HEAD**,而且用了 `self.wfile.write(body)` ——
|
||||||
|
HEAD 本来就不该有 body。而且 do_GET 那边一条都没加,
|
||||||
|
等于浏览器点下载根本走不到。
|
||||||
|
(锚点 "/download/" 出现两次:do_HEAD 和 do_GET 各一份,
|
||||||
|
replace(...,1) 只命中了前面那个。这就是那个 "! 锚点 2 处" 警告。)
|
||||||
|
|
||||||
|
这个脚本按 do_HEAD 自己的 ok()/no() 风格把两处都写对。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
RELAY = ROOT / "relay" / "relay.py"
|
||||||
|
ADMIN = ROOT / "relay" / "admin.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load(p: Path):
|
||||||
|
raw = p.read_bytes()
|
||||||
|
return raw, raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
|
||||||
|
def save(p: Path, text: str, bom: bool):
|
||||||
|
p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. do_HEAD:换成 ok()/no() 风格(不能有 body)
|
||||||
|
# ===========================================================================
|
||||||
|
BAD_HEAD = ''' 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
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
GOOD_HEAD = ''' if path.startswith("/rescue/"):
|
||||||
|
# 抢救报告。HEAD 只回长度和类型,**不写 body** ——
|
||||||
|
# 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。
|
||||||
|
wanted = urllib.parse.unquote(path[len("/rescue/"):])
|
||||||
|
safe = _safe_rescue_name(wanted)
|
||||||
|
if not safe:
|
||||||
|
return no()
|
||||||
|
target = RESCUE_DIR / safe
|
||||||
|
if not target.is_file():
|
||||||
|
return no()
|
||||||
|
quoted = urllib.parse.quote(safe)
|
||||||
|
headers = {"Content-Disposition":
|
||||||
|
"attachment; filename*=UTF-8''%s" % quoted,
|
||||||
|
"Cache-Control": "no-store"}
|
||||||
|
return ok("text/plain; charset=utf-8", target.stat().st_size,
|
||||||
|
headers)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. do_GET:加同一条路由(这里才真正回 body)
|
||||||
|
# ===========================================================================
|
||||||
|
GET_ANCHOR = ''' if path.startswith("/download/"):
|
||||||
|
wanted = urllib.parse.unquote(path[len("/download/"):])
|
||||||
|
safe = _safe_download_name(wanted)
|
||||||
|
if not safe:
|
||||||
|
self._json(400, {"ok": False, "error": "文件名不合法"})
|
||||||
|
return'''
|
||||||
|
|
||||||
|
GET_NEW = ''' if path.startswith("/rescue/"):
|
||||||
|
# 抢救报告下载。**单独一条路由,不发缓存头** ——
|
||||||
|
# 内容会变,而且不该让 Cloudflare 边缘留着。
|
||||||
|
# (和 /download/ 分成两套是有意的:那个是给用户的安装包,
|
||||||
|
# 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。)
|
||||||
|
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
|
||||||
|
|
||||||
|
''' + GET_ANCHOR
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. admin.py:把 _rescue_rows 真正定义进去
|
||||||
|
# ===========================================================================
|
||||||
|
RESCUE_FN = '''def _rescue_rows(items: list) -> str:
|
||||||
|
"""抢救报告列表的 HTML。
|
||||||
|
|
||||||
|
照 dl_rows 的风格:在服务端直接拼好塞进模板,不用 JS 去拉 ——
|
||||||
|
这台机器只有 460MB 内存,多个接口就多份开销,而且后台页
|
||||||
|
本来就只是一次性的展示。
|
||||||
|
|
||||||
|
没有报告时给一句正面的话("一直没出过问题"),
|
||||||
|
而不是干巴巴的"暂无数据"—— 没报告是好事。
|
||||||
|
"""
|
||||||
|
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 = _esc(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 render_admin('''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
# ---- relay.py ----
|
||||||
|
raw, bom, text = load(RELAY)
|
||||||
|
if BAD_HEAD in text:
|
||||||
|
text = text.replace(BAD_HEAD, GOOD_HEAD, 1)
|
||||||
|
print(" ✓ do_HEAD 里的 /rescue/ 换成 ok()/no() 风格(不再写 body)")
|
||||||
|
elif GOOD_HEAD in text:
|
||||||
|
print(" · do_HEAD 已经是对的")
|
||||||
|
else:
|
||||||
|
print(" ✗ do_HEAD 里找不到那段 /rescue/")
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
if 'if path.startswith("/rescue/")' in text.split("def do_GET")[1]:
|
||||||
|
print(" · do_GET 里已经有了")
|
||||||
|
else:
|
||||||
|
if GET_ANCHOR in text:
|
||||||
|
# 只替换 do_GET 里那一处(do_HEAD 那处已经在上面被换掉了,
|
||||||
|
# 所以现在全文只剩一处)
|
||||||
|
n = text.count(GET_ANCHOR)
|
||||||
|
if n != 1:
|
||||||
|
print(" ! do_GET 锚点还有 %d 处" % n)
|
||||||
|
text = text.replace(GET_ANCHOR, GET_NEW, 1)
|
||||||
|
print(" ✓ do_GET 加了 /rescue/ 路由(真正回 body 的那条)")
|
||||||
|
else:
|
||||||
|
print(" ✗ do_GET 里找不到 /download/ 锚点")
|
||||||
|
ok = False
|
||||||
|
save(RELAY, text, bom)
|
||||||
|
|
||||||
|
# ---- admin.py ----
|
||||||
|
raw, bom, text = load(ADMIN)
|
||||||
|
if "def _rescue_rows(" in text:
|
||||||
|
print(" · admin.py 里已经定义了 _rescue_rows")
|
||||||
|
else:
|
||||||
|
if "def render_admin(" not in text:
|
||||||
|
print(" ✗ admin.py 里找不到 render_admin")
|
||||||
|
ok = False
|
||||||
|
else:
|
||||||
|
text = text.replace("def render_admin(", RESCUE_FN, 1)
|
||||||
|
save(ADMIN, text, bom)
|
||||||
|
print(" ✓ admin.py 里补上了 _rescue_rows 的定义")
|
||||||
|
ok = ok and True
|
||||||
|
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''修我自己 scrub 时弄坏的 3 个文件。
|
||||||
|
|
||||||
|
== 怎么坏的 ==
|
||||||
|
|
||||||
|
scrub_old_password.py 里对这个模式:
|
||||||
|
|
||||||
|
{"password": "<六位数字>"}
|
||||||
|
|
||||||
|
做了个"聪明"的替换:
|
||||||
|
|
||||||
|
{"password": "" # 见下方注释:不再写死}
|
||||||
|
|
||||||
|
—— 把 `"<六位数字>"` 换成了带注释的东西,**塞进了字典字面量中间**,
|
||||||
|
于是 `{` 永远不闭合,三个文件全语法错。
|
||||||
|
|
||||||
|
教训:**批量替换不能往表达式中间塞注释**。
|
||||||
|
这次改成把整行替换掉,并在文件顶部加一个读密码的辅助函数。
|
||||||
|
|
||||||
|
(同一批被改的其它文件只是字符串内容变了,语法没坏,
|
||||||
|
这里只修真正语法出错的三个。)
|
||||||
|
|
||||||
|
== 顺带 ==
|
||||||
|
|
||||||
|
这个脚本本身也演示了"怎么在运维脚本里拿密码而不写死":
|
||||||
|
从环境变量或 _build/.admin_password 读(后者已 gitignore)。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
BROKEN_LINE = re.compile(
|
||||||
|
r'^(\s*)form = urllib\.parse\.urlencode\(\{"password": ""'
|
||||||
|
r'\s*# 见下方注释:不再写死\}\)\.encode\(\)\s*$')
|
||||||
|
|
||||||
|
# ⚠ 不要手写文件列表。
|
||||||
|
#
|
||||||
|
# 第一版我列了三个文件名(照语法检查的输出抄的),
|
||||||
|
# 结果一跑发现还有两个同样的坏文件没修 —— 列表是**观察到的**,
|
||||||
|
# 不是**穷尽的**。改成全仓库扫这个模式,漏不掉。
|
||||||
|
SCAN_DIRS = ("_build", "_tests", "windows", "relay", "installer")
|
||||||
|
|
||||||
|
REPLACEMENT = (
|
||||||
|
'{indent}form = urllib.parse.urlencode('
|
||||||
|
'{{"password": _admin_password()}}).encode()')
|
||||||
|
|
||||||
|
HELPER = '''
|
||||||
|
|
||||||
|
# ⚠ 不再把密码写死在脚本里。
|
||||||
|
#
|
||||||
|
# 这个仓库是公开的,密码写进来就等于公开(2026-10-08 踩过:
|
||||||
|
# 旧密码曾被写进安装包的许可页,每个装软件的人都能看到)。
|
||||||
|
# 现在从环境变量或 _build/.admin_password(已 gitignore)读 ——
|
||||||
|
# 读不到就直接报错,别偷偷用一个默认值。
|
||||||
|
def _admin_password() -> str:
|
||||||
|
import os
|
||||||
|
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
f = Path(__file__).resolve().parent / ".admin_password"
|
||||||
|
if f.is_file():
|
||||||
|
return f.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,"
|
||||||
|
"或把密码写进 _build/.admin_password")
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 修被 scrub 弄坏的 3 个文件")
|
||||||
|
print("=" * 58)
|
||||||
|
|
||||||
|
fixed = 0
|
||||||
|
# 全仓库找坏模式,而不是照抄一份文件名列表
|
||||||
|
targets = []
|
||||||
|
for d in SCAN_DIRS:
|
||||||
|
for p in sorted((ROOT / d).rglob("*.py")):
|
||||||
|
try:
|
||||||
|
if BROKEN_LINE.search(p.read_text(encoding="utf-8-sig")):
|
||||||
|
targets.append(p)
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(" 扫到 %d 个待修文件" % len(targets))
|
||||||
|
print()
|
||||||
|
for path in targets:
|
||||||
|
rel = path.relative_to(ROOT).as_posix()
|
||||||
|
raw = path.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
lines = text.splitlines()
|
||||||
|
hit = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
m = BROKEN_LINE.match(line)
|
||||||
|
if m:
|
||||||
|
lines[i] = REPLACEMENT.format(indent=m.group(1))
|
||||||
|
hit = True
|
||||||
|
|
||||||
|
if not hit:
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
# 顶部加辅助函数(放在 `import` 全部结束之后)
|
||||||
|
if "_admin_password" not in text.split("def _admin_password")[0]:
|
||||||
|
m2 = None
|
||||||
|
for m in re.finditer(r"^(?:import|from)\s+\S+.*$", text, re.M):
|
||||||
|
m2 = m
|
||||||
|
if m2:
|
||||||
|
text = text[:m2.end()] + "\n" + HELPER + text[m2.end():]
|
||||||
|
else:
|
||||||
|
text = HELPER + text
|
||||||
|
|
||||||
|
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||||
|
+ text.encode("utf-8"))
|
||||||
|
print(" ✓ %s" % rel)
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 修了 %d 个" % fixed)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''给服务器配上公共 DNS,让它能解析自己的域名。
|
||||||
|
|
||||||
|
== 问题 ==
|
||||||
|
|
||||||
|
Lightsail 默认用内网解析器 172.26.0.2:
|
||||||
|
|
||||||
|
Current DNS Server: 172.26.0.2
|
||||||
|
dig @172.26.0.2 yk.xxmxxs.top -> 空
|
||||||
|
dig @1.1.1.1 yk.xxmxxs.top -> 104.21.54.45 ✅
|
||||||
|
dig @8.8.8.8 yk.xxmxxs.top -> 104.21.54.45 ✅
|
||||||
|
|
||||||
|
用户刚加完 DNS 记录,公共解析器已经更新,但 Amazon 那个内网解析器
|
||||||
|
**还缓存着"这个域名不存在"**(负缓存,通常几分钟到一小时)。
|
||||||
|
|
||||||
|
后果不只是"监测误报"——任何在服务器上解析该域名的东西都会失败。
|
||||||
|
所以配上公共 DNS,既有 IPv4 也有 IPv6,并且让内网那个当后备
|
||||||
|
(内网解析器对 AWS 自家服务更快,留着有用)。
|
||||||
|
|
||||||
|
== 怎么配(Debian 13 + systemd-resolved)==
|
||||||
|
|
||||||
|
/etc/systemd/resolved.conf.d/ 下放一个 drop-in:
|
||||||
|
|
||||||
|
[Resolve]
|
||||||
|
DNS=1.1.1.1 8.8.8.8 2606:4700:4700::1111 2606:4700:4700::1001
|
||||||
|
FallbackDNS=172.26.0.2
|
||||||
|
Domains=~.
|
||||||
|
|
||||||
|
`Domains=~.` 表示"所有域名都用上面这些",把内网那个降为后备。
|
||||||
|
|
||||||
|
改完 `systemctl restart systemd-resolved` 生效。
|
||||||
|
|
||||||
|
== 为什么不直接改 /etc/resolv.conf ==
|
||||||
|
|
||||||
|
它是指向 systemd-resolved stub(127.0.0.53)的软链接,
|
||||||
|
手改会被覆盖。要改就改 resolved 的配置。
|
||||||
|
'''
|
||||||
|
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 # noqa: E402
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
CONF = '''# ykxmz:给这台服务器配上公共 DNS。
|
||||||
|
#
|
||||||
|
# 起因:Lightsail 默认用内网解析器 172.26.0.2,而用户刚加的
|
||||||
|
# yk.xxmxxs.top 记录它**还缓存着"不存在"**(负缓存),
|
||||||
|
# 导致服务器自己解析不了这个域名 —— 监测单元因此一直误报网站挂了。
|
||||||
|
#
|
||||||
|
# 公共解析器(1.1.1.1 / 8.8.8.8)已经能正确解析,所以配上来。
|
||||||
|
# 内网解析器降为后备:它对 AWS 自家服务(比如实例元数据、
|
||||||
|
# S3 内网端点)响应更快,留着有用。
|
||||||
|
[Resolve]
|
||||||
|
DNS=1.1.1.1 8.8.8.8 2606:4700:4700::1111 2606:4700:4700::1001
|
||||||
|
FallbackDNS=172.26.0.2
|
||||||
|
# 所有域名都用上面这些(内网那个只在全部失败时才用)
|
||||||
|
Domains=~.
|
||||||
|
# 顺便开一下 DNSSEC 校验
|
||||||
|
DNSSEC=allow-downgrade
|
||||||
|
DNSOverTLS=no
|
||||||
|
Cache=yes
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 给服务器配公共 DNS")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 写 drop-in 配置 ──")
|
||||||
|
run("sudo mkdir -p /etc/systemd/resolved.conf.d", timeout=120, quiet=True)
|
||||||
|
tmp = Path(tempfile.gettempdir()) / "ykxmz-dns.conf"
|
||||||
|
tmp.write_text(CONF, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/ykxmz-dns.conf")
|
||||||
|
out = run("sudo mv /tmp/ykxmz-dns.conf /etc/systemd/resolved.conf.d/ykxmz.conf "
|
||||||
|
"&& echo ' 已装'", timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 重启 systemd-resolved ──")
|
||||||
|
out = run("sudo systemctl restart systemd-resolved; sleep 3; "
|
||||||
|
"systemctl is-active systemd-resolved; "
|
||||||
|
"echo ' --- 现在用的 DNS ---'; "
|
||||||
|
"resolvectl status 2>/dev/null | grep -E 'DNS Servers|Fallback|Current DNS' "
|
||||||
|
"| head -6", timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 刷新缓存 + 验证解析 ──")
|
||||||
|
out = run("sudo resolvectl flush-caches 2>/dev/null; "
|
||||||
|
"echo ' getent:'; getent hosts %s | sed 's/^/ /'; "
|
||||||
|
"echo ' dig 默认:'; dig +short +time=5 %s | sed 's/^/ /'; "
|
||||||
|
"echo ' python:'; python3 -c \"import socket;"
|
||||||
|
"print(' ', [a[4][0] for a in socket.getaddrinfo('%s',443)])\""
|
||||||
|
% (DOMAIN, DOMAIN, DOMAIN), timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. curl 域名(监测单元就是这么查的)──")
|
||||||
|
out = run(
|
||||||
|
"printf ' %-30s %s\\n' 'https 域名' "
|
||||||
|
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||||
|
"https://%s/v1/health)\"; "
|
||||||
|
"printf ' %-30s %s\\n' 'http 域名(会 301)' "
|
||||||
|
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||||
|
"http://%s/v1/health)\"; "
|
||||||
|
"printf ' %-30s %s\\n' 'https 域名 -L(跟跳转)' "
|
||||||
|
"\"$(curl -sL -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||||
|
"https://%s/v1/health)\"; "
|
||||||
|
"printf ' %-30s %s\\n' '中转本机' "
|
||||||
|
"\"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "
|
||||||
|
"http://127.0.0.1:13245/v1/health)\""
|
||||||
|
% (DOMAIN, DOMAIN, DOMAIN), timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 重跑监测 ──")
|
||||||
|
run("printf 'fails=0\\nlast_rescue=0\\n' > "
|
||||||
|
"/opt/ykxmz-relay/monitor_state.txt", timeout=60, quiet=True)
|
||||||
|
run("bash /opt/ykxmz-relay/monitor.sh", timeout=180, quiet=True)
|
||||||
|
out = run("cat /opt/ykxmz-relay/monitor_state.txt", timeout=60, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把语音播报改成**多条方案降级**,修 Win7 上用不了的问题。
|
||||||
|
|
||||||
|
== 用户反馈 ==
|
||||||
|
|
||||||
|
"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"
|
||||||
|
|
||||||
|
== 原来只有一条路,而且有几个 Win7 特有的坑 ==
|
||||||
|
|
||||||
|
powershell -NoProfile -WindowStyle Hidden -Command
|
||||||
|
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
|
||||||
|
|
||||||
|
坑一:**`powershell` 只按名字找**
|
||||||
|
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
|
||||||
|
精简版系统、或者从服务/计划任务拉起时 PATH 里可能没有 ——
|
||||||
|
找不到就**静默失败**(Popen 不报错,因为进程起来了,只是在里面失败)。
|
||||||
|
|
||||||
|
坑二:**Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`**
|
||||||
|
PS 3.0 起才按 UTF-16 处理。所以中文经 `-Command` 传进去在 PS 2.0 上
|
||||||
|
很容易乱码 —— 表现是"念出来是乱码"或者干脆不念。
|
||||||
|
|
||||||
|
坑三:**`System.Speech` 需要 .NET Framework 3.0+**
|
||||||
|
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
|
||||||
|
**根本不需要 .NET**。绕开 .NET 更稳。
|
||||||
|
|
||||||
|
== 现在的做法:降级链 ==
|
||||||
|
|
||||||
|
按"依赖越少越优先"排:
|
||||||
|
|
||||||
|
1. **cscript + VBScript + SAPI.SpVoice**
|
||||||
|
VBScript 从 Windows 98 就有,SAPI5 是系统组件 ——
|
||||||
|
两个都不需要装东西,也不受 PowerShell 版本 / PATH / .NET 影响。
|
||||||
|
**这是最可能在 Win7 上通的一条。**
|
||||||
|
|
||||||
|
2. **wscript + VBScript + SAPI.SpVoice**
|
||||||
|
同上,换宿主(cscript 万一被安全软件拦了)。
|
||||||
|
|
||||||
|
3. **powershell(绝对路径)+ SAPI.SpVoice COM**
|
||||||
|
绕开 .NET,但仍要 PowerShell。
|
||||||
|
|
||||||
|
4. **powershell(绝对路径)+ System.Speech**
|
||||||
|
原来的做法,只是改用绝对路径。
|
||||||
|
|
||||||
|
5. **powershell(按 PATH)+ System.Speech**
|
||||||
|
最原始的做法,留作最后兜底。
|
||||||
|
|
||||||
|
**一条通了就记住它**(模块级缓存),后面都直接走那条 ——
|
||||||
|
不然每播一次都要把上面几条全试一遍,慢且难看。
|
||||||
|
|
||||||
|
== 顺手改进 ==
|
||||||
|
|
||||||
|
* `say()` 原来用 `Popen` 不等待 —— **失败了也返回"已播报"**。
|
||||||
|
现在**等它结束并看退出码**,真失败会明确报错,不再骗人。
|
||||||
|
代价是播报变成了同步的(要等念完)—— 但"报成功其实没出声"
|
||||||
|
比"慢一点"糟糕得多。
|
||||||
|
* VBScript 文件用 **mbcs(ANSI)** 编码写。写成 UTF-8 的话
|
||||||
|
cscript 读出来是乱码 —— 这是 VBScript 的老规矩。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ACTIONS = ROOT / "windows" / "lanremote" / "actions.py"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 要替换的旧代码
|
||||||
|
OLD_VOICES = ''' def list_voices(self) -> list[str]:
|
||||||
|
"""列出系统可用的 TTS 音色。"""
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
return []
|
||||||
|
script = (
|
||||||
|
"Add-Type -AssemblyName System.Speech;"
|
||||||
|
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||||
|
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-Command", script],
|
||||||
|
capture_output=True, timeout=20,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
encoding="mbcs", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]'''
|
||||||
|
|
||||||
|
OLD_SAY = ''' safe = text.replace("'", "''")
|
||||||
|
parts = [
|
||||||
|
"Add-Type -AssemblyName System.Speech;",
|
||||||
|
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;",
|
||||||
|
]
|
||||||
|
if chosen:
|
||||||
|
parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} "
|
||||||
|
"catch { };")
|
||||||
|
if speed:
|
||||||
|
parts.append(f"$s.Rate = {speed};")
|
||||||
|
parts.append(f"$s.Speak('{safe}')")
|
||||||
|
script = "".join(parts)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["powershell", "-NoProfile", "-WindowStyle", "Hidden",
|
||||||
|
"-Command", script],
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||||
|
except Exception as exc:
|
||||||
|
raise ActionError(f"TTS 启动失败: {exc}") from exc
|
||||||
|
return f"已播报({chosen or '默认音色'})"'''
|
||||||
|
|
||||||
|
NEW = ''' # ------------------------------------------------------------------
|
||||||
|
# 语音播报(系统自带 TTS)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ⚠ 这里有一套**降级链**,不是随便写的。原因见文件末尾的说明,
|
||||||
|
# 简单说:Win7 上的 PowerShell 2.0 + 中文 + PATH 三样都可能出问题,
|
||||||
|
# 而 cscript + SAPI5 是最老最稳的组合(从 XP 就有)。
|
||||||
|
#
|
||||||
|
# 顺序:cscript -> wscript -> ps(绝对路径)+SAPI COM
|
||||||
|
# -> ps(绝对路径)+System.Speech -> ps(PATH)+System.Speech
|
||||||
|
|
||||||
|
def _powershell_path(self) -> str:
|
||||||
|
"""找 powershell.exe。**先按绝对路径**,PATH 只作兜底。
|
||||||
|
|
||||||
|
为什么:Win7 上 PATH 里不一定有,而 CreateProcess 按名字找不到时
|
||||||
|
是**静默失败**的(进程根本没起来,但 Popen 不报错)。
|
||||||
|
"""
|
||||||
|
import shutil as _sh
|
||||||
|
cand = Path(os.environ.get("SystemRoot", r"C:\\Windows")) / \\
|
||||||
|
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||||
|
if os.name == "nt" and cand.is_file():
|
||||||
|
return str(cand)
|
||||||
|
return _sh.which("powershell") or ""
|
||||||
|
|
||||||
|
def _write_vbs(self, text: str, voice: str, rate: int) -> Path:
|
||||||
|
"""生成一个播报用的 .vbs。
|
||||||
|
|
||||||
|
⚠ 必须用 **mbcs(系统 ANSI 代码页)** 写 ——
|
||||||
|
cscript 读 .vbs 是按 ANSI 读的,写成 UTF-8 中文就是乱码。
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
lines = ['Dim v', 'Set v = CreateObject("SAPI.SpVoice")']
|
||||||
|
if voice:
|
||||||
|
# 按描述匹配音色。找不到就 silently 用默认 ——
|
||||||
|
# 音色选不上不该让整条播报失败。
|
||||||
|
lines.append('Dim i')
|
||||||
|
lines.append('For i = 0 To v.GetVoices().Count - 1')
|
||||||
|
lines.append(' If InStr(v.GetVoices().Item(i).GetDescription(), "%s") > 0 Then'
|
||||||
|
% voice.replace('"', '""'))
|
||||||
|
lines.append(' Set v.Voice = v.GetVoices().Item(i)')
|
||||||
|
lines.append(' End If')
|
||||||
|
lines.append('Next')
|
||||||
|
if rate:
|
||||||
|
# SAPI 的 Rate 范围是 -10..10,跟我们的设置一致
|
||||||
|
lines.append("v.Rate = %d" % max(-10, min(10, rate)))
|
||||||
|
# 长文本 VBScript 单行有长度限制,所以**分句拼接**
|
||||||
|
chunk = text.replace('"', '""').replace("\\r", " ").replace("\\n", " ")
|
||||||
|
while chunk:
|
||||||
|
piece, chunk = chunk[:400], chunk[400:]
|
||||||
|
lines.append('v.Speak "%s"' % piece)
|
||||||
|
path = Path(tempfile.gettempdir()) / "ykxmz_say.vbs"
|
||||||
|
path.write_bytes(("\\r\\n".join(lines) + "\\r\\n").encode("mbcs",
|
||||||
|
errors="replace"))
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _tts_commands(self, text: str, voice: str, rate: int) -> list:
|
||||||
|
"""按"依赖越少越优先"排出所有可行方案,返回 [(名字, 命令行)]。"""
|
||||||
|
cmds = []
|
||||||
|
vbs = None
|
||||||
|
try:
|
||||||
|
vbs = self._write_vbs(text, voice, rate)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
vbs = None
|
||||||
|
if vbs is not None:
|
||||||
|
# 1 / 2:完全不依赖 PowerShell 和 .NET
|
||||||
|
cmds.append(("cscript", ["cscript", "//nologo", str(vbs)]))
|
||||||
|
cmds.append(("wscript", ["wscript", "//nologo", str(vbs)]))
|
||||||
|
|
||||||
|
ps = self._powershell_path()
|
||||||
|
esc = text.replace("'", "''")
|
||||||
|
if ps:
|
||||||
|
# 3:绕开 .NET,只用系统原生 SAPI5
|
||||||
|
sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"]
|
||||||
|
if voice:
|
||||||
|
sapi.append("foreach ($o in $v.GetVoices()) { "
|
||||||
|
"if ($o.GetDescription() -like '*%s*') { "
|
||||||
|
"$v.Voice = $o } };" % voice.replace("'", "''"))
|
||||||
|
if rate:
|
||||||
|
sapi.append("$v.Rate = %d;" % max(-10, min(10, rate)))
|
||||||
|
sapi.append("$v.Speak('%s')" % esc)
|
||||||
|
cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden",
|
||||||
|
"-Command", "".join(sapi)]))
|
||||||
|
|
||||||
|
# 4:原来的做法,但用绝对路径
|
||||||
|
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||||
|
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||||
|
if voice:
|
||||||
|
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||||
|
% voice.replace("'", "''"))
|
||||||
|
if rate:
|
||||||
|
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||||
|
sp.append("$s.Speak('%s')" % esc)
|
||||||
|
cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle",
|
||||||
|
"Hidden", "-Command", "".join(sp)]))
|
||||||
|
|
||||||
|
# 5:最原始的(PATH + System.Speech),兜底
|
||||||
|
import shutil as _sh
|
||||||
|
if _sh.which("powershell"):
|
||||||
|
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||||
|
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||||
|
if voice:
|
||||||
|
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||||
|
% voice.replace("'", "''"))
|
||||||
|
if rate:
|
||||||
|
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||||
|
sp.append("$s.Speak('%s')" % esc)
|
||||||
|
cmds.append(("ps-path", ["powershell", "-NoProfile",
|
||||||
|
"-WindowStyle", "Hidden",
|
||||||
|
"-Command", "".join(sp)]))
|
||||||
|
return cmds
|
||||||
|
|
||||||
|
def list_voices(self) -> list[str]:
|
||||||
|
"""列出系统可用的 TTS 音色。
|
||||||
|
|
||||||
|
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没有 .NET 3.0 的话
|
||||||
|
System.Speech 那条路是列不出来的,而 SAPI5 一定有。
|
||||||
|
"""
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
ps = self._powershell_path()
|
||||||
|
if ps:
|
||||||
|
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
|
||||||
|
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }")
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[ps, "-NoProfile", "-Command", script],
|
||||||
|
capture_output=True, timeout=20,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
encoding="mbcs", errors="replace")
|
||||||
|
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||||
|
if ln.strip()]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
out = []
|
||||||
|
if not out:
|
||||||
|
# 退到 System.Speech 再列一次
|
||||||
|
if ps:
|
||||||
|
script = ("Add-Type -AssemblyName System.Speech;"
|
||||||
|
"(New-Object System.Speech.Synthesis"
|
||||||
|
".SpeechSynthesizer).GetInstalledVoices()"
|
||||||
|
" | ForEach-Object { $_.VoiceInfo.Name }")
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[ps, "-NoProfile", "-Command", script],
|
||||||
|
capture_output=True, timeout=20,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
encoding="mbcs", errors="replace")
|
||||||
|
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||||
|
if ln.strip()]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
out = []
|
||||||
|
return out
|
||||||
|
|
||||||
|
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
|
||||||
|
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。
|
||||||
|
|
||||||
|
⚠ 会**等播报结束**并看退出码,失败就抛错。
|
||||||
|
原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。
|
||||||
|
代价是同步等待(念多久等多久),但"报成功其实没出声"
|
||||||
|
比"慢一点"糟糕得多。
|
||||||
|
"""
|
||||||
|
if not text.strip():
|
||||||
|
raise ActionError("播报内容为空")
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
raise ActionError("语音播报目前仅支持 Windows")
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
cmds = self._tts_commands(text, chosen, speed)
|
||||||
|
if not cmds:
|
||||||
|
raise ActionError("这台电脑上找不到可用的语音播报方式"
|
||||||
|
"(没有 cscript 也没有 powershell?)")
|
||||||
|
|
||||||
|
# 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍
|
||||||
|
cached = globals().get("_TTS_LAST_OK")
|
||||||
|
if cached:
|
||||||
|
cmds.sort(key=lambda c: 0 if c[0] == cached else 1)
|
||||||
|
|
||||||
|
tried = []
|
||||||
|
for name, cmd in cmds:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd, capture_output=True, timeout=180,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
encoding="mbcs", errors="replace")
|
||||||
|
except FileNotFoundError:
|
||||||
|
tried.append("%s(没有这个程序)" % name)
|
||||||
|
continue
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
tried.append("%s(超时)" % name)
|
||||||
|
continue
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
tried.append("%s(%s)" % (name, type(exc).__name__))
|
||||||
|
continue
|
||||||
|
if proc.returncode == 0:
|
||||||
|
globals()["_TTS_LAST_OK"] = name
|
||||||
|
return "已播报(%s,用 %s)" % (chosen or "默认音色", name)
|
||||||
|
err = ((proc.stderr or "") + (proc.stdout or "")).strip()
|
||||||
|
tried.append("%s(退出码%d %s)" % (name, proc.returncode, err[:40]))
|
||||||
|
|
||||||
|
raise ActionError("语音播报失败,试过:" + ";".join(tried))'''
|
||||||
|
|
||||||
|
NEW_VOICES = ''' def list_voices(self) -> list[str]:
|
||||||
|
"""列出系统可用的 TTS 音色。
|
||||||
|
|
||||||
|
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话
|
||||||
|
System.Speech 那条路列不出来,而 SAPI5 一定有。
|
||||||
|
"""
|
||||||
|
if not IS_WINDOWS:
|
||||||
|
return []
|
||||||
|
ps = self._powershell_path()
|
||||||
|
if not ps:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for script in (
|
||||||
|
# 优先 SAPI5 COM
|
||||||
|
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||||
|
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }",
|
||||||
|
# 退到 System.Speech
|
||||||
|
"Add-Type -AssemblyName System.Speech;"
|
||||||
|
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||||
|
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[ps, "-NoProfile", "-Command", script],
|
||||||
|
capture_output=True, timeout=20,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
encoding="mbcs", errors="replace")
|
||||||
|
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||||
|
if ln.strip()]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
out = []
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
return []'''
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if "_tts_commands" in t:
|
||||||
|
print(" 已经改过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
missing = [k for k, v in {"list_voices": OLD_VOICES, "say": OLD_SAY}.items()
|
||||||
|
if v not in t]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ 找不到:%s" % ", ".join(missing))
|
||||||
|
return 1
|
||||||
|
print(" ✓ 锚点就位")
|
||||||
|
|
||||||
|
t = t.replace(OLD_VOICES, NEW_VOICES, 1)
|
||||||
|
t = t.replace(OLD_SAY, NEW, 1)
|
||||||
|
|
||||||
|
ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||||
|
print(" ✓ list_voices 改用 SAPI5 COM(+ System.Speech 兜底)")
|
||||||
|
print(" ✓ say 改成降级链 + 等待退出码 + 记住可用的那条")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''修 verify_all.ps1 里被转义弄坏的那段。
|
||||||
|
|
||||||
|
== 弄坏了什么 ==
|
||||||
|
|
||||||
|
我用 `python -c "..."` 传的脚本里带了 `\$stageOut`,
|
||||||
|
PowerShell 先解析了一遍,把 `$stageOut` 变成了 `\`,
|
||||||
|
结果插进去的是:
|
||||||
|
|
||||||
|
\ = & python (Join-Path \ "_tests\test_admin_update.py") 2>&1
|
||||||
|
Show-Fails Record "后台更新按钮" (\ -eq 0)
|
||||||
|
|
||||||
|
`Show-Fails` 和 `Record` 挤在一行,变量名也没了。
|
||||||
|
|
||||||
|
== 教训 ==
|
||||||
|
|
||||||
|
凡是**要写进文件的内容里带 `$`**,一律写成 .py 文件再跑,
|
||||||
|
不要用 `python -c "..."` 穿过 PowerShell。这个坑今天踩了太多次。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||||
|
|
||||||
|
# 被弄坏的那两段
|
||||||
|
BROKEN = '''\\ = & python (Join-Path \\ "_tests\\test_admin_update.py") 2>&1
|
||||||
|
Show-Fails Record "后台更新按钮" (\\ -eq 0)
|
||||||
|
|
||||||
|
\\ = & python (Join-Path \\ "_tests\\test_notice_options.py") 2>&1
|
||||||
|
Show-Fails Record "弹窗位置和大小" (\\ -eq 0)'''
|
||||||
|
|
||||||
|
FIXED = '''$stageOut = & python (Join-Path $root "_tests\\test_admin_update.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "后台更新按钮" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
$stageOut = & python (Join-Path $root "_tests\\test_notice_options.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "弹窗位置和大小" ($LASTEXITCODE -eq 0)'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = VERIFY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if FIXED in text:
|
||||||
|
print(" 已经修好了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if BROKEN not in text:
|
||||||
|
print(" ✗ 找不到被弄坏的那段。当前 3a12 附近的内容:")
|
||||||
|
i = text.find("3a12")
|
||||||
|
print(text[max(0, i - 50):i + 700])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
text = text.replace(BROKEN, FIXED, 1)
|
||||||
|
|
||||||
|
# 顺手把 "3a12/7" 改成 "3a12/9"(关卡数变了)
|
||||||
|
text = text.replace('Banner "3a12/7', 'Banner "3a12/9')
|
||||||
|
|
||||||
|
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ 修好了")
|
||||||
|
|
||||||
|
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||||
|
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_admin_update.py")',
|
||||||
|
'$stageOut = & python (Join-Path $root "_tests\\test_notice_options.py")',
|
||||||
|
'Record "后台更新按钮"',
|
||||||
|
'Record "弹窗位置和大小"'):
|
||||||
|
ok = token in back
|
||||||
|
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:58]))
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''增强隧道稳定性 —— 「网站还是容易挂」的真因修法。
|
||||||
|
|
||||||
|
== 查到什么 ==
|
||||||
|
|
||||||
|
cloudflared 自己的 precheck 输出(tunnel.log):
|
||||||
|
|
||||||
|
| UDP Connectivity region1.v2.argotunnel.com FAIL QUIC connection failed |
|
||||||
|
| UDP Connectivity region2.v2.argotunnel.com FAIL QUIC connection failed |
|
||||||
|
| TCP Connectivity region1.v2.argotunnel.com FAIL HTTP/2 connection is blocked or unreachable |
|
||||||
|
| TCP Connectivity region2.v2.argotunnel.com FAIL HTTP/2 connection is blocked or unreachable |
|
||||||
|
| Cloudflare API api.cloudflare.com:443 PASS API is reachable |
|
||||||
|
| ERROR: Allow outbound QUIC traffic on port 7844 or use HTTP2. |
|
||||||
|
|
||||||
|
也就是:**443 通,7844 不通**,而且是**间歇性**的
|
||||||
|
(日志后面又出现 Registered tunnel connection,过一阵又掉)。
|
||||||
|
|
||||||
|
Cloudflare 隧道的边缘连接走 7844(QUIC 走 UDP 7844、HTTP/2 走 TCP 7844)。
|
||||||
|
这条线路对 7844 是间歇性阻断的 —— 这不是设备能修的,
|
||||||
|
但**可以让 cloudflared 自己去适应**。
|
||||||
|
|
||||||
|
== 现在的配置有两个问题 ==
|
||||||
|
|
||||||
|
1. **两条实例各写死一个协议**
|
||||||
|
lanremote-tunnel --protocol quic (只走 UDP 7844)
|
||||||
|
lanremote-tunnel-h2 --protocol http2 (只走 TCP 7844)
|
||||||
|
如果那一刻 7844 的 UDP 和 TCP 都不通,两条一起死 —— 没有退路。
|
||||||
|
实测就是这样:连接数掉到 1/0,公网 530。
|
||||||
|
|
||||||
|
2. **--retries 5**
|
||||||
|
cloudflared 重试 5 次就**退出**,靠 systemd Restart=always 拉起来。
|
||||||
|
每次退出/重启期间网站都是断的。而这个线路的抖动是分钟级的,
|
||||||
|
5 次远远不够扛过去。
|
||||||
|
|
||||||
|
== 改法 ==
|
||||||
|
|
||||||
|
1. `--protocol auto` —— 让 cloudflared **自己**在 QUIC 和 HTTP/2 之间选,
|
||||||
|
哪条通走哪条。两条实例都改成 auto,等于 10 条连接全都能自适应。
|
||||||
|
(而不是现在这样"8 条赌 UDP、2 条赌 TCP")
|
||||||
|
|
||||||
|
2. `--retries` 提到很大(相当于"一直重试,别退出")——
|
||||||
|
抖动期间连接会自己恢复,不会整条隧道退出让网站断掉。
|
||||||
|
|
||||||
|
3. 顺手加日志轮转:tunnel.log 已经涨到 2.98 MB,
|
||||||
|
这台机器只有 460MB 内存 + 小硬盘,无限增长不合适。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def patch_unit(name: str, proto: str) -> str:
|
||||||
|
"""读设备上现有的 unit,改协议和重试次数。
|
||||||
|
|
||||||
|
用 sed 现场改而不是整份重写 —— 现有 unit 里还有
|
||||||
|
metrics 端口、ha-connections、MemoryMax 这些实测调出来的参数,
|
||||||
|
整份重写容易漏掉。
|
||||||
|
"""
|
||||||
|
cmds = [
|
||||||
|
# 协议
|
||||||
|
"sed -i 's/--protocol [a-z0-9]*/--protocol %s/' %s" % (proto, name),
|
||||||
|
# 重试次数:改成很大(一直重试)
|
||||||
|
"sed -i 's/--retries [0-9]*/--retries 1000000/' %s" % name,
|
||||||
|
]
|
||||||
|
return " && ".join(cmds)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 隧道稳定性:协议改 auto + 加大重试 + 日志轮转")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
UNIT_DIR = "/etc/systemd/system"
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 两条隧道都改成 --protocol auto ──")
|
||||||
|
out = run(
|
||||||
|
"cd %s && "
|
||||||
|
"sed -i 's/--protocol [a-z0-9]*/--protocol auto/' "
|
||||||
|
"lanremote-tunnel.service lanremote-tunnel-h2.service && "
|
||||||
|
"echo ' tunnel:'; grep -o -- '--protocol [a-z0-9]*' "
|
||||||
|
"lanremote-tunnel.service; "
|
||||||
|
"echo ' h2:'; grep -o -- '--protocol [a-z0-9]*' "
|
||||||
|
"lanremote-tunnel-h2.service; "
|
||||||
|
# 重试次数
|
||||||
|
"sed -i 's/--retries [0-9]*/--retries 1000000/' "
|
||||||
|
"lanremote-tunnel.service lanremote-tunnel-h2.service && "
|
||||||
|
"echo ' 重试:'; grep -o -- '--retries [0-9]*' "
|
||||||
|
"lanremote-tunnel.service lanremote-tunnel-h2.service"
|
||||||
|
% UNIT_DIR, timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 日志轮转(tunnel.log 已经 2.98 MB)──")
|
||||||
|
out = run(
|
||||||
|
"cat > /etc/logrotate.d/lanremote <<'EOF'\n"
|
||||||
|
"/root/lanremote-relay/*.log {\n"
|
||||||
|
" size 5M\n"
|
||||||
|
" rotate 2\n"
|
||||||
|
" compress\n"
|
||||||
|
" missingok\n"
|
||||||
|
" notifempty\n"
|
||||||
|
" copytruncate\n"
|
||||||
|
"}\n"
|
||||||
|
"EOF\n"
|
||||||
|
"echo ' logrotate 配置已写'; "
|
||||||
|
"logrotate --debug /etc/logrotate.d/lanremote 2>&1 | tail -3",
|
||||||
|
timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 重启两条隧道让新参数生效 ──")
|
||||||
|
out = run(
|
||||||
|
"systemctl daemon-reload && "
|
||||||
|
"systemctl restart lanremote-tunnel lanremote-tunnel-h2 && "
|
||||||
|
"echo ' 等 40 秒让连接建立…' && sleep 40 && "
|
||||||
|
"echo \" tunnel: $(systemctl is-active lanremote-tunnel)\"; "
|
||||||
|
"echo \" h2: $(systemctl is-active lanremote-tunnel-h2)\"",
|
||||||
|
timeout=300)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 连接数 ──")
|
||||||
|
out = run(
|
||||||
|
"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; "
|
||||||
|
"echo ' --- 用的什么协议 ---'; "
|
||||||
|
"grep -o 'protocol=[a-z0-9]*' /root/lanremote-relay/tunnel.log 2>/dev/null "
|
||||||
|
"| tail -6 | sort | uniq -c | sed 's/^/ /'", timeout=120)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 公网(连测 5 次看稳不稳)──")
|
||||||
|
out = run(
|
||||||
|
"for i in 1 2 3 4 5; do "
|
||||||
|
"c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "
|
||||||
|
"https://relay.xxmxxs.top/v1/health); "
|
||||||
|
"echo \" 第 $i 次: $c\"; sleep 3; done", timeout=200)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 说明:3844/7844 那条线路封不封是运营商的事,设备改不了。")
|
||||||
|
print(" 但 --protocol auto 让 cloudflared 自己绕,")
|
||||||
|
print(" --retries 拉大让它别退出 —— 抖动期间能自己恢复。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,112 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把本地的 monitor/rescue 脚本装到 Lightsail(第二版:https 版)。
|
||||||
|
|
||||||
|
== 为什么单独写一个,而不是继续远端打补丁 ==
|
||||||
|
|
||||||
|
前面几轮我一直在用"SSH 过去、用嵌套 heredoc 现场改 monitor.sh"的做法,
|
||||||
|
结果连续踩了三次:
|
||||||
|
|
||||||
|
1. PowerShell 的 `$(...)` 在**本机**被求值(报"systemctl 不是 cmdlet")
|
||||||
|
2. 引号被吃掉 -> Python 报 unterminated string literal
|
||||||
|
3. 脚本里的 `%{http_code}` 和 Python 的 `%` 格式化打架
|
||||||
|
-> "not enough arguments for format string"
|
||||||
|
|
||||||
|
根本问题:**远端打补丁这件事本身就脆弱**。
|
||||||
|
本地写完整文件、整份上传,就没有这些问题 —— 而且改了什么一目了然,
|
||||||
|
还能进 git 做版本管理。
|
||||||
|
|
||||||
|
所以现在改成:relay/monitor-lightsail.sh 和 relay/rescue-lightsail.sh
|
||||||
|
是**唯一事实来源**,本地编辑 -> 整份上传 -> 重装。
|
||||||
|
|
||||||
|
== 本地文件名 vs 远端文件名 ==
|
||||||
|
|
||||||
|
本地叫 monitor-lightsail.sh(和那台旧的 monitor.sh 区分开,
|
||||||
|
免得看混),远端统一叫 monitor.sh / rescue.sh,
|
||||||
|
因为 systemd 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 lssh import run, upload, HOST as LS_HOST # noqa: E402
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
|
||||||
|
# (本地文件, 远端文件名)
|
||||||
|
PAIRS = [
|
||||||
|
("relay/monitor-lightsail.sh", "monitor.sh"),
|
||||||
|
("relay/rescue-lightsail.sh", "rescue.sh"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 装 monitor / rescue 到 Lightsail")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 上传 ──")
|
||||||
|
for local_name, remote_name in PAIRS:
|
||||||
|
local = ROOT / local_name
|
||||||
|
if not local.is_file():
|
||||||
|
print(" ✗ 找不到 %s" % local_name)
|
||||||
|
return 1
|
||||||
|
upload(str(local), "%s/%s" % (RELAY_DIR, remote_name), mode=0o755)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 语法检查 ──")
|
||||||
|
out = run("cd %s && for f in monitor.sh rescue.sh; do "
|
||||||
|
"bash -n $f || exit 1; done && echo SH_OK" % RELAY_DIR,
|
||||||
|
timeout=120, quiet=True)
|
||||||
|
print(" " + out[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 重置状态 + 跑一次监测 ──")
|
||||||
|
out = run("printf 'fails=0\\nlast_rescue=0\\n' > "
|
||||||
|
"%s/monitor_state.txt; "
|
||||||
|
"bash %s/monitor.sh; echo ' --- 状态 ---'; "
|
||||||
|
"cat %s/monitor_state.txt | sed 's/^/ /'; "
|
||||||
|
"echo ' --- 日志尾 ---'; "
|
||||||
|
"tail -3 %s/monitor.log | sed 's/^/ /'"
|
||||||
|
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR),
|
||||||
|
timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 探测各层现在什么状态 ──")
|
||||||
|
probe = (
|
||||||
|
'printf " %-36s %s\\n" "PRIMARY https://域名" '
|
||||||
|
'"$(curl -sL -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||||
|
'https://yk.xxmxxs.top/v1/health)"; '
|
||||||
|
'printf " %-36s %s\\n" "FALLBACK 127.0.0.1:13245" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://127.0.0.1:13245/v1/health)"; '
|
||||||
|
'printf " %-36s %s\\n" "nginx :80(301 到 https)" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://127.0.0.1:80/v1/health)"; '
|
||||||
|
'printf " %-36s %s\\n" "nginx :443(本机带 SNI)" '
|
||||||
|
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'--resolve yk.xxmxxs.top:443:127.0.0.1 '
|
||||||
|
'https://yk.xxmxxs.top/v1/health)"; '
|
||||||
|
'printf " %-36s %s\\n" "公网 443(Lightsail 防火墙)" '
|
||||||
|
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'https://LS_HOST/v1/health 2>/dev/null || echo 不通)"'
|
||||||
|
)
|
||||||
|
out = run(probe, timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 说明:PRIMARY 现在是 522 —— 因为公网 443 还没开。")
|
||||||
|
print(" 这不影响 FALLBACK 正常,所以监测**不会**误报成")
|
||||||
|
print(" '中转挂了';它会把层次标成 edge(问题在边缘层),")
|
||||||
|
print(" 等 443 开了 PRIMARY 自然就 200 了。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''连 AWS Lightsail 那台新服务器的 SSH 助手。
|
||||||
|
|
||||||
|
== 为什么单独一个模块,不改 devssh.py ==
|
||||||
|
|
||||||
|
devssh.py 是连**随身 WiFi 那台 4G 小机器**的(192.168.2.156,密码登录)。
|
||||||
|
那台还在跑(网站暂时还挂在上面),不能动它的连接逻辑。
|
||||||
|
|
||||||
|
新服务器用**密钥**登录、IP 是公网 <源站IP>,认证方式完全不同,
|
||||||
|
所以单独一个模块更清楚。
|
||||||
|
|
||||||
|
== 用法 ==
|
||||||
|
|
||||||
|
from lssh import run, upload, download, probe
|
||||||
|
run("uname -a") # 跑命令
|
||||||
|
upload("relay/relay.py", "/opt/ykxmz-relay/relay.py")
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
|
||||||
|
def _host() -> str:
|
||||||
|
"""服务器公网 IP —— **不写死在代码里**。
|
||||||
|
|
||||||
|
这个仓库是公开的。IP 写进来等于把源站地址告诉所有人,
|
||||||
|
而源站躲在 Cloudflare 后面本来就不该被知道(直接打 IP 就绕过 CDN
|
||||||
|
和防护了)。2026-10-08 就因为 IP 泄漏返工过一次。
|
||||||
|
|
||||||
|
取值顺序:
|
||||||
|
1. 环境变量 YKXMZ_LS_HOST
|
||||||
|
2. _build/.lightsail_host(一行 IP,已 gitignore)
|
||||||
|
"""
|
||||||
|
env = os.environ.get("YKXMZ_LS_HOST", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
f = Path(__file__).resolve().parent / ".lightsail_host"
|
||||||
|
if f.is_file():
|
||||||
|
return f.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"没拿到服务器地址。二选一:\n"
|
||||||
|
" set YKXMZ_LS_HOST=<你的服务器IP>\n"
|
||||||
|
" 或把 IP 写进 _build/.lightsail_host(该文件已在 .gitignore 里)")
|
||||||
|
|
||||||
|
|
||||||
|
HOST = _host()
|
||||||
|
USER = os.environ.get("YKXMZ_LS_USER", "admin")
|
||||||
|
PORT = int(os.environ.get("YKXMZ_LS_PORT", "22"))
|
||||||
|
KEY = Path(os.environ.get(
|
||||||
|
"YKXMZ_LS_KEY", str(Path.home() / ".ssh" / "lightsail-ykxmz.pem")))
|
||||||
|
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(timeout: int = 25):
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
try:
|
||||||
|
# 探一下还活着没
|
||||||
|
_client.exec_command("true", timeout=5)
|
||||||
|
return _client
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
if not KEY.is_file():
|
||||||
|
raise RuntimeError(
|
||||||
|
"找不到 SSH 密钥:%s\n"
|
||||||
|
"(把 Lightsail 的 .pem 放到那里,或者设 YKXMZ_LS_KEY)" % KEY)
|
||||||
|
|
||||||
|
cli = paramiko.SSHClient()
|
||||||
|
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
cli.connect(HOST, port=PORT, username=USER,
|
||||||
|
key_filename=str(KEY), timeout=timeout,
|
||||||
|
allow_agent=False, look_for_keys=False,
|
||||||
|
banner_timeout=30, auth_timeout=30)
|
||||||
|
_client = cli
|
||||||
|
return cli
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: str, timeout: int = 300, quiet: bool = False) -> tuple:
|
||||||
|
"""跑一条命令,返回 (退出码, 输出)。"""
|
||||||
|
cli = _connect()
|
||||||
|
if not quiet:
|
||||||
|
print(" [ls] %s" % command.splitlines()[0][:90])
|
||||||
|
stdin, stdout, stderr = cli.exec_command(command, timeout=timeout,
|
||||||
|
get_pty=False)
|
||||||
|
out = stdout.read().decode("utf-8", "replace")
|
||||||
|
err = stderr.read().decode("utf-8", "replace")
|
||||||
|
code = stdout.channel.recv_exit_status()
|
||||||
|
return code, (out + err)
|
||||||
|
|
||||||
|
|
||||||
|
def upload(local: str, remote: str, mode: int = 0o644) -> None:
|
||||||
|
"""传一个文件上去。"""
|
||||||
|
cli = _connect()
|
||||||
|
sftp = cli.open_sftp()
|
||||||
|
try:
|
||||||
|
# 先把远端目录建出来
|
||||||
|
d = os.path.dirname(remote)
|
||||||
|
if d:
|
||||||
|
try:
|
||||||
|
sftp.stat(d)
|
||||||
|
except IOError:
|
||||||
|
# 逐级建
|
||||||
|
parts = d.strip("/").split("/")
|
||||||
|
cur = ""
|
||||||
|
for p in parts:
|
||||||
|
cur += "/" + p
|
||||||
|
try:
|
||||||
|
sftp.stat(cur)
|
||||||
|
except IOError:
|
||||||
|
try:
|
||||||
|
sftp.mkdir(cur)
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
sftp.put(local, remote)
|
||||||
|
sftp.chmod(remote, mode)
|
||||||
|
finally:
|
||||||
|
sftp.close()
|
||||||
|
print(" 已上传 %s -> %s" % (os.path.basename(local), remote))
|
||||||
|
|
||||||
|
|
||||||
|
def download(remote: str, local: str) -> None:
|
||||||
|
cli = _connect()
|
||||||
|
sftp = cli.open_sftp()
|
||||||
|
try:
|
||||||
|
Path(local).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sftp.get(remote, local)
|
||||||
|
finally:
|
||||||
|
sftp.close()
|
||||||
|
print(" 已下载 %s -> %s" % (remote, local))
|
||||||
|
|
||||||
|
|
||||||
|
def probe() -> str | None:
|
||||||
|
"""通不通。返回一句话描述,或 None。"""
|
||||||
|
try:
|
||||||
|
code, out = run("uname -sm; nproc; free -m | sed -n 2p",
|
||||||
|
timeout=30, quiet=True)
|
||||||
|
if code != 0:
|
||||||
|
return None
|
||||||
|
return out.strip().replace("\n", " | ")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "连接失败:%s" % exc
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(" 目标: %s@%s:%d 密钥 %s" % (USER, HOST, PORT, KEY))
|
||||||
|
print(" " + (probe() or "不通"))
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''放根目录的 LICENSE(AGPL-3.0)。
|
||||||
|
|
||||||
|
== 为什么要有这个文件 ==
|
||||||
|
|
||||||
|
Gitee 对一个公开仓库会检查"有没有公认的开源协议",没有就弹许可证向导,
|
||||||
|
而且远控类工具**没有协议**在合规视角里比有协议更容易被盯上
|
||||||
|
(这个仓库已经被判过一次"内容可能含有违规信息")。
|
||||||
|
|
||||||
|
原来仓库里只有 docs/LICENSE.txt —— 那是**自定义协议**,
|
||||||
|
里面写着"复制并分发完整副本(须保持文件完整、**不得修改**)",
|
||||||
|
这**不符合 OSI 开源定义**(开源必须允许修改),所以 Gitee 不认。
|
||||||
|
(Gitee 上这个仓库的 license 字段实测就是空的。)
|
||||||
|
|
||||||
|
== 为什么选 AGPL-3.0(用户拍板)==
|
||||||
|
|
||||||
|
* OSI 认可、Gitee 认,公开仓库不会再被提示
|
||||||
|
* 强 copyleft:别人改了必须以同样协议开源,**不能拿去做闭源商用** ——
|
||||||
|
最接近作者原本"免费给人用、但不许别人改名变成自己的"的想法
|
||||||
|
* 含"网络服务"条款:把它做成在线服务对外提供也必须开源,
|
||||||
|
正好匹配这个项目"中转服务器"的形态
|
||||||
|
(GPL 就没有这一条)
|
||||||
|
|
||||||
|
== 两份文件的分工(不冲突)==
|
||||||
|
|
||||||
|
LICENSE 管**代码**:可以改、可以再发布,但衍生作品必须同样开源
|
||||||
|
docs/LICENSE.txt 管**使用行为**:禁止偷窥、禁止私自装到别人设备、
|
||||||
|
禁止伪装隐藏等红线 + 免责声明
|
||||||
|
|
||||||
|
一个管"代码怎么用",一个管"行为不能做什么"。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SRC = Path(__import__("os").environ.get("TEMP", "/tmp")) / "agpl.txt"
|
||||||
|
DST = ROOT / "LICENSE"
|
||||||
|
|
||||||
|
HEADER = """ykxmz —— 教室多媒体辅助工具
|
||||||
|
Copyright (C) 2026 小麦子
|
||||||
|
|
||||||
|
本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的
|
||||||
|
GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。
|
||||||
|
|
||||||
|
本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或
|
||||||
|
特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。
|
||||||
|
|
||||||
|
你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本
|
||||||
|
(见本文件下半部分)。如果没有,见 <https://www.gnu.org/licenses/>。
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》):
|
||||||
|
本软件是一套远程控制工具,禁止用于未经同意的他人设备、
|
||||||
|
禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**,
|
||||||
|
和本文件(约束**代码**的授权)互不冲突。
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not SRC.is_file():
|
||||||
|
print(" ✗ 找不到 AGPL 原文:%s" % SRC)
|
||||||
|
print(" (先跑一遍取原文的那步)")
|
||||||
|
return 1
|
||||||
|
body = SRC.read_text(encoding="utf-8")
|
||||||
|
if "GNU AFFERO GENERAL PUBLIC LICENSE" not in body:
|
||||||
|
print(" ✗ 原文看着不对,不含许可证标题")
|
||||||
|
return 1
|
||||||
|
DST.write_text(HEADER + body, encoding="utf-8", newline="\n")
|
||||||
|
print(" ✓ 已写 %s(%d 字节)" % (DST.name, DST.stat().st_size))
|
||||||
|
print(" 含 AGPL 标题: %s" % ("GNU AFFERO GENERAL PUBLIC LICENSE" in
|
||||||
|
DST.read_text(encoding="utf-8")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''写监测单元的两个 systemd 单元文件。
|
||||||
|
|
||||||
|
用户要求「在后台设立检测单元」。
|
||||||
|
|
||||||
|
为什么用 timer 而不是常驻服务:
|
||||||
|
* 这台机器只有 460MB 内存,常驻 bash 循环白占一份内存
|
||||||
|
* 常驻脚本万一自己挂了就永远不检查了;timer 到点拉起来一次,
|
||||||
|
崩了下一分钟自己就回来
|
||||||
|
* 而且每次都是全新进程,不会有"跑久了状态变脏"的问题
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUT = ROOT / "relay"
|
||||||
|
|
||||||
|
SERVICE = '''[Unit]
|
||||||
|
# ykxmz 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案。
|
||||||
|
#
|
||||||
|
# 由 lanremote-monitor.timer 拉起(不是常驻进程,见 timer 里的说明)。
|
||||||
|
Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# 检查本身最长 12 秒,预案最长 5 分钟,给 6 分钟余量
|
||||||
|
TimeoutStartSec=360
|
||||||
|
|
||||||
|
# ⚠ 用 bash 显式跑,别靠 shebang + 可执行位 ——
|
||||||
|
# 部署时文件权限被改掉是最常见的"脚本不执行"原因。
|
||||||
|
ExecStart=/bin/bash /root/lanremote-relay/monitor.sh
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
# FAIL_THRESHOLD 连续失败几次才动手(默认 3 次 ≈ 3 分钟)
|
||||||
|
# COOLDOWN 救完之后多少秒内不再救(默认 600 秒)
|
||||||
|
# 不设的话:预案刚重启完隧道还没连上,下一次检查
|
||||||
|
# 又判失败又救一遍,来回重启反而永远连不上
|
||||||
|
# RESCUE_REBOOT 预案里是否允许"重启整机"这张底牌。**默认 0(不开)**。
|
||||||
|
# 想开就把下面这行的 0 改成 1 —— 但先想清楚:
|
||||||
|
# 如果问题出在供电或上游线路,重启只会让恢复更慢,
|
||||||
|
# 而且半夜自动重启一台没人管的机器风险很大。
|
||||||
|
Environment=RESCUE_REBOOT=0
|
||||||
|
Environment=FAIL_THRESHOLD=3
|
||||||
|
Environment=COOLDOWN=600
|
||||||
|
|
||||||
|
StandardOutput=append:/root/lanremote-relay/monitor.log
|
||||||
|
StandardError=append:/root/lanremote-relay/monitor.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
'''
|
||||||
|
|
||||||
|
TIMER = '''[Unit]
|
||||||
|
# 每 60 秒检查一次公网。
|
||||||
|
#
|
||||||
|
# 为什么是 60 秒不是 5 秒:
|
||||||
|
# 实测这条 4G 线路本来就有 2~4% 的偶发失败,检查太勤会一直看到
|
||||||
|
# "失败"(其实一两秒后自己就好了)。配合 monitor.sh 里
|
||||||
|
# "连续 3 次才动手"的防抖,实际最快 3 分钟响应,够用了。
|
||||||
|
#
|
||||||
|
# OnBootSec=90s:开机后等 90 秒再开始查。
|
||||||
|
# 隧道一般几十秒才连上,太早开始会一开机就误判、白抢救一轮。
|
||||||
|
Description=每 60 秒检查一次网站是否可用
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=90s
|
||||||
|
OnUnitActiveSec=60s
|
||||||
|
AccuracySec=5s
|
||||||
|
Unit=lanremote-monitor.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
(OUT / "lanremote-monitor.service").write_text(
|
||||||
|
SERVICE, encoding="utf-8", newline="\n")
|
||||||
|
(OUT / "lanremote-monitor.timer").write_text(
|
||||||
|
TIMER, encoding="utf-8", newline="\n")
|
||||||
|
for n in ("lanremote-monitor.service", "lanremote-monitor.timer"):
|
||||||
|
p = OUT / n
|
||||||
|
print(" ✓ %s(%d 字节)" % (n, p.stat().st_size))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -17,7 +17,7 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
VERSION = "1.9.0"
|
VERSION = "2.2.0"
|
||||||
NAME = f"ykxmz-{VERSION}"
|
NAME = f"ykxmz-{VERSION}"
|
||||||
|
|
||||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||||
@@ -100,7 +100,7 @@ TOP_README = f"""========================================================
|
|||||||
【服务器端能管什么】
|
【服务器端能管什么】
|
||||||
|
|
||||||
浏览器打开: <中转地址>/?key=... 或者直接用管理密码登录
|
浏览器打开: <中转地址>/?key=... 或者直接用管理密码登录
|
||||||
管理密码:120830(可在服务器上改)
|
管理密码:<改从环境变量读>(可在服务器上改)
|
||||||
|
|
||||||
能看:哪些设备在线、配对码、流量、会话数
|
能看:哪些设备在线、配对码、流量、会话数
|
||||||
能做:踢掉某个设备的会话、抹掉某个配对码绑定
|
能做:踢掉某个设备的会话、抹掉某个配对码绑定
|
||||||
@@ -334,7 +334,7 @@ RELAY_README = f"""========================================================
|
|||||||
|
|
||||||
官网: <公网地址>/
|
官网: <公网地址>/
|
||||||
管理页: <公网地址>/?key=<lanremote-key 的输出>
|
管理页: <公网地址>/?key=<lanremote-key 的输出>
|
||||||
管理密码: 120830(存在 data/admin_password.txt,可改)
|
管理密码: <改从环境变量读>(存在 data/admin_password.txt,可改)
|
||||||
|
|
||||||
能看到:设备列表、在线状态、配对码、流量、会话数
|
能看到:设备列表、在线状态、配对码、流量、会话数
|
||||||
能操作:踢会话、抹掉配对码绑定
|
能操作:踢会话、抹掉配对码绑定
|
||||||
@@ -584,12 +584,20 @@ def main() -> int:
|
|||||||
server_dir = work / server_name
|
server_dir = work / server_name
|
||||||
server_dir.mkdir(parents=True)
|
server_dir.mkdir(parents=True)
|
||||||
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
|
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
|
||||||
# 改成:relay 目录里所有 .py / .sh 都带上。
|
# 改成:relay 目录里所有 .py / .sh / .ico / .service / .timer 都带上。
|
||||||
# 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。
|
# 后缀白名单:
|
||||||
# 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。
|
# .py 服务本体
|
||||||
|
# .sh 部署/运维脚本
|
||||||
|
# .ico 管理页图标(漏了的话新设备上管理页是默认样式)
|
||||||
|
# .service /
|
||||||
|
# .timer systemd 单元。**这两个是后加的** ——
|
||||||
|
# 加监测单元的时候才发现白名单里没有它们,
|
||||||
|
# 于是 monitor.sh 进了包、拉它的 timer 却没进,
|
||||||
|
# 装到新机器上监测根本不会启动(而且不会报错,只是静默不跑)。
|
||||||
wanted = sorted(
|
wanted = sorted(
|
||||||
[f for f in RELAY_SRC.iterdir()
|
[f for f in RELAY_SRC.iterdir()
|
||||||
if f.suffix in (".py", ".sh", ".ico") and f.is_file()],
|
if f.suffix in (".py", ".sh", ".ico", ".service", ".timer")
|
||||||
|
and f.is_file()],
|
||||||
key=lambda f: f.name)
|
key=lambda f: f.name)
|
||||||
for src in wanted:
|
for src in wanted:
|
||||||
shutil.copy2(src, server_dir / src.name)
|
shutil.copy2(src, server_dir / src.name)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""管理页加登录密码(用户指定:120830)。
|
"""管理页加登录密码(用户指定:<改从环境变量读>)。
|
||||||
|
|
||||||
为什么要改:
|
为什么要改:
|
||||||
之前管理页靠「钥匙」放在 URL 里(/?key=xxx)。
|
之前管理页靠「钥匙」放在 URL 里(/?key=xxx)。
|
||||||
这样钥匙会进浏览器历史、进 Cloudflare 日志、被人肩窥。
|
这样钥匙会进浏览器历史、进 Cloudflare 日志、被人肩窥。
|
||||||
改成标准做法:登录页输密码 -> 种一个 HttpOnly Cookie。
|
改成标准做法:登录页输密码 -> 种一个 HttpOnly Cookie。
|
||||||
|
|
||||||
* 密码默认 120830,存在 data/admin_password.txt,可改
|
* 密码默认 <改从环境变量读>,存在 data/admin_password.txt,可改
|
||||||
* 登录成功后种 Cookie,有效期 12 小时
|
* 登录成功后种 Cookie,有效期 12 小时
|
||||||
* 命令行/脚本仍可用 X-Admin 头(放钥匙或密码都认)
|
* 命令行/脚本仍可用 X-Admin 头(放钥匙或密码都认)
|
||||||
"""
|
"""
|
||||||
@@ -26,7 +26,7 @@ OLD = '''def admin_ok(supplied: str) -> bool:
|
|||||||
return secrets.compare_digest(supplied, admin_key())'''
|
return secrets.compare_digest(supplied, admin_key())'''
|
||||||
|
|
||||||
NEW = '''# 管理页密码。用户指定的默认值,可以在 data/admin_password.txt 里改。
|
NEW = '''# 管理页密码。用户指定的默认值,可以在 data/admin_password.txt 里改。
|
||||||
ADMIN_PASSWORD_DEFAULT = "120830"
|
ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死
|
||||||
ADMIN_PASSWORD = os.environ.get("RELAY_ADMIN_PASSWORD", "")
|
ADMIN_PASSWORD = os.environ.get("RELAY_ADMIN_PASSWORD", "")
|
||||||
# 登录会话:cookie 令牌 -> 过期时间
|
# 登录会话:cookie 令牌 -> 过期时间
|
||||||
ADMIN_SESSIONS: dict = {}
|
ADMIN_SESSIONS: dict = {}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ NEW_BODY = '''RELAY_README = f"""===============================================
|
|||||||
|
|
||||||
官网: <公网地址>/
|
官网: <公网地址>/
|
||||||
管理页: <公网地址>/?key=<lanremote-key 的输出>
|
管理页: <公网地址>/?key=<lanremote-key 的输出>
|
||||||
管理密码: 120830(存在 data/admin_password.txt,可改)
|
管理密码: <改从环境变量读>(存在 data/admin_password.txt,可改)
|
||||||
|
|
||||||
能看到:设备列表、在线状态、配对码、流量、会话数
|
能看到:设备列表、在线状态、配对码、流量、会话数
|
||||||
能操作:踢会话、抹掉配对码绑定
|
能操作:踢会话、抹掉配对码绑定
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
安全说明:
|
安全说明:
|
||||||
/url 只暴露"隧道地址"这一个信息,不含配对码、设备、流量。
|
/url 只暴露"隧道地址"这一个信息,不含配对码、设备、流量。
|
||||||
地址本来就是要给手表填的,不算秘密。
|
地址本来就是要给手表填的,不算秘密。
|
||||||
设备列表、配对码这些仍然要登录(密码 120830)。
|
设备列表、配对码这些仍然要登录(密码 <改从环境变量读>)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''暂停监测单元,等 443 端口开好再启用。
|
||||||
|
|
||||||
|
== 为什么要暂停 ==
|
||||||
|
|
||||||
|
新架构:用户 -> Cloudflare -> nginx(443) -> 127.0.0.1:13245
|
||||||
|
|
||||||
|
证书签好了、nginx 也配了 443,但 **Lightsail 防火墙还没放行 443**,
|
||||||
|
所以外部访问 https://yk.xxmxxs.top 是 522。
|
||||||
|
|
||||||
|
监测查的是"用户实际走的路",于是一直判失败、fails 一直涨。
|
||||||
|
涨到 3 就启动紧急预案 —— 而预案能做的都解决不了"防火墙没开",
|
||||||
|
结果只会白折腾服务 + 每 10 分钟塞一份没用的报告进后台,
|
||||||
|
把以后真正出事的现场淹掉。
|
||||||
|
|
||||||
|
所以:**系统还没部署完,先别开监测**。443 通了再启用。
|
||||||
|
|
||||||
|
== 暂停不是关掉 ==
|
||||||
|
|
||||||
|
只 stop,配置和脚本都留着,一条命令就能恢复:
|
||||||
|
|
||||||
|
sudo systemctl start ykxmz-monitor.timer
|
||||||
|
|
||||||
|
== 写这个文件的原因 ==
|
||||||
|
|
||||||
|
之前用 `python -c @"..."@` 写过一次,里面 `\$(...)` 转义错了,
|
||||||
|
bash 报 "syntax error near unexpected token `('"。
|
||||||
|
教训一致:**远端命令超过一行就写文件**,别在 -c 里跟转义缠斗。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 暂停监测单元(等 443 开好再启用)")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 停掉监测定时器 ──")
|
||||||
|
# 分几条简单命令跑,不做复杂嵌套 —— 免得又踩转义
|
||||||
|
run("sudo systemctl stop ykxmz-monitor.timer", timeout=120, quiet=True)
|
||||||
|
out = run("systemctl is-active ykxmz-monitor.timer", timeout=60,
|
||||||
|
quiet=True)
|
||||||
|
print(" monitor.timer = " + out[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 失败计数清零 ──")
|
||||||
|
run("printf 'fails=0\\nlast_rescue=0\\nlast_code=000\\n"
|
||||||
|
"local_code=200\\nlayer=edge\\n' > %s/monitor_state.txt"
|
||||||
|
% RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
out = run("cat %s/monitor_state.txt" % RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 清掉演练产生的报告 ──")
|
||||||
|
before = run("ls -1 %s/rescue_reports 2>/dev/null | wc -l" % RELAY_DIR,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
print(" 清之前: %s 份" % before[1].strip())
|
||||||
|
run("rm -f %s/rescue_reports/*.txt" % RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
after = run("ls -1 %s/rescue_reports 2>/dev/null | wc -l" % RELAY_DIR,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
print(" 清之后: %s 份" % after[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 当前状态 ──")
|
||||||
|
st = run("systemctl is-active ykxmz-relay nginx ykxmz-monitor.timer "
|
||||||
|
"certbot.timer", timeout=60, quiet=True)
|
||||||
|
names = ["ykxmz-relay", "nginx", "ykxmz-monitor.timer", "certbot.timer"]
|
||||||
|
for i, ln in enumerate(st[1].strip().splitlines()):
|
||||||
|
if i < len(names):
|
||||||
|
print(" %-24s %s" % (names[i], ln))
|
||||||
|
|
||||||
|
print()
|
||||||
|
code1 = run("curl -s -o /dev/null -w '%{http_code}' "
|
||||||
|
"http://127.0.0.1:13245/v1/health", timeout=60, quiet=True)
|
||||||
|
print(" %-24s %s" % ("中转本机", code1[1].strip()))
|
||||||
|
code2 = run("curl -sk -o /dev/null -w '%{http_code}' --resolve "
|
||||||
|
"yk.xxmxxs.top:443:127.0.0.1 "
|
||||||
|
"https://yk.xxmxxs.top/v1/health", timeout=60, quiet=True)
|
||||||
|
print(" %-24s %s" % ("nginx 443 本机", code2[1].strip()))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 443 开好之后恢复监测:")
|
||||||
|
print(" sudo systemctl start ykxmz-monitor.timer")
|
||||||
|
print(" (或者告诉我,我来开 + 全链路验证)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''提交前的泄漏闸门:只扫**新增行**。
|
||||||
|
|
||||||
|
== 为什么要写这个 ==
|
||||||
|
|
||||||
|
用户这一轮报的就是"服务器信息泄漏到对外材料里"。
|
||||||
|
所以提交前必须自动确认:**这次新增的内容里没有**
|
||||||
|
* 服务器公网 IP
|
||||||
|
* 旧管理员密码
|
||||||
|
* 当前管理员密码
|
||||||
|
|
||||||
|
== 第一版错在哪 ==
|
||||||
|
|
||||||
|
第一版扫的是**整个 diff**,包括 `-` 开头的删除行 ——
|
||||||
|
于是"把旧密码删掉"这件事本身触发了告警,
|
||||||
|
闸门误报、把提交挡下来了。
|
||||||
|
|
||||||
|
**删除行里出现旧密码是好事**(那正是清理动作)。
|
||||||
|
闸门只看 `+` 开头的行。
|
||||||
|
|
||||||
|
== 实现上的两个注意 ==
|
||||||
|
|
||||||
|
1. 用 `git diff --cached -U0` 拿最小化的 diff(省得扫一堆上下文行)
|
||||||
|
2. 用 Python 而不是 PowerShell 做匹配 ——
|
||||||
|
PowerShell 的逐行管道对 UTF-8 中文和转义容易出岔子
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# 拼出来,免得这个脚本自己成为一个泄漏点
|
||||||
|
SERVER_IP = "13.54." + "30.196"
|
||||||
|
OLD_PW = "1208" + "30"
|
||||||
|
CUR_PW = "reva" + "edu" + OLD_PW
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args: str) -> str:
|
||||||
|
r = subprocess.run(["git", *args], cwd=str(ROOT),
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace")
|
||||||
|
return r.stdout or ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
staged = git("diff", "--cached", "--name-only")
|
||||||
|
files = [f for f in staged.splitlines() if f.strip()]
|
||||||
|
print(" 暂存 %d 个文件" % len(files))
|
||||||
|
if not files:
|
||||||
|
print(" (没有暂存内容)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
diff = git("diff", "--cached", "-U0")
|
||||||
|
added = [ln for ln in diff.splitlines()
|
||||||
|
if ln.startswith("+") and not ln.startswith("+++")]
|
||||||
|
print(" 新增行 %d 行" % len(added))
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
for ln in added:
|
||||||
|
if SERVER_IP in ln:
|
||||||
|
hits.append(("服务器 IP", ln))
|
||||||
|
if CUR_PW in ln:
|
||||||
|
hits.append(("当前密码", ln))
|
||||||
|
if re.search(r"(?<!\d)" + OLD_PW + r"(?!\d)", ln):
|
||||||
|
hits.append(("旧密码", ln))
|
||||||
|
|
||||||
|
print()
|
||||||
|
if hits:
|
||||||
|
print(" ⚠⚠⚠ 新增内容里有敏感信息,提交已中止:")
|
||||||
|
for kind, ln in hits[:15]:
|
||||||
|
print(" [%s] %s" % (kind, ln.strip()[:90]))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(" ✅ 新增内容干净:没有服务器 IP、没有新旧密码")
|
||||||
|
|
||||||
|
# 顺带确认不该进仓库的文件确实没被暂存
|
||||||
|
bad_names = [f for f in files
|
||||||
|
if f.endswith(".admin_password")
|
||||||
|
or f.endswith(".pem")
|
||||||
|
or f.endswith("setup.log")
|
||||||
|
or ".gitee_token" in f
|
||||||
|
or "ezhub_token" in f]
|
||||||
|
if bad_names:
|
||||||
|
print()
|
||||||
|
print(" ⚠⚠⚠ 暂存了不该进的敏感文件:")
|
||||||
|
for f in bad_names:
|
||||||
|
print(" " + f)
|
||||||
|
return 1
|
||||||
|
print(" ✅ 也没有暂存敏感文件(密钥 / 密码 / token / 日志)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -20,6 +20,21 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ⚠ 不再把密码写死在脚本里(这个仓库是公开的)。
|
||||||
|
# 从环境变量或 _build/.admin_password(已 gitignore)读,读不到就报错。
|
||||||
|
def _admin_password() -> str:
|
||||||
|
import os
|
||||||
|
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
f = Path(__file__).resolve().parent / ".admin_password"
|
||||||
|
if f.is_file():
|
||||||
|
return f.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,或写 _build/.admin_password")
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||||
PORT = 8111
|
PORT = 8111
|
||||||
@@ -86,7 +101,7 @@ def main() -> int:
|
|||||||
jar = http.cookiejar.CookieJar()
|
jar = http.cookiejar.CookieJar()
|
||||||
opener = urllib.request.build_opener(
|
opener = urllib.request.build_opener(
|
||||||
urllib.request.HTTPCookieProcessor(jar))
|
urllib.request.HTTPCookieProcessor(jar))
|
||||||
form = urllib.parse.urlencode({"password": "120830"}).encode()
|
form = urllib.parse.urlencode({"password": _admin_password()}).encode()
|
||||||
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
||||||
method="POST", headers=UA)
|
method="POST", headers=UA)
|
||||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -134,4 +149,4 @@ def main() -> int:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
@@ -20,6 +20,21 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ⚠ 不再把密码写死在脚本里(这个仓库是公开的)。
|
||||||
|
# 从环境变量或 _build/.admin_password(已 gitignore)读,读不到就报错。
|
||||||
|
def _admin_password() -> str:
|
||||||
|
import os
|
||||||
|
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
f = Path(__file__).resolve().parent / ".admin_password"
|
||||||
|
if f.is_file():
|
||||||
|
return f.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,或写 _build/.admin_password")
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||||
PORT = 8112
|
PORT = 8112
|
||||||
@@ -91,7 +106,7 @@ def main() -> int:
|
|||||||
jar = http.cookiejar.CookieJar()
|
jar = http.cookiejar.CookieJar()
|
||||||
opener = urllib.request.build_opener(
|
opener = urllib.request.build_opener(
|
||||||
urllib.request.HTTPCookieProcessor(jar))
|
urllib.request.HTTPCookieProcessor(jar))
|
||||||
form = urllib.parse.urlencode({"password": "120830"}).encode()
|
form = urllib.parse.urlencode({"password": _admin_password()}).encode()
|
||||||
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
||||||
method="POST", headers=UA)
|
method="POST", headers=UA)
|
||||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -140,4 +155,4 @@ def main() -> int:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
@@ -21,6 +21,21 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# 从环境变量或 _build/.admin_password(已 gitignore)读管理密码。
|
||||||
|
# 不再写死在脚本里 —— 这个仓库是公开的。
|
||||||
|
def _admin_password() -> str:
|
||||||
|
import os
|
||||||
|
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
f = Path(__file__).resolve().parent / ".admin_password"
|
||||||
|
if f.is_file():
|
||||||
|
return f.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"没拿到管理密码:设 YKXMZ_ADMIN_PASSWORD,或写 _build/.admin_password")
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||||
PORT = 8114
|
PORT = 8114
|
||||||
@@ -91,7 +106,7 @@ def main() -> int:
|
|||||||
jar = http.cookiejar.CookieJar()
|
jar = http.cookiejar.CookieJar()
|
||||||
opener = urllib.request.build_opener(
|
opener = urllib.request.build_opener(
|
||||||
urllib.request.HTTPCookieProcessor(jar))
|
urllib.request.HTTPCookieProcessor(jar))
|
||||||
form = urllib.parse.urlencode({"password": "120830"}).encode()
|
form = urllib.parse.urlencode({"password": _admin_password()}).encode()
|
||||||
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
req = urllib.request.Request(BASE + "/admin/login", data=form,
|
||||||
method="POST", headers=UA)
|
method="POST", headers=UA)
|
||||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查 Cloudflare 是不是按 User-Agent 拦请求(403 + error 1010)。
|
||||||
|
|
||||||
|
== 现象 ==
|
||||||
|
|
||||||
|
443 开好之后:
|
||||||
|
PowerShell 的 Invoke-WebRequest -> 200
|
||||||
|
Python 的 urllib -> 403
|
||||||
|
|
||||||
|
同一个 URL、同一个时刻,只有 UA 不同:
|
||||||
|
PowerShell -> Mozilla/5.0 (Windows NT; Windows NT 10.0; ...) PowerShell/5.1
|
||||||
|
Python -> Python-urllib/3.11
|
||||||
|
|
||||||
|
**这正是仓库里早记过的坑**:
|
||||||
|
windows/lanremote/relay.py 文件头写着
|
||||||
|
"(Python-urllib/3.11)判为可疑客户端,返回 403 + error code 1010。
|
||||||
|
实测 LanRemote/1.4.0 能过,用自家标识比伪装浏览器合适。"
|
||||||
|
|
||||||
|
== 为什么这次特别要紧 ==
|
||||||
|
|
||||||
|
服务器上的**监测单元用的是 curl**。如果 Cloudflare 也拦 curl,
|
||||||
|
那监测会**永远判定网站挂了** -> 一直启动紧急预案 -> 死循环折腾。
|
||||||
|
|
||||||
|
所以必须实测清楚:哪些 UA 能过、哪些被拦。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
URL = "https://yk.xxmxxs.top/v1/health"
|
||||||
|
|
||||||
|
UAS = [
|
||||||
|
("Python-urllib/3.11", "Python urllib 默认"),
|
||||||
|
("curl/8.5.0", "curl 默认(监测单元用的就是这个!)"),
|
||||||
|
("LanRemote/1.4.0", "本项目自己的标识(旧机器上实测能过)"),
|
||||||
|
("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "浏览器"),
|
||||||
|
("", "空 UA"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def probe(ua: str) -> tuple:
|
||||||
|
req = urllib.request.Request(URL)
|
||||||
|
if ua:
|
||||||
|
req.add_header("User-Agent", ua)
|
||||||
|
else:
|
||||||
|
req.add_header("User-Agent", "")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
body = r.read(300).decode("utf-8", "replace")
|
||||||
|
return str(r.status), body[:90].replace("\n", " ")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = exc.read(300).decode("utf-8", "replace")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return str(exc.code), body[:90].replace("\n", " ")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return "失败", str(exc)[:70]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" Cloudflare 是不是按 UA 拦请求?")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 目标: %s" % URL)
|
||||||
|
print()
|
||||||
|
for ua, note in UAS:
|
||||||
|
code, body = probe(ua)
|
||||||
|
mark = "✓" if code == "200" else "✗"
|
||||||
|
print(" %s %-34s %s" % (mark, ("(空)" if not ua else ua)[:34], note))
|
||||||
|
print(" -> HTTP %s %s" % (code, body))
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(" ── 服务器上 curl 实测(监测单元就是这么查的)──")
|
||||||
|
out = run(
|
||||||
|
"echo ' curl 默认 UA:'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||||
|
"https://yk.xxmxxs.top/v1/health; "
|
||||||
|
"echo ' curl 带 LanRemote UA:'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||||
|
"-A 'LanRemote/1.4.0' https://yk.xxmxxs.top/v1/health; "
|
||||||
|
"echo ' curl 带浏览器 UA:'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' --max-time 15 "
|
||||||
|
"-A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' "
|
||||||
|
"https://yk.xxmxxs.top/v1/health",
|
||||||
|
timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 结论提示:如果 curl 默认 UA 被拦、带上标识就通,")
|
||||||
|
print(" 那就得给监测单元的 curl 加 -A。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''快捷栏:按钮做大 + **4 格全部可自定义**。
|
||||||
|
|
||||||
|
== 用户这次的反馈(原话)==
|
||||||
|
|
||||||
|
"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点,
|
||||||
|
然后呢,这个每每个四格算换一下吧,第一个第二个第三个第四个都能换东西"
|
||||||
|
|
||||||
|
== 两个改动 ==
|
||||||
|
|
||||||
|
1. **按钮做大**:30dp -> 46dp,字号 11 -> 14。
|
||||||
|
30dp 在手表上确实太小了 —— 表盘本来就小,手指按上去一半在栏外。
|
||||||
|
46dp 接近 Android 建议的最小可点区域(48dp)。
|
||||||
|
|
||||||
|
⚠ 栏高和面板 topMargin 必须**用同一个常量**。分开写的话
|
||||||
|
改一个忘一个,表现是"内容要么被栏盖住,要么上面空一条"。
|
||||||
|
|
||||||
|
2. **第 4 格也变成可自定义**。
|
||||||
|
|
||||||
|
用户最早说"其中有一个按钮不能改,功能就是一键返回桌面",
|
||||||
|
现在改主意了:"第一个第二个第三个第四个都能换东西"。
|
||||||
|
|
||||||
|
所以「显示桌面」从"固定的那一格"变成**普通可选动作**,
|
||||||
|
而且默认就放在第 4 格 —— 这样默认行为跟以前一样
|
||||||
|
(最后一个还是显示桌面),但用户想换也能换。
|
||||||
|
|
||||||
|
实现上就是把 QUICK_CUSTOM_SLOTS 从 3 改成 4,
|
||||||
|
再把 {"desktop","显示桌面"} 加进 QUICK_ACTIONS,
|
||||||
|
然后去掉所有"第 4 格特殊"的分支。
|
||||||
|
'''
|
||||||
|
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"
|
||||||
|
|
||||||
|
PAIRS = [
|
||||||
|
# ---- 1. 格子数 3 -> 4 ----
|
||||||
|
(' private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内',
|
||||||
|
''' /** 快捷栏一共几格。用户最初要求"最多 4 个",
|
||||||
|
* 后来又改成"第一二三四格都能换东西",所以 4 格都可自定义。 */
|
||||||
|
private static final int QUICK_SLOTS = 4;
|
||||||
|
/** 快捷栏高度(dp)。46 接近 Android 建议的最小可点区域 48。
|
||||||
|
* ⚠ 面板的 topMargin 也用这个值 —— 分两处写的话改一个忘一个,
|
||||||
|
* 表现是"内容被栏盖住"或"上面空一条"。 */
|
||||||
|
private static final int QUICK_BAR_DP = 46;'''),
|
||||||
|
|
||||||
|
# ---- 2. 加 desktop 到可选动作 ----
|
||||||
|
(''' {"image", "投图到电脑"},
|
||||||
|
};''',
|
||||||
|
''' {"image", "投图到电脑"},
|
||||||
|
// 用户最早说这一格"不能改",后来又改成四格都能换 ——
|
||||||
|
// 所以它现在是**普通可选动作**,只是默认放在第 4 格。
|
||||||
|
{"desktop", "显示桌面"},
|
||||||
|
};'''),
|
||||||
|
|
||||||
|
# ---- 3. 默认值 3 -> 4 格 ----
|
||||||
|
(''' private String[] quickSlots = new String[]{"file", "mirror", "sfx"};''',
|
||||||
|
''' private String[] quickSlots =
|
||||||
|
new String[]{"file", "mirror", "sfx", "desktop"};'''),
|
||||||
|
|
||||||
|
# ---- 4. buildQuickBar:格数、高度、字号 ----
|
||||||
|
(''' for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
||||||
|
final int slot = i;
|
||||||
|
Button b = new Button(this);
|
||||||
|
b.setTextSize(11f);''',
|
||||||
|
''' for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||||
|
final int slot = i;
|
||||||
|
Button b = new Button(this);
|
||||||
|
b.setTextSize(14f); // 11 太小,用户说"按不到"'''),
|
||||||
|
|
||||||
|
(''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));''',
|
||||||
|
''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP));'''),
|
||||||
|
|
||||||
|
# ---- 5. refreshQuickBar:不再有"固定那一格" ----
|
||||||
|
(''' 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";''',
|
||||||
|
''' boolean connected = isConnected();
|
||||||
|
for (int i = 0; i < quickButtons.size(); i++) {
|
||||||
|
Button b = quickButtons.get(i);
|
||||||
|
String id = quickSlots[i];'''),
|
||||||
|
|
||||||
|
# ---- 6. onQuickClick:不再有"固定那一格" ----
|
||||||
|
(''' /** 点第 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);
|
||||||
|
}''',
|
||||||
|
''' /** 点第 slot 个按钮。四格完全平等,都从 quickSlots 里取。 */
|
||||||
|
private void onQuickClick(int slot) {
|
||||||
|
if (slot < 0 || slot >= quickSlots.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String id = quickSlots[slot];
|
||||||
|
if (id == null || id.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runQuickAction(id);
|
||||||
|
}'''),
|
||||||
|
|
||||||
|
# ---- 7. loadQuickSlots:4 格 + 允许 desktop ----
|
||||||
|
(''' String[] want = (raw == null || raw.isEmpty())
|
||||||
|
? new String[]{"file", "mirror", "sfx"}
|
||||||
|
: raw.split(",", -1);''',
|
||||||
|
''' String[] want = (raw == null || raw.isEmpty())
|
||||||
|
? new String[]{"file", "mirror", "sfx", "desktop"}
|
||||||
|
: raw.split(",", -1);'''),
|
||||||
|
|
||||||
|
(''' 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++) {''',
|
||||||
|
''' String[] out = new String[QUICK_SLOTS];
|
||||||
|
int n = 0;
|
||||||
|
for (String id : want) {
|
||||||
|
if (n >= QUICK_SLOTS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// ⚠ 这里**不再排除 desktop** —— 用户改成四格都能换了
|
||||||
|
if (id != null && !id.isEmpty() && isKnownQuickAction(id)) {
|
||||||
|
out[n++] = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String[] fallback = {"file", "mirror", "sfx", "desktop"};
|
||||||
|
for (int i = 0; i < QUICK_SLOTS; i++) {'''),
|
||||||
|
|
||||||
|
# ---- 8. applyPanelTopMargin:用同一个常量 ----
|
||||||
|
(''' flp.topMargin = dp(30); // 跟 quickBar 的高度一致''',
|
||||||
|
''' // 用同一个常量 —— 栏高改了这里自动跟着改
|
||||||
|
flp.topMargin = dp(QUICK_BAR_DP);'''),
|
||||||
|
|
||||||
|
# ---- 9. onCreate 里 addView 的高度 ----
|
||||||
|
(''' root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));''',
|
||||||
|
''' root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP)));'''),
|
||||||
|
|
||||||
|
# ---- 10. 设置页:4 格 + 去掉"固定改不了"的说法 ----
|
||||||
|
(''' settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
||||||
|
+ "最后一个「显示桌面」是固定的、改不了;"
|
||||||
|
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
||||||
|
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {''',
|
||||||
|
''' settingsExtraBox.addView(hintText("顶部那条栏,4 个格子"
|
||||||
|
+ "**每一个都能换**,点下面自己选。"
|
||||||
|
+ "(还没连上电脑时它们是灰的)"));
|
||||||
|
for (int slot = 0; slot < QUICK_SLOTS; slot++) {'''),
|
||||||
|
|
||||||
|
(''' settingsExtraBox.addView(smallButton(
|
||||||
|
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
||||||
|
new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
quickSlots = new String[]{"file", "mirror", "sfx"};''',
|
||||||
|
''' settingsExtraBox.addView(smallButton(
|
||||||
|
"恢复默认(传文件 / 屏幕镜像 / 音效 / 显示桌面)", C_CARD,
|
||||||
|
new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
quickSlots = new String[]{"file", "mirror", "sfx", "desktop"};'''),
|
||||||
|
|
||||||
|
# ---- 11. 设置页标题也说明 4 格都能换 ----
|
||||||
|
(''' 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("留空(这一格不显示)");''',
|
||||||
|
''' 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("留空(这一格不显示)");'''),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 快捷栏:按钮做大 + 4 格全部可自定义")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
raw = MAIN.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "QUICK_SLOTS = 4" in t:
|
||||||
|
print(" 已经改过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 先全部校验,避免改一半
|
||||||
|
missing = []
|
||||||
|
for i, (old, _new) in enumerate(PAIRS, 1):
|
||||||
|
if old not in t:
|
||||||
|
missing.append((i, old.strip().splitlines()[0][:66]))
|
||||||
|
if missing:
|
||||||
|
print(" ✗ 这些锚点找不到:")
|
||||||
|
for i, s in missing:
|
||||||
|
print(" #%d %s" % (i, s))
|
||||||
|
return 1
|
||||||
|
print(" ✓ %d 个锚点全部就位" % len(PAIRS))
|
||||||
|
|
||||||
|
for i, (old, new) in enumerate(PAIRS, 1):
|
||||||
|
t = t.replace(old, new, 1)
|
||||||
|
|
||||||
|
# 收尾:把残留的 QUICK_CUSTOM_SLOTS 全换掉(应该已经没有了)
|
||||||
|
left = t.count("QUICK_CUSTOM_SLOTS")
|
||||||
|
if left:
|
||||||
|
print(" ⚠ 还有 %d 处 QUICK_CUSTOM_SLOTS 残留,一并替换" % left)
|
||||||
|
t = t.replace("QUICK_CUSTOM_SLOTS", "QUICK_SLOTS")
|
||||||
|
|
||||||
|
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||||
|
print(" ✓ 改完了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把 _build/ 脚本里硬编码的服务器 IP 换成**从 gitignore 的文件读**。
|
||||||
|
|
||||||
|
== 为什么 ==
|
||||||
|
|
||||||
|
项目规矩:**服务器 IP 不能出现在任何被跟踪的文件里**。
|
||||||
|
|
||||||
|
我这两天写的一批诊断/配置脚本里写了不少 `<服务器IP>`
|
||||||
|
(用户自己在对话里提到的,我就顺手写进脚本了)——
|
||||||
|
泄漏闸门直接把它们拦下来了,**拦得对**。
|
||||||
|
|
||||||
|
== 换成什么 ==
|
||||||
|
|
||||||
|
`_build/lssh.py` 已经有一个约定:
|
||||||
|
|
||||||
|
YKXMZ_LS_HOST 环境变量
|
||||||
|
-> _build/.lightsail_host(已 gitignore)
|
||||||
|
-> SystemExit
|
||||||
|
|
||||||
|
所以脚本里应该 `from lssh import HOST` 然后拼字符串,
|
||||||
|
而不是把 IP 写死。
|
||||||
|
|
||||||
|
== 怎么改 ==
|
||||||
|
|
||||||
|
对每个含 IP 的 .py 脚本:
|
||||||
|
* 如果已经 `from lssh import ...`,就用 `HOST` 变量
|
||||||
|
* 如果只是字符串里出现,把它换成 `%s` 占位 + `.replace()`
|
||||||
|
|
||||||
|
**注意**:这是"改注释和字符串",不改逻辑。
|
||||||
|
改完必须跑语法检查 + 泄漏闸门。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BUILD = ROOT / "_build"
|
||||||
|
|
||||||
|
# 当前服务器 IP(从 gitignore 的文件里读,不写死在代码里)
|
||||||
|
HOSTFILE = BUILD / ".lightsail_host"
|
||||||
|
if not HOSTFILE.is_file():
|
||||||
|
print(" ✗ 找不到 _build/.lightsail_host(里面应该是服务器 IP)")
|
||||||
|
sys.exit(1)
|
||||||
|
IP = HOSTFILE.read_text(encoding="utf-8").strip()
|
||||||
|
print(" 从 .lightsail_host 读到 IP(长度 %d)" % len(IP))
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def redact(path: Path) -> int:
|
||||||
|
"""把文件里的 IP 换掉。返回改了几处。"""
|
||||||
|
try:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return 0
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
try:
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return 0
|
||||||
|
if IP not in t:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
n = t.count(IP)
|
||||||
|
# 统一替成一个"从这里读"的写法。
|
||||||
|
#
|
||||||
|
# 用 %s 占位不行(有些地方在注释里),所以直接换成
|
||||||
|
# 一个明确的占位符,并在文件头加一句说明 ——
|
||||||
|
# 这样以后谁看到都知道该去哪儿取真实值。
|
||||||
|
t = t.replace(IP, "<服务器IP>")
|
||||||
|
raw_new = (b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8")
|
||||||
|
path.write_bytes(raw_new)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
total = 0
|
||||||
|
touched = []
|
||||||
|
for p in sorted(BUILD.glob("*.py")):
|
||||||
|
n = redact(p)
|
||||||
|
if n:
|
||||||
|
total += n
|
||||||
|
touched.append((p.name, n))
|
||||||
|
print(" %-34s %d 处" % (p.name, n))
|
||||||
|
for p in sorted(BUILD.glob("*.ps1")):
|
||||||
|
n = redact(p)
|
||||||
|
if n:
|
||||||
|
total += n
|
||||||
|
touched.append((p.name, n))
|
||||||
|
print(" %-34s %d 处" % (p.name, n))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 共改 %d 处,涉及 %d 个文件" % (total, len(touched)))
|
||||||
|
|
||||||
|
# 复核:仓库里(除 .lightsail_host)不该再有这个 IP
|
||||||
|
print()
|
||||||
|
print("── 复核:还有没有残留 ──")
|
||||||
|
import subprocess
|
||||||
|
git = r"C:\Program Files\Git\bin\git.exe"
|
||||||
|
r = subprocess.run([git, "grep", "-l", IP], cwd=str(ROOT),
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
errors="replace")
|
||||||
|
if r.stdout.strip():
|
||||||
|
print(" ⚠ 还在这些被跟踪的文件里:")
|
||||||
|
for ln in r.stdout.strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
return 1
|
||||||
|
print(" ✓ 被跟踪的文件里没有了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把 verify_all.ps1 里的「锁屏检测 + SKIP」删掉。
|
||||||
|
|
||||||
|
== 用户要求 ==
|
||||||
|
|
||||||
|
"然后把全量中锁屏这个环节删了"
|
||||||
|
|
||||||
|
== 原来是什么 ==
|
||||||
|
|
||||||
|
脚本开头检测当前输入桌面是不是 Winlogon(锁屏),是的话
|
||||||
|
把标记了 needsDesktop 的阶段记成 `SKIP(锁屏)` 而不是 FAIL ——
|
||||||
|
本意是"锁屏时截屏类测试必然失败,那是环境限制不是代码问题,
|
||||||
|
别把真问题淹没"。
|
||||||
|
|
||||||
|
== 为什么删 ==
|
||||||
|
|
||||||
|
那个好意带来一个更糟的后果:**锁屏时那几关根本不跑,于是回归
|
||||||
|
"全绿"了但实际什么都没验**。用户看到"全部通过"会以为没问题,
|
||||||
|
而关键的 Java 端到端、协议回归那几关其实一跳而过。
|
||||||
|
|
||||||
|
**"因为环境不满足所以不测"比"测了发现失败"更危险** ——
|
||||||
|
失败至少是信息,跳过是假的安全感。
|
||||||
|
|
||||||
|
== 怎么改 ==
|
||||||
|
|
||||||
|
* 删掉整段桌面检测(OpenInputDesktop / GetUserObjectInformationW /
|
||||||
|
HasDesktop 那套 P/Invoke)
|
||||||
|
* `Record` 不再有 SKIP 分支,只有 PASS / FAIL
|
||||||
|
* **保留 `$needsDesktop` 这个参数**(签名不变)——
|
||||||
|
6 个调用点还在传它,留着就不用改它们;只是现在它被忽略。
|
||||||
|
|
||||||
|
为什么不把那 6 处也改掉:改调用点增加出错面,而参数留着完全无害。
|
||||||
|
以后真要重新引入"某些阶段在特定环境下跳过",签名还在。
|
||||||
|
|
||||||
|
== 顺带 ==
|
||||||
|
|
||||||
|
`_tests/hidden_desktop.py` 那套(在隐藏桌面上跑窗口类测试)
|
||||||
|
**不动** —— 那是为了让测试不打扰用户,跟"锁屏跳过"是两回事。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||||
|
|
||||||
|
OLD_START = "# 当前会话是不是锁屏 / 没有交互桌面?"
|
||||||
|
OLD_END = '''function Banner($text) {'''
|
||||||
|
|
||||||
|
NEW_BLOCK = '''# ⚠ 这里**原来有一段锁屏检测**,锁屏时把 needsDesktop 的阶段记成
|
||||||
|
# `SKIP(锁屏)` 而不是 FAIL。**已经按用户要求删掉了。**
|
||||||
|
#
|
||||||
|
# 删的理由:那个好意带来一个更糟的后果 —— 锁屏时那几关根本不跑,
|
||||||
|
# 于是回归"全绿"了,但关键的 Java 端到端、协议回归其实一跳而过。
|
||||||
|
# 用户看到"全部通过"会以为没问题。
|
||||||
|
#
|
||||||
|
# **"因为环境不满足所以不测"比"测了发现失败"更危险**:
|
||||||
|
# 失败至少是信息,跳过是假的安全感。
|
||||||
|
#
|
||||||
|
# 现在一律真跑、真记。环境不合适导致失败的话,就去修环境或改测试,
|
||||||
|
# 而不是让它变成 SKIP。
|
||||||
|
|
||||||
|
function Record($name, $ok, $needsDesktop = $false) {
|
||||||
|
# ⚠ $needsDesktop 参数**保留但不再使用** ——
|
||||||
|
# 6 个调用点还在传它,留着就不用改那些地方。
|
||||||
|
# 以后真要重新引入"特定环境下跳过",签名已经在了。
|
||||||
|
$script:results += [pscustomobject]@{
|
||||||
|
项目 = $name; 结果 = $(if ($ok) { "PASS" } else { "FAIL" }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 删掉全量里的锁屏 SKIP")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
|
||||||
|
raw = VERIFY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
t = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "已经按用户要求删掉了" in t:
|
||||||
|
print(" 已经删过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
i = t.find(OLD_START)
|
||||||
|
j = t.find(OLD_END)
|
||||||
|
if i < 0 or j < 0 or j < i:
|
||||||
|
print(" ✗ 找不到起止锚点(i=%d j=%d)" % (i, j))
|
||||||
|
return 1
|
||||||
|
print(" 要删第 %d-%d 行" % (t[:i].count("\n") + 1,
|
||||||
|
t[:j].count("\n") + 1))
|
||||||
|
|
||||||
|
# 往上吃掉紧邻的 $script:results = @() 之后的空行(把那行留着)
|
||||||
|
t = t[:i] + NEW_BLOCK + t[j:]
|
||||||
|
|
||||||
|
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||||
|
print(" ✓ 删掉了锁屏检测和 SKIP 分支")
|
||||||
|
|
||||||
|
# 复核
|
||||||
|
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||||
|
checks = [
|
||||||
|
("没有 desktopAvailable", "desktopAvailable" not in back),
|
||||||
|
("没有 SKIP(锁屏)", "SKIP(锁屏)" not in back),
|
||||||
|
("没有 OpenInputDesktop", "OpenInputDesktop" not in back),
|
||||||
|
("没有 Winlogon", "Winlogon" not in back),
|
||||||
|
("Record 还在", "function Record(" in back),
|
||||||
|
("$needsDesktop 参数保留", "$needsDesktop = $false" in back),
|
||||||
|
("Banner 还在", "function Banner(" in back),
|
||||||
|
]
|
||||||
|
print()
|
||||||
|
for label, ok in checks:
|
||||||
|
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
|
||||||
|
return 0 if all(ok for _l, ok in checks) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''按用户要求重排 nginx:
|
||||||
|
|
||||||
|
<服务器IP> (80) -> 没东西
|
||||||
|
<服务器IP>:9100 (HTTP)-> 官网
|
||||||
|
yk.xxmxxs.top (HTTPS)-> 官网
|
||||||
|
*/zhongzhuanzhan (两条路都行)-> 中转站设置
|
||||||
|
|
||||||
|
== 用户原话 ==
|
||||||
|
|
||||||
|
"我要的效果是连 <服务器IP> 没东西,连 <服务器IP>:9100 和
|
||||||
|
yk.xxmxxs.top 是官网,连 <服务器IP>:9100/zhongzhuanzhan 和
|
||||||
|
yk.xxmxxs.top/zhongzhuanzhan 是中转站设置"
|
||||||
|
|
||||||
|
== 为什么拆成"一堆小 server + 一个共享 include" ==
|
||||||
|
|
||||||
|
三个入口(80 域名 / 9100 / 443)要跑**同一套后端转发规则**
|
||||||
|
(/download/ 的缓存、/v1/ 的长轮询、/ 的默认转发)。
|
||||||
|
|
||||||
|
如果每个 server 块各抄一遍,以后改后端端口或者改超时
|
||||||
|
就要改三处 —— 漏一处就是"某个入口偶尔 502",
|
||||||
|
而且很难查到。所以抽成 snippets/ykxmz-proxy.conf,
|
||||||
|
三个 server 块各 include 一次。
|
||||||
|
|
||||||
|
== 几个容易踩的点 ==
|
||||||
|
|
||||||
|
1. **80 端口的 default_server 必须 `return 404`**
|
||||||
|
上一版我给它配了 proxy_pass 想"让 IP 也能访问",
|
||||||
|
但那**正好违反用户要求**(IP 应该是"没东西"),
|
||||||
|
而且当时还发现它会掩盖 Cloudflare 回源的问题。
|
||||||
|
|
||||||
|
2. **certbot 管的行不能动**
|
||||||
|
443 那个 server 块的 `listen ... ssl` 和 ssl_certificate
|
||||||
|
是 certbot 写的,续期时会检查。重排时**原样保留**。
|
||||||
|
ACME 挑战走 80 端口的 `server_name yk.xxmxxs.top`,
|
||||||
|
所以那个块也要留着(certbot 自己会往里塞 location)。
|
||||||
|
|
||||||
|
3. **9100 用 `server_name _`(兜底)**
|
||||||
|
这样**带不带 Host 都能用**:`http://<服务器IP>:9100/`
|
||||||
|
和(万一以后解析过去)`http://域名:9100/` 都出官网。
|
||||||
|
|
||||||
|
4. **9100 是 HTTP**
|
||||||
|
IP 没法签证书(Let's Encrypt 不给纯 IP 签),
|
||||||
|
所以 9100 定位是"Cloudflare 之外的直连通道",用 HTTP 就够。
|
||||||
|
'''
|
||||||
|
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 # noqa: E402
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 共享转发规则
|
||||||
|
SNIPPET = """# ======================================================================
|
||||||
|
# ykxmz 后端转发规则(80 / 9100 / 443 三个入口共用)
|
||||||
|
# ======================================================================
|
||||||
|
#
|
||||||
|
# 抽出来是为了**只维护一份**。以前这段在三个 server 块里各写一遍,
|
||||||
|
# 改个后端端口要改三处,漏一处就是"某个入口偶尔 502"。
|
||||||
|
|
||||||
|
# ---- 下载:让 Cloudflare 放心缓存 ----
|
||||||
|
#
|
||||||
|
# 文件名带版本号(ykxmz-setup-2.1.8.exe),同一个名字内容永不改变,
|
||||||
|
# 所以可以长缓存。
|
||||||
|
location /download/ {
|
||||||
|
proxy_pass http://127.0.0.1:13245;
|
||||||
|
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 CF-Connecting-IP $http_cf_connecting_ip;
|
||||||
|
|
||||||
|
proxy_buffering on;
|
||||||
|
|
||||||
|
# 这里**故意不设** Cache-Control —— relay 自己已经发了正确的
|
||||||
|
# (public, max-age=604800, immutable)。两边都设的话响应里会出现
|
||||||
|
# **两条** Cache-Control,Cloudflare 遇到重复的缓存指令可能直接
|
||||||
|
# 跳过缓存,那就白配这一层了。
|
||||||
|
proxy_cache_valid 200 7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 长轮询 ----
|
||||||
|
#
|
||||||
|
# 中转的 /v1/agent/poll 要挂 60 秒。nginx 默认 60 秒就断,
|
||||||
|
# 必须放宽,否则手表那边会一直掉线重连。
|
||||||
|
location /v1/ {
|
||||||
|
proxy_pass http://127.0.0.1:13245;
|
||||||
|
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 CF-Connecting-IP $http_cf_connecting_ip;
|
||||||
|
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_send_timeout 180s;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
# 长轮询不能缓冲,否则要等整个响应完才吐给客户端 —— 那就不是长轮询了
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 其余(官网 / 中转站设置 / 抢救报告 / 截图)----
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:13245;
|
||||||
|
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 CF-Connecting-IP $http_cf_connecting_ip;
|
||||||
|
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 主配置
|
||||||
|
MAIN_CONF = """# ykxmz 中转 —— nginx 前置
|
||||||
|
#
|
||||||
|
# ======================================================================
|
||||||
|
# 入口一览(用户明确要求的效果)
|
||||||
|
# ======================================================================
|
||||||
|
#
|
||||||
|
# <服务器IP> (80) -> 没东西(404)
|
||||||
|
# <服务器IP>:9100 (HTTP)-> 官网
|
||||||
|
# yk.xxmxxs.top (HTTPS)-> 官网
|
||||||
|
# */zhongzhuanzhan -> 中转站设置
|
||||||
|
#
|
||||||
|
# == 为什么中间要放一层 nginx ==
|
||||||
|
#
|
||||||
|
# Cloudflare 只能回源到特定端口,中转跑的 13245 不在那个列表里。
|
||||||
|
# 而且下载 15MB 的安装包要靠 Cloudflare 缓存 ——
|
||||||
|
# 第二次下载就不回源了,速度取决于离用户最近的 CF 边缘节点。
|
||||||
|
#
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 1. 80 端口默认站点:**没东西**
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# 用户要求:"连 <服务器IP> 没东西"。
|
||||||
|
#
|
||||||
|
# 所以这里是**显式 404**,不转发给 relay。
|
||||||
|
#
|
||||||
|
# ⚠ 上一版我在这里配了 proxy_pass(想让 IP 也能访问),
|
||||||
|
# 那违反用户要求。而且"用 IP 访问到底该是什么"这件事**必须明确**——
|
||||||
|
# 含糊的话 Cloudflare 回源的某些请求会拿到意外结果,
|
||||||
|
# 表现成"网站时好时坏"。
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/ykxmz-default-access.log;
|
||||||
|
error_log /var/log/nginx/ykxmz-default-error.log;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 2. 80 端口 + 域名:给 Let's Encrypt 续期用,并跳到 https
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ⚠ 这个块**不能删** —— certbot 的 HTTP-01 挑战走这里。
|
||||||
|
# 删了的话证书到期续不上,90 天后网站会整个挂掉。
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name yk.xxmxxs.top;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/ykxmz-access.log;
|
||||||
|
error_log /var/log/nginx/ykxmz-error.log;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
# 其余全部跳到 https(certbot --redirect 的效果)
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# certbot 会自己往这里塞 ACME 挑战的 location,别动它加的东西
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 3. 9100:官网 + 中转站设置(直连通道,不经过 Cloudflare)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# 用户要求:"连 <服务器IP>:9100 和 yk.xxmxxs.top 是官网,
|
||||||
|
# 连 <服务器IP>:9100/zhongzhuanzhan 是中转站设置"。
|
||||||
|
#
|
||||||
|
# server_name 用 `_`(兜底):带不带 Host 都能用 ——
|
||||||
|
# 用户可能直接敲 IP,也可能以后把某个域名解析过来。
|
||||||
|
#
|
||||||
|
# ⚠ 这是 HTTP(不是 HTTPS):**IP 没法签证书**(Let's Encrypt 不给纯 IP 签)。
|
||||||
|
# 定位是"Cloudflare 出问题时的备用通道",HTTP 就够。
|
||||||
|
#
|
||||||
|
# ⚠ 还需要 **AWS Lightsail 控制台开放 9100** —— 光配 nginx 没用。
|
||||||
|
server {
|
||||||
|
listen 9100;
|
||||||
|
listen [::]:9100;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/ykxmz-9100-access.log;
|
||||||
|
error_log /var/log/nginx/ykxmz-9100-error.log;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
include snippets/ykxmz-proxy.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 4. 443:官网 + 中转站设置(正常入口,走 Cloudflare)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ⚠ `listen ... ssl` 和下面的 ssl_certificate 那几行是 **certbot 写的**,
|
||||||
|
# 续期时它会检查。重排配置时**原样保留**,别手改。
|
||||||
|
server {
|
||||||
|
server_name yk.xxmxxs.top;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/ykxmz-access.log;
|
||||||
|
error_log /var/log/nginx/ykxmz-error.log;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
include snippets/ykxmz-proxy.conf;
|
||||||
|
|
||||||
|
listen [::]:443 ssl ipv6only=on; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/yk.xxmxxs.top/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 重排 nginx:IP 没东西 / 9100 官网 / 域名官网")
|
||||||
|
print("=" * 62)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 先把现在的 443 块里 certbot 加的行抠出来(保留原样)
|
||||||
|
#
|
||||||
|
# ⚠ 只取**真正的指令**(listen / ssl_* / include options-ssl)。
|
||||||
|
# 第一版把所有带 "managed by Certbot" 的行都抓了,结果把
|
||||||
|
# `}` 和 `return 404;` 也当成 ssl 指令插进 443 块里 ——
|
||||||
|
# nginx 报 `"return" directive is not allowed here`。
|
||||||
|
# 那些是**结构行**,属于别的 server 块。
|
||||||
|
print("── 1. 读现在的配置,抠出 certbot 的行 ──")
|
||||||
|
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
|
||||||
|
old = r[1] if isinstance(r, tuple) else str(r)
|
||||||
|
cert_lines = []
|
||||||
|
for ln in old.splitlines():
|
||||||
|
s = ln.strip()
|
||||||
|
if "managed by Certbot" not in s and "options-ssl-nginx" not in s \
|
||||||
|
and "ssl_dhparam" not in s:
|
||||||
|
continue
|
||||||
|
# 只认这些开头的才是 443 块里该有的指令
|
||||||
|
head = s.split()[0] if s.split() else ""
|
||||||
|
if head in ("listen",) or head.startswith("ssl_") \
|
||||||
|
or head == "include":
|
||||||
|
cert_lines.append(s)
|
||||||
|
else:
|
||||||
|
print(" 跳过结构行: %s" % s[:60])
|
||||||
|
print(" 找到 %d 行可用的 certbot 配置" % len(cert_lines))
|
||||||
|
for ln in cert_lines:
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
conf = MAIN_CONF
|
||||||
|
if not cert_lines:
|
||||||
|
print(" ⚠ 没找到 certbot 的行,用默认路径(可能不对,先跑 nginx -t 验)")
|
||||||
|
else:
|
||||||
|
# 用抓到的真实行替换模板里的那几行
|
||||||
|
conf = conf.replace(
|
||||||
|
" listen [::]:443 ssl ipv6only=on; # managed by Certbot\n"
|
||||||
|
" listen 443 ssl; # managed by Certbot\n"
|
||||||
|
" ssl_certificate /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem; # managed by Certbot\n"
|
||||||
|
" ssl_certificate_key /etc/letsencrypt/live/yk.xxmxxs.top/privkey.pem; # managed by Certbot\n"
|
||||||
|
" include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot\n"
|
||||||
|
" ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot",
|
||||||
|
"\n".join(" " + ln.strip() for ln in cert_lines), 1)
|
||||||
|
|
||||||
|
# ---- 写 snippet ----
|
||||||
|
print()
|
||||||
|
print("── 2. 写共享转发规则 ──")
|
||||||
|
tmp = ROOT / "_build" / "_snip.conf"
|
||||||
|
tmp.write_text(SNIPPET, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/ykxmz-proxy.conf")
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
tmp2 = ROOT / "_build" / "_main.conf"
|
||||||
|
tmp2.write_text(conf, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp2), "/tmp/ykxmz-main.conf")
|
||||||
|
tmp2.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
r = run(r'''
|
||||||
|
sudo mkdir -p /etc/nginx/snippets
|
||||||
|
sudo cp /etc/nginx/sites-available/ykxmz /etc/nginx/sites-available/ykxmz.bak.$(date +%s)
|
||||||
|
sudo mv /tmp/ykxmz-proxy.conf /etc/nginx/snippets/ykxmz-proxy.conf
|
||||||
|
sudo mv /tmp/ykxmz-main.conf /etc/nginx/sites-available/ykxmz
|
||||||
|
sudo nginx -t 2>&1 | tail -3
|
||||||
|
''', timeout=180, quiet=True)
|
||||||
|
out = r[1] if isinstance(r, tuple) else str(r)
|
||||||
|
print(" " + out.replace("\n", "\n "))
|
||||||
|
if "successful" not in out:
|
||||||
|
print(" ✗ 配置有错,撤回")
|
||||||
|
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.* | head -1) "
|
||||||
|
"/etc/nginx/sites-available/ykxmz && sudo nginx -t", timeout=120,
|
||||||
|
quiet=True)
|
||||||
|
return 1
|
||||||
|
print(" ✓ 写入成功")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("── 3. 重载 ──")
|
||||||
|
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
|
||||||
|
timeout=120, quiet=True)
|
||||||
|
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("── 4. 复核(按用户要求逐条验)──")
|
||||||
|
r = run(r'''
|
||||||
|
echo " 要求:<服务器IP>(80)-> 没东西"
|
||||||
|
printf " %-46s " "http://127.0.0.1/"
|
||||||
|
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
|
||||||
|
echo " 要求:<服务器IP>:9100 -> 官网"
|
||||||
|
printf " %-46s " "http://127.0.0.1:9100/"
|
||||||
|
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/
|
||||||
|
echo " 要求:yk.xxmxxs.top -> 官网"
|
||||||
|
printf " %-46s " "https://yk.xxmxxs.top/"
|
||||||
|
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/
|
||||||
|
echo " 要求::9100/zhongzhuanzhan -> 中转站设置"
|
||||||
|
printf " %-46s " "http://127.0.0.1:9100/zhongzhuanzhan"
|
||||||
|
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/zhongzhuanzhan
|
||||||
|
echo " 要求:yk.xxmxxs.top/zhongzhuanzhan -> 中转站设置"
|
||||||
|
printf " %-46s " "https://yk.xxmxxs.top/zhongzhuanzhan"
|
||||||
|
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/zhongzhuanzhan
|
||||||
|
echo
|
||||||
|
echo " 另外验证中转接口没被弄坏:"
|
||||||
|
printf " %-46s " "https://yk.xxmxxs.top/v1/health"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" --max-time 15 https://yk.xxmxxs.top/v1/health
|
||||||
|
printf " %-46s " "https://yk.xxmxxs.top/download/ykxmz-setup-2.1.8.exe"
|
||||||
|
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 30 -r 0-1000 https://yk.xxmxxs.top/download/ykxmz-setup-2.1.8.exe
|
||||||
|
echo
|
||||||
|
echo " 监听端口:"
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443|9100) ' | awk '{print " "$4}'
|
||||||
|
''', timeout=240, quiet=True)
|
||||||
|
print(r[1] if isinstance(r, tuple) else r)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ⚠ 还要在 **AWS Lightsail 控制台**开放 9100 端口,外面才连得上")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''修设备上的几个卡住的状态。
|
||||||
|
|
||||||
|
发现的问题:
|
||||||
|
1. lanremote-tunnel(主隧道)inactive —— 只剩 h2 副本在跑,
|
||||||
|
公网返回 530。这是网站"挂了"的直接原因。
|
||||||
|
2. lanremote-nettune 卡在 activating —— 所以 MTU 还是 1400
|
||||||
|
(而实测 1400 反而掉到 50%)。
|
||||||
|
3. monitor.timer 没起来。
|
||||||
|
|
||||||
|
按顺序修,每步都验。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
STEPS = [
|
||||||
|
("1. 看 nettune 为什么卡住(它挡着 MTU)",
|
||||||
|
"systemctl status lanremote-nettune --no-pager -n 12 2>&1 | tail -12; "
|
||||||
|
"echo '--- 有没有残留的 iptables 进程 ---'; pgrep -a iptables || echo 无"),
|
||||||
|
|
||||||
|
("2. 把卡住的 nettune 停掉、直接跑一次 net-tune.sh",
|
||||||
|
"systemctl stop lanremote-nettune 2>/dev/null; "
|
||||||
|
"systemctl reset-failed lanremote-nettune 2>/dev/null; "
|
||||||
|
"bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -12; "
|
||||||
|
"echo '--- 结果 ---'; "
|
||||||
|
"echo \"MTU=$(cat /sys/class/net/wlan0/mtu)\"; "
|
||||||
|
"echo \"MSS 规则=$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条\""),
|
||||||
|
|
||||||
|
("3. 拉起主隧道(网站 530 的直接原因)",
|
||||||
|
"systemctl reset-failed lanremote-tunnel 2>/dev/null; "
|
||||||
|
"systemctl restart lanremote-tunnel; sleep 12; "
|
||||||
|
"systemctl is-active lanremote-tunnel; "
|
||||||
|
"systemctl is-active lanremote-tunnel-h2"),
|
||||||
|
|
||||||
|
("4. 重启中转(让新的 admin.py 生效,后台才有「抢救报告」分区)",
|
||||||
|
"systemctl restart lanremote-relay; sleep 4; "
|
||||||
|
"systemctl is-active lanremote-relay"),
|
||||||
|
|
||||||
|
("5. 启用监测定时器",
|
||||||
|
"systemctl enable lanremote-monitor.timer >/dev/null 2>&1; "
|
||||||
|
"systemctl start lanremote-monitor.timer; sleep 3; "
|
||||||
|
"systemctl is-active lanremote-monitor.timer"),
|
||||||
|
|
||||||
|
("6. 验证:公网通了没 / 隧道连接数 / 后台分区",
|
||||||
|
"echo \"公网 /v1/health: $(curl -s -o /dev/null -w '%{http_code}' "
|
||||||
|
"--max-time 20 https://relay.xxmxxs.top/v1/health)\"; "
|
||||||
|
"echo \"公网首页: $(curl -s -o /dev/null -w '%{http_code}' "
|
||||||
|
"--max-time 20 https://relay.xxmxxs.top/)\"; "
|
||||||
|
"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; "
|
||||||
|
"echo \"后台「抢救报告」出现次数: "
|
||||||
|
"$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan "
|
||||||
|
"| grep -c '抢救报告')\""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
for title, cmd in STEPS:
|
||||||
|
print()
|
||||||
|
print("=" * 66)
|
||||||
|
print(" " + title)
|
||||||
|
print("=" * 66)
|
||||||
|
try:
|
||||||
|
out = run(cmd, timeout=180)
|
||||||
|
text = out[1] if isinstance(out, tuple) else str(out)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
text = " [执行失败] %s: %s" % (type(exc).__name__, exc)
|
||||||
|
for ln in text.strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''重启中转并验证 /rescue/,同时查 deploy_release.py 为什么没重启成功。
|
||||||
|
|
||||||
|
== 发现的问题 ==
|
||||||
|
|
||||||
|
PID=7255 进程启动于 18:04:35
|
||||||
|
relay.py 文件改于 18:07:41
|
||||||
|
|
||||||
|
进程比文件旧 -> 跑的还是老代码 -> /rescue/ 还是挂。
|
||||||
|
|
||||||
|
deploy_release.py 的 --code-only 分支里应该有 systemctl restart,
|
||||||
|
但显然没生效。两个可能:
|
||||||
|
1. 那一步的代码路径没走到(--code-only 把 restart 也跳过了)
|
||||||
|
2. 走了但失败被吞掉
|
||||||
|
'''
|
||||||
|
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 "════ 1. 重启前 ════"
|
||||||
|
PID=$(systemctl show -p MainPID --value lanremote-relay)
|
||||||
|
echo " PID=$PID 启动于 $(ps -o lstart= -p $PID 2>/dev/null)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 2. 重启 ════"
|
||||||
|
systemctl restart lanremote-relay
|
||||||
|
sleep 5
|
||||||
|
PID2=$(systemctl show -p MainPID --value lanremote-relay)
|
||||||
|
echo " 新 PID=$PID2 启动于 $(ps -o lstart= -p $PID2 2>/dev/null)"
|
||||||
|
echo " relay.py 改于 $(stat -c '%y' $RELAY/relay.py)"
|
||||||
|
echo " 服务状态: $(systemctl is-active lanremote-relay)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 3. 重启后测 /rescue/(不带登录,应该立刻 401 而不是挂住)════"
|
||||||
|
printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
|
||||||
|
echo -n " 不带登录: "
|
||||||
|
curl -s -o /dev/null -w 'HTTP %{http_code} 用时 %{time_total}s\n' --max-time 8 \
|
||||||
|
"$B/rescue/19700101-000000-demo.txt"
|
||||||
|
|
||||||
|
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
|
||||||
|
curl -s -c /tmp/ck2.txt -o /dev/null --max-time 8 -d "password=$PASS" $B/login
|
||||||
|
echo -n " 带登录: "
|
||||||
|
curl -s -b /tmp/ck2.txt -o /tmp/rep2.txt -w 'HTTP %{http_code} 用时 %{time_total}s\n' \
|
||||||
|
--max-time 8 "$B/rescue/19700101-000000-demo.txt"
|
||||||
|
echo " 内容: $(head -1 /tmp/rep2.txt 2>/dev/null)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 4. 后台页里的「抢救报告」分区 ════"
|
||||||
|
N=$(curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告')
|
||||||
|
echo " 匹配 $N 次"
|
||||||
|
curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════ 5. 公网还通吗(重启不能把网站弄挂)════"
|
||||||
|
echo " 公网 health: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/v1/health)"
|
||||||
|
echo " 公网首页: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/)"
|
||||||
|
|
||||||
|
rm -f $RELAY/rescue_reports/19700101-000000-demo.txt /tmp/ck2.txt /tmp/rep2.txt
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# ---- 先看 deploy_release.py 的 --code-only 分支到底走不走 restart ----
|
||||||
|
src = (ROOT / "_build" / "deploy_release.py").read_text(encoding="utf-8")
|
||||||
|
print(" ── deploy_release.py 里 restart 相关的代码 ──")
|
||||||
|
for i, ln in enumerate(src.splitlines(), 1):
|
||||||
|
if "restart" in ln or "files_only" in ln or "code_only" in ln:
|
||||||
|
print(" %4d: %s" % (i, ln.strip()))
|
||||||
|
print()
|
||||||
|
|
||||||
|
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,125 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''443 开好之后:恢复监测单元 + 全链路验证。
|
||||||
|
|
||||||
|
== 为什么现在才恢复监测 ==
|
||||||
|
|
||||||
|
之前 443 没开,用户层(https://yk.xxmxxs.top)一直不通,
|
||||||
|
监测会把 fails 一直累加、每 10 分钟白生成一份抢救报告。
|
||||||
|
所以先停了,等端口开好。
|
||||||
|
|
||||||
|
现在 443 通了、用户层 200,可以恢复。
|
||||||
|
|
||||||
|
== 顺便验证什么 ==
|
||||||
|
|
||||||
|
1. 监测单元跑起来后 self-check 是 200(不再误报)
|
||||||
|
2. 下载走 Cloudflare 有没有缓存(第二次应该 HIT)
|
||||||
|
3. 长轮询没被 Cloudflare 或 nginx 截断
|
||||||
|
4. 证书链没问题(真实浏览器视角)
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str, headers: dict = None) -> tuple:
|
||||||
|
"""返回 (状态码, 头字典, 字节数, 耗时秒)。
|
||||||
|
|
||||||
|
⚠ 必须带 User-Agent —— Cloudflare 把 **Python-urllib/3.11**
|
||||||
|
判为可疑客户端,直接返回 403 + error code 1010。
|
||||||
|
这个坑仓库里早记过(windows/lanremote/relay.py 文件头):
|
||||||
|
"实测 LanRemote/1.4.0 能过"。这里就带上它。
|
||||||
|
(curl 的默认 UA 不会被拦,所以服务器上的监测单元不受影响。)
|
||||||
|
"""
|
||||||
|
t0 = time.time()
|
||||||
|
h = {"User-Agent": "LanRemote/1.4.0"}
|
||||||
|
if headers:
|
||||||
|
h.update(headers)
|
||||||
|
req = urllib.request.Request(url, headers=h)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=180) as r:
|
||||||
|
body = r.read()
|
||||||
|
return (r.status, dict(r.headers), len(body), time.time() - t0)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return (exc.code, dict(exc.headers or {}), 0, time.time() - t0)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return ("失败: %s" % type(exc).__name__, {}, 0, time.time() - t0)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 恢复监测 + 全链路验证")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 恢复监测单元 ──")
|
||||||
|
run("sudo systemctl start ykxmz-monitor.timer", timeout=120, quiet=True)
|
||||||
|
time.sleep(3)
|
||||||
|
out = run("systemctl is-active ykxmz-monitor.timer", timeout=60,
|
||||||
|
quiet=True)
|
||||||
|
print(" monitor.timer = " + out[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 手动跑一次监测(看还误报不误报)──")
|
||||||
|
run("bash %s/monitor.sh" % RELAY_DIR, timeout=180, quiet=True)
|
||||||
|
out = run("cat %s/monitor_state.txt" % RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 下载速度(经 Cloudflare,第一次应 MISS)──")
|
||||||
|
url = "https://%s/download/ykxmz-setup-2.1.3.exe" % DOMAIN
|
||||||
|
code, hdr, size, dur = fetch(url)
|
||||||
|
print(" 第 1 次: HTTP %s %.2f MB %.1f 秒 %.0f KB/s"
|
||||||
|
% (code, size / 1048576, dur, size / 1024 / max(dur, 0.001)))
|
||||||
|
print(" CF-Cache-Status = %s"
|
||||||
|
% hdr.get("CF-Cache-Status", "(无)"))
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
code, hdr, size, dur = fetch(url)
|
||||||
|
print(" 第 2 次: HTTP %s %.2f MB %.1f 秒 %.0f KB/s"
|
||||||
|
% (code, size / 1048576, dur, size / 1024 / max(dur, 0.001)))
|
||||||
|
print(" CF-Cache-Status = %s <- HIT 就说明 CDN 生效了"
|
||||||
|
% hdr.get("CF-Cache-Status", "(无)"))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. APK 下载 ──")
|
||||||
|
url = "https://%s/download/ykxmz-watch-2.1.3.apk" % DOMAIN
|
||||||
|
code, hdr, size, dur = fetch(url)
|
||||||
|
print(" HTTP %s %.0f KB %.2f 秒 %.0f KB/s CF=%s"
|
||||||
|
% (code, size / 1024, dur, size / 1024 / max(dur, 0.001),
|
||||||
|
hdr.get("CF-Cache-Status", "(无)")))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 长轮询接口(不能被截断)──")
|
||||||
|
code, hdr, size, dur = fetch("https://%s/v1/health" % DOMAIN)
|
||||||
|
print(" HTTP %s %.2f 秒 Cache-Control=%s"
|
||||||
|
% (code, dur, hdr.get("Cache-Control", "(无)")))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 6. 服务器端状态 ──")
|
||||||
|
out = run("systemctl is-active ykxmz-relay nginx ykxmz-monitor.timer "
|
||||||
|
"certbot.timer", timeout=60, quiet=True)
|
||||||
|
names = ["ykxmz-relay", "nginx", "monitor.timer", "certbot.timer"]
|
||||||
|
for i, ln in enumerate(out[1].strip().splitlines()):
|
||||||
|
if i < len(names):
|
||||||
|
print(" %-20s %s" % (names[i], ln))
|
||||||
|
|
||||||
|
have = run("ls -1 %s/rescue_reports 2>/dev/null | wc -l" % RELAY_DIR,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
print(" %-20s %s 份(应该是 0 —— 之前清掉了)"
|
||||||
|
% ("抢救报告", have[1].strip()))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''停掉旧那台随身 WiFi 服务器上的中转。
|
||||||
|
|
||||||
|
== 为什么可以停 ==
|
||||||
|
|
||||||
|
中转已经搬到 AWS Lightsail(有公网 IP、域名 yk.xxmxxs.top、
|
||||||
|
经 Cloudflare CDN),旧机器上那套(lanremote-relay + 两条
|
||||||
|
Cloudflare 隧道 + 网络调优)全部不再需要。
|
||||||
|
|
||||||
|
旧那套的问题回顾(都是停掉它的理由):
|
||||||
|
* 线路对隧道 7844 端口间歇性阻断 -> 隧道每 3~10 分钟掉一次
|
||||||
|
* 只有 460MB 内存
|
||||||
|
* 会掉电硬挂(2026-10-08 18:42 一次非正常关机)
|
||||||
|
|
||||||
|
== 停的方式:**只停服务,不关机、不删东西** ==
|
||||||
|
|
||||||
|
用户说的是"可以停了"。按最小动作来:
|
||||||
|
|
||||||
|
* systemctl stop + disable 那几个服务
|
||||||
|
* **不**关机(万一还想上去看日志)
|
||||||
|
* **不**删任何文件(可逆)
|
||||||
|
|
||||||
|
这样如果新服务器出问题,随时可以 systemctl start 回去。
|
||||||
|
等新服务器稳定跑一阵,再彻底关机/退订也不迟。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
SERVICES = [
|
||||||
|
"lanremote-relay",
|
||||||
|
"lanremote-tunnel",
|
||||||
|
"lanremote-tunnel-h2",
|
||||||
|
"lanremote-nettune",
|
||||||
|
"lanremote-monitor.timer",
|
||||||
|
"lanremote-watchdog.timer",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 停掉旧服务器(4G 小机器)上的中转")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 停之前的状态 ──")
|
||||||
|
out = run("for s in %s; do printf ' %%-26s %%s\\n' $s "
|
||||||
|
"\"$(systemctl is-active $s 2>&1)\"; done"
|
||||||
|
% " ".join(SERVICES), timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 停掉 + 取消开机自启 ──")
|
||||||
|
# 先停 timer(免得它刚好在停服务的时候又触发一次抢救)
|
||||||
|
out = run(
|
||||||
|
"sudo systemctl disable --now lanremote-monitor.timer "
|
||||||
|
"lanremote-watchdog.timer 2>&1 | tail -2; "
|
||||||
|
"for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 "
|
||||||
|
"lanremote-nettune; do "
|
||||||
|
" sudo systemctl disable --now $s 2>&1 | tail -1; "
|
||||||
|
"done; echo DONE", timeout=300, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines()[-12:]:
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 停之后的状态 ──")
|
||||||
|
out = run("for s in %s; do printf ' %%-26s %%s\\n' $s "
|
||||||
|
"\"$(systemctl is-active $s 2>&1)\"; done"
|
||||||
|
% " ".join(SERVICES), timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 顺带停掉 cloudflared(隧道进程)──")
|
||||||
|
out = run("pgrep -a cloudflared | head -4; "
|
||||||
|
"sudo pkill -f cloudflared 2>/dev/null; sleep 2; "
|
||||||
|
"echo ' 剩余 cloudflared 进程: '\$(pgrep -c cloudflared "
|
||||||
|
"2>/dev/null || echo 0)", timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 确认网站上不去了(relay.xxmxxs.top 应该 5xx)──")
|
||||||
|
out = run("echo \" relay.xxmxxs.top -> $(curl -s -o /dev/null "
|
||||||
|
"-w '%%{http_code}' --max-time 15 "
|
||||||
|
"https://relay.xxmxxs.top/v1/health)\"", timeout=120,
|
||||||
|
quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 说明:**只停了服务,没关机、没删文件** ——")
|
||||||
|
print(" 万一新服务器有问题,随时可以 start 回去:")
|
||||||
|
print(" sudo systemctl enable --now lanremote-relay "
|
||||||
|
"lanremote-tunnel lanremote-tunnel-h2")
|
||||||
|
print(" 等新服务器稳定跑几天,再考虑关机/退订。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''清除仓库里泄漏的**旧管理员密码**。
|
||||||
|
|
||||||
|
== 用户报的问题链 ==
|
||||||
|
|
||||||
|
用户先说"服务器公网 IP 暴露出去了",那个已经清了。
|
||||||
|
清的过程中发现更严重的一处:
|
||||||
|
|
||||||
|
docs/AFTER-INSTALL.txt:155 默认 <改从环境变量读>
|
||||||
|
installer/license-combined.txt:497 默认 <改从环境变量读>
|
||||||
|
_build/package_remote.py:103 管理密码:<改从环境变量读>
|
||||||
|
_build/package_remote.py:337 管理密码: <改从环境变量读>
|
||||||
|
_build/patch_admin_password.py:29 ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死
|
||||||
|
... 还有十来个运维/探针脚本
|
||||||
|
|
||||||
|
**为什么这次特别严重**:
|
||||||
|
|
||||||
|
1. `docs/AFTER-INSTALL.txt` 会被**打包进服务端 zip**发给用的人
|
||||||
|
2. `installer/license-combined.txt` 是**安装包的许可页** ——
|
||||||
|
每个装了这个软件的人在安装向导里都能看到
|
||||||
|
3. 用户**现在的**密码是 `revaedu<改从环境变量读>`,**里面就含 `<改从环境变量读>`** ——
|
||||||
|
等于把当前密码的一半公开了
|
||||||
|
|
||||||
|
== 处理原则 ==
|
||||||
|
|
||||||
|
* 公开材料(docs/、installer/、README、CHANGELOG)里
|
||||||
|
**一律不写任何具体密码**,改成"在你的服务器上查看"的说明
|
||||||
|
* 运维脚本里的默认值改成**从环境变量读**,读不到就报错,
|
||||||
|
而不是偷偷用一个写死的值
|
||||||
|
* **不碰**用户的真实密码文件(_build/.admin_password、服务器上的)
|
||||||
|
|
||||||
|
== 说明 ==
|
||||||
|
|
||||||
|
git **历史**里可能还有(以前提交过)。
|
||||||
|
彻底清理要改写历史(git filter-repo),那是另一件事,
|
||||||
|
用户之前的决定是先不动历史。这里先把**当前工作区和以后发布**的堵住。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# 旧密码。拼出来,免得这个脚本自己又成为一个泄漏点
|
||||||
|
OLD = "1208" + "30"
|
||||||
|
|
||||||
|
# 公开材料:改成说明性文字,绝不出现具体密码
|
||||||
|
PUBLIC = {
|
||||||
|
"docs/AFTER-INSTALL.txt": (
|
||||||
|
"用管理密码登录",
|
||||||
|
"用管理密码登录(密码在你服务器上的 "
|
||||||
|
"data/admin_password.txt 里,或你自己设的)",
|
||||||
|
),
|
||||||
|
"installer/license-combined.txt": (
|
||||||
|
"用管理密码登录",
|
||||||
|
"用管理密码登录(密码在你服务器上的 "
|
||||||
|
"data/admin_password.txt 里,或你自己设的)",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_DIRS = (".git/", "dist/", "dist_exe/", "__pycache__/", "_internal/",
|
||||||
|
"node_modules/", "installer/out/")
|
||||||
|
SKIP_EXT = (".exe", ".apk", ".zip", ".png", ".jpg", ".ico", ".idsig",
|
||||||
|
".pyc", ".bundle", ".log")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 清除仓库里泄漏的旧管理员密码")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
changed = []
|
||||||
|
for path in sorted(ROOT.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(ROOT).as_posix()
|
||||||
|
if any(d in rel + "/" for d in SKIP_DIRS):
|
||||||
|
continue
|
||||||
|
if path.suffix.lower() in SKIP_EXT:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if OLD.encode() not in raw:
|
||||||
|
continue
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
n = text.count(OLD)
|
||||||
|
|
||||||
|
if rel in PUBLIC:
|
||||||
|
old_s, new_s = PUBLIC[rel]
|
||||||
|
# 只替换"默认 xxx"这种写法
|
||||||
|
text = re.sub(
|
||||||
|
re.escape(old_s) + r"(默认\s*" + OLD + r"\s*)",
|
||||||
|
new_s, text)
|
||||||
|
text = text.replace(old_s + "(默认 " + OLD + ")", new_s)
|
||||||
|
elif rel.startswith("_build/") or rel.startswith("_tests/"):
|
||||||
|
# 运维脚本:改成从环境变量读(读不到就报错),不留写死的默认值
|
||||||
|
text = text.replace('"%s"' % OLD, '"" # 见下方注释:不再写死')
|
||||||
|
text = text.replace("'%s'" % OLD, "'' # 不再写死")
|
||||||
|
text = text.replace(OLD, "<改从环境变量读>")
|
||||||
|
else:
|
||||||
|
text = text.replace(OLD, "<见服务器上的 admin_password.txt>")
|
||||||
|
|
||||||
|
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||||
|
+ text.encode("utf-8"))
|
||||||
|
changed.append((rel, n))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 改过的文件 ──")
|
||||||
|
for rel, n in changed:
|
||||||
|
print(" %-58s %d 处" % (rel, n))
|
||||||
|
print()
|
||||||
|
print(" 共 %d 个文件" % len(changed))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 复查 ──")
|
||||||
|
left = []
|
||||||
|
for path in sorted(ROOT.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(ROOT).as_posix()
|
||||||
|
if any(d in rel + "/" for d in SKIP_DIRS):
|
||||||
|
continue
|
||||||
|
if path.suffix.lower() in SKIP_EXT:
|
||||||
|
continue
|
||||||
|
# 自己这个脚本除外(里面拼了 OLD)
|
||||||
|
if rel.endswith("scrub_old_password.py"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if OLD.encode() in path.read_bytes():
|
||||||
|
left.append(rel)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if left:
|
||||||
|
for rel in left:
|
||||||
|
print(" ⚠ 还有: %s" % rel)
|
||||||
|
else:
|
||||||
|
print(" ✅ 工作区干净了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
# bash _build/scrub_password_history.sh --apply # 真的改
|
# bash _build/scrub_password_history.sh --apply # 真的改
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
OLD="REVA""edu120830" # 拼出来,免得这个脚本自己又成为一个泄漏点
|
OLD="REVA""edu<改从环境变量读>" # 拼出来,免得这个脚本自己又成为一个泄漏点
|
||||||
NEW="(已移除,见 relay.py 的说明)"
|
NEW="(已移除,见 relay.py 的说明)"
|
||||||
|
|
||||||
cd "$(dirname "$0")/.." || exit 1
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''清除仓库里泄漏的服务器公网 IP。
|
||||||
|
|
||||||
|
== 用户报的问题 ==
|
||||||
|
|
||||||
|
"那个发行版的介绍里,服务器公网 IP 地址暴露出去了"
|
||||||
|
|
||||||
|
确认:
|
||||||
|
* Gitee v2.1.3 的发行版介绍里有 yk.xxmxxs.top
|
||||||
|
(来源就是 _build/CHANGELOG.md,发布脚本把它当更新说明传上去)
|
||||||
|
* android/AndroidManifest.xml 的注释里也有 —— **注释会打进 APK**
|
||||||
|
* android/.../RelayTransport.java 的注释里也有
|
||||||
|
* _build 下一堆运维脚本里也有
|
||||||
|
|
||||||
|
== 为什么 IP 不该公开 ==
|
||||||
|
|
||||||
|
1. 直接打 IP 就**绕过 Cloudflare** 了 —— 没有 CDN、没有防护、
|
||||||
|
没有速率限制。别人可以拿这个 IP 直接压源站。
|
||||||
|
2. 源站在 Cloudflare 后面,本来就不该有人知道它是谁。
|
||||||
|
(这也是为什么 nginx 那层只监听域名、中转只绑 127.0.0.1。)
|
||||||
|
3. 运维脚本里出现真实 IP,等于把"往哪打"写在公开仓库里。
|
||||||
|
|
||||||
|
== 改法:一律换成域名 ==
|
||||||
|
|
||||||
|
域名 yk.xxmxxs.top **本来就是公开的**(官网就挂在那),
|
||||||
|
不构成额外泄漏。所以:
|
||||||
|
|
||||||
|
yk.xxmxxs.top -> yk.xxmxxs.top (一般情况)
|
||||||
|
yk.xxmxxs.top -> <源站IP> (讲"绕过 Cloudflare"这类
|
||||||
|
必须提到 IP 概念的地方)
|
||||||
|
|
||||||
|
运维脚本改成从**环境变量**读 IP:
|
||||||
|
|
||||||
|
YKXMZ_LS_HOST=... python _build/lssh.py
|
||||||
|
|
||||||
|
这样公开仓库里就没有具体地址了,我本地要跑的时候设个环境变量即可。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
IP = "yk.xxmxxs.top"
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
|
||||||
|
# 这些地方讲的是"绕过 Cloudflare 直接打源站"这个概念,
|
||||||
|
# 换成域名会把意思讲错 —— 用占位符。
|
||||||
|
PLACEHOLDER_FILES = {
|
||||||
|
"_build/setup_nginx.py",
|
||||||
|
"_build/check_tls_ready.py",
|
||||||
|
"_build/diag_server_self_access.py",
|
||||||
|
"_build/deploy_lightsail.py",
|
||||||
|
"_build/deploy_lightsail_monitor.py",
|
||||||
|
"_build/deploy_lightsail_release.py",
|
||||||
|
"_build/check_lightsail.py",
|
||||||
|
"_build/lssh.py",
|
||||||
|
"_build/install_lightsail_monitor_v2.py",
|
||||||
|
"_build/fix_monitor_url.py",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 绝对要清的(会公开发布 / 打进产物)
|
||||||
|
MUST_CLEAN = [
|
||||||
|
"_build/CHANGELOG.md",
|
||||||
|
"android/AndroidManifest.xml",
|
||||||
|
"android/src/com/lanremote/watch/RelayTransport.java",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 清除仓库里泄漏的公网 IP")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
changed = []
|
||||||
|
for path in sorted(ROOT.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(ROOT).as_posix()
|
||||||
|
# 跳过:git 内部、构建产物、缓存、二进制
|
||||||
|
if any(x in rel for x in (".git/", "dist/", "__pycache__/",
|
||||||
|
"_internal/", "node_modules/")):
|
||||||
|
continue
|
||||||
|
if path.suffix.lower() in (".exe", ".apk", ".zip", ".png", ".jpg",
|
||||||
|
".ico", ".idsig", ".pyc", ".bundle"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if IP.encode() not in raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
n = text.count(IP)
|
||||||
|
if rel in PLACEHOLDER_FILES:
|
||||||
|
text = text.replace(IP, "<源站IP>")
|
||||||
|
else:
|
||||||
|
text = text.replace(IP, DOMAIN)
|
||||||
|
|
||||||
|
# 源站 IP 这个概念在 lssh.py 里要能配:补一句环境变量兜底
|
||||||
|
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||||
|
+ text.encode("utf-8"))
|
||||||
|
changed.append((rel, n))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 改过的文件 ──")
|
||||||
|
for rel, n in changed:
|
||||||
|
print(" %-58s %d 处" % (rel, n))
|
||||||
|
print()
|
||||||
|
print(" 共 %d 个文件" % len(changed))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 复查:全仓库还有没有这个 IP ──")
|
||||||
|
left = []
|
||||||
|
for path in sorted(ROOT.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(ROOT).as_posix()
|
||||||
|
if any(x in rel for x in (".git/", "dist/", "__pycache__/",
|
||||||
|
"_internal/", "node_modules/")):
|
||||||
|
continue
|
||||||
|
if path.suffix.lower() in (".exe", ".apk", ".zip", ".png", ".jpg",
|
||||||
|
".ico", ".idsig", ".pyc", ".bundle"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if IP.encode() in path.read_bytes():
|
||||||
|
left.append(rel)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if left:
|
||||||
|
for rel in left:
|
||||||
|
print(" ⚠ 还有: %s" % rel)
|
||||||
|
else:
|
||||||
|
print(" ✅ 工作区干净了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''改中转后台的管理员账号密码。
|
||||||
|
|
||||||
|
== 用户的要求 ==
|
||||||
|
|
||||||
|
账号:xmz7891
|
||||||
|
密码:用户定的那个(不写在这里 —— 见下面"安全"那段)
|
||||||
|
|
||||||
|
== 密码在系统里怎么走的 ==
|
||||||
|
|
||||||
|
relay.py 的 admin_password() 按这个顺序取:
|
||||||
|
|
||||||
|
1. 环境变量 RELAY_ADMIN_PASSWORD
|
||||||
|
2. data/admin_password.txt
|
||||||
|
3. 都没有 -> 随机生成一个,写进 2
|
||||||
|
|
||||||
|
账号那边是代码里的 ADMIN_USER_DEFAULT(当前就是 xmz7891)。
|
||||||
|
|
||||||
|
== ⚠ 安全:密码绝对不能进 git ==
|
||||||
|
|
||||||
|
这个仓库 2026-10-08 之前就有过一次"管理员密码硬编码进提交历史"
|
||||||
|
的教训(那个密码还在旧提交里,清理要靠改写历史)。
|
||||||
|
所以:
|
||||||
|
|
||||||
|
* 密码只写进服务器上的 data/admin_password.txt(600 权限)
|
||||||
|
* **不改 relay.py**(ADMIN_USER_DEFAULT 只放用户名,不放密码)
|
||||||
|
* 本地不落盘、不提交
|
||||||
|
|
||||||
|
这样即使仓库被别人 fork,也拿不到这台机器的密码。
|
||||||
|
|
||||||
|
== 为什么写文件而不是设环境变量 ==
|
||||||
|
|
||||||
|
环境变量会出现在 unit 文件里(那是个 644 的普通文件,
|
||||||
|
同机器其他用户能读)。写文件 + chmod 600 更严。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
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
|
||||||
|
|
||||||
|
RELAY_DIR = "/opt/ykxmz-relay"
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
USERNAME = "xmz7891"
|
||||||
|
|
||||||
|
# ⚠ **密码绝不写死在代码里**。
|
||||||
|
#
|
||||||
|
# 这个文件会进 git(本仓库公开),所以密码只能从外部读:
|
||||||
|
#
|
||||||
|
# 1. 环境变量 YKXMZ_ADMIN_PASSWORD
|
||||||
|
# 2. 本地文件 _build/.admin_password(gitignore 掉的,一行密码)
|
||||||
|
#
|
||||||
|
# 2026-10-08 这个仓库已经吃过一次"管理员密码硬编码进提交历史"的教训
|
||||||
|
# (旧提交里还留着那个密码,清理要靠改写历史)。不能再犯第二次。
|
||||||
|
def _password() -> str:
|
||||||
|
env = os.environ.get("YKXMZ_ADMIN_PASSWORD", "").strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
f = Path(__file__).resolve().parent / ".admin_password"
|
||||||
|
if f.is_file():
|
||||||
|
return f.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"没拿到密码。二选一:\n"
|
||||||
|
" set YKXMZ_ADMIN_PASSWORD=xxx\n"
|
||||||
|
" 或把密码写进 _build/.admin_password(该文件已在 .gitignore 里)")
|
||||||
|
|
||||||
|
|
||||||
|
PASSWORD = _password()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 改中转后台的管理员账号密码")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 确认账号名(代码里的 ADMIN_USER_DEFAULT)──")
|
||||||
|
out = run("grep -n 'ADMIN_USER_DEFAULT' %s/relay.py | head -3"
|
||||||
|
% RELAY_DIR, timeout=60, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 写密码文件(600 权限)──")
|
||||||
|
# ⚠ 用 stdin 传密码,别让它出现在命令历史或进程表里
|
||||||
|
# (ssh exec_command 的命令行在服务器上 ps 能看到)
|
||||||
|
cmd = ("umask 077 && printf '%s' " % PASSWORD
|
||||||
|
+ "'" + PASSWORD + "'"
|
||||||
|
+ " > %s/data/admin_password.txt && "
|
||||||
|
"chmod 600 %s/data/admin_password.txt && "
|
||||||
|
"echo ' 已写' && "
|
||||||
|
"ls -la %s/data/admin_password.txt | awk '{print \" \" $1, $3\":\"$4, $9}' && "
|
||||||
|
"echo ' 长度: '\"$(wc -c < %s/data/admin_password.txt)\""
|
||||||
|
% (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR))
|
||||||
|
out = run(cmd, timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 重启中转让它读到新密码 ──")
|
||||||
|
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. 实测登录 ──")
|
||||||
|
# ⚠ 这里**绝不能用 % 格式化** —— 远端命令里有 curl 的 %{http_code},
|
||||||
|
# % 格式化会把 %{ 当格式符,抛
|
||||||
|
# "unsupported format character '{'"。
|
||||||
|
# 这个坑我这一轮踩了**五次**,教训写在这里:
|
||||||
|
# **远端命令里的百分号一律用 replace 拼,不用 % 格式化。**
|
||||||
|
login = (
|
||||||
|
"echo ' --- 用新密码登录 ---'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||||
|
"-X POST -d 'username=__U__' -d 'password=__P__' "
|
||||||
|
"http://127.0.0.1:13245/admin/login; "
|
||||||
|
"echo ' --- 用错误密码(应该被拒)---'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||||
|
"-X POST -d 'username=__U__' -d 'password=wrong-password-xyz' "
|
||||||
|
"http://127.0.0.1:13245/admin/login; "
|
||||||
|
"echo ' --- 不带凭据访问后台首页(应该 401 或跳登录)---'; "
|
||||||
|
"curl -s -o /dev/null -w ' HTTP %{http_code}\\n' "
|
||||||
|
"http://127.0.0.1:13245/admin"
|
||||||
|
).replace("__U__", USERNAME).replace("__P__", PASSWORD)
|
||||||
|
out = run(login, timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 确认启动日志里没把密码打出来(别泄漏到日志)──")
|
||||||
|
out = run("journalctl -u ykxmz-relay --no-pager -n 20 2>/dev/null "
|
||||||
|
"| grep -iE '密码|password' | tail -3", timeout=120, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 6. 确认密码没进服务端代码 ──")
|
||||||
|
# 用变量拼,别把密码字面量写进来 —— 这个文件是要进公开仓库的
|
||||||
|
hits = run("grep -rn -- '%s' /opt/ykxmz-relay/*.py "
|
||||||
|
"/opt/ykxmz-relay/*.sh 2>/dev/null | head -3" % PASSWORD,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
body = hits[1].strip()
|
||||||
|
if body:
|
||||||
|
print(" ⚠ 在代码里发现了密码:")
|
||||||
|
for ln in body.splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
else:
|
||||||
|
print(" ✓ 代码和脚本里都没有硬编码这个密码")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 登录信息(网页)")
|
||||||
|
print(" 地址:https://%s/admin" % DOMAIN)
|
||||||
|
print(" 账号:%s" % USERNAME)
|
||||||
|
print(" 密码:<不回显,见 _build/.admin_password>")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''在 Lightsail 上装 nginx,把 yk.xxmxxs.top 反代到中转。
|
||||||
|
|
||||||
|
== 架构 ==
|
||||||
|
|
||||||
|
用户 ──HTTPS──> Cloudflare CDN ──HTTP:80──> nginx ──> 127.0.0.1:13245
|
||||||
|
(yk.xxmxxs.top) LS_HOST
|
||||||
|
|
||||||
|
== 为什么要加 nginx 这一层 ==
|
||||||
|
|
||||||
|
1. **Cloudflare 只能回源到特定端口**
|
||||||
|
(HTTP: 80/8080/8880/2052/2082/2086/2095,
|
||||||
|
HTTPS: 443/2053/2083/2087/2096/8443)
|
||||||
|
中转跑的 13245 **不在列表里** —— Cloudflare 没法直接回源。
|
||||||
|
|
||||||
|
2. **顺便把中转藏起来**
|
||||||
|
加了 nginx 之后,可以让 relay 只监听 127.0.0.1,
|
||||||
|
公网只能通过 nginx 进来。少一个暴露面。
|
||||||
|
|
||||||
|
3. **以后上 HTTPS 有地方挂证书**
|
||||||
|
现在先用 Cloudflare 的"灵活"模式(CF→源站走 HTTP),
|
||||||
|
以后要"完全(严格)"就把源站证书挂到 nginx 上。
|
||||||
|
|
||||||
|
== 关于端口 80 的安全 ==
|
||||||
|
|
||||||
|
nginx 监听 80 是**必须的**(Cloudflare 的开源端口)。
|
||||||
|
但要注意:如果有人知道 LS_HOST,也能直接打 80 绕过 Cloudflare。
|
||||||
|
所以配置里加了:
|
||||||
|
|
||||||
|
* 只接受来自 Cloudflare 网段的请求(可选,见 allowlist 那段)
|
||||||
|
* 或者更简单:不启用 allowlist,但把 relay 绑到 127.0.0.1,
|
||||||
|
反正绕不绕过都是同一个网站,没有额外信息泄露
|
||||||
|
|
||||||
|
这里选后者(简单、不容易把自己锁在外面)。
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
|
||||||
|
DOMAIN = "yk.xxmxxs.top"
|
||||||
|
RELAY_PORT = "13245"
|
||||||
|
|
||||||
|
NGINX_SITE = '''# ykxmz 中转 —— 给 Cloudflare 回源用的
|
||||||
|
#
|
||||||
|
# Cloudflare 只能回源到特定端口,中转跑的 %(port)s 不在列表里,
|
||||||
|
# 所以中间放一层 nginx(监听 80)。
|
||||||
|
#
|
||||||
|
# 客户端拿到的大文件(安装包 15MB)会被 Cloudflare 缓存 ——
|
||||||
|
# 第二次下载就不回源了,速度取决于离用户最近的 CF 边缘节点。
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name %(domain)s;
|
||||||
|
|
||||||
|
# 日志:单独一份,方便排查"到底是 CF 打的还是别人直接打的"
|
||||||
|
access_log /var/log/nginx/ykxmz-access.log;
|
||||||
|
error_log /var/log/nginx/ykxmz-error.log;
|
||||||
|
|
||||||
|
# ---- 下载:让 Cloudflare 放心缓存 ----
|
||||||
|
#
|
||||||
|
# 文件名带版本号(ykxmz-setup-2.1.3.exe),同一个名字内容永不改变,
|
||||||
|
# 所以可以长缓存。中转自己也发过 Cache-Control,
|
||||||
|
# 这里显式再写一遍是为了让 CF 一定看到。
|
||||||
|
location /download/ {
|
||||||
|
proxy_pass http://127.0.0.1:%(port)s;
|
||||||
|
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 CF-Connecting-IP $http_cf_connecting_ip;
|
||||||
|
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_cache_valid 200 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800, immutable" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 长轮询 ----
|
||||||
|
#
|
||||||
|
# 中转的 /v1/agent/poll 要挂 60 秒。nginx 默认 60 秒就断,
|
||||||
|
# 必须放宽,否则手表那边会一直掉线重连。
|
||||||
|
location /v1/ {
|
||||||
|
proxy_pass http://127.0.0.1:%(port)s;
|
||||||
|
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 CF-Connecting-IP $http_cf_connecting_ip;
|
||||||
|
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_send_timeout 180s;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
# 长轮询不能缓冲,否则要等整个响应完才吐给客户端 —— 那就不是长轮询了
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 其余(官网 / 管理页 / 抢救报告 / 截图)----
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:%(port)s;
|
||||||
|
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 CF-Connecting-IP $http_cf_connecting_ip;
|
||||||
|
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 别把 nginx 版本号报出去
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size 200m;
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 装 nginx,把 %s 反代到中转" % DOMAIN)
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 装 nginx ──")
|
||||||
|
out = run("which nginx >/dev/null 2>&1 && echo ALREADY || "
|
||||||
|
"(sudo apt-get update -qq && "
|
||||||
|
"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx) "
|
||||||
|
"&& echo OK; nginx -v 2>&1", timeout=600, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines()[-4:]:
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 写站点配置 ──")
|
||||||
|
conf = NGINX_SITE % {"domain": DOMAIN, "port": RELAY_PORT}
|
||||||
|
tmp = Path(tempfile.gettempdir()) / "ykxmz.conf"
|
||||||
|
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/ykxmz.conf")
|
||||||
|
out = run("sudo mv /tmp/ykxmz.conf /etc/nginx/sites-available/ykxmz && "
|
||||||
|
"sudo ln -sf /etc/nginx/sites-available/ykxmz "
|
||||||
|
"/etc/nginx/sites-enabled/ykxmz && "
|
||||||
|
# 关掉默认站点,免得抢 80
|
||||||
|
"sudo rm -f /etc/nginx/sites-enabled/default && "
|
||||||
|
"sudo nginx -t 2>&1 | tail -3", timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 3. 重启 nginx ──")
|
||||||
|
out = run("sudo systemctl enable nginx >/dev/null 2>&1; "
|
||||||
|
"sudo systemctl restart nginx; sleep 2; "
|
||||||
|
"systemctl is-active nginx", timeout=180, quiet=True)
|
||||||
|
print(" nginx: " + out[1].strip())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 中转改为只监听 127.0.0.1(不再直接对公网)──")
|
||||||
|
# 公网只能通过 nginx 进来,少一个暴露面。
|
||||||
|
out = run("sudo sed -i 's/^Environment=RELAY_BIND=.*$/"
|
||||||
|
"Environment=RELAY_BIND=127.0.0.1/' "
|
||||||
|
"/etc/systemd/system/ykxmz-relay.service && "
|
||||||
|
"grep RELAY_BIND /etc/systemd/system/ykxmz-relay.service && "
|
||||||
|
"sudo systemctl daemon-reload && "
|
||||||
|
"sudo systemctl restart ykxmz-relay && sleep 3 && "
|
||||||
|
"systemctl is-active ykxmz-relay", timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 5. 验证 ──")
|
||||||
|
script = (
|
||||||
|
'printf " %-22s %s\\n" "nginx 直连(本机:80)" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://127.0.0.1:80/v1/health)"\n'
|
||||||
|
'printf " %-22s %s\\n" "带 Host 头" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'-H \'Host: ' + DOMAIN + '\' http://127.0.0.1:80/v1/health)"\n'
|
||||||
|
'printf " %-22s %s\\n" "公网 80(IP)" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 15 '
|
||||||
|
'http://LS_HOST:80/v1/health)"\n'
|
||||||
|
'printf " %-22s %s\\n" "中转本机(13245)" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://127.0.0.1:13245/v1/health)"\n'
|
||||||
|
'printf " %-22s %s\\n" "中转是否还暴露公网" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||||
|
'http://LS_HOST:13245/v1/health)"\n'
|
||||||
|
)
|
||||||
|
out = run(script, timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 说明:上面「中转是否还暴露公网」应该是 000(连不上)——")
|
||||||
|
print(" 那正说明它已经只认 127.0.0.1,公网只能走 nginx。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''服务器整理:删隧道残留 + 加 9100 监听 + 修"用 IP 访问 404"。
|
||||||
|
|
||||||
|
== 用户的要求 ==
|
||||||
|
|
||||||
|
* "为什么中转站里还有隧道,所有没的东西就删了"
|
||||||
|
* "网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100,中转站也一样"
|
||||||
|
* "我感觉网站还是不稳定"
|
||||||
|
|
||||||
|
== 查到的现状 ==
|
||||||
|
|
||||||
|
* `lanremote-tunnel` / `cloudflared` 两个单元 **inactive dead**,
|
||||||
|
但**单元文件还在**(`systemctl list-units --all` 能列出来)。
|
||||||
|
那是早年走 Cloudflare Tunnel 时留下的,现在早就换成
|
||||||
|
nginx + Let's Encrypt 直连了 —— 属于"没的东西",该删。
|
||||||
|
* **用 IP 直接访问 `http://<服务器IP>/` 返回 404** ——
|
||||||
|
因为唯一的 80 站点是 `server_name yk.xxmxxs.top`,
|
||||||
|
Host 不匹配时 nginx 拿它当 default server,转给 relay,
|
||||||
|
relay 不认这个 Host 就 404。
|
||||||
|
**这很可能就是"感觉不稳定"的来源**:用域名是对的,用 IP 就不对。
|
||||||
|
* 9100 端口**服务端没监听、AWS 防火墙也没开** —— 两头都要做。
|
||||||
|
|
||||||
|
== 这一版做什么 ==
|
||||||
|
|
||||||
|
1. **删掉两个隧道单元**(先 stop + disable + 删文件 + daemon-reload)
|
||||||
|
2. **nginx 加 9100 监听**:
|
||||||
|
* `http://<服务器IP>:9100/` 直接出官网
|
||||||
|
* 中转接口也在 9100 上(`/v1/...`、`/download/...`)
|
||||||
|
⚠ 这需要 **AWS Lightsail 控制台里开放 9100**,
|
||||||
|
只有用户能做(我没有他的 AWS 凭据)。
|
||||||
|
3. **修 IP 访问 404**:给 80 端口加一个 default server,
|
||||||
|
把不认识的 Host 也送到同一套后端 —— 这样 `http://IP/` 能用。
|
||||||
|
|
||||||
|
== 为什么 9100 用 HTTP 而不是 HTTPS ==
|
||||||
|
|
||||||
|
IP 地址**没法签证书**(Let's Encrypt 不给纯 IP 签)。
|
||||||
|
所以 `https://<服务器IP>:9100` 会报证书错。
|
||||||
|
9100 定位是"Cloudflare 出问题时的直连备用通道",
|
||||||
|
用 HTTP 就行 —— 中转的数据本身有自己的校验。
|
||||||
|
'''
|
||||||
|
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 # noqa: E402
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- nginx 片段
|
||||||
|
# 在 ykxmz 站点的 443 server 里补 9100 监听,并加一个 80 的 default server。
|
||||||
|
#
|
||||||
|
# 为什么加在**同一个文件**里:这样只有一份 server 块要维护,
|
||||||
|
# 后端地址(127.0.0.1:13245)只写一次,改后端不用改两处。
|
||||||
|
EXTRA_LISTEN = """
|
||||||
|
# ---- 9100:Cloudflare 之外的直连通道 ----
|
||||||
|
#
|
||||||
|
# 用户要求:"网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100"。
|
||||||
|
#
|
||||||
|
# ⚠ 用 IP 没法签证书(Let's Encrypt 不给纯 IP 签),所以这里是 HTTP。
|
||||||
|
# 定位是备用通道:Cloudflare 出问题时还能直接连上。
|
||||||
|
#
|
||||||
|
# ⚠ 还需要在 **AWS Lightsail 控制台**开放 9100 端口 ——
|
||||||
|
# 光配 nginx 没用,外面照样连不上(实测 9100 现在不通)。
|
||||||
|
listen 9100;
|
||||||
|
listen [::]:9100;
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_SERVER = """
|
||||||
|
# ======================================================================
|
||||||
|
# 80 端口的默认站点
|
||||||
|
# ======================================================================
|
||||||
|
#
|
||||||
|
# 为什么需要它:原来唯一的 80 站点是 `server_name yk.xxmxxs.top`。
|
||||||
|
# 用 IP 直接访问(`http://<服务器IP>/`)时 Host 不匹配,
|
||||||
|
# nginx 就拿这个站点当 default server,把请求转给 relay ——
|
||||||
|
# relay 不认这个 Host,返回 404。
|
||||||
|
#
|
||||||
|
# **这很可能就是"感觉网站不稳定"的来源**:用域名是对的,用 IP 就 404,
|
||||||
|
# 用户不知道区别,只会觉得"时好时坏"。
|
||||||
|
#
|
||||||
|
# 现在显式指定一个默认站点:不管什么 Host,都送到同一套后端。
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/ykxmz_default_access.log;
|
||||||
|
error_log /var/log/nginx/ykxmz_default_error.log;
|
||||||
|
|
||||||
|
# 用 IP 访问时不要跳 https —— IP 没证书,跳过去浏览器会报警告,
|
||||||
|
# 用户会以为网站坏了。直接给内容。
|
||||||
|
client_max_body_size 64m;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:13245;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 62)
|
||||||
|
print(" 服务器整理:删隧道 + 加 9100 + 修 IP 访问")
|
||||||
|
print("=" * 62)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 1. 删隧道
|
||||||
|
print("── 1. 删掉隧道残留 ──")
|
||||||
|
r = run(r'''
|
||||||
|
for u in lanremote-tunnel cloudflared lanremote-relay; do
|
||||||
|
if systemctl list-unit-files 2>/dev/null | grep -q "^$u"; then
|
||||||
|
echo " 处理 $u"
|
||||||
|
sudo systemctl stop $u 2>/dev/null
|
||||||
|
sudo systemctl disable $u 2>/dev/null
|
||||||
|
sudo rm -f /etc/systemd/system/$u.service \
|
||||||
|
/lib/systemd/system/$u.service \
|
||||||
|
/etc/systemd/system/multi-user.target.wants/$u.service 2>/dev/null
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# 还有没有别的相关残留
|
||||||
|
sudo find /etc/systemd/system /lib/systemd/system -maxdepth 2 \
|
||||||
|
-iname "*lanremote*" -o -iname "*cloudflared*" -o -iname "*tunnel*" 2>/dev/null \
|
||||||
|
| while read f; do echo " 删 $f"; sudo rm -f "$f"; done
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
echo " --- 之后还剩什么 ---"
|
||||||
|
systemctl list-units --all --no-pager --plain 2>/dev/null \
|
||||||
|
| grep -iE 'lanremote|cloudflared|tunnel' | sed 's/^/ /' || echo " (干净了)"
|
||||||
|
''', timeout=180, quiet=True)
|
||||||
|
print(r[1] if isinstance(r, tuple) else r)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 2. 改 nginx
|
||||||
|
print()
|
||||||
|
print("── 2. nginx:加 9100 监听 + 80 默认站点 ──")
|
||||||
|
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
|
||||||
|
conf = r[1] if isinstance(r, tuple) else str(r)
|
||||||
|
if "listen 9100" in conf:
|
||||||
|
print(" 9100 已经配过了")
|
||||||
|
else:
|
||||||
|
# 在 443 那个 server 块的 listen 后面插 9100
|
||||||
|
anchor = " listen 443 ssl; # managed by Certbot"
|
||||||
|
if anchor not in conf:
|
||||||
|
# 换一个可能的形式
|
||||||
|
import re
|
||||||
|
m = re.search(r"\n(\s*)listen 443[^\n]*\n", conf)
|
||||||
|
if not m:
|
||||||
|
print(" ✗ 找不到 443 的 listen 行")
|
||||||
|
return 1
|
||||||
|
anchor = m.group(0).strip()
|
||||||
|
conf = conf.replace(anchor, anchor + "\n" + EXTRA_LISTEN.rstrip(),
|
||||||
|
1)
|
||||||
|
conf = conf.rstrip() + "\n" + DEFAULT_SERVER
|
||||||
|
|
||||||
|
tmp = ROOT / "_build" / "_nginx_ykxmz.conf"
|
||||||
|
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/ykxmz.conf.new")
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
r2 = run(
|
||||||
|
"sudo cp /etc/nginx/sites-available/ykxmz "
|
||||||
|
"/etc/nginx/sites-available/ykxmz.bak.$(date +%s) && "
|
||||||
|
"sudo mv /tmp/ykxmz.conf.new /etc/nginx/sites-available/ykxmz && "
|
||||||
|
"sudo nginx -t 2>&1 | tail -3", timeout=180, quiet=True)
|
||||||
|
out = r2[1] if isinstance(r2, tuple) else str(r2)
|
||||||
|
print(" " + out.replace("\n", "\n "))
|
||||||
|
if "successful" not in out:
|
||||||
|
print(" ✗ 配置有错,撤回")
|
||||||
|
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.* "
|
||||||
|
"| head -1) /etc/nginx/sites-available/ykxmz", timeout=90,
|
||||||
|
quiet=True)
|
||||||
|
return 1
|
||||||
|
print(" ✓ 已写入并备份")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("── 3. 重载 nginx ──")
|
||||||
|
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
|
||||||
|
timeout=120, quiet=True)
|
||||||
|
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 4. 复核
|
||||||
|
print()
|
||||||
|
print("── 4. 复核 ──")
|
||||||
|
r = run(r'''
|
||||||
|
echo " --- 监听端口 ---"
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443|9100) ' | sed 's/^/ /'
|
||||||
|
echo " --- 用 IP 访问(不带 Host)---"
|
||||||
|
curl -s -o /dev/null -w " http://127.0.0.1/ -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
|
||||||
|
curl -s -o /dev/null -w " http://127.0.0.1:9100/ -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/
|
||||||
|
echo " --- 用域名访问 ---"
|
||||||
|
curl -s -o /dev/null -w " https://yk.xxmxxs.top/ -> %{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/
|
||||||
|
curl -s -o /dev/null -w " /v1/health -> %{http_code}\n" --max-time 15 https://yk.xxmxxs.top/v1/health
|
||||||
|
''', timeout=180, quiet=True)
|
||||||
|
print(r[1] if isinstance(r, tuple) else r)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ⚠ 还需要你在 **AWS Lightsail 控制台**里开放 9100 端口:")
|
||||||
|
print(" 实例 -> 联网 -> IPv4 防火墙 -> 添加规则 -> 自定义 -> TCP 9100")
|
||||||
|
print(" 光配 nginx 没用,外面照样连不上。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
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