mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 17:10:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+3
-1
@@ -78,4 +78,6 @@ desktop.ini
|
||||
admin_password.txt
|
||||
*admin_password*.txt
|
||||
/_build/_hdr_data/
|
||||
/_build/relay_info.json
|
||||
/_build/relay_info.json
|
||||
# 后台管理密码(绝不进仓库)
|
||||
_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)
|
||||
|
||||
@@ -46,9 +45,9 @@
|
||||
|
||||
| 装在哪 | 是什么 | 文件 |
|
||||
|---|---|---|
|
||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-1.8.2.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.8.2.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-1.8.2.apk` |
|
||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.4.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.4.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.4.apk` |
|
||||
|
||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
||||
|
||||
@@ -82,7 +81,7 @@
|
||||
|
||||
### 第一步:电脑上装被控端
|
||||
|
||||
双击 `ykxmz-setup-1.8.2.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
双击 `ykxmz-setup-2.1.4.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
|
||||
第一次运行会弹出窗口,填两样:
|
||||
|
||||
@@ -95,12 +94,12 @@
|
||||
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
||||
> 想彻底退出,窗口上点「退出」。
|
||||
|
||||
> 不想用安装包的话,`ykxmz-agent-1.8.2.zip` 是绿色版:
|
||||
> 不想用安装包的话,`ykxmz-agent-2.1.4.zip` 是绿色版:
|
||||
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||
|
||||
### 第二步:手表上装 App
|
||||
|
||||
把 `ykxmz-watch-1.8.2.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
把 `ykxmz-watch-2.1.4.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
|
||||
打开 App,首页有四个入口:
|
||||
|
||||
@@ -140,26 +139,34 @@
|
||||
|
||||
这一页是给上课准备的。
|
||||
|
||||
> **先说清楚作用范围**:被控端装在**一台** Windows 电脑上
|
||||
> —— 就是讲台那台,或者你自己的机器。
|
||||
> **所有操作只影响这一台**,看不到也管不着其他电脑。
|
||||
> 装的时候要输配对码,没配对码连不上。
|
||||
|
||||
| 按钮 | 干什么 |
|
||||
|---|---|
|
||||
| **课堂专注模式** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
|
||||
| **关闭游戏窗口** | 扫一遍常见游戏进程名,找到就结束掉 |
|
||||
| **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 |
|
||||
| **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** |
|
||||
| **课堂专注模式** | 在**本机 hosts** 里把常见游戏站点指到本地,本机打开是空白页 |
|
||||
| **结束游戏进程** | 扫**本机**正在跑的常见游戏进程,结束掉 |
|
||||
| **看有哪些限制** | 列出当前开了哪些,别自己忘了 |
|
||||
| **一键恢复全部** | 把这节课改的全撤掉,**下课必点** |
|
||||
|
||||
屏蔽是改 hosts 文件,不是装驱动,所以:
|
||||
怎么实现的、有什么边界,一并说清楚:
|
||||
|
||||
- 学生自己删掉 hosts 里那几行就绕过去了 —— 它挡的是"随手点开",不是防作弊
|
||||
- 换个游戏站就得重新加,这个列表是写死的常见站点
|
||||
- 重启电脑有的会保留、有的不会,取决于你们学校的还原卡
|
||||
- 屏蔽是改**本机** `hosts` 文件,不装驱动、不装服务
|
||||
- **只挡"随手点开"**:懂行的人自己把 hosts 里那几行删了就过去了。
|
||||
它防的不是刻意行为,是减少无关干扰
|
||||
- 站点列表是写死的常见站点,换一个站就得重新加
|
||||
- **全部改动可逆**,点「一键恢复全部」或「下课清理」就还原
|
||||
- 重启后保不保留,取决于你们学校有没有装还原卡
|
||||
|
||||
### 屏幕
|
||||
|
||||
- **实时屏幕镜像** —— 手表上看教室里那台电脑在显示什么。默认 4 fps,在手表上太小看不清,主要是"看一眼有没有人乱动"
|
||||
- **实时屏幕镜像** —— 手表上看**被控端那台电脑**在显示什么(低头确认课件放没放对,不用走回讲台)
|
||||
- **一键截屏** —— 存到手表相册
|
||||
|
||||
> ⚠️ **屏幕只要被看,电脑右上角就有一条红色提示条,关不掉。**
|
||||
> 这是刻意做的:教室里那台电脑是公家的,谁在看必须让人知道。
|
||||
> 这是刻意做的:谁在看,电脑前面的人必须能立刻知道。
|
||||
> 电脑前面的人随时可以点提示条上的「停止共享」把你掐断。
|
||||
|
||||
### 媒体 / 音频
|
||||
@@ -216,7 +223,7 @@
|
||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||
|
||||
把 `ykxmz-server-1.8.2.zip` 解压到那台机器上,然后一条命令:
|
||||
把 `ykxmz-server-2.1.4.zip` 解压到那台机器上,然后一条命令:
|
||||
|
||||
```bash
|
||||
bash deploy.sh
|
||||
@@ -404,7 +411,7 @@ App 把错误翻译成人话了,看到哪句就照做:
|
||||
|
||||
## 安全
|
||||
|
||||
教室里那台电脑是公家的东西,所以这套工具的安全设计偏"让人知道"而不是"藏起来"。
|
||||
被控端装的往往是公家的电脑,所以这套工具的安全设计偏"让人知道"而不是"低调运行"。
|
||||
|
||||
**每次连接都要配对。** 电脑上生成 6 位数字,只在窗口上显示,手表输对了才连得上。
|
||||
|
||||
@@ -418,11 +425,14 @@ App 把错误翻译成人话了,看到哪句就照做:
|
||||
|
||||
**不写注册表。** 数据全在程序目录下的 `data/` 里,拔了硬盘拷走就能换台机器跑。
|
||||
|
||||
**不在后台偷偷上传。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站。
|
||||
**只在你自己填的地址之间通信。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站,不经过任何第三方。
|
||||
|
||||
> **这个工具不藏自己。** 没有"任务管理器里看不见",没有"免杀对抗",没有伪装成系统进程。
|
||||
> 被控端全程可见、可随时断开、权限可随时关掉。
|
||||
> 因为管的是教室的电脑,藏起来只会让老师不放心。
|
||||
**所有行为都在明面上。** 被控端在托盘有图标、窗口看得见、随时可以退出、
|
||||
权限可以逐项关闭。它不做任何"让机器上其他程序看不见自己"的事 ——
|
||||
因为管的是自己要用的一台电脑,能看见、能随时停,才让人放心。
|
||||
|
||||
**代码全公开。** 三端加起来不到两万行,都是纯标准库,
|
||||
想看它到底干了什么直接读源码就行。
|
||||
|
||||
---
|
||||
|
||||
@@ -446,7 +456,7 @@ windows\build_exe.ps1
|
||||
```powershell
|
||||
android\build_apk.ps1
|
||||
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
||||
# 产物:dist\ykxmz-watch-1.8.2.apk
|
||||
# 产物:dist\ykxmz-watch-2.1.4.apk
|
||||
```
|
||||
|
||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||
|
||||
@@ -1,3 +1,168 @@
|
||||
## 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
|
||||
|
||||
- **安装时就能设中转服务器**:安装向导那一页现在是两个输入框 ——
|
||||
|
||||
@@ -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,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,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,6 +146,26 @@ def main() -> int:
|
||||
print(f" docs/{path.name}")
|
||||
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
|
||||
|
||||
# ---- 会断言成品名的测试 ----
|
||||
for name in ("test_package_remote.py", "test_apk_contents.py"):
|
||||
path = ROOT / "_tests" / name
|
||||
|
||||
@@ -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 # 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://<源站IP>:80"
|
||||
DIRECT_URL = "http://<源站IP>:%s" % 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,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 # noqa: E402
|
||||
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
IP = "<源站IP>"
|
||||
|
||||
|
||||
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,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,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 # 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(<源站IP>),端口 %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://<源站IP>:%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 # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
PORT = "13245"
|
||||
PUBLIC_URL = "http://<源站IP>:%s" % PORT
|
||||
|
||||
MONITOR = r'''#!/bin/bash
|
||||
# ============================================================================
|
||||
# 网站监测单元(Lightsail 版)
|
||||
# ============================================================================
|
||||
#
|
||||
# 每 60 秒从**公网**查一次,连续 3 次不通就叫紧急预案。
|
||||
#
|
||||
# 为什么不查 127.0.0.1:
|
||||
# 本机 200 只说明进程活着。用户真正关心的是"打开
|
||||
# http://<源站IP>: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 # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
PORT = "13245"
|
||||
PUBLIC = "http://<源站IP>:%s" % 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,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 " 收尾:清掉负载测试造的设备和会话"
|
||||
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"}'
|
||||
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,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,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 # 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:<源站IP> 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,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())
|
||||
@@ -2,10 +2,10 @@
|
||||
r'''修上一步弄坏的登录表单。
|
||||
|
||||
上一步的正则插账号时把字典插坏了:
|
||||
urlencode({"username": "xmz7891", {"password": "120830"})
|
||||
urlencode({"username": "xmz7891", {"password": "" # 见下方注释:不再写死})
|
||||
^ 多了个左括号
|
||||
应该是:
|
||||
urlencode({"username": "xmz7891", "password": "120830"})
|
||||
urlencode({"username": "xmz7891", "password": "" # 见下方注释:不再写死})
|
||||
'''
|
||||
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
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OLD = "REVA" + "edu120830" # 拼出来,本脚本自己不含明文
|
||||
OLD = "REVA" + "edu<改从环境变量读>" # 拼出来,本脚本自己不含明文
|
||||
|
||||
TARGETS = [
|
||||
ROOT / "_tests" / "test_relay_admin_login.py",
|
||||
|
||||
@@ -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 # noqa: E402
|
||||
|
||||
RELAY_DIR = "/opt/ykxmz-relay"
|
||||
DOMAIN = "yk.xxmxxs.top"
|
||||
FALLBACK = "http://<源站IP>:80"
|
||||
|
||||
|
||||
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://<源站IP>: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://<源站IP>:80/v1/health)"; '
|
||||
'printf " %-28s %s\\n" "IP:13245 (已关闭)" '
|
||||
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 8 '
|
||||
'http://<源站IP>: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
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OLD = "REVA" + "edu120830"
|
||||
OLD = "REVA" + "edu<改从环境变量读>"
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# -*- 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>"
|
||||
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,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())
|
||||
@@ -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 # 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://<源站IP>/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())
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# -*- 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
|
||||
|
||||
HOST = os.environ.get("YKXMZ_LS_HOST", "<源站IP>")
|
||||
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
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
VERSION = "2.0.0"
|
||||
VERSION = "2.1.4"
|
||||
NAME = f"ykxmz-{VERSION}"
|
||||
|
||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||
@@ -100,7 +100,7 @@ TOP_README = f"""========================================================
|
||||
【服务器端能管什么】
|
||||
|
||||
浏览器打开: <中转地址>/?key=... 或者直接用管理密码登录
|
||||
管理密码:120830(可在服务器上改)
|
||||
管理密码:<改从环境变量读>(可在服务器上改)
|
||||
|
||||
能看:哪些设备在线、配对码、流量、会话数
|
||||
能做:踢掉某个设备的会话、抹掉某个配对码绑定
|
||||
@@ -334,7 +334,7 @@ RELAY_README = f"""========================================================
|
||||
|
||||
官网: <公网地址>/
|
||||
管理页: <公网地址>/?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.mkdir(parents=True)
|
||||
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
|
||||
# 改成:relay 目录里所有 .py / .sh 都带上。
|
||||
# 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。
|
||||
# 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。
|
||||
# 改成:relay 目录里所有 .py / .sh / .ico / .service / .timer 都带上。
|
||||
# 后缀白名单:
|
||||
# .py 服务本体
|
||||
# .sh 部署/运维脚本
|
||||
# .ico 管理页图标(漏了的话新设备上管理页是默认样式)
|
||||
# .service /
|
||||
# .timer systemd 单元。**这两个是后加的** ——
|
||||
# 加监测单元的时候才发现白名单里没有它们,
|
||||
# 于是 monitor.sh 进了包、拉它的 timer 却没进,
|
||||
# 装到新机器上监测根本不会启动(而且不会报错,只是静默不跑)。
|
||||
wanted = sorted(
|
||||
[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)
|
||||
for src in wanted:
|
||||
shutil.copy2(src, server_dir / src.name)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理页加登录密码(用户指定:120830)。
|
||||
"""管理页加登录密码(用户指定:<改从环境变量读>)。
|
||||
|
||||
为什么要改:
|
||||
之前管理页靠「钥匙」放在 URL 里(/?key=xxx)。
|
||||
这样钥匙会进浏览器历史、进 Cloudflare 日志、被人肩窥。
|
||||
改成标准做法:登录页输密码 -> 种一个 HttpOnly Cookie。
|
||||
|
||||
* 密码默认 120830,存在 data/admin_password.txt,可改
|
||||
* 密码默认 <改从环境变量读>,存在 data/admin_password.txt,可改
|
||||
* 登录成功后种 Cookie,有效期 12 小时
|
||||
* 命令行/脚本仍可用 X-Admin 头(放钥匙或密码都认)
|
||||
"""
|
||||
@@ -26,7 +26,7 @@ OLD = '''def admin_ok(supplied: str) -> bool:
|
||||
return secrets.compare_digest(supplied, admin_key())'''
|
||||
|
||||
NEW = '''# 管理页密码。用户指定的默认值,可以在 data/admin_password.txt 里改。
|
||||
ADMIN_PASSWORD_DEFAULT = "120830"
|
||||
ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死
|
||||
ADMIN_PASSWORD = os.environ.get("RELAY_ADMIN_PASSWORD", "")
|
||||
# 登录会话:cookie 令牌 -> 过期时间
|
||||
ADMIN_SESSIONS: dict = {}
|
||||
|
||||
@@ -97,7 +97,7 @@ NEW_BODY = '''RELAY_README = f"""===============================================
|
||||
|
||||
官网: <公网地址>/
|
||||
管理页: <公网地址>/?key=<lanremote-key 的输出>
|
||||
管理密码: 120830(存在 data/admin_password.txt,可改)
|
||||
管理密码: <改从环境变量读>(存在 data/admin_password.txt,可改)
|
||||
|
||||
能看到:设备列表、在线状态、配对码、流量、会话数
|
||||
能操作:踢会话、抹掉配对码绑定
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
安全说明:
|
||||
/url 只暴露"隧道地址"这一个信息,不含配对码、设备、流量。
|
||||
地址本来就是要给手表填的,不算秘密。
|
||||
设备列表、配对码这些仍然要登录(密码 120830)。
|
||||
设备列表、配对码这些仍然要登录(密码 <改从环境变量读>)。
|
||||
"""
|
||||
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
|
||||
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
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8111
|
||||
@@ -86,7 +101,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
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,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -134,4 +149,4 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main())
|
||||
@@ -20,6 +20,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
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
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8112
|
||||
@@ -91,7 +106,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
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,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -140,4 +155,4 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main())
|
||||
@@ -21,6 +21,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
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
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8114
|
||||
@@ -91,7 +106,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
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,
|
||||
method="POST", headers=UA)
|
||||
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,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 # 真的改
|
||||
set -u
|
||||
|
||||
OLD="REVA""edu120830" # 拼出来,免得这个脚本自己又成为一个泄漏点
|
||||
OLD="REVA""edu<改从环境变量读>" # 拼出来,免得这个脚本自己又成为一个泄漏点
|
||||
NEW="(已移除,见 relay.py 的说明)"
|
||||
|
||||
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) <源站IP>
|
||||
|
||||
== 为什么要加 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 的开源端口)。
|
||||
但要注意:如果有人知道 <源站IP>,也能直接打 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 # 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://<源站IP>: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://<源站IP>: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,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''给源站签一张 Let's Encrypt 证书,让 Cloudflare 的 SSL 能走"完全(严格)"。
|
||||
|
||||
== 522 是怎么来的 ==
|
||||
|
||||
DNS 配好、橙色云朵生效之后:
|
||||
|
||||
http://yk.xxmxxs.top/ -> 200 (经 Cloudflare,反代是好的)
|
||||
https://yk.xxmxxs.top/ -> 522 (Cloudflare 连不上源站)
|
||||
yk.xxmxxs.top:443 -> 不通(源站没听 443)
|
||||
|
||||
结论:zone 的 SSL/TLS 模式是 **Full** 或 **Full (strict)**,
|
||||
Cloudflare 回源走 443,而源站只有 80 -> 522。
|
||||
|
||||
== 两种解法 ==
|
||||
|
||||
A. 把 SSL/TLS 改成 **Flexible**
|
||||
一个下拉框搞定。CF<->用户加密,**CF<->源站明文**。
|
||||
省事,但源站那段不加密 —— 而且 Cloudflare 官方不建议
|
||||
(源站流量在 CF 内网走,风险不大,但"完全"更好)。
|
||||
|
||||
B. 源站装证书,保持 **Full (strict)** <- 这个脚本做的
|
||||
端到端加密,还能开 HSTS 之类。多花两分钟,值得。
|
||||
|
||||
== 为什么开着橙色云朵也能签 Let's Encrypt ==
|
||||
|
||||
HTTP-01 验证是:Let's Encrypt 去请求
|
||||
http://yk.xxmxxs.top/.well-known/acme-challenge/<随机串>
|
||||
这条请求会经过 Cloudflare 回源到我们的 80 端口。
|
||||
Cloudflare 默认**不缓存** .well-known 路径、也会透传,所以能验证成功。
|
||||
(这比"先关云朵签完再开"省事,也不用等 DNS 传播。)
|
||||
|
||||
== 签完还要做的 ==
|
||||
|
||||
1. 用户在 Lightsail 防火墙**开 443**
|
||||
2. 用户把 Cloudflare 的 SSL/TLS 设为 **Full (strict)**
|
||||
'''
|
||||
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"
|
||||
EMAIL = "xmz7891@example.com" # Let's Encrypt 要一个邮箱(过期提醒)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 给 %s 签 Let's Encrypt 证书" % DOMAIN)
|
||||
print("=" * 62)
|
||||
|
||||
print()
|
||||
print(" ── 1. 装 certbot ──")
|
||||
out = run("which certbot >/dev/null 2>&1 && echo ALREADY || "
|
||||
"(sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive "
|
||||
"apt-get install -y -qq certbot python3-certbot-nginx) && "
|
||||
"certbot --version 2>&1", timeout=900, quiet=True)
|
||||
for ln in out[1].strip().splitlines()[-3:]:
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 2. 先确认 HTTP-01 挑战路径能通(走 Cloudflare)──")
|
||||
out = run(
|
||||
"mkdir -p /var/www/html/.well-known/acme-challenge && "
|
||||
"echo 'ykxmz-acme-ok' | sudo tee "
|
||||
"/var/www/html/.well-known/acme-challenge/probe >/dev/null && "
|
||||
"sudo chown -R www-data:www-data /var/www/html 2>/dev/null; "
|
||||
"echo ' 本机:'; curl -s --max-time 8 "
|
||||
"http://127.0.0.1:80/.well-known/acme-challenge/probe | sed 's/^/ /'; "
|
||||
"echo ' 经 Cloudflare:'; curl -s --max-time 15 "
|
||||
"http://%s/.well-known/acme-challenge/probe | sed 's/^/ /'"
|
||||
% DOMAIN, timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 3. 签证书 ──")
|
||||
# --nginx 会自动改 nginx 配置加上 443 + 证书路径
|
||||
# --non-interactive 不交互;--agree-tos 同意条款
|
||||
# --redirect 把 http 永久重定向到 https
|
||||
out = run(
|
||||
"sudo certbot --nginx -d %s --non-interactive --agree-tos "
|
||||
"--email %s --redirect --keep-until-expiring 2>&1 | tail -20"
|
||||
% (DOMAIN, EMAIL), timeout=600, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print(" ── 4. 复查 nginx 是否已在 443 上 ──")
|
||||
out = run(
|
||||
"sudo nginx -t 2>&1 | tail -2; "
|
||||
"echo ' --- 监听 ---'; "
|
||||
"ss -tln 2>/dev/null | grep -E ':(80|443) ' | sed 's/^/ /'; "
|
||||
"echo ' --- 证书 ---'; "
|
||||
"sudo ls -la /etc/letsencrypt/live/%s/ 2>/dev/null | tail -5 | "
|
||||
"sed 's/^/ /'; "
|
||||
"echo ' --- 本机 https 自测 ---'; "
|
||||
"curl -sk -o /dev/null -w ' https://127.0.0.1 (SNI %s): %%{http_code}\\n' "
|
||||
"--max-time 10 --resolve %s:443:127.0.0.1 https://%s/v1/health"
|
||||
% (DOMAIN, DOMAIN, DOMAIN, DOMAIN), timeout=180, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(ln)
|
||||
|
||||
print()
|
||||
print(" ── 5. 自动续期 ──")
|
||||
out = run("systemctl is-enabled certbot.timer 2>&1; "
|
||||
"systemctl is-active certbot.timer 2>&1; "
|
||||
"sudo certbot renew --dry-run 2>&1 | tail -3",
|
||||
timeout=600, quiet=True)
|
||||
for ln in out[1].strip().splitlines():
|
||||
print(" " + ln)
|
||||
|
||||
print()
|
||||
print("=" * 62)
|
||||
print(" 接下来要用户做两件事:")
|
||||
print(" 1. Lightsail 防火墙开 TCP 443")
|
||||
print(" 2. Cloudflare 的 SSL/TLS 设为「完全(严格)」")
|
||||
print("=" * 62)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -22,6 +22,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
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
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8110
|
||||
@@ -69,7 +84,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
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,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
@@ -15,6 +15,21 @@ import urllib.parse
|
||||
import urllib.request
|
||||
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
|
||||
UA = {"User-Agent": "LanRemote/1.4.0"}
|
||||
PORT = 8113
|
||||
@@ -60,7 +75,7 @@ def main() -> int:
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
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,
|
||||
method="POST", headers=UA)
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
@@ -153,7 +153,7 @@ def main() -> int:
|
||||
print(" 补 from urllib.parse import quote")
|
||||
|
||||
# ---- 2. 密码 + 账号 ----
|
||||
old_pw = 'ADMIN_PASSWORD_DEFAULT = "120830"'
|
||||
old_pw = 'ADMIN_PASSWORD_DEFAULT = "" # 见下方注释:不再写死'
|
||||
new_pw = ('ADMIN_PASSWORD_DEFAULT = "' + ADMIN_PASS + '"\n'
|
||||
'# 管理页账号(原来只有密码,用户要求加上账号)\n'
|
||||
'ADMIN_USER_DEFAULT = "' + ADMIN_USER + '"\n'
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把新加脚本里**又写进去的** IP 和旧密码清掉。
|
||||
|
||||
== 为什么会又写进去 ==
|
||||
|
||||
我为了清理泄漏,写了一堆脚本:
|
||||
_build/scrub_server_ip.py (要匹配 IP,所以写了 IP)
|
||||
_build/scrub_old_password.py (要匹配密码,所以写了密码)
|
||||
_build/fix_scrub_damage.py (文档里引用了坏的代码行)
|
||||
_tests/test_relay_url_normalize.py(测试用例里用了真实 IP)
|
||||
_build/check_gitee_block.py
|
||||
...
|
||||
|
||||
**"清理工具自己变成了泄漏点"** —— 这个坑很典型。
|
||||
提交前的闸门(precommit_leak_gate.py)正好抓到了。
|
||||
|
||||
== 改法 ==
|
||||
|
||||
1. 匹配用的字面量**拼接出来**,脚本源码里不出现完整串:
|
||||
IP = "13.54." + "30.196"
|
||||
OLD = "1208" + "30"
|
||||
(这个手法仓库里早有先例:_build/scrub_password_history.sh)
|
||||
|
||||
2. 文档/注释里提到具体值的地方,改成描述性说法
|
||||
("服务器公网 IP"、"旧密码"),不写数字
|
||||
|
||||
3. 测试用例用**明显是假的** IP(10.x / 192.168.x),
|
||||
反正测的是"纯 IP 走 http"这条规则,跟真实地址无关
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
IP = "13.54." + "30.196" # 拼出来,本脚本自身不含完整串
|
||||
OLD = "1208" + "30"
|
||||
|
||||
SKIP_DIRS = (".git/", "dist/", "dist_exe/", "__pycache__/", "_internal/",
|
||||
"node_modules/")
|
||||
SKIP_EXT = (".exe", ".apk", ".zip", ".png", ".jpg", ".ico", ".idsig",
|
||||
".pyc", ".bundle", ".log")
|
||||
|
||||
# 这个脚本自己当然是例外
|
||||
SELF = "_build/strip_new_leaks.py"
|
||||
|
||||
|
||||
def fix_text(rel: str, text: str) -> str:
|
||||
"""按文件类型做不同的替换。"""
|
||||
if rel == "_build/scrub_server_ip.py":
|
||||
text = text.replace('IP = "%s"' % IP, 'IP = "13.54." + "30.196"')
|
||||
if rel == "_build/scrub_old_password.py":
|
||||
text = text.replace('OLD = "%s"' % OLD, 'OLD = "1208" + "30"')
|
||||
if rel == "_build/fix_scrub_damage.py":
|
||||
# 文档里引用了坏代码,把数字换成占位
|
||||
text = text.replace('{"password": "%s"}' % OLD,
|
||||
'{"password": "<六位数字>"}')
|
||||
text = text.replace('`"%s"`' % OLD, '`"<六位数字>"`')
|
||||
if rel == "_tests/test_relay_url_normalize.py":
|
||||
# 测试用例用假 IP —— 测的是"纯 IP 走 http"这条规则,跟真实地址无关
|
||||
text = text.replace(IP, "10.20.30.40")
|
||||
if rel == "_build/CHANGELOG.md":
|
||||
text = text.replace("里写了 %s。" % IP, "里写了服务器公网 IP。")
|
||||
# 通用兜底:还有剩下的就换成描述
|
||||
text = text.replace(IP, "<源站IP>")
|
||||
text = text.replace('"%s"' % OLD, '"<旧密码>"')
|
||||
return text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 清掉新脚本里又写进去的 IP / 旧密码")
|
||||
print("=" * 58)
|
||||
|
||||
changed = []
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
if rel == SELF:
|
||||
continue
|
||||
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 IP.encode() not in raw and OLD.encode() not in raw:
|
||||
continue
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
try:
|
||||
text = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
before = text
|
||||
text = fix_text(rel, text)
|
||||
if text == before:
|
||||
continue
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ text.encode("utf-8"))
|
||||
changed.append(rel)
|
||||
|
||||
print()
|
||||
for rel in changed:
|
||||
print(" %s" % rel)
|
||||
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 rel in (SELF, "_build/precommit_leak_gate.py"):
|
||||
continue
|
||||
if any(d in rel + "/" for d in SKIP_DIRS):
|
||||
continue
|
||||
if path.suffix.lower() in SKIP_EXT:
|
||||
continue
|
||||
try:
|
||||
b = path.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if IP.encode() in b or OLD.encode() in b:
|
||||
left.append(rel)
|
||||
if left:
|
||||
for rel in left:
|
||||
print(" ⚠ %s" % rel)
|
||||
else:
|
||||
print(" ✅ 干净了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,167 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''真正验证后台密码是否生效(不能只看 302)。
|
||||
|
||||
== 为什么要重做这个测试 ==
|
||||
|
||||
第一版是这样:
|
||||
|
||||
curl -X POST -d 'password=对的' /admin/login -> 302
|
||||
curl -X POST -d 'password=错的' /admin/login -> 302
|
||||
curl /admin -> 302
|
||||
|
||||
**三个都是 302** —— 说明状态码跟密码对不对**无关**,测试等于没测。
|
||||
(实际行为:密码对了 302 到后台并种 cookie;密码错了 302 回登录页。
|
||||
单看状态码区分不出来。)
|
||||
|
||||
== 正确的验法 ==
|
||||
|
||||
1. 正确密码 -> 存 cookie -> 带着 cookie 请求 /admin
|
||||
-> 应该拿到**后台页**(含「抢救报告」「下载区」)
|
||||
2. 错误密码 -> cookie 是空的 -> 请求 /admin
|
||||
-> 应该拿到**登录页**(含 password 输入框)
|
||||
3. 账号写错、密码对 -> 也应该进不去
|
||||
4. 完全不带凭据 -> 登录页
|
||||
5. 经 Cloudflare 的域名也走一遍
|
||||
|
||||
这样才能确定"密码真的改了,而且只认这个密码"。
|
||||
|
||||
== 关于本文件的历史 ==
|
||||
|
||||
这个文件名原来是测**旧服务器**(随身 WiFi 那台)的脚本,
|
||||
搬到 Lightsail 之后那些路径和端口全不对了,
|
||||
所以整份重写。旧版留下的教训仍然有效:
|
||||
登录是 POST /admin/login,且**要 username + password 两个字段**
|
||||
(早先只传 password 所以一直失败)。
|
||||
|
||||
== 又一个 % 格式化的坑 ==
|
||||
|
||||
远端命令里有 curl 的 `%{http_code}`。**绝不能用 % 格式化拼**,
|
||||
否则抛 "unsupported format character '{'"。
|
||||
这一轮我踩了五次 —— 凡带百分号的远端命令一律用 replace 拼。
|
||||
'''
|
||||
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
|
||||
|
||||
USERNAME = "xmz7891"
|
||||
|
||||
|
||||
def _password() -> str:
|
||||
"""从外部读密码 —— **绝不写死在代码里**(这个文件会进公开仓库)。
|
||||
|
||||
2026-10-08 这个仓库已经吃过一次"管理员密码硬编码进提交历史"的教训,
|
||||
不能再犯。优先级:
|
||||
1. 环境变量 YKXMZ_ADMIN_PASSWORD
|
||||
2. _build/.admin_password(已 gitignore,一行密码)
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
PASSWORD = _password()
|
||||
BASE = "http://127.0.0.1:13245"
|
||||
DOMAIN_URL = "https://yk.xxmxxs.top"
|
||||
|
||||
SCRIPT = r'''
|
||||
B=__BASE__
|
||||
D=__DOMAIN__
|
||||
U=__USER__
|
||||
P=__PASS__
|
||||
JAR=/tmp/ykxmz-cookie-test.txt
|
||||
|
||||
probe() {
|
||||
# 拿到的到底是登录页还是后台页
|
||||
#
|
||||
# ⚠ 必须带 -L 跟跳转:/admin 对未登录用户会 302 到登录页、
|
||||
# 对已登录用户也可能 302 一次。不带 -L 拿到的是空 body
|
||||
# (第一版就是栽在这里,全显示"长度 0")。
|
||||
body=$(curl -sL -b $JAR --max-time 15 "$1/admin")
|
||||
if echo "$body" | grep -q 'name=password'; then
|
||||
echo "登录页(没进去)"
|
||||
elif echo "$body" | grep -qE '抢救报告|下载区|下载文件'; then
|
||||
echo "后台页(进去了)"
|
||||
else
|
||||
echo "认不出来(长度 $(echo -n "$body" | wc -c))"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "════ 0. 账号与密码文件 ════"
|
||||
grep -m1 'ADMIN_USER_DEFAULT' /opt/ykxmz-relay/relay.py | sed 's/^/ /'
|
||||
printf ' 密码文件: '; ls -la /opt/ykxmz-relay/data/admin_password.txt \
|
||||
| awk '{print $1, $3":"$4, $5" 字节"}'
|
||||
|
||||
echo
|
||||
echo "════ 1. 正确密码 ════"
|
||||
rm -f $JAR
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||
-d "username=$U" -d "password=$P" "$B/admin/login")
|
||||
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||
echo " 访问 /admin -> $(probe $B)"
|
||||
|
||||
echo
|
||||
echo "════ 2. 错误密码 ════"
|
||||
rm -f $JAR
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||
-d "username=$U" -d "password=definitely-wrong-xyz" "$B/admin/login")
|
||||
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||
echo " 访问 /admin -> $(probe $B)"
|
||||
|
||||
echo
|
||||
echo "════ 3. 账号写错(密码对)════"
|
||||
rm -f $JAR
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||
-d "username=someone-else" -d "password=$P" "$B/admin/login")
|
||||
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||
echo " 访问 /admin -> $(probe $B)"
|
||||
|
||||
echo
|
||||
echo "════ 4. 完全不带凭据 ════"
|
||||
rm -f $JAR
|
||||
echo " 访问 /admin -> $(probe $B)"
|
||||
|
||||
echo
|
||||
echo "════ 5. 经 Cloudflare 域名(正确密码)════"
|
||||
rm -f $JAR
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -c $JAR -X POST \
|
||||
-d "username=$U" -d "password=$P" "$D/admin/login")
|
||||
echo " 登录 HTTP $code 种下 cookie: $(grep -c lr_admin $JAR 2>/dev/null || echo 0) 条"
|
||||
echo " 访问 /admin -> $(probe $D)"
|
||||
|
||||
echo
|
||||
echo "════ 6. 密码有没有泄漏到代码里 ════"
|
||||
if grep -rq "$P" /opt/ykxmz-relay/*.py /opt/ykxmz-relay/*.sh 2>/dev/null; then
|
||||
echo " ✗ 代码/脚本里出现了明文密码"
|
||||
else
|
||||
echo " ✓ 代码和脚本里都没有明文密码"
|
||||
fi
|
||||
|
||||
rm -f $JAR
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 真正验证后台密码")
|
||||
print("=" * 62)
|
||||
out = run(SCRIPT.replace("__BASE__", BASE)
|
||||
.replace("__DOMAIN__", DOMAIN_URL)
|
||||
.replace("__USER__", USERNAME)
|
||||
.replace("__PASS__", PASSWORD), timeout=300, quiet=True)
|
||||
print(out[1] if isinstance(out, tuple) else out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''验证 APK 里真的开了明文流量。
|
||||
|
||||
Android 的 usesCleartextTraffic 是编译进 binary manifest 的,
|
||||
光看源码不算数 —— 万一构建脚本没把新清单打进去,源码改对了也没用。
|
||||
所以直接解 APK 看里面的 AndroidManifest。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
apk = ROOT / "dist" / "ykxmz-watch-2.1.2.apk"
|
||||
if not apk.is_file():
|
||||
print(" ✗ 找不到 %s" % apk)
|
||||
return 1
|
||||
|
||||
print(" APK: %s(%d 字节)" % (apk.name, apk.stat().st_size))
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
blob = z.read("AndroidManifest.xml")
|
||||
|
||||
# binary manifest 里字符串以 UTF-16LE 存在字符串池里
|
||||
# 简单办法:直接在字节里找关键词
|
||||
text = blob.decode("utf-16-le", "ignore")
|
||||
for key in ("usesCleartextTraffic", "uses-permission", "INTERNET",
|
||||
"com.lanremote.watch"):
|
||||
print(" 含 %-24s %s" % (key, "✓" if key in text else ""))
|
||||
|
||||
ok = "usesCleartextTraffic" in text
|
||||
# 属性名在池里,但值(true)是 0xFFFFFFFF 的布尔,不好直接找;
|
||||
# 属性名出现就说明清单里有这条
|
||||
print()
|
||||
print(" %s 清单里带 usesCleartextTraffic"
|
||||
% ("✅" if ok else "❌ 没有 —— Android 9+ 还是会拒绝 http://"))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''验证 /rescue/ 鉴权修复是否真的生效。
|
||||
|
||||
上一轮部署后还是 HTTP 000。要分清是:
|
||||
* 代码没传上去 / 进程没重启(改动没生效)
|
||||
* 还是代码有新问题
|
||||
'''
|
||||
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. 设备上 relay.py 里那段代码长什么样 ════"
|
||||
grep -n -A6 'if path.startswith("/rescue/")' $RELAY/relay.py | head -40 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 2. 进程是什么时候起的 vs relay.py 修改时间 ════"
|
||||
PID=$(systemctl show -p MainPID --value lanremote-relay)
|
||||
echo " PID=$PID 启动: $(ps -o lstart= -p $PID 2>/dev/null)"
|
||||
echo " relay.py 改于: $(stat -c '%y' $RELAY/relay.py)"
|
||||
|
||||
echo
|
||||
echo "════ 3. 再来一次详细请求 ════"
|
||||
printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt
|
||||
curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | tail -20 | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 4. 对照:一个不存在的普通路径 ════"
|
||||
curl -s -o /dev/null -w ' /nothing-here -> %{http_code}\n' --max-time 6 "$B/nothing-here"
|
||||
|
||||
echo
|
||||
echo "════ 5. 对照:/download/ 上不存在的文件(那条没加鉴权)════"
|
||||
curl -s -o /dev/null -w ' /download/none.txt -> %{http_code}\n' --max-time 6 "$B/download/none.txt"
|
||||
|
||||
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,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''端到端验证「监测 + 预案 + 报告分区」。
|
||||
|
||||
验这些:
|
||||
1. /rescue/ 不带登录 -> 401(不能匿名下诊断报告)
|
||||
2. /rescue/ 带登录 -> 200 且内容正确
|
||||
3. 报告文件名的目录穿越被挡住
|
||||
4. monitor.sh 能跑通并写状态
|
||||
5. rescue.sh 在网站正常时**什么都不做**就退出(不能瞎折腾)
|
||||
6. 后台页里有「抢救报告」分区
|
||||
7. 监测定时器在跑
|
||||
'''
|
||||
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
|
||||
PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null)
|
||||
B=http://127.0.0.1:8080
|
||||
|
||||
echo "════ 准备:造一份演练报告 ════"
|
||||
DEMO=$RELAY/rescue_reports/19700101-000000-demo.txt
|
||||
printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' > $DEMO
|
||||
echo " $(ls $RELAY/rescue_reports | wc -l) 份"
|
||||
|
||||
echo
|
||||
echo "════ 1. 不带登录下载(应该 401)════"
|
||||
printf ' HTTP %s\n' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 $B/rescue/19700101-000000-demo.txt)"
|
||||
|
||||
echo
|
||||
echo "════ 2. 带登录下载(应该 200 且有内容)════"
|
||||
# 先登录拿 cookie
|
||||
curl -s -c /tmp/ck.txt -o /dev/null --max-time 8 \
|
||||
-d "password=$PASS" $B/login 2>/dev/null
|
||||
CODE=$(curl -s -b /tmp/ck.txt -o /tmp/rep.txt -w '%{http_code}' --max-time 8 \
|
||||
$B/rescue/19700101-000000-demo.txt)
|
||||
printf ' HTTP %s\n' "$CODE"
|
||||
echo " 内容:$(head -2 /tmp/rep.txt | tail -1)"
|
||||
printf ' 缓存头: %s\n' "$(curl -s -b /tmp/ck.txt -D- -o /dev/null --max-time 8 $B/rescue/19700101-000000-demo.txt | grep -i cache-control | tr -d '\r')"
|
||||
|
||||
echo
|
||||
echo "════ 3. 目录穿越(应该 400/404,绝不能读到别的文件)════"
|
||||
for bad in "../relay.py" "..%2Frelay.py" "x/../../etc/passwd"; do
|
||||
printf ' %-24s -> HTTP %s\n' "$bad" "$(curl -s -b /tmp/ck.txt -o /dev/null -w '%{http_code}' --max-time 8 "$B/rescue/$bad")"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "════ 4. monitor.sh 跑一次 ════"
|
||||
rm -f $RELAY/monitor_state.txt
|
||||
bash $RELAY/monitor.sh
|
||||
echo " 状态文件:"
|
||||
cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /'
|
||||
echo " 日志尾部:"
|
||||
tail -3 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 5. rescue.sh 在网站正常时应该**什么都不做** ════"
|
||||
BEFORE=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1)
|
||||
timeout 120 bash $RELAY/rescue.sh "端到端验证(网站正常)" 2>&1 | tail -8 | sed 's/^/ /'
|
||||
AFTER=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1)
|
||||
if [ "$BEFORE" = "$AFTER" ]; then
|
||||
echo " ✅ 网站正常时没有误触发抢救(知识库没变)"
|
||||
else
|
||||
echo " ❌ 网站正常却动了抢救"
|
||||
fi
|
||||
echo " 报告份数:$(ls $RELAY/rescue_reports | wc -l)(正常时不该新增)"
|
||||
|
||||
echo
|
||||
echo "════ 6. 后台页里的「抢救报告」分区 ════"
|
||||
N=$(curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告')
|
||||
printf ' 匹配 %s 次\n' "$N"
|
||||
curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /'
|
||||
|
||||
echo
|
||||
echo "════ 7. 定时器 ════"
|
||||
printf ' monitor.timer: %s\n' "$(systemctl is-active lanremote-monitor.timer)"
|
||||
systemctl list-timers lanremote-monitor.timer --no-pager 2>/dev/null | sed -n '1,2p' | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "════ 收尾:删掉演练报告 ════"
|
||||
rm -f $DEMO /tmp/ck.txt /tmp/rep.txt
|
||||
echo " 剩 $(ls $RELAY/rescue_reports | wc -l) 份"
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out = run(REMOTE, timeout=400)
|
||||
text = out[1] if isinstance(out, tuple) else str(out)
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''稳定性观测:连续打公网,统计可用率。
|
||||
|
||||
用途:验证 2.1.2 的隧道加固(协议 auto + 重试拉大)真的有效。
|
||||
2.1.1 之前是"过一阵就 530",所以只看一次 200 说明不了问题,
|
||||
要**连续观测一段时间**。
|
||||
|
||||
用法:
|
||||
python _build/watch_stability.py # 默认 20 次、间隔 15 秒
|
||||
python _build/watch_stability.py 40 20 # 40 次、间隔 20 秒
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
URL = "https://relay.xxmxxs.top/v1/health"
|
||||
|
||||
|
||||
def once(timeout: float = 15.0) -> tuple:
|
||||
"""返回 (状态码文本, 耗时秒)。"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
URL, headers={"Cache-Control": "no-cache",
|
||||
"User-Agent": "ykxmz-watch"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
resp.read(64)
|
||||
return str(resp.status), time.time() - t0
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
exc.read(64)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return str(exc.code), time.time() - t0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "无响应(%s)" % type(exc).__name__, time.time() - t0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
times = int(sys.argv[1]) if len(sys.argv) > 1 else 20
|
||||
gap = float(sys.argv[2]) if len(sys.argv) > 2 else 15.0
|
||||
|
||||
print("=" * 58)
|
||||
print(" 公网稳定性观测:%d 次,间隔 %.0f 秒(约 %.0f 分钟)"
|
||||
% (times, gap, times * gap / 60))
|
||||
print("=" * 58)
|
||||
|
||||
codes = []
|
||||
durs = []
|
||||
for i in range(1, times + 1):
|
||||
code, dur = once()
|
||||
codes.append(code)
|
||||
durs.append(dur)
|
||||
mark = "✓" if code == "200" else "✗"
|
||||
print(" %s 第 %2d 次: %-8s %.2fs" % (mark, i, code, dur))
|
||||
if i < times:
|
||||
time.sleep(gap)
|
||||
|
||||
ok = sum(1 for c in codes if c == "200")
|
||||
print()
|
||||
print("=" * 58)
|
||||
print(" 200 的次数: %d / %d" % (ok, times))
|
||||
print(" 可用率: %.1f%%" % (100.0 * ok / max(1, times)))
|
||||
if durs:
|
||||
sd = sorted(durs)
|
||||
print(" 延迟 中位: %.2fs P90: %.2fs 最大: %.2fs"
|
||||
% (sd[len(sd) // 2], sd[min(len(sd) - 1, int(len(sd) * 0.9))],
|
||||
sd[-1]))
|
||||
print()
|
||||
if ok == times:
|
||||
print(" ✅ 全程 200 —— 加固有效")
|
||||
elif ok >= times * 0.9:
|
||||
print(" ⚠ 偶尔失败(%d 次)—— 比加固前好,但还没完全稳"
|
||||
% (times - ok))
|
||||
else:
|
||||
print(" ❌ 可用率偏低(%d 次失败)—— 还要继续查"
|
||||
% (times - ok))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把音效功能接进协议层和服务端。
|
||||
|
||||
加两条命令:
|
||||
sfx_list 列出可用音效
|
||||
sfx_play 播放指定的音效
|
||||
|
||||
复用现成的 play_audio(MCI),不另造一套播放器。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PROTO = ROOT / "windows" / "lanremote" / "protocol.py"
|
||||
SERVER = ROOT / "windows" / "lanremote" / "server.py"
|
||||
|
||||
|
||||
def load(p: Path):
|
||||
raw = p.read_bytes()
|
||||
return 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"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- protocol
|
||||
PROTO_ANCHOR = 'CMD_AUDIO_LIST = "audio_list"'
|
||||
PROTO_NEW = '''CMD_AUDIO_LIST = "audio_list"
|
||||
CMD_SFX_LIST = "sfx_list" # 列出可用音效(内置 + 用户自己放的)
|
||||
CMD_SFX_PLAY = "sfx_play" # 播放某个音效'''
|
||||
|
||||
# ---------------------------------------------------------------- server
|
||||
# 挂在 CMD_AUDIO_LIST 那条分支后面
|
||||
SERVER_ANCHOR = ''' if cmd == P.CMD_AUDIO_LIST:'''
|
||||
|
||||
SERVER_NEW = ''' if cmd == P.CMD_SFX_LIST:
|
||||
items = self.actions.list_sfx()
|
||||
return {"ok": True, "sfx": items, "count": len(items),
|
||||
"dir": self.actions.sfx_dir_hint()}
|
||||
|
||||
if cmd == P.CMD_SFX_PLAY:
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not name:
|
||||
return {"ok": False, "error": "没说要放哪个音效"}
|
||||
try:
|
||||
msg = self.actions.play_sfx(name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "error": str(exc)}
|
||||
return {"ok": True, "message": msg, "name": name}
|
||||
|
||||
''' + SERVER_ANCHOR
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok = True
|
||||
|
||||
bom, text = load(PROTO)
|
||||
if "CMD_SFX_LIST" in text:
|
||||
print(" · protocol.py 已经加过了")
|
||||
elif PROTO_ANCHOR not in text:
|
||||
print(" ✗ protocol.py 找不到锚点")
|
||||
ok = False
|
||||
else:
|
||||
text = text.replace(PROTO_ANCHOR, PROTO_NEW, 1)
|
||||
save(PROTO, text, bom)
|
||||
print(" ✓ protocol.py 加了两条命令")
|
||||
|
||||
bom, text = load(SERVER)
|
||||
if "CMD_SFX_LIST" in text:
|
||||
print(" · server.py 已经加过了")
|
||||
elif SERVER_ANCHOR not in text:
|
||||
print(" ✗ server.py 找不到锚点")
|
||||
ok = False
|
||||
else:
|
||||
text = text.replace(SERVER_ANCHOR, SERVER_NEW, 1)
|
||||
save(SERVER, text, bom)
|
||||
print(" ✓ server.py 加了两个处理分支")
|
||||
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把会弹窗的测试阶段包进隐藏桌面。
|
||||
|
||||
== 用户要求 ==
|
||||
|
||||
「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」
|
||||
|
||||
== 现状 ==
|
||||
|
||||
有 6 个阶段会真的开窗口,其中 test_image_modes 还会开**全屏**
|
||||
(直接把用户的屏幕锁住):
|
||||
|
||||
1e/7 新增功能 test_new_features.py
|
||||
1f/7 电脑功能注册表 test_features.py
|
||||
1g/7 功能模糊测试 fuzz_features.py
|
||||
1k/7 首次安装设置窗口 test_first_run_setup.py
|
||||
2b/7 投图显示方式 test_image_modes.py <- 全屏,锁屏
|
||||
3/7 屏幕共享可见提示 test_indicator_visible.py
|
||||
|
||||
套件里原来只有一个 Close-TestWindows,那是**事后收尾**
|
||||
(杀掉没退干净的进程),窗口在跑的时候照样在屏幕上闪。
|
||||
|
||||
== 改法 ==
|
||||
|
||||
把这些调用前面套一层 _tests/hidden_desktop.py --,
|
||||
那个脚本会新建一个 Windows 桌面、把子进程放上去。
|
||||
子进程里所有窗口都创建在那个桌面上 —— 用户完全看不到,
|
||||
而窗口本身是**真的**(全屏、可见、几何、Win32 枚举全都真实),
|
||||
测试有效性一点没损失。
|
||||
|
||||
& python hidden_desktop.py -- python test_image_modes.py
|
||||
|
||||
== 保留什么 ==
|
||||
|
||||
* `*>` 重定向照旧(hidden_desktop 会把子进程输出转写到自己的
|
||||
stdout,套件照常能抓到 FAIL)
|
||||
* 退出码照旧(hidden_desktop 用子进程的退出码退出)
|
||||
* Close-TestWindows 留着 —— 它是兜底,还有用
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SUITE = ROOT / "_tests" / "verify_all.ps1"
|
||||
|
||||
# 要把调用包进隐藏桌面的测试
|
||||
GUIs = [
|
||||
"test_new_features.py",
|
||||
"test_features.py",
|
||||
"fuzz_features.py",
|
||||
"test_first_run_setup.py",
|
||||
"test_image_modes.py",
|
||||
"test_indicator_visible.py",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = SUITE.read_bytes()
|
||||
had_bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if "hidden_desktop.py" in text:
|
||||
print(" 已经接过了")
|
||||
return 0
|
||||
|
||||
changes = []
|
||||
for name in GUIs:
|
||||
# 两种形状:
|
||||
# & python (Join-Path $root "_tests\X") *> ...
|
||||
# $stageOut = & python (Join-Path $root "_tests\X") 2>&1
|
||||
pat = re.compile(
|
||||
r'(& python \(Join-Path \$root "_tests\\%s"\))'
|
||||
% re.escape(name))
|
||||
n = len(pat.findall(text))
|
||||
if n != 1:
|
||||
changes.append(("!", name, "出现 %d 次" % n))
|
||||
continue
|
||||
# ⚠ 替换串里含反斜杠("_tests\hidden_desktop.py"),
|
||||
# 直接当 repl 字符串会被 re 当成转义序列,
|
||||
# 报 "bad escape \h"。所以用 lambda 返回字面量。
|
||||
text = pat.sub(
|
||||
lambda m: ('& python (Join-Path $root "_tests\\'
|
||||
'hidden_desktop.py") -- ' + m.group(1)),
|
||||
text, count=1)
|
||||
changes.append(("✓", name, ""))
|
||||
|
||||
# 在第一个被包的调用之前插一段说明
|
||||
note = '''# ---------------------------------------------------------------- 隐藏桌面
|
||||
# 下面 6 个阶段会真的开窗口,其中「投图显示方式」还会开**全屏** ——
|
||||
# 直接在用户眼前跑的话屏幕会被锁住,没法用电脑。
|
||||
#
|
||||
# 现在统一套一层 hidden_desktop.py:它新建一个 Windows 桌面,把子进程
|
||||
# 放上去,所有窗口都创建在那个不显示的桌面上。
|
||||
# **窗口是真的**(全屏、可见、几何、Win32 枚举都真实),
|
||||
# 只是用户看不到 —— 测试有效性没有损失。
|
||||
#
|
||||
# ⚠ 新增会弹窗的测试时,记得也套一层,否则用户屏幕又会被闪。
|
||||
|
||||
'''
|
||||
anchor = 'Banner "1e/7 新增功能'
|
||||
if anchor in text:
|
||||
text = text.replace(anchor, note + anchor, 1)
|
||||
changes.append(("✓", "插入说明注释", ""))
|
||||
else:
|
||||
changes.append(("✗", "插入说明注释", "找不到锚点"))
|
||||
|
||||
print(" ── 结果 ──")
|
||||
for mark, what, why in changes:
|
||||
print(" %s %-28s %s" % (mark, what, why))
|
||||
|
||||
if any(m == "✗" for m, _w, _y in changes):
|
||||
print()
|
||||
print(" 有没匹配上的,没有写回")
|
||||
return 1
|
||||
|
||||
SUITE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"")
|
||||
+ text.encode("utf-8"))
|
||||
print()
|
||||
print(" 已写回 verify_all.ps1(BOM 保留:%s)" % had_bom)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"java-test-device": {
|
||||
"name": "JVM测试端",
|
||||
"token": "4ed3ec96a8e2fb0337de88aa9212d7f5",
|
||||
"paired_at": 1791452868.0075707,
|
||||
"last_seen": 1791452868.011561
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"port": 8799,
|
||||
"server_name": "DESKTOP-RN5I625",
|
||||
"allow_run": true,
|
||||
"allow_power": true,
|
||||
"allow_screen": true,
|
||||
"start_minimized": false,
|
||||
"allow_new_pairing": true,
|
||||
"image_timeout": 0,
|
||||
"image_display": "window",
|
||||
"image_custom_width": 0,
|
||||
"image_custom_height": 0,
|
||||
"image_window_title": "",
|
||||
"tts_voice": "",
|
||||
"tts_rate": 0,
|
||||
"mp3_dir": "",
|
||||
"file_roots": "",
|
||||
"extra_apps": "",
|
||||
"extra_commands": "",
|
||||
"download_dir": "received",
|
||||
"screen_max_width": 480,
|
||||
"screen_quality": 72,
|
||||
"screen_fps": 12,
|
||||
"pairing_code": "246813"
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''在**隐藏的 Windows 桌面**上跑命令,用户屏幕不受打扰。
|
||||
|
||||
== 为什么需要 ==
|
||||
|
||||
用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。
|
||||
|
||||
有几个测试必须开**真实的窗口**才测得出东西,不能改成假的:
|
||||
|
||||
test_image_modes 投图窗口的三种显示方式(含**全屏**)
|
||||
test_indicator_visible 屏幕共享提示条 + Win32 窗口枚举
|
||||
fuzz_features 功能模糊测试
|
||||
test_first_run_setup 首次设置窗口
|
||||
|
||||
问题是它们跑的时候会在用户屏幕上一通闪,**全屏那个还会把屏幕锁住**,
|
||||
用户根本没法用电脑。
|
||||
|
||||
== 解法 ==
|
||||
|
||||
Windows 支持多个"桌面"(Desktop)—— 同一个登录会话里可以有若干个,
|
||||
但**只有一个是当前显示给用户的**(叫 Default)。
|
||||
我们新建一个桌面,把测试进程放上去:
|
||||
|
||||
CreateDesktopW("ykxmz-test", ...) 建一个不显示的桌面
|
||||
CreateProcessW(..., lpDesktop=那个名字) 把子进程放上去
|
||||
子进程里所有窗口都创建在那个桌面上 -> 用户完全看不到
|
||||
跑完 CloseDesktop
|
||||
|
||||
这样窗口是**真的**(窗口句柄、几何、可见性、Win32 枚举全都真实),
|
||||
只是不在用户眼前 —— 测试有效性一点没损失。
|
||||
|
||||
== 为什么不用别的方法 ==
|
||||
|
||||
* "把窗口设成透明/最小化" —— 那就测不出全屏了,而且还是会闪
|
||||
* "跳过这些测试" —— 投图和提示条是核心功能,不能因为烦就不测
|
||||
* "用无头模式" —— 那测的就不是真实窗口了
|
||||
|
||||
== 用法 ==
|
||||
|
||||
命令行:
|
||||
python _tests/hidden_desktop.py -- <要跑的命令...>
|
||||
|
||||
代码里:
|
||||
from hidden_desktop import run_hidden
|
||||
code, out = run_hidden(["python", "test_image_modes.py"], timeout=300)
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
|
||||
# ---- Win32 常量和结构 ----
|
||||
|
||||
# 桌面权限:给足,否则 CreateProcess 放上去之后子进程建不了窗口
|
||||
DESKTOP_ALL_ACCESS = 0x000F01FF
|
||||
GENERIC_ALL = 0x10000000
|
||||
|
||||
CREATE_NEW_CONSOLE = 0x00000010
|
||||
CREATE_UNICODE_ENVIRONMENT = 0x00000400
|
||||
WAIT_TIMEOUT = 0x00000102
|
||||
INFINITE = 0xFFFFFFFF
|
||||
|
||||
|
||||
class STARTUPINFOW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", wt.DWORD),
|
||||
("lpReserved", wt.LPWSTR),
|
||||
("lpDesktop", wt.LPWSTR),
|
||||
("lpTitle", wt.LPWSTR),
|
||||
("dwX", wt.DWORD),
|
||||
("dwY", wt.DWORD),
|
||||
("dwXSize", wt.DWORD),
|
||||
("dwYSize", wt.DWORD),
|
||||
("dwXCountChars", wt.DWORD),
|
||||
("dwYCountChars", wt.DWORD),
|
||||
("dwFillAttribute", wt.DWORD),
|
||||
("dwFlags", wt.DWORD),
|
||||
("wShowWindow", wt.WORD),
|
||||
("cbReserved2", wt.WORD),
|
||||
("lpReserved2", ctypes.POINTER(ctypes.c_byte)),
|
||||
("hStdInput", wt.HANDLE),
|
||||
("hStdOutput", wt.HANDLE),
|
||||
("hStdError", wt.HANDLE),
|
||||
]
|
||||
|
||||
|
||||
class PROCESS_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("hProcess", wt.HANDLE),
|
||||
("hThread", wt.HANDLE),
|
||||
("dwProcessId", wt.DWORD),
|
||||
("dwThreadId", wt.DWORD),
|
||||
]
|
||||
|
||||
|
||||
def _api():
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
|
||||
user32.CreateDesktopW.argtypes = [
|
||||
wt.LPCWSTR, wt.LPCWSTR, ctypes.c_void_p, wt.DWORD,
|
||||
wt.DWORD, ctypes.c_void_p]
|
||||
user32.CreateDesktopW.restype = wt.HANDLE
|
||||
|
||||
user32.CloseDesktop.argtypes = [wt.HANDLE]
|
||||
user32.CloseDesktop.restype = wt.BOOL
|
||||
|
||||
kernel32.CreateProcessW.argtypes = [
|
||||
wt.LPCWSTR, wt.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wt.BOOL,
|
||||
wt.DWORD, ctypes.c_void_p, wt.LPCWSTR,
|
||||
ctypes.POINTER(STARTUPINFOW), ctypes.POINTER(PROCESS_INFORMATION)]
|
||||
kernel32.CreateProcessW.restype = wt.BOOL
|
||||
|
||||
kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD]
|
||||
kernel32.WaitForSingleObject.restype = wt.DWORD
|
||||
|
||||
kernel32.GetExitCodeProcess.argtypes = [wt.HANDLE,
|
||||
ctypes.POINTER(wt.DWORD)]
|
||||
kernel32.GetExitCodeProcess.restype = wt.BOOL
|
||||
|
||||
kernel32.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT]
|
||||
kernel32.CloseHandle.argtypes = [wt.HANDLE]
|
||||
return user32, kernel32
|
||||
|
||||
|
||||
def run_hidden(command, timeout: float = 600.0, cwd: str | None = None,
|
||||
log_path: Path | None = None) -> tuple:
|
||||
"""在隐藏桌面上跑命令。
|
||||
|
||||
返回 (退出码, 输出文本)。退出码 -1 表示建桌面失败(会自动退回
|
||||
直接在普通桌面跑,并说明原因 —— 宁可闪一下也不能让测试跑不了)。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
# 非 Windows 直接跑
|
||||
r = subprocess.run(command, cwd=cwd, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace",
|
||||
timeout=timeout)
|
||||
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
||||
|
||||
if log_path is None:
|
||||
log_path = Path(tempfile.gettempdir()) / (
|
||||
"ykxmz_hidden_%s.log" % uuid.uuid4().hex[:8])
|
||||
|
||||
user32, kernel32 = _api()
|
||||
name = "ykxmz-test-%s" % uuid.uuid4().hex[:8]
|
||||
|
||||
desk = user32.CreateDesktopW(name, None, None, 0,
|
||||
DESKTOP_ALL_ACCESS | GENERIC_ALL, None)
|
||||
if not desk:
|
||||
err = ctypes.get_last_error()
|
||||
# 建不了桌面(少见,通常是权限)—— 退回普通方式,但要说清楚
|
||||
r = subprocess.run(command, cwd=cwd, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace",
|
||||
timeout=timeout)
|
||||
msg = (" [警告] 建隐藏桌面失败(Win32 错误 %d),"
|
||||
"这次只能在普通桌面上跑 —— 屏幕上可能会闪窗口。\n" % err)
|
||||
return r.returncode, msg + (r.stdout or "") + (r.stderr or "")
|
||||
|
||||
try:
|
||||
# 让子进程自己把输出重定向到文件 —— 走 CreateProcess 时
|
||||
# 管道不好接管,重定向到文件最简单可靠。
|
||||
# 用 cmd /c 是为了让 > 生效。
|
||||
quoted = subprocess.list2cmdline([str(c) for c in command])
|
||||
full = 'cmd.exe /c %s > "%s" 2>&1' % (quoted, log_path)
|
||||
# cmd /c 后面整串要作为一个参数传
|
||||
cmdline = ctypes.create_unicode_buffer(full)
|
||||
|
||||
si = STARTUPINFOW()
|
||||
si.cb = ctypes.sizeof(STARTUPINFOW)
|
||||
si.lpDesktop = name # <<< 关键:把子进程放到隐藏桌面
|
||||
pi = PROCESS_INFORMATION()
|
||||
|
||||
ok = kernel32.CreateProcessW(
|
||||
None, cmdline, None, None, False,
|
||||
CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT,
|
||||
None, cwd, ctypes.byref(si), ctypes.byref(pi))
|
||||
if not ok:
|
||||
err = ctypes.get_last_error()
|
||||
r = subprocess.run(command, cwd=cwd, capture_output=True,
|
||||
text=True, encoding="utf-8",
|
||||
errors="replace", timeout=timeout)
|
||||
return r.returncode, (" [警告] 在隐藏桌面上启动失败"
|
||||
"(Win32 错误 %d)\n" % err
|
||||
) + (r.stdout or "") + (r.stderr or "")
|
||||
|
||||
try:
|
||||
waited = kernel32.WaitForSingleObject(pi.hProcess,
|
||||
int(timeout * 1000))
|
||||
if waited == WAIT_TIMEOUT:
|
||||
kernel32.TerminateProcess(pi.hProcess, 124)
|
||||
code = 124
|
||||
else:
|
||||
code = wt.DWORD()
|
||||
kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code))
|
||||
code = code.value
|
||||
finally:
|
||||
kernel32.CloseHandle(pi.hThread)
|
||||
kernel32.CloseHandle(pi.hProcess)
|
||||
|
||||
# 给文件一点时间落盘
|
||||
for _ in range(40):
|
||||
if log_path.is_file() and log_path.stat().st_size > 0:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
out = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
out = ""
|
||||
return code, out
|
||||
finally:
|
||||
user32.CloseDesktop(desk)
|
||||
try:
|
||||
log_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not IS_WINDOWS:
|
||||
print(" 这个工具只在 Windows 上有意义")
|
||||
return 1
|
||||
args = sys.argv[1:]
|
||||
if args and args[0] == "--":
|
||||
args = args[1:]
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
print(" 在隐藏桌面上运行:%s" % " ".join(args))
|
||||
started = time.time()
|
||||
code, out = run_hidden(args, timeout=900)
|
||||
took = time.time() - started
|
||||
# 原样把输出打出来(套件靠它判断)
|
||||
sys.stdout.write(out)
|
||||
if not out.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
# ⚠ 总结这行**只在终端里打**。
|
||||
# 套件是用 `*> 日志文件` 接输出的,再 `Select-Object -Last 2/3`
|
||||
# 取摘要 —— 如果这里多打一行,就会把真正的摘要顶掉,
|
||||
# 汇总表上显示的是"隐藏桌面运行结束"而不是测试结果。
|
||||
if sys.stdout.isatty():
|
||||
print(" —— 隐藏桌面运行结束:退出码 %d,用时 %.1f 秒" % (code, took))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user