Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1f0db22c2 | |||
| d32811b804 | |||
| a1c74caa0c | |||
| 2259f7c847 | |||
| 76d78ac71f | |||
| 0230f1ca99 | |||
| 39139e95f7 | |||
| 50ec4d9717 | |||
| d72550e11f | |||
| 4e7f54b9ff | |||
| 330a63b050 | |||
| df73459ea5 | |||
| b22b4b0d99 | |||
| 594525dd73 | |||
| e84fdedc4b | |||
| 60a1aea85e | |||
| 219bb364bc | |||
| 32ff47731c | |||
| ddd1020a3a | |||
| 7f4cc10d25 | |||
| d2dea2debf | |||
| 02ea33520d | |||
| cf094f6e6f | |||
| e30fdaa004 | |||
| b99be7d514 | |||
| 672d455467 | |||
| 5574b7c89a | |||
| 086b5d6624 | |||
| a88268b0cd | |||
| b52fac1394 | |||
| 3a4ce9c751 | |||
| 42db78c6a0 | |||
| 920a78fdae | |||
| 19ffc80130 | |||
| 10d2c48cf0 | |||
| 9da61c9482 | |||
| abdcb881f3 | |||
| 2b4a2cb2d5 | |||
| ddf0ddf71e | |||
| 0205334cd8 | |||
| b903a09fb1 | |||
| 656cc7ca5b |
@@ -0,0 +1,23 @@
|
||||
# Разработка ведётся в том числе на Windows, где часто включён core.autocrlf=true.
|
||||
# Всё, что исполняется или читается на target-сервере Debian, обязано остаться LF:
|
||||
# CRLF в shell-скрипте даёт `$'\r': command not found`, а в systemd unit и env-файле
|
||||
# ломает разбор значений.
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
*.tpl text eol=lf
|
||||
*.env text eol=lf
|
||||
*.service text eol=lf
|
||||
*.nft text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.sql text eol=lf
|
||||
|
||||
# Бинарные ассеты не должны трогаться нормализацией.
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.gif binary
|
||||
*.jpg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
+1340
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,661 @@
|
||||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2026 Flamy studio
|
||||
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.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
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/>.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# HY2XS
|
||||
# HY2XS Core
|
||||
|
||||
<p align="center">
|
||||
<img src="apps/frontend/src/assets/logo.png" width="96" alt="HY2XS logo">
|
||||
@@ -13,7 +13,9 @@
|
||||
<a href="#возможности">Возможности</a> ·
|
||||
<a href="#быстрый-старт-для-нового-сервера">Быстрый старт</a> ·
|
||||
<a href="#конфигурация-hy2xsenv">Конфигурация</a> ·
|
||||
<a href="#версионная-политика-hysteria2">Версии</a> ·
|
||||
<a href="#сборка-release-пакета">Сборка</a> ·
|
||||
<a href="CHANGELOG.md">Changelog</a> ·
|
||||
<a href="#лицензия">Лицензия</a>
|
||||
</p>
|
||||
|
||||
@@ -22,7 +24,7 @@
|
||||
<img alt="Architecture" src="https://img.shields.io/badge/arch-amd64%20%2F%20x86__64-555555">
|
||||
<img alt="Firewall" src="https://img.shields.io/badge/firewall-nftables-2563eb">
|
||||
<img alt="Runtime" src="https://img.shields.io/badge/runtime-systemd-111827">
|
||||
<img alt="License" src="https://img.shields.io/badge/license-MIT-green">
|
||||
<img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0--only-green">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -39,7 +41,7 @@ HY2XS подходит для сценария, где нужен один produ
|
||||
|
||||
HY2XS release‑пакет разворачивает и настраивает:
|
||||
|
||||
- официальный upstream‑бинарник Hysteria2, закреплённый в metadata пакета и проверяемый по SHA256;
|
||||
- официальный upstream‑бинарник Hysteria2: последняя стабильная версия выбирается при сборке пакета, закрепляется в его metadata и проверяется по SHA256;
|
||||
- HY2XS admin — встроенную админ‑панель для управления users/peers, трафиком, конфигурацией, логами и состоянием сервера;
|
||||
- systemd‑юнит `hysteria-server` для Hysteria2;
|
||||
- systemd‑юнит `hy2xs-admin` для админ‑панели;
|
||||
@@ -108,6 +110,8 @@ Windows и macOS можно использовать для разработки
|
||||
- не включает IPv6‑production baseline;
|
||||
- не настраивает `sshd` автоматически;
|
||||
- не предоставляет полноценный uninstall/update framework;
|
||||
- не обновляет Hysteria2 на уже работающем сервере: `reconfigure` намеренно не является Hysteria updater;
|
||||
- не мигрирует установки `0.x` на `1.0.0` — переход выполняется чистой установкой, см. [CHANGELOG](CHANGELOG.md);
|
||||
- не выполняет сложную миграцию старых неизвестных состояний сервера;
|
||||
- не реализует Telegram‑бота, port hopping и универсальный access‑delivery workflow;
|
||||
- не предназначен для установки поверх давно используемого сервера с неизвестными firewall/systemd‑правками.
|
||||
@@ -134,7 +138,17 @@ hy2xs-install/
|
||||
└── metadata/
|
||||
```
|
||||
|
||||
При запуске `install.sh` пакет проверяет `metadata/checksums.txt`, устанавливает orchestrator в `/usr/local/lib/hy2xs/hy2xs-orchestrator`, создаёт symlink `/usr/local/bin/hy2xs-orchestrator`, копирует package assets в `/usr/local/lib/hy2xs/package` и передаёт управление install‑only orchestrator.
|
||||
При запуске `install.sh` пакет проверяет `metadata/checksums.txt` и выполняет
|
||||
read‑only clean‑host preflight **из распакованного архива**. После этого он
|
||||
передаёт управление install‑only orchestrator через `exec` — и больше не делает
|
||||
ничего: сам `install.sh` не изменяет на сервере ни одного файла.
|
||||
|
||||
Всю раскладку выполняет уже оркестратор: ставит себя в
|
||||
`/usr/local/lib/hy2xs/hy2xs-orchestrator`, создаёт symlink
|
||||
`/usr/local/bin/hy2xs-orchestrator`, копирует package assets в
|
||||
`/usr/local/lib/hy2xs/package` и продолжает установку. Это сделано ради одного
|
||||
свойства: у изменений сервера ровно один владелец, поэтому при любом отказе
|
||||
известно, что именно было создано и что откатывать.
|
||||
|
||||
## Сетевая модель по умолчанию
|
||||
|
||||
@@ -147,10 +161,79 @@ hy2xs-install/
|
||||
| TrafficStats Hysteria2 | `127.0.0.1:36712` |
|
||||
| Firewall mode | `takeover` в packaged baseline |
|
||||
| Hysteria2 auth | `http` через локальный HY2XS admin |
|
||||
| Hysteria2 obfs | `salamander` |
|
||||
| Hysteria2 obfs | `gecko` (512/1200); `salamander` доступен как режим совместимости |
|
||||
| Congestion fallback | `bbr`, профиль `standard` |
|
||||
| QUIC stateless reset | включён |
|
||||
|
||||
Важно: `HY2XS_SSH_PORT` нужен HY2XS для nftables‑правил и проверки доступности SSH‑порта. Сам `sshd` проект не перенастраивает. SSH на `2323` и вход только по ключу нужно настроить до запуска `./install.sh`.
|
||||
|
||||
## Версионная политика Hysteria2
|
||||
|
||||
HY2XS **не привязан к конкретному номеру версии Hysteria**.
|
||||
|
||||
> Источник по умолчанию берёт последний стабильный релиз Hysteria, доступный на момент сборки пакета. Разрешённая версия, URL артефакта и контрольная сумма замораживаются в получившемся install‑пакете.
|
||||
|
||||
Как это работает:
|
||||
|
||||
```text
|
||||
build machine target server
|
||||
───────────── ─────────────
|
||||
определить последнюю стабильную ─┐
|
||||
взять ожидаемый SHA-256 из │
|
||||
upstream hashes.txt │
|
||||
скачать артефакт и сверить его ├─► release‑пакет ──► скачать ровно
|
||||
проверить, что бинарник принимает │ version + url этот артефакт,
|
||||
канонический конфиг HY2XS │ + sha256 сверить SHA-256
|
||||
заморозить version/url/sha256 ─┘ и `hysteria version`
|
||||
```
|
||||
|
||||
Контрольная сумма берётся из upstream‑ассета `hashes.txt`, а не считается
|
||||
только локально: локальный пересчёт подтверждает, что файл не изменился после
|
||||
скачивания, но не доказывает, что скачан именно ожидаемый upstream artifact.
|
||||
|
||||
Что это даёт:
|
||||
|
||||
- новая установка получает актуальную Hysteria без ручного обновления version lock;
|
||||
- если между сборкой пакета и его установкой выйдет новая версия, **содержимое установки не изменится**;
|
||||
- повторная установка старого пакета поставит ту же версию, что и в день сборки;
|
||||
- несовместимый upstream ломает сборку, а не сервер оператора.
|
||||
|
||||
Переопределения при сборке:
|
||||
|
||||
```bash
|
||||
# по умолчанию: последняя стабильная
|
||||
./tools/build/build.sh
|
||||
|
||||
# закрепить конкретную версию
|
||||
HYSTERIA_VERSION_OVERRIDE=v2.12.2 ./tools/build/build.sh
|
||||
|
||||
# офлайн-сборка по закоммиченному tools/build/hysteria-lock.env
|
||||
HYSTERIA_CHANNEL=pinned ./tools/build/build.sh
|
||||
```
|
||||
|
||||
Фактически установленная версия видна в `/etc/hysteria/post-install.env` (`HY2_VERSION`), а способ её выбора — в `HY2_RESOLUTION`.
|
||||
|
||||
Обновление Hysteria на уже работающем сервере в текущем релизе не поддерживается: `reconfigure` намеренно не является Hysteria updater. Это сохраняет immutable‑контракт развёртывания.
|
||||
|
||||
## Обфускация
|
||||
|
||||
Новые установки HY2XS используют **Gecko**.
|
||||
|
||||
Gecko помечен upstream как **experimental**. Он достраивается поверх Salamander: помимо scramble он дополнительно фрагментирует QUIC handshake на пакеты случайного размера. HY2XS использует upstream‑defaults размеров пакетов `512/1200` как проверенный production‑профиль.
|
||||
|
||||
**Salamander остаётся поддержанным режимом совместимости.** Смена типа обфускации требует соответствующих изменений на клиенте: это изменение wire‑совместимости, а не косметическая настройка.
|
||||
|
||||
| | Gecko | Salamander |
|
||||
| --- | --- | --- |
|
||||
| Статус upstream | experimental | stable |
|
||||
| Роль в HY2XS | default для новых установок | режим совместимости |
|
||||
| Параметр | `HY2XS_HYSTERIA_OBFS_TYPE=gecko` | `HY2XS_HYSTERIA_OBFS_TYPE=salamander` |
|
||||
| В клиентской ссылке | `obfs=gecko` | `obfs=salamander` |
|
||||
|
||||
Экспериментальность upstream остаётся контролируемым риском, потому что одновременно выполняются три условия: Salamander доступен как fallback, каждая разрешённая версия проходит compatibility gate до выпуска пакета, и существующие серверы никогда не переводятся на Gecko молча.
|
||||
|
||||
Размеры пакетов Gecko не выносятся в конфигурацию: официальная схема `hysteria2://` не умеет их передавать, поэтому нестандартные значения сделали бы клиентскую ссылку неполной.
|
||||
|
||||
## Быстрый старт для нового сервера
|
||||
|
||||
Ниже приведён полный путь для оператора, который работает с Windows и ставит HY2XS на чистый Debian 13 сервер.
|
||||
@@ -185,6 +268,20 @@ vpn.example.com -> SERVER_IP
|
||||
|
||||
Если у домена есть AAAA‑запись, при строгой политике `HY2XS_DNS_AAAA_POLICY=strict` установка будет остановлена, потому что текущий production‑профиль HY2XS является IPv4‑only.
|
||||
|
||||
A‑запись должна указывать именно на этот сервер, а не просто существовать. Preflight сверяет её с публичными IPv4, назначенными интерфейсам машины, и останавливает установку при расхождении:
|
||||
|
||||
```text
|
||||
DNS IPv4 mismatch for HY2XS_PUBLIC_HOST vpn.example.com:
|
||||
DNS A records: 185.xxx.xxx.10
|
||||
server public IPv4: 185.xxx.xxx.27
|
||||
|
||||
Update the DNS A record before using this server.
|
||||
```
|
||||
|
||||
Та же проверка выполняется в `reconfigure` и `doctor`, поэтому принудительная смена IPv4 провайдером не остаётся незамеченной. Адрес сервера определяется локально, без обращения к внешним сервисам определения IP.
|
||||
|
||||
Если сервер работает за NAT или на floating IP — это топология вне текущего baseline; осознанное решение оформляется значением `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
|
||||
|
||||
### 2. Создайте SSH‑ключ на Windows
|
||||
|
||||
Откройте PowerShell:
|
||||
@@ -379,29 +476,46 @@ HY2XS_IPV6_ENABLED=false
|
||||
HY2XS_TLS_MODE=acme
|
||||
HY2XS_ACME_TYPE=http
|
||||
HY2XS_HYSTERIA_AUTH_MODE=http
|
||||
HY2XS_HYSTERIA_OBFS_TYPE=salamander
|
||||
HY2XS_HYSTERIA_OBFS_TYPE=gecko
|
||||
HY2XS_UI_PUBLIC_ACCESS=false
|
||||
```
|
||||
|
||||
Если нужен режим совместимости со старыми клиентами, укажите `HY2XS_HYSTERIA_OBFS_TYPE=salamander`. Подробнее — в разделе [Обфускация](#обфускация).
|
||||
|
||||
### 9. Запустите установку
|
||||
|
||||
```bash
|
||||
./install.sh --config /root/hy2xs-target.env --non-interactive
|
||||
```
|
||||
|
||||
Во время установки HY2XS:
|
||||
Установка идёт в две фазы с жёсткой границей между ними.
|
||||
|
||||
1. проверит checksums release‑пакета;
|
||||
2. установит orchestrator в `/usr/local/lib/hy2xs`;
|
||||
3. создаст runtime‑каталоги и service users;
|
||||
4. запишет `/etc/hy2xs/hy2xs.env`;
|
||||
5. разложит bundled HY2XS admin;
|
||||
6. скачает pinned Hysteria2 binary из upstream и проверит SHA256;
|
||||
7. создаст `/etc/hysteria/config.yaml`;
|
||||
8. установит systemd‑юниты;
|
||||
9. применит nftables‑правила;
|
||||
10. выполнит smoke‑checks;
|
||||
11. зафиксирует успешное состояние в `/var/lib/hy2xs/install-state.json`.
|
||||
**PHASE 0 — только чтение.** До её успешного завершения на сервере не
|
||||
изменяется ни один файл, включая `/usr/local/lib/hy2xs`:
|
||||
|
||||
1. проверит, что запущено от root;
|
||||
2. проверит checksums release‑пакета;
|
||||
3. запустит clean‑host preflight **из распакованного архива**: платформа
|
||||
Debian 13 amd64, отсутствие предыдущей установки, валидность конфигурации.
|
||||
|
||||
**PHASE 1 — применение изменений.** Её целиком выполняет оркестратор, которому
|
||||
`install.sh` передал управление через `exec`:
|
||||
|
||||
4. установит сам себя в `/usr/local/lib/hy2xs` и разложит runtime‑пакет;
|
||||
5. создаст runtime‑каталоги и service users;
|
||||
6. запишет `/etc/hy2xs/hy2xs.env`;
|
||||
7. разложит bundled HY2XS admin;
|
||||
8. скачает закреплённый в пакете Hysteria2 binary из upstream, проверит SHA256 и фактическую версию;
|
||||
9. создаст `/etc/hysteria/config.yaml`;
|
||||
10. установит systemd‑юниты;
|
||||
11. применит nftables‑правила;
|
||||
12. выполнит smoke‑checks;
|
||||
13. зафиксирует успешное состояние в `/var/lib/hy2xs/install-state.json`.
|
||||
|
||||
Если PHASE 0 не прошла, установщик завершается с ошибкой и **сервер остаётся в
|
||||
том же состоянии, в котором был**. HY2XS v1 не устанавливается поверх
|
||||
предыдущего поколения и не мигрирует его состояние: очистка старой установки —
|
||||
отдельная явная операция, см. [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md).
|
||||
|
||||
### 10. Получите bootstrap‑пароль админки
|
||||
|
||||
@@ -507,9 +621,11 @@ hy2xs-orchestrator status \
|
||||
|
||||
| Переменная | Назначение | Значение по умолчанию в packaged baseline |
|
||||
| --- | --- | --- |
|
||||
| `HY2XS_CONFIG_SCHEMA_VERSION` | Версия схемы конфигурации HY2XS. Конфигурация другой схемы отклоняется fail‑fast | `2` |
|
||||
| `HY2XS_IPV6_ENABLED` | IPv6‑режим. В production baseline должен быть `false` | `false` |
|
||||
| `HY2XS_DOMAIN` | Домен для ACME и deploy‑профиля | `fi.api.withen.pro` |
|
||||
| `HY2XS_DNS_AAAA_POLICY` | Поведение при наличии AAAA‑записи: `strict`, `warn`, `off` | `strict` |
|
||||
| `HY2XS_PUBLIC_ENDPOINT_POLICY` | Строгость проверки того, что A‑записи публичного endpoint ведут на IPv4 этого сервера: `strict`, `warn`, `off` | `strict` |
|
||||
| `HY2XS_PUBLIC_HOST` | Публичный host без схемы, порта и path | `fi.api.withen.pro` |
|
||||
| `HY2XS_PUBLIC_PORT` | Публичный порт Hysteria2 endpoint | `443` |
|
||||
| `HY2XS_SSH_PORT` | SSH‑порт, который будет разрешён firewall‑правилами | `2323` |
|
||||
@@ -534,8 +650,8 @@ hy2xs-orchestrator status \
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `127.0.0.1` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
|
||||
| `HY2XS_HYSTERIA_OBFS_TYPE` | Obfuscation type. Фиксированное значение production‑профиля | `salamander` |
|
||||
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Salamander password | `__GENERATE__` |
|
||||
| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
|
||||
| `HY2XS_HYSTERIA_OBFS_PASSWORD` | Пароль обфускации; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_HYSTERIA_BANDWIDTH_UP` | Hysteria2 upstream bandwidth | `50 mbps` |
|
||||
| `HY2XS_HYSTERIA_BANDWIDTH_DOWN` | Hysteria2 downstream bandwidth | `50 mbps` |
|
||||
| `HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH` | Игнорировать bandwidth клиента | `false` |
|
||||
@@ -555,6 +671,50 @@ hy2xs-orchestrator status \
|
||||
|
||||
При `managed` и `takeover` генерируется nftables‑конфигурация с default drop policy, разрешением loopback, established/related, SSH‑порта, ACME challenge‑порта, Hysteria2 UDP‑порта и ICMP echo‑request.
|
||||
|
||||
### Защита от потери доступа при смене firewall
|
||||
|
||||
При `HY2XS_FIREWALL_STAGED_APPLY=true` (значение по умолчанию) перед применением новых правил HY2XS взводит rollback guard — транзиентный systemd‑юнит с окном 45 секунд. Если операция не снимет его вовремя, guard вернёт прежний firewall, и SSH останется доступным.
|
||||
|
||||
Таймеру явно задаётся `AccuracySec=1s`, поэтому «45 секунд» — это реальный контракт, а не приблизительный: по умолчанию `systemd.timer` разрешает себе сработать в окне `[цель; цель + AccuracySec]`, где `AccuracySec` — одна минута, и обещанное окно превращалось бы в 45–105 секунд. Вторым свойством задаётся `RemainAfterElapse=no`: отработавший таймер обязан выгрузиться, иначе он навсегда блокировал бы следующую операцию (см. [«Одна операция за раз»](#одна-операция-за-раз)).
|
||||
|
||||
Окно намеренно короткое и **не** обязано покрывать smoke‑checks: на медленном сервере они идут дольше. Вместо этого guard оставляет за собой факт срабатывания в `/run/hy2xs/rollback/<op-id>/auto-rollback-fired`, и операция не имеет права объявить себя успешной, если этот файл появился, — сервер в такой момент работает на прежнем firewall, а не на том, который она сгенерировала. Установка завершится отказом с `phase: firewall_guard_fired`, и её нужно повторить после устранения причины медленного прохода.
|
||||
|
||||
Дополнительно smoke сверяет, что действующий firewall — именно тот, который сгенерирован для текущей конфигурации: разбора `/etc/nftables.conf` для этого недостаточно, потому что прежний ruleset тоже валиден.
|
||||
|
||||
## Одна операция за раз
|
||||
|
||||
`install`, `reconfigure`, `repair` и `doctor` сериализованы эксклюзивным замком `/run/lock/hy2xs-orchestrator.lock`. Вторая операция отказывает сразу и **до первой мутации**:
|
||||
|
||||
```text
|
||||
another HY2XS operation is already in progress: reconfigure (pid 4242, started at …)
|
||||
```
|
||||
|
||||
Это не перестраховка: конфиги, unit‑файлы, `/etc/nftables.conf` и маркер установки — общие, и две одновременные операции записывают их поверх друг друга, после чего откат одной «восстанавливает» состояние поверх изменений другой.
|
||||
|
||||
`status` и `diagnostics collect` замок не берут — они нужны в том числе во время долгой операции, — но сообщают о ней в своём выводе.
|
||||
|
||||
Замок снимается сам при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH. Если процесс был убит `kill -9`, следующая операция обнаружит мёртвого держателя и переиспользует замок самостоятельно.
|
||||
|
||||
Замка при этом недостаточно: он действует, пока жив процесс‑держатель, а rollback guard firewall — отдельный объект systemd, который свой процесс переживает. Аварийно умершая операция оставляет guard вооружённым, и он способен вернуть прежний firewall уже посреди следующей операции. Поэтому условие старта — не «предыдущая операция мертва», а «у неё не осталось исполнителей, способных изменить систему»:
|
||||
|
||||
```text
|
||||
previous HY2XS operation is no longer running, but its firewall rollback guard
|
||||
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
|
||||
```
|
||||
|
||||
Ждать в этом случае нужно не дольше 45–46 секунд с момента применения firewall.
|
||||
|
||||
Покоем считаются ровно два состояния юнита — `inactive` и `failed`: отработавший guard больше ничего не сделает, а отказ по `failed` заблокировал бы `repair`, которым чинят последствия. Всё остальное, включая незнакомые барьеру состояния systemd, операцию запрещает.
|
||||
|
||||
Отдельный случай — когда состояние guard'а вообще не удалось выяснить:
|
||||
|
||||
```text
|
||||
unable to verify firewall rollback guard state; systemd query failed,
|
||||
refusing to start a lifecycle operation
|
||||
```
|
||||
|
||||
Здесь ждать нечего: отсутствие ответа systemd — это отсутствие доказательства, а не доказательство покоя, и разбираться нужно с systemd. Барьер обязан **доказать**, что у предыдущей операции не осталось исполнителей, способных изменить firewall; молчаливое «наверное, всё в порядке» однажды означало бы срабатывание старого таймера поверх новой операции.
|
||||
|
||||
## Реконфигурация
|
||||
|
||||
После изменения `/etc/hy2xs/hy2xs.env` сначала выполните dry‑run:
|
||||
@@ -583,14 +743,21 @@ hy2xs-orchestrator reconfigure \
|
||||
|
||||
| Команда | Назначение |
|
||||
| --- | --- |
|
||||
| `hy2xs-orchestrator preflight-install` | Read‑only проверка чистоты хоста; ничего не меняет |
|
||||
| `hy2xs-orchestrator status` | Показать состояние платформы, сервисов, firewall и install marker |
|
||||
| `hy2xs-orchestrator doctor` | Выполнить preflight и smoke‑checks текущей установки |
|
||||
| `hy2xs-orchestrator doctor` | Выполнить preflight и smoke‑checks текущей установки; сервисы **не перезапускает** |
|
||||
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
||||
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
||||
| `hy2xs-orchestrator repair` | Попытаться восстановить partial install state |
|
||||
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
|
||||
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/log/hy2xs/diagnostics` |
|
||||
| `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов |
|
||||
|
||||
`repair` без `--allow-partial-state` работает только поверх полностью успешной
|
||||
установки. В обоих режимах он сначала проверяет, что
|
||||
`/var/lib/hy2xs/install-state.json` принадлежит текущему поколению продукта
|
||||
(`product`, `release_line`, `config_schema_version`), и отказывается работать
|
||||
поверх чужого состояния.
|
||||
|
||||
Пример сбора диагностики:
|
||||
|
||||
```bash
|
||||
@@ -637,6 +804,35 @@ permitopen 127.0.0.1:8080 localhost:8080
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Установка отказывается: обнаружена предыдущая установка
|
||||
|
||||
```text
|
||||
[hy2xs] ERROR: На сервере обнаружена предыдущая или посторонняя установка.
|
||||
HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.
|
||||
Ни один файл на сервере не изменён.
|
||||
```
|
||||
|
||||
Это ожидаемое поведение, а не сбой. Отказ происходит в PHASE 0, до любой
|
||||
мутации: сервер остался в том состоянии, в котором был.
|
||||
|
||||
Что делать:
|
||||
|
||||
1. сохраните нужные данные (база пиров, конфиг) — см.
|
||||
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md);
|
||||
2. посмотрите план очистки: `sudo ./purge-v0.sh`;
|
||||
3. выполните очистку: `sudo ./purge-v0.sh --apply --yes-i-know`;
|
||||
4. повторите установку.
|
||||
|
||||
Отдельный случай — отказ вида
|
||||
`HY2XS_CONFIG_SCHEMA_VERSION отсутствует в конфигурации`. Он означает, что
|
||||
переданный `--config` относится к предыдущему поколению: до v1 этого поля не
|
||||
существовало. Создайте конфиг заново по разделу «Создайте конфиг для своего
|
||||
сервера».
|
||||
|
||||
Если установка HY2XS v1 упала **после** начала применения изменений, полная
|
||||
очистка не нужна — используйте
|
||||
`hy2xs-orchestrator repair --allow-partial-state`.
|
||||
|
||||
### Установка падает на DNS AAAA
|
||||
|
||||
Причина: домен имеет IPv6 AAAA‑запись, а HY2XS production profile является IPv4‑only.
|
||||
@@ -646,6 +842,23 @@ permitopen 127.0.0.1:8080 localhost:8080
|
||||
1. удалить AAAA‑запись у домена;
|
||||
2. либо временно установить `HY2XS_DNS_AAAA_POLICY=warn`, если оператор осознанно принимает риск клиентских IPv6‑маршрутов вне текущего baseline.
|
||||
|
||||
### `DNS IPv4 mismatch`: DNS ведёт не на этот сервер
|
||||
|
||||
Причина: A‑запись публичного endpoint указывает на адрес, которого нет среди публичных IPv4 этого сервера. Типичный случай — провайдер принудительно сменил IP, а DNS остался старым: сервисы на машине живы, но клиентская ссылка отправляет людей на другой адрес.
|
||||
|
||||
Решения:
|
||||
|
||||
1. сверить фактический адрес сервера и обновить A‑запись:
|
||||
|
||||
```bash
|
||||
ip -4 addr show scope global
|
||||
```
|
||||
|
||||
2. дождаться истечения TTL и повторить `hy2xs-orchestrator doctor`;
|
||||
3. если в строке `server public IPv4:` пусто — на интерфейсах нет публичного IPv4 (сервер за NAT). Это вне baseline; при осознанном решении установите `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
|
||||
|
||||
Если A‑записей несколько и среди них есть посторонняя, проверка тоже отказывает: HY2XS — single‑host профиль, и второй backend за тем же именем означает, что часть клиентов попадёт не на этот сервер.
|
||||
|
||||
### Установка падает на проверке SSH‑порта
|
||||
|
||||
HY2XS firewall‑слой проверяет, что порт из `HY2XS_SSH_PORT` уже слушается. Если указано `2323`, но `sshd` продолжает слушать только `22`, установка остановится.
|
||||
@@ -703,6 +916,43 @@ hy2xs-orchestrator redact-config \
|
||||
|
||||
Обычному пользователю не нужно собирать проект из исходников. Этот раздел нужен maintainer’у, который готовит release‑архив.
|
||||
|
||||
### Перед работой: сверьте среду с контрактом
|
||||
|
||||
```bash
|
||||
./tools/dev/doctor.sh # Linux/macOS
|
||||
.\tools\dev\doctor.ps1 # Windows (PowerShell 7+)
|
||||
```
|
||||
|
||||
```text
|
||||
HY2XS development environment
|
||||
contract: versions.env (HY2XS 1.0.0, release line 1)
|
||||
|
||||
Go:
|
||||
required: 1.26.7
|
||||
found: 1.25.6
|
||||
FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.7
|
||||
|
||||
Node:
|
||||
required: 24.20.0
|
||||
found: 24.20.0
|
||||
OK
|
||||
```
|
||||
|
||||
Скрипт ничего не устанавливает и не меняет — он отвечает на один вопрос:
|
||||
совпадает ли эта машина с контрактом сборки.
|
||||
|
||||
Раньше `versions.env` был контрактом только для сборки: она скачивает Go, Node и
|
||||
Bun ровно тех версий, что там записаны, сверяя контрольные суммы, а машина
|
||||
разработчика не проверялась никак. Расхождение обнаруживалось на Debian, внутри
|
||||
release‑сборки, и выглядело как «у меня работало».
|
||||
|
||||
Расхождение не гипотетическое. Директива `go` в `apps/go.mod` — это языковой
|
||||
baseline модуля, а не выбор компилятора, поэтому локальный Go другой минорной
|
||||
линии собирал проект успешно, пока релизный бинарь компилировался на 1.26.7 и
|
||||
наследовал **её** stdlib: проверялся не тот код, который уезжает в production.
|
||||
Поэтому `go.mod` теперь объявляет `toolchain` явно, а `doctor` показывает
|
||||
расхождение до сборки, а не после.
|
||||
|
||||
Сборка поддерживается на Debian 13 amd64 из чистого git work tree.
|
||||
|
||||
```bash
|
||||
@@ -716,26 +966,100 @@ git status --short
|
||||
|
||||
Подготовьте build env:
|
||||
|
||||
Версии и контрольные суммы toolchain **не задаются переменными окружения**: они
|
||||
объявлены в корневом [`versions.env`](versions.env), и сборка берёт их оттуда.
|
||||
Раньше их приходилось передавать снаружи, из-за чего воспроизводимая сборка в
|
||||
чистой Debian‑среде требовала предварительного знания четырёх SHA‑256.
|
||||
|
||||
```bash
|
||||
export PACKAGE_VERSION=0.2.2
|
||||
export BUILD_ID=prod-$(date -u +%Y%m%dT%H%M%SZ)
|
||||
|
||||
# Для переносимости между x86_64-серверами без AVX2 предпочтителен baseline artifact.
|
||||
# Ожидаемый digest выбирается автоматически: в versions.env зафиксированы обе суммы.
|
||||
export BUN_FLAVOR=x64-baseline
|
||||
|
||||
# Builder требует SHA256 для скачиваемых toolchain-архивов.
|
||||
# Значения нужно брать из официальных release/checksum источников для конкретных версий.
|
||||
export GO_ARCHIVE_SHA256=<sha256-go1.21.13-linux-amd64.tar.gz>
|
||||
export NODE_ARCHIVE_SHA256=<sha256-node-v20.19.0-linux-x64.tar.xz>
|
||||
export BUN_ARCHIVE_SHA256=<sha256-bun-linux-x64-baseline-1.3.13.zip>
|
||||
# Опционально: снимает anonymous rate limit при разрешении upstream-релиза.
|
||||
export GITHUB_TOKEN=<token>
|
||||
```
|
||||
|
||||
`PACKAGE_VERSION` тоже приходит из `versions.env` (`HY2XS_VERSION`). Шаг
|
||||
`verify_versions_contract` роняет сборку, если версия продукта, схема
|
||||
конфигурации, целевая платформа или `packageManager` в `package.json`
|
||||
разошлись с контрактом.
|
||||
|
||||
Запустите сборку:
|
||||
|
||||
```bash
|
||||
./tools/build/build.sh
|
||||
```
|
||||
|
||||
Сборка последовательно:
|
||||
|
||||
1. проверяет контракт `versions.env` (`verify_versions_contract`);
|
||||
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
|
||||
3. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
|
||||
4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
||||
5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
|
||||
6. прогоняет `go vet` и `go test` для HY2XS admin;
|
||||
7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
|
||||
8. формирует архив и прогоняет acceptance‑проверки.
|
||||
|
||||
Любой сбой на шагах 1–7 останавливает сборку до создания пакета.
|
||||
|
||||
Тесты и типы (шаги 2 и 6) — такой же обязательный гейт, как проверка
|
||||
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
|
||||
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
|
||||
утверждение опирается на фактический прогон, а не на намерение.
|
||||
|
||||
Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`,
|
||||
`go vet ./...` и `go test ./...` запускаются напрямую и tarball не создают.
|
||||
|
||||
Переменные, управляющие выбором версии Hysteria:
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
| --- | --- | --- |
|
||||
| `HYSTERIA_CHANNEL` | из `versions.env` (`stable`) | `stable` — разрешить последнюю стабильную; `pinned` — офлайн‑сборка по `tools/build/hysteria-lock.env` |
|
||||
| `HYSTERIA_VERSION_OVERRIDE` | пусто | Закрепить конкретную версию `vX.Y.Z` |
|
||||
| `HYSTERIA_COMPAT_GATE` | `true` | Compatibility gate; для release‑сборок обязателен |
|
||||
| `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass |
|
||||
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл |
|
||||
|
||||
Проверка зависимостей переменными не управляется: у неё **нет аварийного
|
||||
выхода**. Релизный артефакт HY2XS невозможно собрать с непройденным гейтом, и
|
||||
поле `dependency_security_gate` в `metadata/package.env` принимает единственное
|
||||
значение `true`.
|
||||
|
||||
Раньше здесь были описаны два способа выпустить релиз, зная об уязвимости. Ими
|
||||
они не являлись: финальная приёмка архива требует буквально
|
||||
`dependency_security_gate=true`, поэтому сборка с любым из них проходила весь
|
||||
цикл и падала на последнем шаге. Документированная операция, которую продукт сам
|
||||
же запрещает, — хуже отсутствующей.
|
||||
|
||||
`pnpm audit` при этом проверяет **весь** lock‑граф frontend, а не только
|
||||
production‑подграф. Причина в том, что build tooling исполняется на build‑машине
|
||||
и порождает production‑бандл: уязвимость в `vite`/`rollup` уезжает в артефакт,
|
||||
хотя сами они на сервер не копируются. Ровно такой случай и был найден — DOM
|
||||
clobbering в Rollup затрагивал генерируемый бандл, а проверка по одному
|
||||
production‑подграфу его не показывала.
|
||||
|
||||
Если advisory вышло в неудачный момент, чинится это обновлением графа
|
||||
(`apps/go.sum`, `apps/frontend/pnpm-lock.yaml`) или версии toolchain в
|
||||
`versions.env`. Для локальной работы обходить нечего: `go test ./...`,
|
||||
`govulncheck ./...` и `pnpm audit` запускаются напрямую и tarball не создают.
|
||||
|
||||
Проверка versions‑контракта и проверка зависимостей отвечают на разные вопросы.
|
||||
Первая следит, что зафиксированные версии **согласованы между собой**; вторая —
|
||||
что про эти версии **не стало известно плохого**. Зафиксированный граф не
|
||||
стареет только на бумаге: advisory по нему выходят и после фиксации, а сборка
|
||||
релиза — единственный момент, когда это расхождение ловится дёшево.
|
||||
|
||||
Полный E2E с реальным клиентом Hysteria запускается отдельно (нужен Go: ссылка
|
||||
берётся из production‑генератора, а не из отдельной реализации внутри теста):
|
||||
|
||||
```bash
|
||||
HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
```text
|
||||
@@ -748,7 +1072,7 @@ dist/hy2xs-install-<version>.tar.gz
|
||||
ls -lh dist/hy2xs-install-*.tar.gz
|
||||
sha256sum dist/hy2xs-install-*.tar.gz
|
||||
|
||||
tar -tzf dist/hy2xs-install-0.2.2.tar.gz | grep -E \
|
||||
tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
|
||||
'^(hy2xs-install/install.sh|hy2xs-install/orchestrator/hy2xs-orchestrator|hy2xs-install/ui/hy2xs-admin/hy2xs-admin|hy2xs-install/metadata/checksums.txt)$'
|
||||
```
|
||||
|
||||
@@ -762,6 +1086,12 @@ tar -tzf dist/hy2xs-install-0.2.2.tar.gz | grep -E \
|
||||
├── orchestrator/ # install-only orchestrator на Bun + TypeScript
|
||||
├── package/ # skeleton будущего install package
|
||||
├── tools/build/ # production builder и packaging pipeline
|
||||
├── tools/dev/ # doctor: сверка среды разработки с versions.env
|
||||
├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria
|
||||
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения
|
||||
├── docs/ # спецификации baseline, тестов и эксплуатации
|
||||
├── versions.env # контракт продукта, платформы и toolchain
|
||||
├── CHANGELOG.md
|
||||
├── README.md
|
||||
└── LICENSE
|
||||
```
|
||||
@@ -783,4 +1113,12 @@ HY2XS рассчитан на операторов, которым нужен в
|
||||
|
||||
## Лицензия
|
||||
|
||||
Проект распространяется по лицензии MIT. Текст лицензии находится в [`LICENSE`](LICENSE).
|
||||
HY2XS распространяется на условиях **GNU Affero General Public License v3.0 only** (`AGPL-3.0-only`).
|
||||
|
||||
Полный текст лицензии находится в [`LICENSE`](LICENSE).
|
||||
|
||||
Сторонние библиотеки и зависимости сохраняют собственные лицензии.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">Разработано во <a href="https://flamy.studio">Flamy Studio</a>.</p>
|
||||
|
||||
+9
-2
@@ -35,12 +35,19 @@ func run(cmd *cobra.Command, args []string) {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
for {
|
||||
// Цикла `for { runServer() }` здесь больше нет.
|
||||
//
|
||||
// Он существовал ради единственного сценария: смена RESET_TRAFFIC_CRON
|
||||
// делала StopServer(), runServer возвращался, и цикл поднимал сервис
|
||||
// заново, чтобы новое расписание попало в новый планировщик. Это был
|
||||
// внутрипроцессный «перезапуск», из-за которого планировщики накапливались
|
||||
// (см. service/cron_scheduler.go). Расписание теперь переносится на месте,
|
||||
// перезапускать сервер незачем, а перезапуском упавшего юнита занимается
|
||||
// systemd (Restart=on-failure) — он для этого и предназначен.
|
||||
if err := runServer(port); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
|
||||
+93
-37
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"hy2xs-admin/dao"
|
||||
@@ -21,33 +22,106 @@ func init() {
|
||||
rootCmd.AddCommand(resetCmd)
|
||||
}
|
||||
|
||||
// Длины восстановительных учётных данных.
|
||||
//
|
||||
// Было по 6 символов на оба поля. Шесть символов алфавита из 62 — это около 36
|
||||
// бит, то есть ровно нижняя граница, которую пропускает HashPassword, и
|
||||
// единственной защитой оставался force_password_change. Пароль восстановления
|
||||
// живёт до первого входа администратора, но до этого входа он полноценно
|
||||
// пускает в панель, поэтому его стойкость должна соответствовать остальным
|
||||
// секретам продукта, а не минимально допустимой.
|
||||
const (
|
||||
resetUsernameLength = 12
|
||||
resetPasswordLength = 24
|
||||
)
|
||||
|
||||
// adminLookup — способ узнать о существующей учётной записи администратора.
|
||||
//
|
||||
// Параметризовано ради теста на отказ хранилища. Отличить «ветку создания» от
|
||||
// «ветки обновления» при недоступной базе иначе нельзя: при по-настоящему
|
||||
// сломанной базе обе ветки заканчиваются ошибкой записи, и наблюдаемый
|
||||
// результат совпадает. Опасен же ровно транзиентный отказ, когда чтение упало,
|
||||
// а запись прошла.
|
||||
type adminLookup func() (entity.AdminUser, error)
|
||||
|
||||
func runReset(cmd *cobra.Command, args []string) {
|
||||
username, err := util.RandomString(6)
|
||||
username, err := util.RandomString(resetUsernameLength)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
password, err := util.RandomString(6)
|
||||
password, err := util.RandomString(resetPasswordLength)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
if err = dao.InitSql(""); err != nil {
|
||||
if err = dao.InitSql(); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
admin, err := dao.GetAdminUser("1 = 1")
|
||||
nowMs := time.Now().UnixMilli()
|
||||
|
||||
lookup := func() (entity.AdminUser, error) { return dao.GetAdminUser("1 = 1") }
|
||||
if err = resetAdminCredentials(lookup, username, password, time.Now().UnixMilli()); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = dao.CloseSqliteDB(); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username))
|
||||
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
|
||||
}
|
||||
|
||||
func resetAdminCredentials(lookup adminLookup, username, password string, nowMs int64) error {
|
||||
// Хеш считается ОДИН раз и ДО записи.
|
||||
//
|
||||
// В ветке обновления стояло `hash, _ := util.HashPassword(password)` внутри
|
||||
// литерала map. Ошибка bcrypt проглатывалась, в password_hash уезжала пустая
|
||||
// строка, а на экран печатался пароль, которым войти уже невозможно:
|
||||
// VerifyPassword отклоняет всё, что не является bcrypt-хешем. То есть
|
||||
// команда восстановления доступа умела молча его отобрать.
|
||||
hash, err := util.HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось захешировать пароль восстановления: %w", err)
|
||||
}
|
||||
|
||||
admin, err := lookup()
|
||||
|
||||
// Отказ хранилища — это не «администратора нет».
|
||||
//
|
||||
// Здесь стояло обычное `if err != nil { создать } else { обновить }`, хотя
|
||||
// слой данных специально различает ErrAdminUserNotFound и ErrStorage.
|
||||
// Склейка опасна не только нарушением смысла sentinel'ов: при транзиентном
|
||||
// отказе SQLite («database is locked») ветка создания отрабатывала успешно,
|
||||
// и в таблице оказывались ДВЕ учётные записи администратора.
|
||||
// `GetAdminUser("1 = 1").First()` дальше отдаёт произвольную из них, то есть
|
||||
// на сервере остаётся вторая рабочая учётка с паролем, который уже был
|
||||
// напечатан на экран.
|
||||
switch {
|
||||
case err == nil:
|
||||
tokenVersion := int64(1)
|
||||
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
||||
tokenVersion = *admin.TokenVersion + 1
|
||||
}
|
||||
if updateErr := dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{
|
||||
"username": username,
|
||||
"password_hash": hash,
|
||||
"force_password_change": 1,
|
||||
"password_changed_at": nowMs,
|
||||
"token_version": tokenVersion,
|
||||
"status": 1,
|
||||
}); updateErr != nil {
|
||||
return fmt.Errorf("не удалось обновить учётную запись администратора: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
|
||||
case errors.Is(err, dao.ErrAdminUserNotFound):
|
||||
tokenVersion := int64(1)
|
||||
status := int64(1)
|
||||
forcePasswordChange := int64(1)
|
||||
passwordChangedAt := nowMs
|
||||
hash, hashErr := util.HashPassword(password)
|
||||
if hashErr != nil {
|
||||
fmt.Println(hashErr.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
adminUser := entity.AdminUser{
|
||||
Username: &username,
|
||||
PasswordHash: &hash,
|
||||
@@ -57,33 +131,15 @@ func runReset(cmd *cobra.Command, args []string) {
|
||||
TokenVersion: &tokenVersion,
|
||||
}
|
||||
if _, saveErr := dao.SaveAdminUser(adminUser); saveErr != nil {
|
||||
fmt.Println(saveErr.Error())
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("не удалось создать учётную запись администратора: %w", saveErr)
|
||||
}
|
||||
} else {
|
||||
tokenVersion := int64(1)
|
||||
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
||||
tokenVersion = *admin.TokenVersion + 1
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"не удалось прочитать учётную запись администратора: база данных не ответила. "+
|
||||
"Сброс не выполнен: создавать вторую учётную запись при недоступной базе нельзя: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{
|
||||
"username": username,
|
||||
"password_hash": func() string {
|
||||
hash, _ := util.HashPassword(password)
|
||||
return hash
|
||||
}(),
|
||||
"force_password_change": 1,
|
||||
"password_changed_at": nowMs,
|
||||
"token_version": tokenVersion,
|
||||
"status": 1,
|
||||
}); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err = dao.CloseSqliteDB(); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username))
|
||||
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// reset-admin — команда восстановления доступа, и ошибиться ей дороже, чем
|
||||
// обычному обработчику: она печатает новые учётные данные на экран и на этом
|
||||
// основании оператор считает доступ восстановленным.
|
||||
//
|
||||
// Здесь закрепляются два дефекта:
|
||||
//
|
||||
// 1. отказ хранилища трактовался как «администратора нет», то есть переводил
|
||||
// команду в ветку СОЗДАНИЯ учётной записи;
|
||||
// 2. ошибка bcrypt в ветке обновления проглатывалась (`hash, _ := ...`), и в
|
||||
// password_hash уезжала пустая строка.
|
||||
|
||||
func newAdminDB(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := dao.RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||
}
|
||||
|
||||
func realLookup() (entity.AdminUser, error) {
|
||||
return dao.GetAdminUser("1 = 1")
|
||||
}
|
||||
|
||||
func countAdmins(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
|
||||
count, err := dao.CountAdminUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось посчитать администраторов: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestResetCreatesAdminWhenNoneExists(t *testing.T) {
|
||||
newAdminDB(t)
|
||||
|
||||
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
|
||||
t.Fatalf("сброс не выполнен: %v", err)
|
||||
}
|
||||
|
||||
if got := countAdmins(t); got != 1 {
|
||||
t.Fatalf("ожидалась одна учётная запись, получено %d", got)
|
||||
}
|
||||
|
||||
admin, err := dao.GetAdminUser("1 = 1")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись не читается: %v", err)
|
||||
}
|
||||
if *admin.Username != "operator-1" {
|
||||
t.Errorf("имя пользователя не записано: %q", *admin.Username)
|
||||
}
|
||||
if !util.VerifyPassword("recovery-password-1", *admin.PasswordHash) {
|
||||
t.Error("напечатанный пароль не подходит к сохранённому хешу")
|
||||
}
|
||||
if *admin.ForcePasswordChange != 1 {
|
||||
t.Error("восстановительный пароль обязан требовать смены при первом входе")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetUpdatesExistingAdminInPlace(t *testing.T) {
|
||||
newAdminDB(t)
|
||||
|
||||
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
|
||||
t.Fatalf("первичный сброс не выполнен: %v", err)
|
||||
}
|
||||
before, err := dao.GetAdminUser("1 = 1")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись не читается: %v", err)
|
||||
}
|
||||
|
||||
if err := resetAdminCredentials(realLookup, "operator-2", "recovery-password-2", 1700000001000); err != nil {
|
||||
t.Fatalf("повторный сброс не выполнен: %v", err)
|
||||
}
|
||||
|
||||
if got := countAdmins(t); got != 1 {
|
||||
t.Fatalf("повторный сброс размножил учётные записи: %d", got)
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("1 = 1")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись не читается: %v", err)
|
||||
}
|
||||
if *after.Id != *before.Id {
|
||||
t.Errorf("учётная запись пересоздана: было id=%d, стало id=%d", *before.Id, *after.Id)
|
||||
}
|
||||
if *after.Username != "operator-2" {
|
||||
t.Errorf("имя пользователя не обновлено: %q", *after.Username)
|
||||
}
|
||||
if !util.VerifyPassword("recovery-password-2", *after.PasswordHash) {
|
||||
t.Error("новый пароль не подходит к сохранённому хешу")
|
||||
}
|
||||
if util.VerifyPassword("recovery-password-1", *after.PasswordHash) {
|
||||
t.Error("прежний пароль всё ещё действует")
|
||||
}
|
||||
// Смена пароля обязана обесценивать выданные ранее токены.
|
||||
if *after.TokenVersion <= *before.TokenVersion {
|
||||
t.Errorf("token_version не увеличен: было %d, стало %d", *before.TokenVersion, *after.TokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// Ключевая регрессия. База ПОЛНОСТЬЮ работоспособна, отказало только чтение —
|
||||
// ровно тот транзиентный случай («database is locked»), из-за которого прежний
|
||||
// код уходил в ветку создания и оставлял на сервере вторую рабочую учётку с
|
||||
// паролем, уже напечатанным на экран.
|
||||
func TestResetRefusesToCreateSecondAdminOnStorageFailure(t *testing.T) {
|
||||
newAdminDB(t)
|
||||
|
||||
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
|
||||
t.Fatalf("первичный сброс не выполнен: %v", err)
|
||||
}
|
||||
before, err := dao.GetAdminUser("1 = 1")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись не читается: %v", err)
|
||||
}
|
||||
|
||||
failingLookup := func() (entity.AdminUser, error) {
|
||||
return entity.AdminUser{}, dao.ErrStorage
|
||||
}
|
||||
|
||||
err = resetAdminCredentials(failingLookup, "operator-2", "recovery-password-2", 1700000001000)
|
||||
if err == nil {
|
||||
t.Fatal("отказ хранилища обязан останавливать сброс, а не трактоваться как отсутствие администратора")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "база данных не ответила") {
|
||||
t.Errorf("сообщение не объясняет причину отказа: %v", err)
|
||||
}
|
||||
|
||||
if got := countAdmins(t); got != 1 {
|
||||
t.Fatalf("при отказе чтения создана вторая учётная запись: всего %d", got)
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("1 = 1")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись не читается: %v", err)
|
||||
}
|
||||
if *after.Id != *before.Id || *after.Username != *before.Username {
|
||||
t.Error("существующая учётная запись изменена при отказе чтения")
|
||||
}
|
||||
if !util.VerifyPassword("recovery-password-1", *after.PasswordHash) {
|
||||
t.Error("прежний пароль перестал действовать, хотя сброс не выполнялся")
|
||||
}
|
||||
if util.VerifyPassword("recovery-password-2", *after.PasswordHash) {
|
||||
t.Error("напечатанный при отказе пароль действует")
|
||||
}
|
||||
}
|
||||
|
||||
// «Записи нет» по-прежнему означает создание: строгость к ErrStorage не имеет
|
||||
// права сломать штатный путь восстановления на пустой базе.
|
||||
func TestResetStillCreatesOnNotFoundSentinel(t *testing.T) {
|
||||
newAdminDB(t)
|
||||
|
||||
notFound := func() (entity.AdminUser, error) {
|
||||
return entity.AdminUser{}, dao.ErrAdminUserNotFound
|
||||
}
|
||||
|
||||
if err := resetAdminCredentials(notFound, "operator-1", "recovery-password-1", 1700000000000); err != nil {
|
||||
t.Fatalf("сброс на пустой базе не выполнен: %v", err)
|
||||
}
|
||||
if got := countAdmins(t); got != 1 {
|
||||
t.Fatalf("ожидалась одна учётная запись, получено %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Sentinel'ы «нет записи» намеренно НЕСУТ ОДИНАКОВЫЙ ТЕКСТ: WrongPassword
|
||||
// уезжает в ответ Hysteria при неудачной machine-auth, и менять его ради
|
||||
// внутренней аккуратности было бы изменением внешнего контракта. Поэтому
|
||||
// различать их можно только через errors.Is, и решение о ветке обязано
|
||||
// опираться на идентичность значения, а не на строку.
|
||||
func TestAdminSentinelsAreDistinguishableOnlyByIdentity(t *testing.T) {
|
||||
if dao.ErrAdminUserNotFound.Error() != dao.ErrPeerNotFound.Error() {
|
||||
t.Log("тексты sentinel'ов разошлись; сравнение по идентичности остаётся обязательным")
|
||||
}
|
||||
if errors.Is(dao.ErrAdminUserNotFound, dao.ErrPeerNotFound) {
|
||||
t.Error("sentinel'ы разных таблиц неразличимы по идентичности")
|
||||
}
|
||||
if errors.Is(dao.ErrStorage, dao.ErrAdminUserNotFound) {
|
||||
t.Error("отказ хранилища опознаётся как отсутствие записи")
|
||||
}
|
||||
if dao.IsNotFound(dao.ErrStorage) {
|
||||
t.Error("IsNotFound истинна для отказа хранилища")
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибка хеширования не имеет права превратиться в пустой password_hash.
|
||||
func TestResetRefusesWhenPasswordCannotBeHashed(t *testing.T) {
|
||||
newAdminDB(t)
|
||||
|
||||
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
|
||||
t.Fatalf("первичный сброс не выполнен: %v", err)
|
||||
}
|
||||
|
||||
// HashPassword отклоняет пароль короче шести символов.
|
||||
err := resetAdminCredentials(realLookup, "operator-2", "abc", 1700000001000)
|
||||
if err == nil {
|
||||
t.Fatal("непригодный пароль обязан останавливать сброс")
|
||||
}
|
||||
|
||||
admin, getErr := dao.GetAdminUser("1 = 1")
|
||||
if getErr != nil {
|
||||
t.Fatalf("учётная запись не читается: %v", getErr)
|
||||
}
|
||||
if !util.IsBcryptHash(*admin.PasswordHash) {
|
||||
t.Errorf("в password_hash оказалась не-bcrypt строка: %q", *admin.PasswordHash)
|
||||
}
|
||||
if !util.VerifyPassword("recovery-password-1", *admin.PasswordHash) {
|
||||
t.Error("прежний пароль перестал действовать после неудачного сброса")
|
||||
}
|
||||
}
|
||||
+98
-27
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,7 +15,11 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func resolveUiBindHost() (string, error) {
|
||||
@@ -29,31 +34,30 @@ func resolveUiBindHost() (string, error) {
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// resolveUiPort проверяет порт, полученный из контракта запуска.
|
||||
//
|
||||
// Источник истины — HY2XS_UI_PORT, который оркестратор подставляет в ExecStart
|
||||
// как `-p`. Раньше это значение по дороге записывалось в SQLite и читалось
|
||||
// оттуда обратно: круг, в котором база не добавляла ни одного факта, но делала
|
||||
// вид, что порт принадлежит ей.
|
||||
func resolveUiPort(port string) (int, error) {
|
||||
trimmed := strings.TrimSpace(port)
|
||||
if trimmed == "" {
|
||||
return 0, errors.New("UI port is required: pass -p <port>")
|
||||
}
|
||||
value, err := strconv.Atoi(trimmed)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return 0, fmt.Errorf("port: %s is invalid", port)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func runServer(port string) error {
|
||||
defer releaseResource()
|
||||
|
||||
middleware.InitLog()
|
||||
if err := initFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dao.InitSql(port); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := middleware.InitCron(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.InitHysteria2(); err != nil {
|
||||
return err
|
||||
}
|
||||
config, err := dao.GetConfig("key = ?", constant.HUIWebContext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
router.Router(r, config.Value)
|
||||
|
||||
serverPort, crtPath, keyPath, err := service.GetServerPortAndCert()
|
||||
uiPort, err := resolveUiPort(port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,20 +66,88 @@ func runServer(port string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
service.InitServer(fmt.Sprintf("%s:%d", bindHost, serverPort), r)
|
||||
if err := service.StartServer(crtPath, keyPath); err != nil && err != http.ErrServerClosed {
|
||||
if err := initFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dao.InitSql(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.InitCron(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Порядок остановки обратный порядку регистрации: планировщик глушится
|
||||
// РАНЬШЕ, чем releaseResource закроет SQLite. Каждая джоба ходит в базу,
|
||||
// и обратный порядок означал бы гарантированные «database is closed» на
|
||||
// каждом штатном завершении сервиса.
|
||||
defer service.StopCron()
|
||||
if err := service.InitHysteria2(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// gin.New(), а не gin.Default().
|
||||
//
|
||||
// gin.Default() подключает gin.Logger(), который печатает в stdout путь
|
||||
// ВМЕСТЕ с query string. Для админки это второй, независимый от logrus
|
||||
// канал журналирования: stdout юнита уезжает в journald, а diagnostics
|
||||
// кладёт `journalctl -u hy2xs-admin` в бандл. Пока он существовал,
|
||||
// machine token из /internal/hysteria/auth?access_token=… утекал в
|
||||
// журнал даже после того, как из собственного логгера админки query
|
||||
// string убрали.
|
||||
//
|
||||
// HTTP-логгер у продукта ровно один — middleware.LogHandler, и он
|
||||
// подключается пофазно в router.Router. Здесь остаётся только Recovery:
|
||||
// паника в обработчике не должна ронять сервис.
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
router.Router(r)
|
||||
|
||||
// TLS внутри админки не поддерживается намеренно: панель слушает
|
||||
// loopback и публикуется через SSH-туннель или reverse proxy, на котором
|
||||
// TLS и заканчивается.
|
||||
service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r)
|
||||
|
||||
// Штатное завершение по сигналу.
|
||||
//
|
||||
// Раньше SIGTERM от systemd просто убивал процесс: соединения обрывались
|
||||
// на середине, SQLite закрывался не через defer, а вместе с процессом, и
|
||||
// джобы планировщика могли быть остановлены посреди записи. Для сервиса,
|
||||
// который ведёт учёт трафика в транзакциях, это не абстракция — обычный
|
||||
// `systemctl restart` попадал в это окно.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- service.StartServer()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
return classifyServeError(err)
|
||||
case <-ctx.Done():
|
||||
logrus.Infof("shutdown signal received, stopping hy2xs-admin")
|
||||
if err := service.StopServer(); err != nil {
|
||||
return err
|
||||
}
|
||||
return classifyServeError(<-serverErr)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyServeError отделяет штатное закрытие сервера от настоящего отказа.
|
||||
func classifyServeError(err error) error {
|
||||
if err == nil || errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
logrus.Errorf("start server err: %v", err)
|
||||
return errors.New("start server err")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseResource() {
|
||||
if err := dao.CloseSqliteDB(); err != nil {
|
||||
logrus.Errorf(err.Error())
|
||||
logrus.Errorf("%v", err)
|
||||
}
|
||||
if err := service.ReleaseHysteria2(); err != nil {
|
||||
logrus.Errorf(err.Error())
|
||||
logrus.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +156,6 @@ func initFile() error {
|
||||
constant.LogDir,
|
||||
constant.SqliteDBDir,
|
||||
constant.BinDir,
|
||||
constant.ExportPathDir,
|
||||
filepath.Dir(constant.SqliteDBPath),
|
||||
}
|
||||
for _, item := range dirs {
|
||||
|
||||
+137
-244
@@ -1,159 +1,176 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
"hy2xs-admin/util"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
func isOrchestratorManagedConfigKey(key string) bool {
|
||||
switch key {
|
||||
case constant.HUIWebPort,
|
||||
constant.HUIWebContext,
|
||||
constant.HUICrtPath,
|
||||
constant.HUIKeyPath,
|
||||
constant.Hysteria2Enable,
|
||||
constant.Hysteria2Config,
|
||||
constant.Hysteria2TrafficStatsSecret:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// Доступ операторского API к таблице `config` — строго по allowlist.
|
||||
//
|
||||
// Что было. Проверка работала denylist'ом из трёх orchestrator-ключей, а
|
||||
// GetConfig/ListConfig принимали произвольную строку. В той же таблице лежат
|
||||
// JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и
|
||||
// HYSTERIA2_TRAFFIC_STATS_SECRET, поэтому авторизованный запрос
|
||||
// `?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов
|
||||
// пиров, а updateConfigs позволял подменить JWT_SECRET и оба peer-ключа.
|
||||
//
|
||||
// То есть опасность, ради которой удаляли generic export/import таблицы
|
||||
// `config`, никуда не делась — она осталась в точечном API.
|
||||
//
|
||||
// Список ключей ведётся в model/constant/config.go: там же, где сами ключи, а
|
||||
// не в слое HTTP.
|
||||
|
||||
func denyUnknownConfigKey(key string, allowed []string, operation string, c *gin.Context) {
|
||||
sort.Strings(allowed)
|
||||
vo.Fail(
|
||||
fmt.Sprintf(
|
||||
"config key %q is not available for %s via API (allowed: %s)",
|
||||
key, operation, strings.Join(allowed, ", "),
|
||||
),
|
||||
c,
|
||||
)
|
||||
}
|
||||
|
||||
// UpdateConfigs применяет партию настроек по принципу «всё или ничего».
|
||||
//
|
||||
// Три прохода, и каждый отвечает за своё:
|
||||
//
|
||||
// 1. проверка партии целиком — права на ключ, дубликаты, значения;
|
||||
// 2. одна транзакция базы;
|
||||
// 3. применение к рантайму.
|
||||
//
|
||||
// Раньше проходов не было вовсе: цикл проверял очередной элемент и тут же его
|
||||
// записывал. Партия «разрешённый ключ + запрещённый» применяла первый и
|
||||
// возвращала ошибку на втором — оператор получал отказ на запрос, который
|
||||
// систему уже изменил.
|
||||
//
|
||||
// Второе изменение того же места: смена расписания больше не роняет
|
||||
// HTTP-сервер. Раньше здесь стоял `go service.StopServer()`, а точка входа
|
||||
// крутила runServer в цикле, из-за чего на процессе накапливались планировщики
|
||||
// (см. service/cron_scheduler.go).
|
||||
func UpdateConfigs(c *gin.Context) {
|
||||
configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
port, crtPath, keyPath, err := service.GetPortAndCert()
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
updates := make([]service.ConfigUpdate, 0, len(configsUpdateDto.ConfigUpdateDtos))
|
||||
seen := make(map[string]struct{}, len(configsUpdateDto.ConfigUpdateDtos))
|
||||
|
||||
for _, item := range configsUpdateDto.ConfigUpdateDtos {
|
||||
key := *item.Key
|
||||
value := *item.Value
|
||||
|
||||
// Отдельное сообщение для ключей, которыми владеет оркестратор: их
|
||||
// отказ — это не «нет такого ключа», а указание на владельца.
|
||||
if isOrchestratorManagedConfigKey(key) {
|
||||
vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", key), c)
|
||||
return
|
||||
}
|
||||
|
||||
if key == constant.HUIWebPort && strconv.FormatInt(port, 10) != value {
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
vo.Fail(fmt.Sprintf("port: %s is invalid", value), c)
|
||||
if !constant.IsPublicWritableConfigKey(key) {
|
||||
denyUnknownConfigKey(key, constant.PublicWritableConfigKeys(), "write", c)
|
||||
return
|
||||
}
|
||||
if !util.IsPortAvailable(uint(port), "tcp") {
|
||||
vo.Fail(fmt.Sprintf("port: %s is used", value), c)
|
||||
return
|
||||
}
|
||||
needRestart = true
|
||||
}
|
||||
if key == constant.HUICrtPath && crtPath != value {
|
||||
if value != "" && !util.Exists(value) {
|
||||
vo.Fail(fmt.Sprintf("crt path: %s is not exist", value), c)
|
||||
return
|
||||
}
|
||||
needRestart = true
|
||||
}
|
||||
if key == constant.HUIKeyPath && keyPath != value {
|
||||
if value != "" && !util.Exists(value) {
|
||||
vo.Fail(fmt.Sprintf("key path: %s is not exist", value), c)
|
||||
return
|
||||
}
|
||||
needRestart = true
|
||||
}
|
||||
|
||||
if key == constant.HUIWebContext {
|
||||
huiWebContext, err := service.GetConfig(constant.HUIWebContext)
|
||||
if err != nil {
|
||||
// Один ключ дважды в одной партии — неоднозначный запрос: какое из
|
||||
// двух значений считать намерением оператора, определить нельзя.
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
vo.Fail(fmt.Sprintf("config key %q appears more than once in the batch", key), c)
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
if err = service.ValidateConfigValue(key, value); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
if *huiWebContext.Value != value {
|
||||
needRestart = true
|
||||
}
|
||||
|
||||
updates = append(updates, service.ConfigUpdate{Key: key, Value: value})
|
||||
}
|
||||
|
||||
if key == constant.ResetTrafficCron {
|
||||
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
|
||||
if err != nil {
|
||||
if err = service.UpdateConfigs(updates); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
if *resetTrafficCron.Value != value {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
|
||||
if err = service.UpdateConfig(key, value); err != nil {
|
||||
if err = applyRuntimeConfigUpdates(updates); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if needRestart {
|
||||
go func() {
|
||||
_ = service.StopServer()
|
||||
}()
|
||||
}
|
||||
|
||||
vo.Success(nil, c)
|
||||
}
|
||||
|
||||
func GetConfig(c *gin.Context) {
|
||||
configDto, err := validateField(c, dto.ConfigDto{})
|
||||
if err != nil {
|
||||
return
|
||||
// applyRuntimeConfigUpdates доносит уже сохранённые настройки до рантайма.
|
||||
//
|
||||
// Отказ здесь недостижим по построению: значения проверены тем же парсером,
|
||||
// которым планировщик их разбирает, а сам планировщик поднимается в runServer
|
||||
// до старта HTTP-сервера — то есть к моменту обработки запроса он всегда
|
||||
// запущен. Проверка оставлена именно поэтому: если этот инвариант когда-нибудь
|
||||
// сломают, отказ должен быть громким, а не молча пропавшей джобой.
|
||||
func applyRuntimeConfigUpdates(updates []service.ConfigUpdate) error {
|
||||
for _, item := range updates {
|
||||
if item.Key != constant.ResetTrafficCron {
|
||||
continue
|
||||
}
|
||||
config, err := service.GetConfig(*configDto.Key)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
configVo := vo.ConfigVo{
|
||||
Key: *config.Key,
|
||||
Value: *config.Value,
|
||||
}
|
||||
|
||||
running := service.Hysteria2IsRunning()
|
||||
if *config.Key == constant.Hysteria2Enable {
|
||||
if running {
|
||||
configVo.Value = "1"
|
||||
} else {
|
||||
configVo.Value = "0"
|
||||
if err := service.RescheduleResetTraffic(item.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
vo.Success(configVo, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор.
|
||||
//
|
||||
// Формально они и так не входят в allowlist, но отказ по ним обязан объяснять
|
||||
// ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого
|
||||
// ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
|
||||
//
|
||||
// HYSTERIA2_ENABLE и HYSTERIA2_CONFIG отсюда убраны вместе с самими ключами:
|
||||
// первым никто не управлял, второй был вторым источником истины рядом с
|
||||
// /etc/hysteria/config.yaml. Serverный конфиг по-прежнему принадлежит
|
||||
// оркестратору — просто теперь он живёт только в файле, а не ещё и в SQLite.
|
||||
func isOrchestratorManagedConfigKey(key string) bool {
|
||||
switch key {
|
||||
case constant.Hysteria2TrafficStatsSecret:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Маршрута GET /config/getConfig здесь больше нет.
|
||||
//
|
||||
// Он принимал произвольный ключ и был вторым, менее заметным входом в ту же
|
||||
// таблицу секретов, что и удалённый generic export. При этом ни одного
|
||||
// потребителя у него не было: панель читает настройки только через listConfig.
|
||||
// Маршрут не оставлен с фильтром, а удалён — точка входа, которой никто не
|
||||
// пользуется, не должна существовать.
|
||||
|
||||
func ListConfig(c *gin.Context) {
|
||||
configsDto, err := validateField(c, dto.ConfigsDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка идёт до обращения к базе: отказ не должен зависеть от того,
|
||||
// существует ли строка с таким ключом.
|
||||
for _, key := range configsDto.Keys {
|
||||
if !constant.IsPublicReadableConfigKey(key) {
|
||||
denyUnknownConfigKey(key, constant.PublicReadableConfigKeys(), "read", c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
configs, err := service.ListConfig(configsDto.Keys)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
@@ -179,159 +196,35 @@ func GetHysteria2Config(c *gin.Context) {
|
||||
vo.Success(config, c)
|
||||
}
|
||||
|
||||
func UpdateHysteria2Config(c *gin.Context) {
|
||||
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
|
||||
}
|
||||
|
||||
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
|
||||
//
|
||||
// Экспорт работает от исходного YAML, а не от типизированной модели: поля,
|
||||
// о которых HY2XS ещё не знает, обязаны пережить выгрузку. Секреты при этом
|
||||
// вырезаются — файл покидает сервер.
|
||||
func ExportHysteria2Config(c *gin.Context) {
|
||||
hysteria2ServerConfig, err := service.GetHysteria2Config()
|
||||
sanitized, err := service.ExportHysteria2ConfigYaml()
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
// Значения по умолчанию
|
||||
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
var hUIWebPort string
|
||||
var trafficStatsSecret string
|
||||
for _, item := range config {
|
||||
if *item.Key == constant.HUIWebPort {
|
||||
hUIWebPort = *item.Value
|
||||
} else if *item.Key == constant.Hysteria2TrafficStatsSecret {
|
||||
trafficStatsSecret = *item.Value
|
||||
}
|
||||
}
|
||||
|
||||
if hUIWebPort == "" || trafficStatsSecret == "" {
|
||||
logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
|
||||
vo.Fail(constant.SysError, c)
|
||||
return
|
||||
}
|
||||
|
||||
authHttpUrl, err := service.GetAuthHttpUrl()
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
authType := "http"
|
||||
authHttpInsecure := true
|
||||
var auth bo.ServerConfigAuth
|
||||
auth.Type = &authType
|
||||
var http bo.ServerConfigAuthHTTP
|
||||
http.URL = &authHttpUrl
|
||||
http.Insecure = &authHttpInsecure
|
||||
auth.HTTP = &http
|
||||
hysteria2ServerConfig.Auth = &auth
|
||||
if hysteria2ServerConfig.TrafficStats == nil {
|
||||
hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{}
|
||||
}
|
||||
hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
|
||||
|
||||
fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405"))
|
||||
filePath := filepath.Join(constant.ExportPathDir, fileName)
|
||||
|
||||
if err = util.ExportFile(filePath, hysteria2ServerConfig, 1); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.Exists(filePath) {
|
||||
vo.Fail("file not exist", c)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Transfer-Encoding", "binary")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||||
c.File(filePath)
|
||||
c.Data(200, "application/octet-stream", sanitized)
|
||||
}
|
||||
|
||||
func ImportHysteria2Config(c *gin.Context) {
|
||||
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
|
||||
}
|
||||
|
||||
func ExportConfig(c *gin.Context) {
|
||||
configs, err := service.ListConfigNotIn([]string{constant.Hysteria2Config})
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
fileName := fmt.Sprintf("SystemConfig-%s.json", time.Now().Format("20060102150405"))
|
||||
filePath := filepath.Join(constant.ExportPathDir, fileName)
|
||||
|
||||
if err = util.ExportFile(filePath, configs, 0); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
if !util.Exists(filePath) {
|
||||
vo.Fail("file not exist", c)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Transfer-Encoding", "binary")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||||
c.File(filePath)
|
||||
}
|
||||
|
||||
func ImportConfig(c *gin.Context) {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
vo.Fail(constant.SysError, c)
|
||||
return
|
||||
}
|
||||
if header.Size > 1024*1024*2 {
|
||||
vo.Fail("the file is too big", c)
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(header.Filename, ".json") {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
vo.Fail("json file read err", c)
|
||||
return
|
||||
}
|
||||
var configs []entity.Config
|
||||
if err = json.Unmarshal(content, &configs); err != nil {
|
||||
vo.Fail("content Unmarshal err", c)
|
||||
return
|
||||
}
|
||||
for _, cfg := range configs {
|
||||
if cfg.Key != nil && isOrchestratorManagedConfigKey(*cfg.Key) {
|
||||
vo.Fail(fmt.Sprintf("%s managed by orchestrator: use hy2xs-orchestrator reconfigure", *cfg.Key), c)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = service.UpsertConfig(configs); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_ = service.StopServer()
|
||||
}()
|
||||
vo.Success(nil, c)
|
||||
}
|
||||
|
||||
func Hysteria2AcmePath(c *gin.Context) {
|
||||
hysteria2AcmePathVo, err := service.Hysteria2AcmePath()
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(hysteria2AcmePathVo, c)
|
||||
}
|
||||
|
||||
func RestartServer(c *gin.Context) {
|
||||
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
|
||||
}
|
||||
|
||||
func UploadCertFile(c *gin.Context) {
|
||||
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
|
||||
}
|
||||
// Generic-выгрузки и загрузки таблицы `config` здесь нет намеренно.
|
||||
//
|
||||
// Она отдавала таблицу целиком, исключая только сырой Hysteria YAML, а в той
|
||||
// же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и
|
||||
// HYSTERIA2_TRAFFIC_STATS_SECRET. Кнопка «Export» в панели выгружала их в
|
||||
// открытом виде, а зеркальный импорт позволял их подменить — включая ключ
|
||||
// шифрования, без которого перестают расшифровываться секреты уже
|
||||
// существующих пиров.
|
||||
//
|
||||
// Осмысленного production-сценария у этой пары не было: конфигурацией сервера
|
||||
// владеет install-оркестратор, перенос пиров делают ImportPeer/ExportPeer, а
|
||||
// серверный конфиг Hysteria выгружается отдельным санитизирующим маршрутом.
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Секреты, которые лежат в таблице `config` рядом с пользовательскими
|
||||
// настройками. Каждый из них раньше можно было прочитать через
|
||||
// GET /api/config/getConfig?key=… и подменить через updateConfigs.
|
||||
var secretConfigKeys = []string{
|
||||
constant.JwtSecret,
|
||||
constant.PeerSecretKey,
|
||||
constant.PeerSecretEncryptionKey,
|
||||
constant.Hysteria2TrafficStatsSecret,
|
||||
}
|
||||
|
||||
type apiResult struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, handler gin.HandlerFunc, path string, body any) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.POST(path, handler)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Отказ обязан наступать ДО обращения к базе: тест выполняется без
|
||||
// инициализированного SQLite, и любой поход в dao здесь уронил бы обработчик.
|
||||
// Это и есть доказательство того, что проверка стоит на входе.
|
||||
func TestListConfigRefusesInternalKeys(t *testing.T) {
|
||||
for _, key := range secretConfigKeys {
|
||||
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{"keys": []string{key}})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Errorf("чтение %s не отклонено: %+v", key, result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "not available for read") {
|
||||
t.Errorf("невнятный отказ для %s: %s", key, result.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос.
|
||||
func TestListConfigRefusesMixedBatch(t *testing.T) {
|
||||
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{
|
||||
"keys": []string{constant.ResetTrafficCron, constant.PeerSecretEncryptionKey},
|
||||
})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("смешанная партия не отклонена: %+v", result)
|
||||
}
|
||||
if len(result.Data) != 0 && string(result.Data) != "null" {
|
||||
t.Fatalf("отказ вернул данные: %s", string(result.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConfigRefusesUnknownKey(t *testing.T) {
|
||||
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{
|
||||
"keys": []string{"TOTALLY_UNKNOWN_KEY"},
|
||||
})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("неизвестный ключ не отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigsRefusesInternalKeys(t *testing.T) {
|
||||
for _, key := range secretConfigKeys {
|
||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||
"configUpdateDtos": []map[string]string{{"key": key, "value": "attacker-controlled"}},
|
||||
})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Errorf("запись %s не отклонена: %+v", key, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ключи оркестратора отклоняются с указанием владельца, а не общим «нет
|
||||
// такого ключа»: оператор должен понять, что менять их надо через reconfigure.
|
||||
func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
constant.Hysteria2TrafficStatsSecret,
|
||||
} {
|
||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||
"configUpdateDtos": []map[string]string{{"key": key, "value": "x"}},
|
||||
})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Errorf("запись %s не отклонена: %+v", key, result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "hy2xs-orchestrator reconfigure") {
|
||||
t.Errorf("отказ по %s не называет владельца: %s", key, result.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Удалённые ключи обязаны отклоняться как неизвестные, а не приниматься.
|
||||
func TestUpdateConfigsRefusesRemovedKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"HYSTERIA2_ENABLE",
|
||||
"HYSTERIA2_CONFIG",
|
||||
"HYSTERIA2_TRAFFIC_TIME",
|
||||
"HYSTERIA2_CONFIG_REMARK",
|
||||
} {
|
||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||
"configUpdateDtos": []map[string]string{{"key": key, "value": "x"}},
|
||||
})
|
||||
if result.Type != "no" {
|
||||
t.Errorf("удалённый ключ %s принят на запись: %+v", key, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigsRefusesUnknownKey(t *testing.T) {
|
||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||
"configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}},
|
||||
})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("неизвестный ключ не отклонён: %+v", result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "not available for write") {
|
||||
t.Fatalf("невнятный отказ: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// newConfigTestDB поднимает настоящую SQLite: проверка «партия не применилась
|
||||
// частично» без базы бессмысленна — именно состояние базы и есть предмет
|
||||
// утверждения. Схема поднимается отдельно от InitSql: bootstrap-администратор
|
||||
// и bootstrap-пир здесь не нужны.
|
||||
func newConfigTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := dao.RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = dao.CloseSqliteDB()
|
||||
})
|
||||
}
|
||||
|
||||
func storedConfigValue(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
|
||||
config, err := dao.GetConfig("key = ?", key)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать %s: %v", key, err)
|
||||
}
|
||||
if config.Value == nil {
|
||||
return ""
|
||||
}
|
||||
return *config.Value
|
||||
}
|
||||
|
||||
// Отказ на любой записи партии обязан отменить партию ЦЕЛИКОМ.
|
||||
//
|
||||
// Разрешённый ключ стоит ПЕРВЫМ намеренно. Предыдущая версия этого теста
|
||||
// ставила запрещённый ключ первым, поэтому проходила и на реализации, которая
|
||||
// проверяла и записывала настройки в одном цикле: до второго элемента дело не
|
||||
// доходило. Поймать частичное применение такой порядок неспособен по
|
||||
// построению, и состояние базы тест тоже не смотрел.
|
||||
func TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden(t *testing.T) {
|
||||
newConfigTestDB(t)
|
||||
|
||||
before := storedConfigValue(t, constant.ResetTrafficCron)
|
||||
|
||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||
"configUpdateDtos": []map[string]string{
|
||||
{"key": constant.ResetTrafficCron, "value": "@weekly"},
|
||||
{"key": constant.JwtSecret, "value": "attacker-controlled"},
|
||||
},
|
||||
})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("партия с секретом не отклонена: %+v", result)
|
||||
}
|
||||
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
|
||||
t.Fatalf("отклонённая партия изменила %s: было %q, стало %q", constant.ResetTrafficCron, before, after)
|
||||
}
|
||||
if secret := storedConfigValue(t, constant.JwtSecret); secret == "attacker-controlled" {
|
||||
t.Fatal("подписывающий ключ перезаписан отклонённой партией")
|
||||
}
|
||||
}
|
||||
|
||||
// Успешная партия обязана и сохраниться, и доехать до рантайма — БЕЗ
|
||||
// перезапуска HTTP-сервера.
|
||||
//
|
||||
// Раньше новое расписание применялось единственным способом: обработчик
|
||||
// вызывал StopServer(), точка входа поднимала runServer заново, и новый
|
||||
// планировщик читал настройку из базы. Тест закрепляет обе половины замены:
|
||||
// значение в базе изменилось, джоба перепланирована, число записей
|
||||
// планировщика не выросло.
|
||||
func TestUpdateConfigsAppliesScheduleWithoutRestart(t *testing.T) {
|
||||
newConfigTestDB(t)
|
||||
|
||||
if err := service.InitCron(); err != nil {
|
||||
t.Fatalf("InitCron: %v", err)
|
||||
}
|
||||
t.Cleanup(service.StopCron)
|
||||
|
||||
before := service.CronEntryCount()
|
||||
|
||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||
"configUpdateDtos": []map[string]string{
|
||||
{"key": constant.ResetTrafficCron, "value": "@weekly"},
|
||||
},
|
||||
})
|
||||
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("корректная партия отклонена: %+v", result)
|
||||
}
|
||||
if stored := storedConfigValue(t, constant.ResetTrafficCron); stored != "@weekly" {
|
||||
t.Fatalf("значение не сохранено: %q", stored)
|
||||
}
|
||||
if !service.ResetTrafficScheduled() {
|
||||
t.Fatal("новое расписание не применилось к планировщику")
|
||||
}
|
||||
if after := service.CronEntryCount(); after != before {
|
||||
t.Fatalf("применение расписания добавило джобы: было %d, стало %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// Невалидное значение в партии обязано отменить её так же, как запрещённый
|
||||
// ключ: разница между «нельзя писать сюда» и «нельзя писать это» касается
|
||||
// только текста ошибки, но не последствий для базы.
|
||||
func TestUpdateConfigsRefusesWholeBatchOnInvalidValue(t *testing.T) {
|
||||
newConfigTestDB(t)
|
||||
|
||||
before := storedConfigValue(t, constant.ResetTrafficCron)
|
||||
|
||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||
"configUpdateDtos": []map[string]string{
|
||||
{"key": constant.ResetTrafficCron, "value": "какая-нибудь ерунда"},
|
||||
},
|
||||
})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("невалидное cron-выражение принято: %+v", result)
|
||||
}
|
||||
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
|
||||
t.Fatalf("невалидное значение попало в базу: было %q, стало %q", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// Один ключ дважды в партии — неоднозначный запрос: какое из двух значений
|
||||
// считать намерением оператора, определить нельзя.
|
||||
func TestUpdateConfigsRefusesDuplicateKeys(t *testing.T) {
|
||||
newConfigTestDB(t)
|
||||
|
||||
before := storedConfigValue(t, constant.ResetTrafficCron)
|
||||
|
||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||
"configUpdateDtos": []map[string]string{
|
||||
{"key": constant.ResetTrafficCron, "value": "@weekly"},
|
||||
{"key": constant.ResetTrafficCron, "value": "@monthly"},
|
||||
},
|
||||
})
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("партия с дубликатом ключа принята: %+v", result)
|
||||
}
|
||||
if after := storedConfigValue(t, constant.ResetTrafficCron); after != before {
|
||||
t.Fatalf("партия с дубликатом изменила базу: было %q, стало %q", before, after)
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,3 @@ func Hysteria2Auth(c *gin.Context) {
|
||||
}
|
||||
vo.Hysteria2AuthSuccess(username, c)
|
||||
}
|
||||
|
||||
func Hysteria2ChangeVersion(c *gin.Context) {
|
||||
vo.Fail("Смена версии Hysteria2 отключена: runtime управляется install-оркестратором HY2XS", c)
|
||||
}
|
||||
|
||||
func ListRelease(c *gin.Context) {
|
||||
vo.Success([]string{}, c)
|
||||
}
|
||||
|
||||
+31
-10
@@ -43,6 +43,14 @@ func LogSystem(c *gin.Context) {
|
||||
vo.Fail("Unable to unmarshal log data", c)
|
||||
continue
|
||||
}
|
||||
// Собственный журнал санитизируется так же, как чужой.
|
||||
//
|
||||
// Раньше через SanitizeLogText проходил только журнал Hysteria: он
|
||||
// «чужой», а свой мы якобы контролируем. Контроль этот держался на
|
||||
// внимательности — ровно до logrus.Warnf с паролем администратора в
|
||||
// dao/sqlite.go. Санитайз здесь стоит не вместо аккуратности в местах
|
||||
// записи, а на случай следующего такого места.
|
||||
logSystemVo.Msg = service.SanitizeLogText(logSystemVo.Msg)
|
||||
logSystemVos = append(logSystemVos, logSystemVo)
|
||||
}
|
||||
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{
|
||||
@@ -80,10 +88,26 @@ func ExportLog(c *gin.Context) {
|
||||
}
|
||||
|
||||
var fileName string
|
||||
var filePath string
|
||||
if *logExportDto.Option == 0 {
|
||||
fileName = fmt.Sprintf("hy2xs-admin-%s.log", time.Now().Format("20060102150405"))
|
||||
filePath = constant.SystemLogPath
|
||||
|
||||
// Журнал админки отдаётся санитизированным, а не файлом «как есть».
|
||||
//
|
||||
// Раньше здесь стоял c.File(constant.SystemLogPath): оператор скачивал
|
||||
// сырой /var/log/hy2xs/hy2xs-admin.log. Журнал Hysteria при этом
|
||||
// проходил через SanitizeLogText — то есть чужому журналу продукт не
|
||||
// доверял, а своему доверял. Асимметрия ничем не обоснована: файл в
|
||||
// обоих случаях покидает сервер и пересылается в переписке.
|
||||
output, exportErr := service.ExportAdminLog()
|
||||
if exportErr != nil {
|
||||
vo.Fail("log file not exist", c)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Content-Transfer-Encoding", "binary")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||||
c.Data(200, "text/plain; charset=utf-8", []byte(output))
|
||||
return
|
||||
} else if *logExportDto.Option == 1 {
|
||||
fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405"))
|
||||
output, exportErr := service.ExportHysteriaJournalLogs(5000)
|
||||
@@ -98,12 +122,9 @@ func ExportLog(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !util.Exists(filePath) {
|
||||
vo.Fail("log file not exist", c)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Transfer-Encoding", "binary")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||||
c.File(filePath)
|
||||
// Обе ветки выше завершаются return. Сюда попадает только неизвестное
|
||||
// значение option, и это отказ, а не отдача пустого файла: раньше здесь
|
||||
// оставался путь с пустым filePath, который сводился к тому же сообщению
|
||||
// окольной дорогой.
|
||||
vo.Fail("unsupported log export option", c)
|
||||
}
|
||||
|
||||
+68
-17
@@ -1,21 +1,21 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
func resolveID(c *gin.Context) (int64, error) {
|
||||
@@ -93,10 +93,17 @@ func UpdatePeer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
peerUpdateDto.Id = &id
|
||||
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) {
|
||||
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" {
|
||||
taken, nameErr := service.ExistPeerName(*peerUpdateDto.Name, id)
|
||||
if nameErr != nil {
|
||||
vo.Fail(nameErr.Error(), c)
|
||||
return
|
||||
}
|
||||
if taken {
|
||||
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
@@ -129,30 +136,68 @@ func GetPeer(c *gin.Context) {
|
||||
vo.Success(peer, c)
|
||||
}
|
||||
|
||||
// peerImportMaxBytes — предел размера загружаемого файла.
|
||||
const peerImportMaxBytes = 2 * 1024 * 1024
|
||||
|
||||
// ImportPeer принимает выгрузку, сделанную ExportPeer.
|
||||
//
|
||||
// Импорт — полноценная дверь в таблицу пиров, поэтому его вход проверяется
|
||||
// так же строго, как обычное создание пира: разбор JSON ограничен по размеру,
|
||||
// неизвестные поля отклоняются, а содержимое записей валидируется в
|
||||
// service.ValidatePeerImportBatch до первой записи в базу.
|
||||
func ImportPeer(c *gin.Context) {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
vo.Fail(constant.SysError, c)
|
||||
return
|
||||
}
|
||||
if header.Size > 1024*1024*2 {
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
if header.Size > peerImportMaxBytes {
|
||||
vo.Fail("the file is too big", c)
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(header.Filename, ".json") {
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(file)
|
||||
|
||||
// Заявленный Size — это то, что сказал клиент; читаем с собственным
|
||||
// пределом, чтобы расхождение не превращалось в чтение произвольного
|
||||
// объёма в память.
|
||||
content, err := io.ReadAll(io.LimitReader(file, peerImportMaxBytes+1))
|
||||
if err != nil {
|
||||
vo.Fail("json file read err", c)
|
||||
return
|
||||
}
|
||||
if len(content) > peerImportMaxBytes {
|
||||
vo.Fail("the file is too big", c)
|
||||
return
|
||||
}
|
||||
|
||||
var peerExports []bo.PeerExport
|
||||
if err = json.Unmarshal(content, &peerExports); err != nil {
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
// Опечатка в имени поля должна быть видимой ошибкой, а не молча
|
||||
// импортированным значением по умолчанию.
|
||||
decoder.DisallowUnknownFields()
|
||||
if err = decoder.Decode(&peerExports); err != nil {
|
||||
vo.Fail("content Unmarshal err", c)
|
||||
return
|
||||
}
|
||||
|
||||
// json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл вида
|
||||
//
|
||||
// [{"name":"peer"}]
|
||||
// {"что-то":"ещё"}
|
||||
//
|
||||
// принимался целиком, а хвост просто не читался: оператор видел «импорт
|
||||
// выполнен» и не узнавал, что применилась половина файла. Файл обязан
|
||||
// содержать ровно один JSON-документ.
|
||||
if err = decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
vo.Fail("json file must contain exactly one JSON document", c)
|
||||
return
|
||||
}
|
||||
|
||||
if err = service.UpsertPeerExport(peerExports); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
@@ -160,6 +205,13 @@ func ImportPeer(c *gin.Context) {
|
||||
vo.Success(nil, c)
|
||||
}
|
||||
|
||||
// ExportPeer отдаёт выгрузку пиров прямо в ответ, не создавая файл на сервере.
|
||||
//
|
||||
// Раньше выгрузка шла через os.Create в /var/lib/hy2xs-admin/export и файл
|
||||
// оставался там навсегда. При includeSecrets=true это означало расшифрованные
|
||||
// секреты пиров — фактические учётные данные доступа — в открытом виде на
|
||||
// диске, накапливающиеся с каждым нажатием кнопки. Артефакт, который покидает
|
||||
// сервер, не должен существовать на сервере дольше самого запроса.
|
||||
func ExportPeer(c *gin.Context) {
|
||||
includeSecrets := strings.EqualFold(strings.TrimSpace(c.Query("includeSecrets")), "true")
|
||||
peerExports, err := service.ListExportPeer(includeSecrets)
|
||||
@@ -167,20 +219,19 @@ func ExportPeer(c *gin.Context) {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(peerExports, "", " ")
|
||||
if err != nil {
|
||||
logrus.Errorf("peer export marshal err: %v", err)
|
||||
vo.Fail(constant.SysError, c)
|
||||
return
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("PeerExport-%s.json", time.Now().Format("20060102150405"))
|
||||
filePath := filepath.Join(constant.ExportPathDir, fileName)
|
||||
if err = util.ExportFile(filePath, peerExports, 0); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
if !util.Exists(filePath) {
|
||||
vo.Fail("file not exist", c)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Transfer-Encoding", "binary")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||||
c.File(filePath)
|
||||
c.Data(200, "application/octet-stream", payload)
|
||||
}
|
||||
|
||||
func ReleaseKickPeer(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
)
|
||||
|
||||
// Разбор загруженного файла проверяется без базы: все три отказа наступают до
|
||||
// первого обращения к слою данных, и это часть контракта — испорченный файл не
|
||||
// должен доходить до транзакции.
|
||||
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать форму: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("не удалось записать файл в форму: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("не удалось закрыть форму: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.POST("/peer-import", ImportPeer)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/peer-import", body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
|
||||
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
||||
// половина файла молча не применялась.
|
||||
func TestImportPeerRejectsTrailingDocument(t *testing.T) {
|
||||
result := postImportFile(t, "peers.json", `[{"name":"alpha1"}]
|
||||
{"name":"ignored"}`)
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("файл с хвостовым документом не отклонён: %+v", result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "exactly one JSON document") {
|
||||
t.Fatalf("невнятный отказ: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPeerRejectsTrailingGarbage(t *testing.T) {
|
||||
for _, content := range []string{
|
||||
"[{\"name\":\"alpha1\"}] trailing",
|
||||
"[{\"name\":\"alpha1\"}][{\"name\":\"bravo2\"}]",
|
||||
"[{\"name\":\"alpha1\"}]\n[]",
|
||||
} {
|
||||
result := postImportFile(t, "peers.json", content)
|
||||
if result.Type != "no" {
|
||||
t.Errorf("файл %q не отклонён: %+v", content, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPeerRejectsUnknownFields(t *testing.T) {
|
||||
result := postImportFile(t, "peers.json", `[{"name":"alpha1","totallyUnknown":1}]`)
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("неизвестное поле не отклонено: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPeerRejectsNonJsonFileName(t *testing.T) {
|
||||
result := postImportFile(t, "peers.txt", `[{"name":"alpha1"}]`)
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("файл не с расширением .json не отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Положительный путь целиком: корректный одиночный документ обязан не только
|
||||
// разобраться, но и доехать до базы. Без него проверки выше доказывали бы
|
||||
// только то, что отклоняется всё подряд.
|
||||
func TestImportPeerAcceptsSingleDocument(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := dao.RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||
|
||||
result := postImportFile(t, "peers.json", `[{"name":"alpha1","quotaBytes":-1,"maxDevices":3}]`)
|
||||
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("корректный файл отклонён: %+v", result)
|
||||
}
|
||||
|
||||
peers, err := dao.ListPeer("name = ?", "alpha1")
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать пиров: %v", err)
|
||||
}
|
||||
if len(peers) != 1 {
|
||||
t.Fatalf("импорт не создал пира: найдено %d", len(peers))
|
||||
}
|
||||
}
|
||||
+21
-6
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"time"
|
||||
)
|
||||
@@ -12,19 +11,35 @@ import (
|
||||
func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, error) {
|
||||
var admin entity.AdminUser
|
||||
if tx := sqliteDB.Model(&entity.AdminUser{}).Where(query, args...).First(&admin); tx.Error != nil {
|
||||
if tx.Error == gorm.ErrRecordNotFound {
|
||||
return admin, errors.New(constant.WrongPassword)
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
return admin, ErrAdminUserNotFound
|
||||
}
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return admin, errors.New(constant.SysError)
|
||||
return admin, ErrStorage
|
||||
}
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
// CountAdminUsers — сколько учётных записей администратора существует.
|
||||
//
|
||||
// Продукт допускает ровно одну, и это ЕДИНСТВЕННОЕ место, где такой вопрос
|
||||
// можно задать: `GetAdminUser` берёт First() и о наличии второй строки не
|
||||
// сообщает. Именно поэтому появление дубликата (ветка создания, выбранная при
|
||||
// отказе чтения) было ненаблюдаемым — вторая рабочая учётка с уже напечатанным
|
||||
// на экран паролем просто существовала, и никакой запрос об этом не говорил.
|
||||
func CountAdminUsers() (int64, error) {
|
||||
var count int64
|
||||
if tx := sqliteDB.Model(&entity.AdminUser{}).Count(&count); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return 0, ErrStorage
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func SaveAdminUser(admin entity.AdminUser) (int64, error) {
|
||||
if tx := sqliteDB.Save(&admin); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return 0, errors.New(constant.SysError)
|
||||
return 0, ErrStorage
|
||||
}
|
||||
return *admin.Id, nil
|
||||
}
|
||||
@@ -36,7 +51,7 @@ func UpdateAdminUser(ids []int64, updates map[string]interface{}) error {
|
||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||
if tx := sqliteDB.Model(&entity.AdminUser{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
testAdminPassword = "bootstrap-admin-password"
|
||||
testBootstrapSecret = "bootstrap-peer-secret-value"
|
||||
testTrafficStatsToken = "traffic-stats-machine-token"
|
||||
)
|
||||
|
||||
// startService воспроизводит запуск сервиса на указанной базе.
|
||||
//
|
||||
// Именно последовательность целиком, а не отдельная функция: дефект, ради
|
||||
// которого написаны эти тесты, проявлялся только на ВТОРОМ запуске.
|
||||
func startService(t *testing.T, dbPath string) error {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||
|
||||
err := InitSqlAt(dbPath)
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
return err
|
||||
}
|
||||
|
||||
func newInstalledService(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := startService(t, dbPath); err != nil {
|
||||
t.Fatalf("первый запуск сервиса не удался: %v", err)
|
||||
}
|
||||
return dbPath
|
||||
}
|
||||
|
||||
func testPeer(name string, authID string, digest string) entity.Peer {
|
||||
remark := ""
|
||||
cipher := ""
|
||||
quota := int64(-1)
|
||||
zero := int64(0)
|
||||
maxDevices := int64(3)
|
||||
|
||||
return entity.Peer{
|
||||
Name: &name,
|
||||
Remark: &remark,
|
||||
AuthId: &authID,
|
||||
SecretDigest: &digest,
|
||||
SecretEncrypted: &cipher,
|
||||
QuotaBytes: "a,
|
||||
DownloadBytes: &zero,
|
||||
UploadBytes: &zero,
|
||||
ExpiresAt: &zero,
|
||||
MaxDevices: &maxDevices,
|
||||
Disabled: &zero,
|
||||
BannedUntil: &zero,
|
||||
LastConnectionAt: &zero,
|
||||
}
|
||||
}
|
||||
|
||||
func bootstrapPeerExists(t *testing.T) bool {
|
||||
t.Helper()
|
||||
|
||||
_, err := GetPeer("name = ?", BootstrapPeerName)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if !IsNotFound(err) {
|
||||
t.Fatalf("неожиданная ошибка при чтении пира установщика: %v", err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Установка создаёт пира установщика и отмечает это в базе.
|
||||
func TestBootstrapPeerIsSeededOnFirstStart(t *testing.T) {
|
||||
newInstalledService(t)
|
||||
|
||||
if !bootstrapPeerExists(t) {
|
||||
t.Fatal("пир установщика не создан при первом запуске")
|
||||
}
|
||||
|
||||
seeded, err := bootstrapPeerSeeded()
|
||||
if err != nil {
|
||||
t.Fatalf("чтение отметки: %v", err)
|
||||
}
|
||||
if !seeded {
|
||||
t.Fatal("отметка о создании пира установщика не выставлена")
|
||||
}
|
||||
}
|
||||
|
||||
// Обычный перезапуск сервиса ничего не меняет: пир на месте, дублей нет.
|
||||
func TestBootstrapPeerSurvivesRestartUntouched(t *testing.T) {
|
||||
dbPath := newInstalledService(t)
|
||||
before, err := GetPeer("name = ?", BootstrapPeerName)
|
||||
if err != nil {
|
||||
t.Fatalf("пир установщика не найден после установки: %v", err)
|
||||
}
|
||||
_ = CloseSqliteDB()
|
||||
|
||||
if err := startService(t, dbPath); err != nil {
|
||||
t.Fatalf("перезапуск сервиса не удался: %v", err)
|
||||
}
|
||||
|
||||
after, err := GetPeer("name = ?", BootstrapPeerName)
|
||||
if err != nil {
|
||||
t.Fatalf("пир установщика пропал после перезапуска: %v", err)
|
||||
}
|
||||
if *after.Id != *before.Id {
|
||||
t.Fatalf("пир установщика пересоздан: id %d -> %d", *before.Id, *after.Id)
|
||||
}
|
||||
|
||||
peers, err := ListPeer("name = ?", BootstrapPeerName)
|
||||
if err != nil {
|
||||
t.Fatalf("список пиров: %v", err)
|
||||
}
|
||||
if len(peers) != 1 {
|
||||
t.Fatalf("после перезапуска пиров установщика %d, ожидался ровно один", len(peers))
|
||||
}
|
||||
}
|
||||
|
||||
// Главный инвариант: удаление пира установщика — необратимый отзыв доступа.
|
||||
//
|
||||
// Раньше HY2XS_ADMIN_CON_PASS оставался в /etc/hy2xs/hy2xs.env, и признаком
|
||||
// «создавать или нет» служило наличие строки в таблице. Поэтому
|
||||
// `systemctl restart hy2xs-admin` после удаления возвращал того же пира с тем
|
||||
// же секретом, молча, и отозванные учётные данные снова становились
|
||||
// действующими.
|
||||
func TestDeletedBootstrapPeerIsNotRecreatedOnRestart(t *testing.T) {
|
||||
dbPath := newInstalledService(t)
|
||||
|
||||
peer, err := GetPeer("name = ?", BootstrapPeerName)
|
||||
if err != nil {
|
||||
t.Fatalf("пир установщика не найден после установки: %v", err)
|
||||
}
|
||||
if err := DeletePeer([]int64{*peer.Id}); err != nil {
|
||||
t.Fatalf("удаление пира установщика отклонено: %v", err)
|
||||
}
|
||||
_ = CloseSqliteDB()
|
||||
|
||||
// Переменная окружения на месте — ровно то состояние, в котором сервис и
|
||||
// живёт после установки.
|
||||
if err := startService(t, dbPath); err != nil {
|
||||
t.Fatalf("перезапуск сервиса после отзыва не удался: %v", err)
|
||||
}
|
||||
|
||||
if bootstrapPeerExists(t) {
|
||||
t.Fatal("пир установщика воскрес после перезапуска: отзыв доступа не переживает restart")
|
||||
}
|
||||
}
|
||||
|
||||
// Отзыв обязан пережить не один перезапуск, а любое их число.
|
||||
func TestDeletedBootstrapPeerStaysGoneAcrossRepeatedRestarts(t *testing.T) {
|
||||
dbPath := newInstalledService(t)
|
||||
|
||||
peer, err := GetPeer("name = ?", BootstrapPeerName)
|
||||
if err != nil {
|
||||
t.Fatalf("пир установщика не найден: %v", err)
|
||||
}
|
||||
if err := DeletePeer([]int64{*peer.Id}); err != nil {
|
||||
t.Fatalf("удаление: %v", err)
|
||||
}
|
||||
_ = CloseSqliteDB()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := startService(t, dbPath); err != nil {
|
||||
t.Fatalf("перезапуск #%d не удался: %v", i+1, err)
|
||||
}
|
||||
if bootstrapPeerExists(t) {
|
||||
t.Fatalf("пир установщика воскрес на перезапуске #%d", i+1)
|
||||
}
|
||||
_ = CloseSqliteDB()
|
||||
}
|
||||
}
|
||||
|
||||
// Отключённый пир остаётся в базе и не переподписывается сам собой.
|
||||
func TestDisabledBootstrapPeerKeepsItsStateAcrossRestart(t *testing.T) {
|
||||
dbPath := newInstalledService(t)
|
||||
|
||||
peer, err := GetPeer("name = ?", BootstrapPeerName)
|
||||
if err != nil {
|
||||
t.Fatalf("пир установщика не найден: %v", err)
|
||||
}
|
||||
if err := UpdatePeer([]int64{*peer.Id}, map[string]interface{}{"disabled": 1}); err != nil {
|
||||
t.Fatalf("отключение пира установщика: %v", err)
|
||||
}
|
||||
_ = CloseSqliteDB()
|
||||
|
||||
if err := startService(t, dbPath); err != nil {
|
||||
t.Fatalf("перезапуск: %v", err)
|
||||
}
|
||||
|
||||
after, err := GetPeer("name = ?", BootstrapPeerName)
|
||||
if err != nil {
|
||||
t.Fatalf("пир установщика пропал: %v", err)
|
||||
}
|
||||
if after.Disabled == nil || *after.Disabled != 1 {
|
||||
t.Fatal("перезапуск снял отключение с пира установщика")
|
||||
}
|
||||
if *after.SecretDigest != *peer.SecretDigest {
|
||||
t.Fatal("перезапуск переподписал пира установщика")
|
||||
}
|
||||
}
|
||||
|
||||
// Отсутствие HY2XS_ADMIN_CON_PASS на чистой базе — отказ старта, а не повод
|
||||
// придумать секрет: он продублирован в /etc/hy2xs/bootstrap-admin.secret.
|
||||
func TestFirstStartWithoutBootstrapSecretFails(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
|
||||
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||
t.Setenv("HY2XS_ADMIN_CON_PASS", "")
|
||||
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||
|
||||
err := InitSqlAt(dbPath)
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("запуск без HY2XS_ADMIN_CON_PASS не отклонён")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HY2XS_ADMIN_CON_PASS") {
|
||||
t.Errorf("отказ не называет переменную: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Перезапуск уже установленного сервиса без переменной обязан работать: пир
|
||||
// создан однажды, и требовать секрет второй раз незачем.
|
||||
func TestRestartWithoutBootstrapSecretSucceeds(t *testing.T) {
|
||||
dbPath := newInstalledService(t)
|
||||
_ = CloseSqliteDB()
|
||||
|
||||
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||
t.Setenv("HY2XS_ADMIN_CON_PASS", "")
|
||||
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||
|
||||
if err := InitSqlAt(dbPath); err != nil {
|
||||
t.Fatalf("перезапуск установленного сервиса без HY2XS_ADMIN_CON_PASS отклонён: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
|
||||
if !bootstrapPeerExists(t) {
|
||||
t.Fatal("пир установщика пропал при перезапуске без переменной")
|
||||
}
|
||||
}
|
||||
|
||||
// Отметка и сам пир пишутся неделимо: иначе падение между двумя операциями
|
||||
// вернуло бы прежнее поведение на следующем старте.
|
||||
func TestBootstrapSeedIsAtomic(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
if err := RunMigrations(); err != nil {
|
||||
t.Fatalf("миграции: %v", err)
|
||||
}
|
||||
|
||||
// Занимаем имя обычной записью: следующая вставка упрётся в UNIQUE(name)
|
||||
// уже ВНУТРИ транзакции. Отметка не имеет права остаться выставленной.
|
||||
if _, err := SavePeer(testPeer(BootstrapPeerName, "some-auth-id", "some-digest")); err != nil {
|
||||
t.Fatalf("подготовка конфликтующей записи: %v", err)
|
||||
}
|
||||
|
||||
if err := seedBootstrapPeerOnce(testPeer(BootstrapPeerName, BootstrapPeerName, "bootstrap-digest")); err == nil {
|
||||
t.Fatal("вставка с конфликтом UNIQUE(name) не отклонена")
|
||||
}
|
||||
|
||||
seeded, err := bootstrapPeerSeeded()
|
||||
if err != nil {
|
||||
t.Fatalf("чтение отметки: %v", err)
|
||||
}
|
||||
if seeded {
|
||||
t.Fatal("отметка выставлена, хотя пир не записан: транзакции нет")
|
||||
}
|
||||
}
|
||||
|
||||
// Токен trafficStats — межкомпонентный контракт: при пустом env и пустой базе
|
||||
// админка обязана отказать, а не сгенерировать значение, которого не знает
|
||||
// Hysteria.
|
||||
func TestTrafficStatsSecretIsNotInvented(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
|
||||
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "")
|
||||
|
||||
err := InitSqlAt(dbPath)
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("запуск без machine token не отклонён: админка придумала его сама")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET") {
|
||||
t.Errorf("отказ не называет переменную: %v", err)
|
||||
}
|
||||
|
||||
cfg, cfgErr := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
if cfgErr == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
|
||||
t.Fatal("в базе появился сгенерированный machine token")
|
||||
}
|
||||
}
|
||||
|
||||
// Уже согласованный токен в базе позволяет запуститься без переменной: это не
|
||||
// изобретение контракта, а использование существующего.
|
||||
func TestTrafficStatsSecretFromDatabaseIsAccepted(t *testing.T) {
|
||||
dbPath := newInstalledService(t)
|
||||
_ = CloseSqliteDB()
|
||||
|
||||
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "")
|
||||
|
||||
if err := InitSqlAt(dbPath); err != nil {
|
||||
t.Fatalf("перезапуск с токеном в базе отклонён: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
|
||||
cfg, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("токен пропал из базы: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(*cfg.Value) != testTrafficStatsToken {
|
||||
t.Fatalf("токен подменён: %q", *cfg.Value)
|
||||
}
|
||||
}
|
||||
+141
-27
@@ -2,31 +2,156 @@ package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SaveConfig(config entity.Config) (int64, error) {
|
||||
if tx := sqliteDB.Save(&config); tx.Error != nil {
|
||||
return saveConfigOn(sqliteDB, config)
|
||||
}
|
||||
|
||||
func saveConfigOn(db *gorm.DB, config entity.Config) (int64, error) {
|
||||
if tx := db.Save(&config); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return 0, errors.New(constant.SysError)
|
||||
return 0, ErrStorage
|
||||
}
|
||||
return *config.Id, nil
|
||||
}
|
||||
|
||||
func UpdateConfig(keys []string, updates map[string]interface{}) error {
|
||||
if len(updates) > 0 {
|
||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||
if tx := sqliteDB.Model(&entity.Config{}).
|
||||
Where("key in ?", keys).
|
||||
Updates(updates); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return updateConfigOn(sqliteDB, keys, updates)
|
||||
}
|
||||
|
||||
// UpsertConfigValue записывает значение ключа, создавая строку, если её нет.
|
||||
//
|
||||
// Заменяет две зеркальные самодельные схемы, которые жили в sqlite.go и обе
|
||||
// были неверны:
|
||||
//
|
||||
// 1. «сначала INSERT, при ошибке UPDATE» — работало, но на КАЖДОЙ первой
|
||||
// загрузке печатало в журнал `duplicated key not allowed` уровня error, по
|
||||
// одной строке на секрет. Оператор видел три ошибки на совершенно здоровом
|
||||
// старте и не имел способа отличить их от настоящих;
|
||||
//
|
||||
// 2. «сначала UPDATE, при ошибке INSERT» — тихо не делало НИЧЕГО, если строки
|
||||
// не было. UPDATE без совпавших строк не является ошибкой: gorm возвращает
|
||||
// nil и RowsAffected = 0, поэтому ветка INSERT не выполнялась, а вызывающий
|
||||
// получал сгенерированный секрет как успешно сохранённый. Для JWT_SECRET
|
||||
// это означало бы подпись токенов ключом, которого нет в базе. Спасала
|
||||
// только предварительная вставка пустых строк в seedBaseConfig — то есть
|
||||
// корректность держалась на порядке выполнения миграций.
|
||||
//
|
||||
// Здесь решение принимается по RowsAffected, а не по наличию ошибки.
|
||||
func UpsertConfigValue(key string, value string) error {
|
||||
if sqliteDB == nil {
|
||||
return errors.New("sqlite is not initialized")
|
||||
}
|
||||
return upsertConfigValueOn(sqliteDB, key, value)
|
||||
}
|
||||
|
||||
// upsertConfigValueOn — та же запись, но по переданному соединению.
|
||||
//
|
||||
// Нужна для операций, которые обязаны быть неделимыми вместе с записью в другую
|
||||
// таблицу: пир установщика и отметка о его создании (см. SeedBootstrapPeerOnce).
|
||||
func upsertConfigValueOn(db *gorm.DB, key string, value string) error {
|
||||
tx := db.Model(&entity.Config{}).
|
||||
Where("key = ?", key).
|
||||
Updates(map[string]interface{}{
|
||||
"value": value,
|
||||
"update_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
if tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return ErrStorage
|
||||
}
|
||||
if tx.RowsAffected > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
remark := key
|
||||
if _, err := saveConfigOn(db, entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigTx — записи в таблицу `config` в границах одной единицы работы.
|
||||
//
|
||||
// Интерфейс повторяет PeerTx и по той же причине: транзакция должна быть
|
||||
// доступна слою service, а *gorm.DB туда протекать не должен. Реализация одна
|
||||
// и та же для глобального соединения и для транзакции, поэтому «обычный» и
|
||||
// «транзакционный» пути не могут разойтись в поведении.
|
||||
type ConfigTx interface {
|
||||
UpdateConfig(keys []string, updates map[string]interface{}) error
|
||||
}
|
||||
|
||||
type configTx struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// WithConfigTx выполняет fn в одной транзакции: либо применяются все записи
|
||||
// партии, либо ни одной.
|
||||
//
|
||||
// Что было. updateConfigs валидировал и записывал настройки в одном цикле:
|
||||
// проверил элемент — записал, проверил следующий. Партия из разрешённого
|
||||
// ключа и запрещённого применяла первый и возвращала ошибку на втором, то есть
|
||||
// оператор получал отказ на запрос, который систему уже изменил. Тест на этот
|
||||
// случай существовал, но ставил запрещённый ключ ПЕРВЫМ и не смотрел в базу,
|
||||
// поэтому поймать частичное применение был неспособен по построению.
|
||||
func WithConfigTx(fn func(tx ConfigTx) error) error {
|
||||
if sqliteDB == nil {
|
||||
return errors.New("sqlite is not initialized")
|
||||
}
|
||||
return sqliteDB.Transaction(func(tx *gorm.DB) error {
|
||||
return fn(configTx{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
func (c configTx) UpdateConfig(keys []string, updates map[string]interface{}) error {
|
||||
return updateConfigOn(c.db, keys, updates)
|
||||
}
|
||||
|
||||
// updateConfigOn обновляет существующие строки и отказывает, если обновлять
|
||||
// было нечего.
|
||||
//
|
||||
// UPDATE без совпавших строк — не ошибка SQL: gorm возвращает nil и
|
||||
// RowsAffected = 0. Пока результат определялся только по tx.Error, отсутствие
|
||||
// строки выглядело успешной записью, и это не теоретическая аккуратность.
|
||||
// Путь оператора выглядит так:
|
||||
//
|
||||
// controller.UpdateConfigs → service.UpdateConfigs (транзакция)
|
||||
// → applyRuntimeConfigUpdates → RescheduleResetTraffic
|
||||
//
|
||||
// Применение к рантайму идёт ПОСЛЕ записи и полагается на её успех. При
|
||||
// отсутствующей строке RESET_TRAFFIC_CRON планировщик получал новое расписание,
|
||||
// API отвечал успехом, а база сохраняла старое значение — и ближайший
|
||||
// перезапуск сервиса молча возвращал прежний cron. Оператор при этом дважды
|
||||
// видел подтверждение изменения, которого не произошло.
|
||||
//
|
||||
// Здесь решение принимается по RowsAffected — ровно так же, как в
|
||||
// UpsertConfigValue ниже, где эта же ошибка уже была разобрана и исправлена.
|
||||
// Разница между двумя функциями одного файла и была настоящим дефектом.
|
||||
func updateConfigOn(db *gorm.DB, keys []string, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||
tx := db.Model(&entity.Config{}).
|
||||
Where("key in ?", keys).
|
||||
Updates(updates)
|
||||
if tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return ErrStorage
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return fmt.Errorf(
|
||||
"config: не найдено ни одной строки для ключей %s; настройка не сохранена",
|
||||
strings.Join(keys, ", "),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -35,11 +160,11 @@ func GetConfig(query interface{}, args ...interface{}) (entity.Config, error) {
|
||||
var config entity.Config
|
||||
if tx := sqliteDB.Model(&entity.Config{}).
|
||||
Where(query, args...).First(&config); tx.Error != nil {
|
||||
if tx.Error == gorm.ErrRecordNotFound {
|
||||
return config, errors.New(constant.ConfigNotExist)
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
return config, ErrConfigNotFound
|
||||
}
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return config, errors.New(constant.SysError)
|
||||
return config, ErrStorage
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
@@ -49,18 +174,7 @@ func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error)
|
||||
if tx := sqliteDB.Model(&entity.Config{}).
|
||||
Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return configs, errors.New(constant.SysError)
|
||||
return configs, ErrStorage
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func UpsertConfig(configs []entity.Config) error {
|
||||
if tx := sqliteDB.Model(&entity.Config{}).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value", "remark", "create_time", "update_time"}),
|
||||
}).Create(configs); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
func newConfigDB(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
}
|
||||
|
||||
// Запись в отсутствующую строку — отказ, а не тихий успех.
|
||||
//
|
||||
// UPDATE без совпавших строк не является ошибкой SQL: gorm возвращает nil и
|
||||
// RowsAffected = 0. Пока результат определялся только по tx.Error, оператор
|
||||
// получал подтверждение изменения, которого не произошло, а
|
||||
// applyRuntimeConfigUpdates следом применял новое значение к планировщику —
|
||||
// то есть рантайм и база расходились до ближайшего перезапуска сервиса.
|
||||
func TestUpdateConfigFailsWhenRowIsMissing(t *testing.T) {
|
||||
newConfigDB(t)
|
||||
|
||||
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil {
|
||||
t.Fatalf("подготовка повреждённой базы: %v", tx.Error)
|
||||
}
|
||||
|
||||
err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"})
|
||||
if err == nil {
|
||||
t.Fatal("запись в отсутствующую строку выдана за успех")
|
||||
}
|
||||
if !strings.Contains(err.Error(), constant.ResetTrafficCron) {
|
||||
t.Errorf("отказ не называет ключ: %v", err)
|
||||
}
|
||||
|
||||
// UpdateConfig обновляет существующее и не имеет права заводить ключи:
|
||||
// иначе он молча создавал бы строки, которых нет в модели продукта.
|
||||
if _, err := GetConfig("key = ?", constant.ResetTrafficCron); !errors.Is(err, ErrConfigNotFound) {
|
||||
t.Fatalf("строка появилась после отказа: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Обычная запись в существующую строку продолжает работать.
|
||||
func TestUpdateConfigWritesExistingRow(t *testing.T) {
|
||||
newConfigDB(t)
|
||||
|
||||
if err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil {
|
||||
t.Fatalf("запись существующего ключа отклонена: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := GetConfig("key = ?", constant.ResetTrafficCron)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if *cfg.Value != "@weekly" {
|
||||
t.Fatalf("значение не сохранено: %q", *cfg.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Транзакционная запись обязана вести себя так же: отказ на отсутствующей
|
||||
// строке откатывает всю партию, а не оставляет её наполовину применённой.
|
||||
func TestConfigTxRollsBackWhenRowIsMissing(t *testing.T) {
|
||||
newConfigDB(t)
|
||||
|
||||
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.JwtSecret); tx.Error != nil {
|
||||
t.Fatalf("подготовка: %v", tx.Error)
|
||||
}
|
||||
|
||||
err := WithConfigTx(func(tx ConfigTx) error {
|
||||
if err := tx.UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": "x"})
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("партия с отсутствующей строкой не отклонена")
|
||||
}
|
||||
|
||||
cfg, getErr := GetConfig("key = ?", constant.ResetTrafficCron)
|
||||
if getErr != nil {
|
||||
t.Fatalf("чтение: %v", getErr)
|
||||
}
|
||||
if *cfg.Value == "@weekly" {
|
||||
t.Fatal("первый ключ партии остался применённым после отказа на втором")
|
||||
}
|
||||
}
|
||||
|
||||
// Отсутствие записи и отказ хранилища — разные ответы.
|
||||
func TestGetConfigReportsNotFoundDistinctly(t *testing.T) {
|
||||
newConfigDB(t)
|
||||
|
||||
_, err := GetConfig("key = ?", "NO_SUCH_KEY")
|
||||
if !errors.Is(err, ErrConfigNotFound) {
|
||||
t.Fatalf("отсутствие ключа не распознано как ErrConfigNotFound: %v", err)
|
||||
}
|
||||
if errors.Is(err, ErrStorage) {
|
||||
t.Fatal("отсутствие ключа выдано за отказ хранилища")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerReportsNotFoundDistinctly(t *testing.T) {
|
||||
newConfigDB(t)
|
||||
|
||||
_, err := GetPeer("name = ?", "no-such-peer")
|
||||
if !errors.Is(err, ErrPeerNotFound) {
|
||||
t.Fatalf("отсутствие пира не распознано как ErrPeerNotFound: %v", err)
|
||||
}
|
||||
if !IsNotFound(err) {
|
||||
t.Fatal("IsNotFound не распознаёт ErrPeerNotFound")
|
||||
}
|
||||
if IsNotFound(ErrStorage) {
|
||||
t.Fatal("IsNotFound принимает отказ хранилища за отсутствие записи")
|
||||
}
|
||||
}
|
||||
|
||||
// UpsertConfigValue, в отличие от UpdateConfig, строку заводит: это её работа.
|
||||
func TestUpsertConfigValueCreatesMissingRow(t *testing.T) {
|
||||
newConfigDB(t)
|
||||
|
||||
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil {
|
||||
t.Fatalf("подготовка: %v", tx.Error)
|
||||
}
|
||||
if err := UpsertConfigValue(constant.ResetTrafficCron, "@daily"); err != nil {
|
||||
t.Fatalf("upsert отклонён: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := GetConfig("key = ?", constant.ResetTrafficCron)
|
||||
if err != nil {
|
||||
t.Fatalf("строка не создана: %v", err)
|
||||
}
|
||||
if *cfg.Value != "@daily" {
|
||||
t.Fatalf("значение не сохранено: %q", *cfg.Value)
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -1,9 +1,7 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/model/vo"
|
||||
"strings"
|
||||
@@ -13,7 +11,7 @@ import (
|
||||
func SaveMetricSample(sample entity.MetricSample) error {
|
||||
if tx := sqliteDB.Save(&sample); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -22,7 +20,7 @@ func LastMetricSample() (entity.MetricSample, error) {
|
||||
var sample entity.MetricSample
|
||||
if tx := sqliteDB.Model(&entity.MetricSample{}).Order("sampled_at desc").Limit(1).Find(&sample); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return sample, errors.New(constant.SysError)
|
||||
return sample, ErrStorage
|
||||
}
|
||||
return sample, nil
|
||||
}
|
||||
@@ -33,7 +31,7 @@ func CleanupMetricSample(olderThanMs int64) error {
|
||||
}
|
||||
if tx := sqliteDB.Exec("DELETE FROM metric_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -54,7 +52,7 @@ func DashboardPeerSummary(nowMs int64) (vo.DashboardPeerVo, error) {
|
||||
COALESCE(SUM(CASE WHEN expires_at > 0 AND expires_at < ? THEN 1 ELSE 0 END),0) AS expired
|
||||
FROM peer`, nowMs).Scan(&r); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return result, errors.New(constant.SysError)
|
||||
return result, ErrStorage
|
||||
}
|
||||
result.Total = r.Total
|
||||
result.Enabled = r.Enabled
|
||||
@@ -75,7 +73,7 @@ func DashboardTrafficSummary() (vo.DashboardTrafficVo, error) {
|
||||
COALESCE(SUM(upload_bytes),0) AS upload
|
||||
FROM peer`).Scan(&r); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return result, errors.New(constant.SysError)
|
||||
return result, ErrStorage
|
||||
}
|
||||
result.DownloadBytes = r.Download
|
||||
result.UploadBytes = r.Upload
|
||||
@@ -125,7 +123,7 @@ func DashboardTopPeers(fromMs int64, toMs int64, limit int, source string) ([]vo
|
||||
ORDER BY total DESC
|
||||
LIMIT ?`, fromMs, toMs, limit).Scan(&rows); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return rows, errors.New(constant.SysError)
|
||||
return rows, ErrStorage
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -164,7 +162,7 @@ func DashboardTrafficTimeseries(fromMs int64, toMs int64, bucketMs int64, source
|
||||
GROUP BY ts
|
||||
ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, queryFrom, toMs).Scan(&rows); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return rows, errors.New(constant.SysError)
|
||||
return rows, ErrStorage
|
||||
}
|
||||
|
||||
rowMap := make(map[int64]vo.DashboardSeriesPointVo, len(rows))
|
||||
@@ -203,7 +201,7 @@ func DashboardSystemTimeseries(fromMs int64, toMs int64, bucketMs int64) ([]vo.D
|
||||
GROUP BY ts
|
||||
ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, fromMs, toMs).Scan(&rows); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return rows, errors.New(constant.SysError)
|
||||
return rows, ErrStorage
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Отличать «строки нет» от «база не ответила» обязан слой данных.
|
||||
//
|
||||
// Что было. Каждый Get* возвращал `errors.New(constant.WrongPassword)` для
|
||||
// gorm.ErrRecordNotFound и `errors.New(constant.SysError)` для всего
|
||||
// остального. Обе — свежие значения ошибки со строкой внутри, поэтому
|
||||
// вызывающий мог отличить одно от другого только сравнением текста, чего никто
|
||||
// не делал: везде проверялось `err != nil` или `err == nil`.
|
||||
//
|
||||
// Из-за этого сбой SQLite неотличим от отсутствия записи, а решения на этом
|
||||
// принимаются серьёзные:
|
||||
//
|
||||
// ensureSecureBootstrapPeer — «пира нет» означает «создать пир заново»;
|
||||
// applyPeerImportEntry — «по auth_id не нашли» означает «искать по имени»
|
||||
// и, не найдя, СОЗДАТЬ запись;
|
||||
// ExistPeerName — «ошибка» означает «имя свободно».
|
||||
//
|
||||
// То есть при недоступной базе продукт не отказывал, а трактовал отказ как
|
||||
// разрешение действовать. Транзакция спасает целостность, но не смысл: решение
|
||||
// уже принято неверно к моменту записи.
|
||||
//
|
||||
// Значения объявлены один раз и возвращаются как есть, поэтому работает
|
||||
// errors.Is. Тексты сохранены прежними: WrongPassword уезжает в ответ Hysteria
|
||||
// при неудачной machine-auth, и менять его здесь было бы изменением внешнего
|
||||
// контракта ради внутренней аккуратности.
|
||||
var (
|
||||
// ErrPeerNotFound — пира с такими условиями в базе нет.
|
||||
ErrPeerNotFound = errors.New(constant.WrongPassword)
|
||||
|
||||
// ErrAdminUserNotFound — учётной записи администратора нет.
|
||||
ErrAdminUserNotFound = errors.New(constant.WrongPassword)
|
||||
|
||||
// ErrConfigNotFound — строки с таким ключом в таблице `config` нет.
|
||||
ErrConfigNotFound = errors.New(constant.ConfigNotExist)
|
||||
|
||||
// ErrStorage — база не смогла ответить. Не «нет данных», а «неизвестно».
|
||||
ErrStorage = errors.New(constant.SysError)
|
||||
)
|
||||
|
||||
// IsNotFound — истина только для «записи нет», и никогда для отказа хранилища.
|
||||
//
|
||||
// Помощник существует, чтобы у вызывающих был ровно один способ задать этот
|
||||
// вопрос: три разных sentinel'а для трёх таблиц иначе неизбежно проверялись бы
|
||||
// по-разному в разных местах.
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, ErrPeerNotFound) ||
|
||||
errors.Is(err, ErrAdminUserNotFound) ||
|
||||
errors.Is(err, ErrConfigNotFound)
|
||||
}
|
||||
+88
-31
@@ -5,57 +5,114 @@ import (
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/entity"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SavePeer(peer entity.Peer) (int64, error) {
|
||||
if tx := sqliteDB.Save(&peer); tx.Error != nil {
|
||||
// PeerTx — операции над пирами в границах одной единицы работы.
|
||||
//
|
||||
// Интерфейс существует, чтобы транзакция была доступна слою service, но
|
||||
// *gorm.DB туда не протекал: выбор ORM остаётся деталью dao. Реализация одна и
|
||||
// та же и для глобального соединения, и для транзакции — за счёт этого
|
||||
// невозможна ситуация, когда «обычный» путь и «транзакционный» расходятся в
|
||||
// поведении.
|
||||
type PeerTx interface {
|
||||
GetPeer(query interface{}, args ...interface{}) (entity.Peer, error)
|
||||
SavePeer(peer entity.Peer) (int64, error)
|
||||
UpdatePeer(ids []int64, updates map[string]interface{}) error
|
||||
}
|
||||
|
||||
type peerTx struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// WithPeerTx выполняет fn в одной транзакции: либо применяются все записи,
|
||||
// либо ни одной.
|
||||
//
|
||||
// Что было. Импорт пиров валидировал партию целиком до первой записи, но сами
|
||||
// записи шли по одной, каждая своим оператором. Комментарий обещал «либо
|
||||
// целиком, либо никак», а на практике UNIQUE-конфликт на 37-й записи оставлял
|
||||
// 36 применённых. Конфликт при этом совершенно реальный: если в базе есть
|
||||
// A(auth_id=a, name=alice) и B(auth_id=b, name=bob), а импорт несёт
|
||||
// (auth_id=a, name=bob), то поиск найдёт A по auth_id и попытается
|
||||
// переименовать его в bob — прямо в UNIQUE(name).
|
||||
func WithPeerTx(fn func(tx PeerTx) error) error {
|
||||
if sqliteDB == nil {
|
||||
return errors.New("sqlite is not initialized")
|
||||
}
|
||||
return sqliteDB.Transaction(func(tx *gorm.DB) error {
|
||||
return fn(peerTx{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
func (p peerTx) SavePeer(peer entity.Peer) (int64, error) {
|
||||
if tx := p.db.Save(&peer); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return 0, errors.New(constant.SysError)
|
||||
return 0, ErrStorage
|
||||
}
|
||||
return *peer.Id, nil
|
||||
}
|
||||
|
||||
func (p peerTx) UpdatePeer(ids []int64, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||
if tx := p.db.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPeer различает «пира нет» и «база не ответила» разными значениями ошибки.
|
||||
//
|
||||
// Раньше оба случая возвращались как свежие errors.New, и вызывающий не имел
|
||||
// способа их разделить иначе как сравнением текста. Разница принципиальна:
|
||||
// «нет» разрешает создать запись, «неизвестно» — не разрешает ничего.
|
||||
func (p peerTx) GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
||||
var peer entity.Peer
|
||||
if tx := p.db.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
return peer, ErrPeerNotFound
|
||||
}
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return peer, ErrStorage
|
||||
}
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
// global — операции вне транзакции. Одна и та же реализация, другое соединение.
|
||||
func global() peerTx {
|
||||
return peerTx{db: sqliteDB}
|
||||
}
|
||||
|
||||
func SavePeer(peer entity.Peer) (int64, error) {
|
||||
return global().SavePeer(peer)
|
||||
}
|
||||
|
||||
func UpdatePeer(ids []int64, updates map[string]interface{}) error {
|
||||
return global().UpdatePeer(ids, updates)
|
||||
}
|
||||
|
||||
func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
||||
return global().GetPeer(query, args...)
|
||||
}
|
||||
|
||||
func DeletePeer(ids []int64) error {
|
||||
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdatePeer(ids []int64, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||
if tx := sqliteDB.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
||||
var peer entity.Peer
|
||||
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
|
||||
if tx.Error == gorm.ErrRecordNotFound {
|
||||
return peer, errors.New(constant.WrongPassword)
|
||||
}
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return peer, errors.New(constant.SysError)
|
||||
}
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
func ListPeer(query interface{}, args ...interface{}) ([]entity.Peer, error) {
|
||||
var peers []entity.Peer
|
||||
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return peers, errors.New(constant.SysError)
|
||||
return peers, ErrStorage
|
||||
}
|
||||
return peers, nil
|
||||
}
|
||||
@@ -76,7 +133,7 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Peer, int64, error) {
|
||||
tx.Count(&total)
|
||||
if tx.Scopes(Paginate(peerPageDto.PageNum, peerPageDto.PageSize)).Order("create_time desc").Find(&peers); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return peers, 0, errors.New(constant.SysError)
|
||||
return peers, 0, ErrStorage
|
||||
}
|
||||
return peers, total, nil
|
||||
}
|
||||
|
||||
+323
-275
@@ -3,7 +3,6 @@ package dao
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
@@ -14,9 +13,6 @@ import (
|
||||
"hy2xs-admin/util"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -24,8 +20,17 @@ import (
|
||||
var sqliteDB *gorm.DB
|
||||
|
||||
func InitSqliteDB() error {
|
||||
return InitSqliteDBAt(constant.SqliteDBPath)
|
||||
}
|
||||
|
||||
// InitSqliteDBAt открывает базу по указанному пути.
|
||||
//
|
||||
// Путь параметризован, а не зашит: без этого единственным способом проверить
|
||||
// поведение слоя данных (транзакционность импорта пиров, в первую очередь)
|
||||
// было бы обращение к реальной /var/lib/hy2xs-admin/hy2xs-admin.db.
|
||||
func InitSqliteDBAt(path string) error {
|
||||
var err error
|
||||
sqliteDB, err = gorm.Open(sqlite.Open(constant.SqliteDBPath), &gorm.Config{
|
||||
sqliteDB, err = gorm.Open(sqlite.Open(path), &gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
@@ -48,19 +53,30 @@ func InitSqliteDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitSql(port string) error {
|
||||
if err := InitSqliteDB(); err != nil {
|
||||
// InitSql поднимает базу админки.
|
||||
//
|
||||
// Порт сюда больше не передаётся: раньше InitSql записывал в config тот самый
|
||||
// порт, который получил аргументом из ExecStart, а startup тут же читал его
|
||||
// обратно. База не была источником этого факта ни на одном шаге.
|
||||
func InitSql() error {
|
||||
return InitSqlAt(constant.SqliteDBPath)
|
||||
}
|
||||
|
||||
// InitSqlAt поднимает базу по указанному пути.
|
||||
//
|
||||
// Путь параметризован по той же причине, что и у InitSqliteDBAt: жизненный
|
||||
// цикл пира установщика — единственное, что доказывает необратимость отзыва
|
||||
// доступа, и проверить его можно только прогнав ПОЛНУЮ последовательность
|
||||
// запуска (миграции → seeder → повторный запуск) дважды на одной базе.
|
||||
// Отдельная проверка ensureSecureBootstrapPeer в отрыве от InitSql доказывала
|
||||
// бы поведение функции, а не поведение сервиса при перезапуске.
|
||||
func InitSqlAt(path string) error {
|
||||
if err := InitSqliteDBAt(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runMigrations(); err != nil {
|
||||
return err
|
||||
}
|
||||
if port != "" {
|
||||
if tx := sqliteDB.Exec("UPDATE config set value = ? where key = 'H_UI_WEB_PORT'", port); tx.Error != nil {
|
||||
logrus.Errorf("sqlite exec err: %v", tx.Error)
|
||||
return errors.New("sqlite exec err")
|
||||
}
|
||||
}
|
||||
if err := ensureSecureBootstrapAdmin(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -77,15 +93,6 @@ func InitSql(port string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
if raw := strings.TrimSpace(os.Getenv(name)); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBoolAsInt(name string, fallback int) int {
|
||||
raw := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
|
||||
if raw == "" {
|
||||
@@ -100,6 +107,22 @@ func envBoolAsInt(name string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ensureSecureBootstrapAdmin создаёт первую учётную запись администратора.
|
||||
//
|
||||
// Пароль приходит ТОЛЬКО из HY2XS_ADMIN_INITIAL_PASSWORD, и его отсутствие —
|
||||
// отказ старта, а не повод придумать пароль самостоятельно.
|
||||
//
|
||||
// Что было. При пустом env админка генерировала пароль сама и печатала его
|
||||
// двумя logrus.Warnf — то есть открытым текстом в /var/log/hy2xs/hy2xs-admin.log.
|
||||
// Этот же файл отдаётся оператору кнопкой выгрузки журнала и попадает в
|
||||
// diagnostics-бандл, который люди пересылают в переписке. Получался постоянный
|
||||
// канал утечки учётных данных администратора.
|
||||
//
|
||||
// Второе, менее очевидное: сгенерированный здесь пароль не знает НИКТО, кроме
|
||||
// журнала. Оркестратор пишет свой в /etc/hy2xs/bootstrap-admin.secret и в
|
||||
// hy2xs.env, и именно он считается контрактом установки. Значит, попадание
|
||||
// сюда означает не «нужно что-то придумать», а повреждённый контракт запуска —
|
||||
// и правильная реакция на него громкая, а не молчаливая.
|
||||
func ensureSecureBootstrapAdmin() error {
|
||||
adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER"))
|
||||
if adminUser == "" {
|
||||
@@ -110,14 +133,16 @@ func ensureSecureBootstrapAdmin() error {
|
||||
}
|
||||
|
||||
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
|
||||
passwordGenerated := false
|
||||
if adminPassword == "" {
|
||||
password, pwdErr := util.RandomString(18)
|
||||
if pwdErr != nil {
|
||||
return pwdErr
|
||||
}
|
||||
adminPassword = password
|
||||
passwordGenerated = true
|
||||
return errors.New(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
|
||||
"Это признак повреждённого контракта запуска: пароль первого администратора\n" +
|
||||
"задаёт install-оркестратор через /etc/hy2xs/hy2xs.env, а копию кладёт в\n" +
|
||||
"/etc/hy2xs/bootstrap-admin.secret. Придумывать его самостоятельно админка не\n" +
|
||||
"будет: такой пароль не знал бы никто, кроме журнала.\n" +
|
||||
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
)
|
||||
}
|
||||
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
|
||||
status := int64(1)
|
||||
@@ -140,28 +165,88 @@ func ensureSecureBootstrapAdmin() error {
|
||||
if _, saveErr := SaveAdminUser(account); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
if passwordGenerated {
|
||||
logrus.Warnf("Initial admin username: %s", adminUser)
|
||||
logrus.Warnf("Initial admin password: %s", adminPassword)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BootstrapPeerName — имя и authId пира, который создаёт установка.
|
||||
//
|
||||
// Объявлено здесь, а не литералом в четырёх местах: то же значение проверяет
|
||||
// service.ReservedBootstrapPeerName, и расхождение между ними означало бы, что
|
||||
// guard защищает не тот пир, который создал seeder.
|
||||
const BootstrapPeerName = "bootstrap-admin-peer"
|
||||
|
||||
// bootstrapPeerSeededValue — значение маркера constant.BootstrapPeerSeeded.
|
||||
const bootstrapPeerSeededValue = "true"
|
||||
|
||||
// ensureSecureBootstrapPeer создаёт пир установщика РОВНО ОДИН РАЗ за жизнь
|
||||
// базы.
|
||||
//
|
||||
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS: он лежит ещё и в
|
||||
// /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора для
|
||||
// проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с файлом, и
|
||||
// первая же проверка подключения после установки провалилась бы — на
|
||||
// корректном во всём остальном сервере.
|
||||
//
|
||||
// Что было и почему это была дыра в отзыве доступа.
|
||||
//
|
||||
// Признаком «создавать или нет» служило наличие строки в таблице пиров. Панель
|
||||
// при этом разрешает удалить bootstrap-admin-peer, и это осознанное решение:
|
||||
// после установки он остаётся обычным действующим доступом, а запрет удаления
|
||||
// означал бы вечный неотзываемый вход. Но HY2XS_ADMIN_CON_PASS продолжает жить
|
||||
// в /etc/hy2xs/hy2xs.env, который читает systemd-юнит, поэтому:
|
||||
//
|
||||
// оператор удаляет bootstrap-admin-peer → доступ действительно исчезает
|
||||
// systemctl restart hy2xs-admin / reboot → InitSql → ensureSecureBootstrapPeer
|
||||
// → строки нет → создать заново
|
||||
// → ТОТ ЖЕ секрет снова действует
|
||||
//
|
||||
// То есть отзыв учётных данных не переживал перезапуск, и восстановление
|
||||
// происходило молча: в журнале ничего, в списке пиров запись просто снова есть.
|
||||
// Отзыв, который не переживает restart, отзывом не является.
|
||||
//
|
||||
// Теперь решение принимается по отдельной отметке в таблице `config`. Она
|
||||
// отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не «существует сейчас»,
|
||||
// поэтому удаление становится окончательным. Отметка и сам пир пишутся одной
|
||||
// транзакцией: иначе падение между двумя операциями вернуло бы прежнее
|
||||
// поведение на следующем старте.
|
||||
//
|
||||
// Порядок проверок сохранён: сначала выясняем, нужно ли вообще создавать пир, и
|
||||
// только потом требуем env. Перезапуск установленного сервиса без переменной
|
||||
// обязан работать штатно.
|
||||
func ensureSecureBootstrapPeer() error {
|
||||
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
|
||||
if bootstrapSecret == "" {
|
||||
generated, err := util.RandomString(24)
|
||||
seeded, err := bootstrapPeerSeeded()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapSecret = generated
|
||||
if seeded {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil {
|
||||
return nil
|
||||
// Пир есть, а отметки нет. На чистой установке это недостижимо: имя
|
||||
// зарезервировано во всех путях записи панели, поэтому единственный
|
||||
// создатель такой строки — этот же seeder. Ветка закрывает базу, созданную
|
||||
// сборкой до появления отметки, и делает функцию идемпотентной, не
|
||||
// возвращая при этом прежнюю семантику: она срабатывает только при
|
||||
// отсутствующем маркере.
|
||||
switch _, err := GetPeer("name = ?", BootstrapPeerName); {
|
||||
case err == nil:
|
||||
return UpsertConfigValue(constant.BootstrapPeerSeeded, bootstrapPeerSeededValue)
|
||||
case !errors.Is(err, ErrPeerNotFound):
|
||||
// База не ответила. «Неизвестно» не даёт права создавать учётные данные.
|
||||
return err
|
||||
}
|
||||
if _, err := GetPeer("name = ?", "bootstrap-admin-peer"); err == nil {
|
||||
return nil
|
||||
|
||||
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
|
||||
if bootstrapSecret == "" {
|
||||
return errors.New(
|
||||
"HY2XS_ADMIN_CON_PASS не задан, а пир установщика bootstrap-admin-peer ещё ни разу не создавался.\n" +
|
||||
"Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" +
|
||||
"/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" +
|
||||
"Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" +
|
||||
"подключения после установки провалилась бы.\n" +
|
||||
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
)
|
||||
}
|
||||
|
||||
secretDigest, err := buildPeerSecretDigest(bootstrapSecret)
|
||||
@@ -169,8 +254,18 @@ func ensureSecureBootstrapPeer() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := GetPeer("secret_digest = ?", secretDigest); err == nil {
|
||||
return nil
|
||||
// Чужой пир с тем же секретом. Создавать нельзя — UNIQUE(secret_digest), —
|
||||
// но и молчать нельзя: секрет установщика уже выдан кому-то ещё.
|
||||
switch _, err := GetPeer("secret_digest = ?", secretDigest); {
|
||||
case err == nil:
|
||||
return errors.New(
|
||||
"секрет из HY2XS_ADMIN_CON_PASS уже принадлежит другому пиру.\n" +
|
||||
"Пир установщика не создан: два пира не могут делить один секрет.\n" +
|
||||
"Переименуйте или удалите этот пир, либо смените HY2XS_ADMIN_CON_PASS\n" +
|
||||
"через `hy2xs-orchestrator reconfigure`.",
|
||||
)
|
||||
case !errors.Is(err, ErrPeerNotFound):
|
||||
return err
|
||||
}
|
||||
|
||||
secretEncrypted, err := encryptBootstrapPeerSecret(bootstrapSecret)
|
||||
@@ -178,8 +273,8 @@ func ensureSecureBootstrapPeer() error {
|
||||
return err
|
||||
}
|
||||
|
||||
name := "bootstrap-admin-peer"
|
||||
authID := "bootstrap-admin-peer"
|
||||
name := BootstrapPeerName
|
||||
authID := BootstrapPeerName
|
||||
remark := "bootstrap peer seeded from HY2XS_ADMIN_CON_PASS"
|
||||
quota := int64(-1)
|
||||
download := int64(0)
|
||||
@@ -205,20 +300,64 @@ func ensureSecureBootstrapPeer() error {
|
||||
BannedUntil: &bannedUntil,
|
||||
LastConnectionAt: &lastConnection,
|
||||
}
|
||||
_, saveErr := SavePeer(peer)
|
||||
return saveErr
|
||||
return seedBootstrapPeerOnce(peer)
|
||||
}
|
||||
|
||||
// bootstrapPeerSeeded читает отметку о том, что пир установщика уже создавался.
|
||||
//
|
||||
// Отсутствие строки — это «ещё не создавался», а не отказ: миграция 000
|
||||
// заводит ключ пустым, но база могла быть создана и до его появления. Любая
|
||||
// ДРУГАЯ ошибка — отказ хранилища, и трактовать её как «не создавался» нельзя:
|
||||
// именно так и появлялся бы пир заново при недоступной базе.
|
||||
func bootstrapPeerSeeded() (bool, error) {
|
||||
cfg, err := GetConfig("key = ?", constant.BootstrapPeerSeeded)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrConfigNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if cfg.Value == nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.TrimSpace(*cfg.Value) == bootstrapPeerSeededValue, nil
|
||||
}
|
||||
|
||||
// seedBootstrapPeerOnce пишет пир и отметку о его создании неделимо.
|
||||
//
|
||||
// Раздельная запись вернула бы исходный дефект в новой форме: упади процесс
|
||||
// между SavePeer и записью отметки, следующий старт снова увидел бы
|
||||
// «не создавался» и — после удаления пира оператором — создал бы его заново.
|
||||
func seedBootstrapPeerOnce(peer entity.Peer) error {
|
||||
if sqliteDB == nil {
|
||||
return errors.New("sqlite is not initialized")
|
||||
}
|
||||
return sqliteDB.Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := (peerTx{db: tx}).SavePeer(peer); err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertConfigValueOn(tx, constant.BootstrapPeerSeeded, bootstrapPeerSeededValue)
|
||||
})
|
||||
}
|
||||
|
||||
func buildPeerSecretDigest(rawSecret string) (string, error) {
|
||||
secretKey, err := getOrCreatePeerSecretDigestKey()
|
||||
secretKey, err := GetOrCreatePeerSecretDigestKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return util.HmacSHA256Hex(rawSecret, secretKey), nil
|
||||
}
|
||||
|
||||
func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
|
||||
raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32)
|
||||
// GetOrCreatePeerSecretEncryptionKey возвращает master-key шифрования секретов
|
||||
// пиров, при необходимости создавая его.
|
||||
//
|
||||
// Экспортирована, чтобы у ключа была ровно одна реализация. Раньше идентичная
|
||||
// копия жила в service/peer_secret.go: две функции с одинаковым именем в разных
|
||||
// пакетах, обе создающие один и тот же материал шифрования. Расхождение между
|
||||
// ними означало бы, что секреты пиров шифруются одним ключом, а
|
||||
// расшифровываются другим, и обнаружилось бы это на живых пирах.
|
||||
func GetOrCreatePeerSecretEncryptionKey() ([]byte, error) {
|
||||
raw, err := GetOrCreateConfigSecret(constant.PeerSecretEncryptionKey, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -226,45 +365,55 @@ func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
|
||||
if decErr == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// Ключ, записанный не в base64: приводим к стабильному представлению один
|
||||
// раз, сохранив исходные байты, — иначе уже зашифрованные секреты пиров
|
||||
// перестали бы расшифровываться.
|
||||
plain := []byte(strings.TrimSpace(raw))
|
||||
if len(plain) < 32 {
|
||||
return nil, errors.New("invalid peer secret encryption key")
|
||||
}
|
||||
plain = plain[:32]
|
||||
encoded := base64.StdEncoding.EncodeToString(plain)
|
||||
if err := UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); err != nil {
|
||||
if err := UpsertConfigValue(constant.PeerSecretEncryptionKey, base64.StdEncoding.EncodeToString(plain)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func encryptBootstrapPeerSecret(rawSecret string) (string, error) {
|
||||
key, err := getOrCreatePeerSecretEncryptionKey()
|
||||
key, err := GetOrCreatePeerSecretEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return util.EncryptAESGCM(rawSecret, key)
|
||||
}
|
||||
|
||||
func getOrCreateConfigKey(key string, size int) (string, error) {
|
||||
// GetOrCreateConfigSecret читает секрет из таблицы `config`, создавая его при
|
||||
// первом обращении. Единственная реализация этой операции в продукте.
|
||||
func GetOrCreateConfigSecret(key string, size int) (string, error) {
|
||||
cfg, err := GetConfig("key = ?", key)
|
||||
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
|
||||
return strings.TrimSpace(*cfg.Value), nil
|
||||
}
|
||||
raw, genErr := util.RandomString(size)
|
||||
value, genErr := util.RandomString(size)
|
||||
if genErr != nil {
|
||||
return "", genErr
|
||||
}
|
||||
value := raw
|
||||
remark := key
|
||||
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); saveErr != nil {
|
||||
if updErr := UpdateConfig([]string{key}, map[string]interface{}{"value": value}); updErr != nil {
|
||||
return "", updErr
|
||||
}
|
||||
if err := UpsertConfigValue(key, value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// RunMigrations приводит схему базы к текущему поколению.
|
||||
//
|
||||
// Экспортирована, чтобы схему можно было поднять отдельно от InitSql: тестам
|
||||
// слоя данных нужна таблица, но не нужны bootstrap-администратор, bootstrap-пир
|
||||
// и генерация JWT-секрета.
|
||||
func RunMigrations() error {
|
||||
return runMigrations()
|
||||
}
|
||||
|
||||
func runMigrations() error {
|
||||
if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
@@ -279,13 +428,24 @@ func runMigrations() error {
|
||||
apply func() error
|
||||
}
|
||||
|
||||
// Миграций 002_migrate_legacy_accounts и 003_archive_legacy_account здесь
|
||||
// больше нет.
|
||||
//
|
||||
// Они переносили таблицу `account` предыдущего поколения в admin_user/peer
|
||||
// и переименовывали её в бэкап. Это compatibility-слой продукта, который
|
||||
// HY2XS v1 принципиально не поддерживает: установка возможна только на
|
||||
// чистый хост, база 0.x не мигрируется ни при каком сценарии, а clean-host
|
||||
// контракт отказывает ещё до создания базы. Живого пути, по которому такая
|
||||
// таблица могла бы оказаться в hy2xs-admin.db, не существует.
|
||||
//
|
||||
// Номера оставшихся миграций сохранены: перенумерация заставила бы их
|
||||
// примениться повторно на уже установленных машинах.
|
||||
migrations := []migration{
|
||||
{version: "000_base_config", apply: migrateBaseConfig},
|
||||
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
|
||||
{version: "002_migrate_legacy_accounts", apply: migrateLegacyAccounts},
|
||||
{version: "003_archive_legacy_account", apply: archiveLegacyAccount},
|
||||
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
|
||||
{version: "005_metric_sample", apply: migrateMetricSampleTable},
|
||||
{version: "006_drop_dead_config_keys", apply: migrateDropDeadConfigKeys},
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
@@ -307,20 +467,27 @@ func runMigrations() error {
|
||||
}
|
||||
|
||||
func seedBaseConfig() error {
|
||||
// Сетевых настроек панели здесь нет: ими владеет оркестратор.
|
||||
//
|
||||
// HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME и
|
||||
// HYSTERIA2_CONFIG_REMARK удалены: ни один из них не имел потребителя в
|
||||
// рантайме, см. model/constant/config.go. Строки этих ключей на уже
|
||||
// установленных машинах убирает миграция 006_drop_dead_config_keys.
|
||||
//
|
||||
// Оставшееся — четыре внутренних секрета и единственная настройка, которой
|
||||
// действительно владеет панель.
|
||||
//
|
||||
// BOOTSTRAP_PEER_SEEDED заводится пустым: пустое значение означает «пир
|
||||
// установщика ещё ни разу не создавался», и это верное состояние для
|
||||
// чистой базы. Отметку выставляет ensureSecureBootstrapPeer той же
|
||||
// транзакцией, которой создаёт пир.
|
||||
defaults := map[string]string{
|
||||
constant.HUIWebPort: "8080",
|
||||
constant.HUIWebContext: "/",
|
||||
constant.HUICrtPath: "",
|
||||
constant.HUIKeyPath: "",
|
||||
constant.JwtSecret: "",
|
||||
constant.Hysteria2Enable: "0",
|
||||
constant.Hysteria2Config: "",
|
||||
constant.Hysteria2TrafficTime: "10",
|
||||
constant.Hysteria2ConfigRemark: "",
|
||||
constant.ResetTrafficCron: "0 0 * * *",
|
||||
constant.Hysteria2TrafficStatsSecret: "",
|
||||
constant.PeerSecretKey: "",
|
||||
constant.PeerSecretEncryptionKey: "",
|
||||
constant.BootstrapPeerSeeded: "",
|
||||
}
|
||||
for k, v := range defaults {
|
||||
if tx := sqliteDB.Exec("INSERT OR IGNORE INTO config(key, value, remark) VALUES(?, ?, ?)", k, v, k); tx.Error != nil {
|
||||
@@ -352,6 +519,34 @@ func migrateBaseConfig() error {
|
||||
return seedBaseConfig()
|
||||
}
|
||||
|
||||
// migrateDropDeadConfigKeys убирает из таблицы `config` ключи, у которых не
|
||||
// осталось ни одного потребителя.
|
||||
//
|
||||
// Миграция нужна не ради места в базе, а ради того же инварианта, что и
|
||||
// allowlist: строка в таблице секретов, которую никто не читает и не пишет,
|
||||
// рано или поздно кем-нибудь прочитается. Ключи перечислены здесь строковыми
|
||||
// литералами намеренно — соответствующих констант в коде уже нет, и появиться
|
||||
// они не должны.
|
||||
//
|
||||
// Миграция безопасна на чистой установке (удалять нечего) и идемпотентна:
|
||||
// применяется один раз и отмечается в schema_migrations. Номера предыдущих
|
||||
// миграций не тронуты — они уже записаны на установленных машинах.
|
||||
func migrateDropDeadConfigKeys() error {
|
||||
deadKeys := []string{
|
||||
"HYSTERIA2_ENABLE",
|
||||
"HYSTERIA2_CONFIG",
|
||||
"HYSTERIA2_TRAFFIC_TIME",
|
||||
"HYSTERIA2_CONFIG_REMARK",
|
||||
}
|
||||
for _, key := range deadKeys {
|
||||
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", key); tx.Error != nil {
|
||||
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
|
||||
return errors.New("sqlite migration exec err")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrationApplied(version string) (bool, error) {
|
||||
var count int64
|
||||
if tx := sqliteDB.Raw("SELECT COUNT(1) FROM schema_migrations WHERE version = ?", version).Scan(&count); tx.Error != nil {
|
||||
@@ -407,125 +602,6 @@ func migrateAdminPeerSplit() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyAccounts() error {
|
||||
if !tableExists("account") {
|
||||
return nil
|
||||
}
|
||||
var accounts []entity.LegacyAccount
|
||||
if tx := sqliteDB.Model(&entity.LegacyAccount{}).Order("id asc").Find(&accounts); tx.Error != nil {
|
||||
logrus.Errorf("sqlite legacy account query err: %v", tx.Error)
|
||||
return errors.New("sqlite legacy account query err")
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
for _, acc := range accounts {
|
||||
if acc.Role != nil && *acc.Role == "admin" {
|
||||
if acc.Username == nil || acc.Pass == nil {
|
||||
continue
|
||||
}
|
||||
_, getErr := GetAdminUser("username = ?", *acc.Username)
|
||||
if getErr == nil {
|
||||
continue
|
||||
}
|
||||
status := int64(1)
|
||||
if acc.Deleted != nil && *acc.Deleted == 1 {
|
||||
status = 0
|
||||
}
|
||||
lastLogin := int64(0)
|
||||
if acc.LoginAt != nil {
|
||||
lastLogin = *acc.LoginAt
|
||||
}
|
||||
passwordChangedAt := nowMs
|
||||
admin := entity.AdminUser{
|
||||
Username: acc.Username,
|
||||
PasswordHash: acc.Pass,
|
||||
Status: &status,
|
||||
ForcePasswordChange: acc.ForcePasswordChange,
|
||||
LastLoginAt: &lastLogin,
|
||||
PasswordChangedAt: &passwordChangedAt,
|
||||
TokenVersion: func() *int64 { v := int64(1); return &v }(),
|
||||
}
|
||||
if _, saveErr := SaveAdminUser(admin); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if acc.Username == nil || acc.ConPass == nil {
|
||||
continue
|
||||
}
|
||||
_, getPeerErr := GetPeer("name = ?", *acc.Username)
|
||||
if getPeerErr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
authId, authErr := util.RandomString(18)
|
||||
if authErr != nil {
|
||||
return authErr
|
||||
}
|
||||
peerSecretKey, keyErr := getOrCreatePeerSecretDigestKey()
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
secretDigest := util.HmacSHA256Hex(*acc.ConPass, peerSecretKey)
|
||||
secretEncrypted := *acc.ConPass
|
||||
quota := int64(0)
|
||||
if acc.Quota != nil {
|
||||
quota = *acc.Quota
|
||||
}
|
||||
download := int64(0)
|
||||
if acc.Download != nil {
|
||||
download = *acc.Download
|
||||
}
|
||||
upload := int64(0)
|
||||
if acc.Upload != nil {
|
||||
upload = *acc.Upload
|
||||
}
|
||||
expires := int64(0)
|
||||
if acc.ExpireTime != nil {
|
||||
expires = *acc.ExpireTime
|
||||
}
|
||||
maxDevices := int64(3)
|
||||
if acc.DeviceNo != nil {
|
||||
maxDevices = *acc.DeviceNo
|
||||
}
|
||||
disabled := int64(0)
|
||||
if acc.Deleted != nil {
|
||||
disabled = *acc.Deleted
|
||||
}
|
||||
bannedUntil := int64(0)
|
||||
if acc.KickUtilTime != nil {
|
||||
bannedUntil = *acc.KickUtilTime
|
||||
}
|
||||
lastConnection := int64(0)
|
||||
if acc.ConAt != nil {
|
||||
lastConnection = *acc.ConAt
|
||||
}
|
||||
remark := ""
|
||||
if acc.Remark != nil {
|
||||
remark = *acc.Remark
|
||||
}
|
||||
peer := entity.Peer{
|
||||
Name: acc.Username,
|
||||
Remark: &remark,
|
||||
AuthId: &authId,
|
||||
SecretDigest: &secretDigest,
|
||||
SecretEncrypted: &secretEncrypted,
|
||||
QuotaBytes: "a,
|
||||
DownloadBytes: &download,
|
||||
UploadBytes: &upload,
|
||||
ExpiresAt: &expires,
|
||||
MaxDevices: &maxDevices,
|
||||
Disabled: &disabled,
|
||||
BannedUntil: &bannedUntil,
|
||||
LastConnectionAt: &lastConnection,
|
||||
}
|
||||
if _, saveErr := SavePeer(peer); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrCreateJwtSecret() (string, error) {
|
||||
if existing, err := GetConfig("key = ?", constant.JwtSecret); err == nil {
|
||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
||||
@@ -536,46 +612,17 @@ func getOrCreateJwtSecret() (string, error) {
|
||||
if secErr != nil {
|
||||
return "", secErr
|
||||
}
|
||||
if err := UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": secret}); err != nil {
|
||||
key := constant.JwtSecret
|
||||
remark := "JWT signing secret"
|
||||
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
|
||||
return "", saveErr
|
||||
}
|
||||
if err := UpsertConfigValue(constant.JwtSecret, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func getOrCreatePeerSecretDigestKey() (string, error) {
|
||||
if existing, err := GetConfig("key = ?", constant.PeerSecretKey); err == nil {
|
||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
||||
return strings.TrimSpace(*existing.Value), nil
|
||||
}
|
||||
}
|
||||
keyValue, keyErr := util.RandomString(48)
|
||||
if keyErr != nil {
|
||||
return "", keyErr
|
||||
}
|
||||
if err := UpdateConfig([]string{constant.PeerSecretKey}, map[string]interface{}{"value": keyValue}); err != nil {
|
||||
key := constant.PeerSecretKey
|
||||
remark := "Peer secret digest key"
|
||||
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &keyValue, Remark: &remark}); saveErr != nil {
|
||||
return "", saveErr
|
||||
}
|
||||
}
|
||||
return keyValue, nil
|
||||
}
|
||||
|
||||
func archiveLegacyAccount() error {
|
||||
if !tableExists("account") {
|
||||
return nil
|
||||
}
|
||||
backupName := fmt.Sprintf("legacy_account_backup_%d", time.Now().Unix())
|
||||
if tx := sqliteDB.Exec("ALTER TABLE account RENAME TO " + backupName); tx.Error != nil {
|
||||
logrus.Errorf("sqlite legacy archive err: %v", tx.Error)
|
||||
return errors.New("sqlite legacy archive err")
|
||||
}
|
||||
return nil
|
||||
// GetOrCreatePeerSecretDigestKey — HMAC-ключ, которым считается secret_digest
|
||||
// пира. Экспортирована по той же причине, что и ключ шифрования: копия этой же
|
||||
// логики жила в service/peer_secret.go под именем GetPeerSecretKey.
|
||||
func GetOrCreatePeerSecretDigestKey() (string, error) {
|
||||
return GetOrCreateConfigSecret(constant.PeerSecretKey, 48)
|
||||
}
|
||||
|
||||
func migrateTrafficTables() error {
|
||||
@@ -662,63 +709,64 @@ func tableExists(tableName string) bool {
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func listSQLMigrationFiles(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]string, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||
files = append(files, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
|
||||
// придумывает его сам.
|
||||
//
|
||||
// Владелец значения — оркестратор. Он же кладёт этот секрет в
|
||||
// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к
|
||||
// /internal/hysteria/auth, и в /etc/hy2xs/hy2xs.env, откуда его читает
|
||||
// systemd-юнит админки. Токен — межкомпонентный контракт, а не внутреннее
|
||||
// состояние панели.
|
||||
//
|
||||
// Что было. При пустом env и пустой базе админка генерировала случайный токен
|
||||
// сама. Записать его в /etc/hysteria/config.yaml она не может — файл
|
||||
// принадлежит оркестратору и для пользователя hy2xs-admin доступен только на
|
||||
// чтение (это проверяет smoke). Результат:
|
||||
//
|
||||
// admin стартовал успешно
|
||||
// Hysteria продолжала слать старый токен
|
||||
// machine auth переставал совпадать
|
||||
//
|
||||
// То есть повреждённый контракт запуска не диагностировался, а маскировался
|
||||
// «починкой» неправильным способом: сервис объявлял себя здоровым, а
|
||||
// авторизация клиентов молча ломалась.
|
||||
//
|
||||
// Тот же принцип, что уже применён к HY2XS_ADMIN_INITIAL_PASSWORD и
|
||||
// HY2XS_ADMIN_CON_PASS: значение, которого не знает никто, кроме сгенерировавшего
|
||||
// его процесса, бесполезно, и правильная реакция на его отсутствие громкая.
|
||||
//
|
||||
// Три допустимых состояния:
|
||||
//
|
||||
// env задан → синхронизировать базу с ним (env главнее);
|
||||
// env пуст, в базе токен есть → рабочее состояние, ничего не делаем;
|
||||
// env пуст, в базе пусто → отказ старта.
|
||||
//
|
||||
// Вторая ветка нужна для ручного `systemctl start` без EnvironmentFile: она не
|
||||
// изобретает контракт, а использует уже согласованный.
|
||||
func ensureTrafficStatsSecret() error {
|
||||
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
|
||||
if envSecret != "" {
|
||||
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
|
||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != envSecret {
|
||||
return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": envSecret})
|
||||
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
key := constant.Hysteria2TrafficStatsSecret
|
||||
remark := "Hysteria2 trafficStats API secret"
|
||||
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &envSecret, Remark: &remark}); saveErr != nil {
|
||||
return saveErr
|
||||
|
||||
existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
if err != nil && !errors.Is(err, ErrConfigNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
|
||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
||||
return nil
|
||||
}
|
||||
secret, secErr := util.RandomString(32)
|
||||
if secErr != nil {
|
||||
return secErr
|
||||
}
|
||||
return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": secret})
|
||||
}
|
||||
secret, secErr := util.RandomString(32)
|
||||
if secErr != nil {
|
||||
return secErr
|
||||
}
|
||||
key := constant.Hysteria2TrafficStatsSecret
|
||||
remark := "Hysteria2 trafficStats API secret"
|
||||
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return nil
|
||||
return errors.New(
|
||||
"HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET не задан, и в базе этого токена тоже нет.\n" +
|
||||
"Это межкомпонентный контракт, а не настройка панели: тем же значением\n" +
|
||||
"Hysteria обращается к /internal/hysteria/auth, и владеет им\n" +
|
||||
"install-оркестратор через /etc/hysteria/config.yaml и /etc/hy2xs/hy2xs.env.\n" +
|
||||
"Сгенерированный админкой токен не попал бы в конфиг Hysteria: этот файл\n" +
|
||||
"доступен ей только на чтение, — и machine auth перестал бы совпадать.\n" +
|
||||
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
)
|
||||
}
|
||||
|
||||
func CloseSqliteDB() error {
|
||||
|
||||
+6
-8
@@ -1,19 +1,17 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
)
|
||||
|
||||
func SaveTrafficSample(sample entity.TrafficSample) error {
|
||||
if tx := sqliteDB.Save(&sample); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -36,7 +34,7 @@ func UpsertTrafficAggregateHourly(peerId int64, hourStart int64, rxBytes int64,
|
||||
}),
|
||||
}).Create(&agg); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -59,7 +57,7 @@ func UpsertTrafficAggregateDaily(peerId int64, dayStart int64, rxBytes int64, tx
|
||||
}),
|
||||
}).Create(&agg); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -74,7 +72,7 @@ func CleanupTrafficSample(olderThanMs int64) error {
|
||||
}
|
||||
if tx := sqliteDB.Exec("DELETE FROM traffic_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -85,7 +83,7 @@ func CleanupTrafficAggregateHourly(olderThanMs int64) error {
|
||||
}
|
||||
if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_hourly WHERE hour_start < ?", olderThanMs); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -96,7 +94,7 @@ func CleanupTrafficAggregateDaily(olderThanMs int64) error {
|
||||
}
|
||||
if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_daily WHERE day_start < ?", olderThanMs); tx.Error != nil {
|
||||
logrus.Errorf("%v", tx.Error)
|
||||
return errors.New(constant.SysError)
|
||||
return ErrStorage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
.vscode
|
||||
.idea
|
||||
*.sh
|
||||
*.md
|
||||
|
||||
src/assets
|
||||
|
||||
.eslintrc.cjs
|
||||
.prettierrc.cjs
|
||||
.stylelintrc.cjs
|
||||
@@ -1,269 +1,84 @@
|
||||
{
|
||||
"globals": {
|
||||
"Component": true,
|
||||
"ComponentPublicInstance": true,
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ElForm": true,
|
||||
"ElMessage": true,
|
||||
"ElMessageBox": true,
|
||||
"ElTree": true,
|
||||
"asyncComputed": true,
|
||||
"autoResetRef": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
"InjectionKey": true,
|
||||
"MaybeRef": true,
|
||||
"MaybeRefOrGetter": true,
|
||||
"PropType": true,
|
||||
"Ref": true,
|
||||
"ShallowRef": true,
|
||||
"Slot": true,
|
||||
"Slots": true,
|
||||
"VNode": true,
|
||||
"WritableComputedRef": true,
|
||||
"computed": true,
|
||||
"computedAsync": true,
|
||||
"computedEager": true,
|
||||
"computedInject": true,
|
||||
"computedWithControl": true,
|
||||
"controlledComputed": true,
|
||||
"controlledRef": true,
|
||||
"createApp": true,
|
||||
"createEventHook": true,
|
||||
"createGlobalState": true,
|
||||
"createInjectionState": true,
|
||||
"createReactiveFn": true,
|
||||
"createSharedComposable": true,
|
||||
"createUnrefFn": true,
|
||||
"customRef": true,
|
||||
"debouncedRef": true,
|
||||
"debouncedWatch": true,
|
||||
"defineAsyncComponent": true,
|
||||
"defineComponent": true,
|
||||
"eagerComputed": true,
|
||||
"effectScope": true,
|
||||
"extendRef": true,
|
||||
"getCurrentInstance": true,
|
||||
"getCurrentScope": true,
|
||||
"getCurrentWatcher": true,
|
||||
"h": true,
|
||||
"ignorableWatch": true,
|
||||
"inject": true,
|
||||
"isDefined": true,
|
||||
"isProxy": true,
|
||||
"isReactive": true,
|
||||
"isReadonly": true,
|
||||
"isRef": true,
|
||||
"makeDestructurable": true,
|
||||
"isShallow": true,
|
||||
"markRaw": true,
|
||||
"nextTick": true,
|
||||
"onActivated": true,
|
||||
"onBeforeMount": true,
|
||||
"onBeforeUnmount": true,
|
||||
"onBeforeUpdate": true,
|
||||
"onClickOutside": true,
|
||||
"onDeactivated": true,
|
||||
"onErrorCaptured": true,
|
||||
"onKeyStroke": true,
|
||||
"onLongPress": true,
|
||||
"onMounted": true,
|
||||
"onRenderTracked": true,
|
||||
"onRenderTriggered": true,
|
||||
"onScopeDispose": true,
|
||||
"onServerPrefetch": true,
|
||||
"onStartTyping": true,
|
||||
"onUnmounted": true,
|
||||
"onUpdated": true,
|
||||
"pausableWatch": true,
|
||||
"onWatcherCleanup": true,
|
||||
"provide": true,
|
||||
"reactify": true,
|
||||
"reactifyObject": true,
|
||||
"reactive": true,
|
||||
"reactiveComputed": true,
|
||||
"reactiveOmit": true,
|
||||
"reactivePick": true,
|
||||
"readonly": true,
|
||||
"ref": true,
|
||||
"refAutoReset": true,
|
||||
"refDebounced": true,
|
||||
"refDefault": true,
|
||||
"refThrottled": true,
|
||||
"refWithControl": true,
|
||||
"resolveComponent": true,
|
||||
"resolveDirective": true,
|
||||
"resolveRef": true,
|
||||
"resolveUnref": true,
|
||||
"shallowReactive": true,
|
||||
"shallowReadonly": true,
|
||||
"shallowRef": true,
|
||||
"syncRef": true,
|
||||
"syncRefs": true,
|
||||
"templateRef": true,
|
||||
"throttledRef": true,
|
||||
"throttledWatch": true,
|
||||
"toRaw": true,
|
||||
"toReactive": true,
|
||||
"toRef": true,
|
||||
"toRefs": true,
|
||||
"toValue": true,
|
||||
"triggerRef": true,
|
||||
"tryOnBeforeMount": true,
|
||||
"tryOnBeforeUnmount": true,
|
||||
"tryOnMounted": true,
|
||||
"tryOnScopeDispose": true,
|
||||
"tryOnUnmounted": true,
|
||||
"unref": true,
|
||||
"unrefElement": true,
|
||||
"until": true,
|
||||
"useActiveElement": true,
|
||||
"useArrayEvery": true,
|
||||
"useArrayFilter": true,
|
||||
"useArrayFind": true,
|
||||
"useArrayFindIndex": true,
|
||||
"useArrayFindLast": true,
|
||||
"useArrayJoin": true,
|
||||
"useArrayMap": true,
|
||||
"useArrayReduce": true,
|
||||
"useArraySome": true,
|
||||
"useArrayUnique": true,
|
||||
"useAsyncQueue": true,
|
||||
"useAsyncState": true,
|
||||
"useAttrs": true,
|
||||
"useBase64": true,
|
||||
"useBattery": true,
|
||||
"useBluetooth": true,
|
||||
"useBreakpoints": true,
|
||||
"useBroadcastChannel": true,
|
||||
"useBrowserLocation": true,
|
||||
"useCached": true,
|
||||
"useClipboard": true,
|
||||
"useCloned": true,
|
||||
"useColorMode": true,
|
||||
"useConfirmDialog": true,
|
||||
"useCounter": true,
|
||||
"useCssModule": true,
|
||||
"useCssVar": true,
|
||||
"useCssVars": true,
|
||||
"useCurrentElement": true,
|
||||
"useCycleList": true,
|
||||
"useDark": true,
|
||||
"useDateFormat": true,
|
||||
"useDebounce": true,
|
||||
"useDebounceFn": true,
|
||||
"useDebouncedRefHistory": true,
|
||||
"useDeviceMotion": true,
|
||||
"useDeviceOrientation": true,
|
||||
"useDevicePixelRatio": true,
|
||||
"useDevicesList": true,
|
||||
"useDisplayMedia": true,
|
||||
"useDocumentVisibility": true,
|
||||
"useDraggable": true,
|
||||
"useDropZone": true,
|
||||
"useElementBounding": true,
|
||||
"useElementByPoint": true,
|
||||
"useElementHover": true,
|
||||
"useElementSize": true,
|
||||
"useElementVisibility": true,
|
||||
"useEventBus": true,
|
||||
"useEventListener": true,
|
||||
"useEventSource": true,
|
||||
"useEyeDropper": true,
|
||||
"useFavicon": true,
|
||||
"useFetch": true,
|
||||
"useFileDialog": true,
|
||||
"useFileSystemAccess": true,
|
||||
"useFocus": true,
|
||||
"useFocusWithin": true,
|
||||
"useFps": true,
|
||||
"useFullscreen": true,
|
||||
"useGamepad": true,
|
||||
"useGeolocation": true,
|
||||
"useIdle": true,
|
||||
"useImage": true,
|
||||
"useInfiniteScroll": true,
|
||||
"useIntersectionObserver": true,
|
||||
"useInterval": true,
|
||||
"useIntervalFn": true,
|
||||
"useKeyModifier": true,
|
||||
"useLastChanged": true,
|
||||
"useLocalStorage": true,
|
||||
"useMagicKeys": true,
|
||||
"useManualRefHistory": true,
|
||||
"useMediaControls": true,
|
||||
"useMediaQuery": true,
|
||||
"useMemoize": true,
|
||||
"useMemory": true,
|
||||
"useMounted": true,
|
||||
"useMouse": true,
|
||||
"useMouseInElement": true,
|
||||
"useMousePressed": true,
|
||||
"useMutationObserver": true,
|
||||
"useNavigatorLanguage": true,
|
||||
"useNetwork": true,
|
||||
"useNow": true,
|
||||
"useObjectUrl": true,
|
||||
"useOffsetPagination": true,
|
||||
"useOnline": true,
|
||||
"usePageLeave": true,
|
||||
"useParallax": true,
|
||||
"usePermission": true,
|
||||
"usePointer": true,
|
||||
"usePointerLock": true,
|
||||
"usePointerSwipe": true,
|
||||
"usePreferredColorScheme": true,
|
||||
"usePreferredContrast": true,
|
||||
"usePreferredDark": true,
|
||||
"usePreferredLanguages": true,
|
||||
"usePreferredReducedMotion": true,
|
||||
"usePrevious": true,
|
||||
"useRafFn": true,
|
||||
"useRefHistory": true,
|
||||
"useResizeObserver": true,
|
||||
"useScreenOrientation": true,
|
||||
"useScreenSafeArea": true,
|
||||
"useScriptTag": true,
|
||||
"useScroll": true,
|
||||
"useScrollLock": true,
|
||||
"useSessionStorage": true,
|
||||
"useShare": true,
|
||||
"useId": true,
|
||||
"useModel": true,
|
||||
"useSlots": true,
|
||||
"useSorted": true,
|
||||
"useSpeechRecognition": true,
|
||||
"useSpeechSynthesis": true,
|
||||
"useStepper": true,
|
||||
"useStorage": true,
|
||||
"useStorageAsync": true,
|
||||
"useStyleTag": true,
|
||||
"useSupported": true,
|
||||
"useSwipe": true,
|
||||
"useTemplateRefsList": true,
|
||||
"useTextDirection": true,
|
||||
"useTextSelection": true,
|
||||
"useTextareaAutosize": true,
|
||||
"useThrottle": true,
|
||||
"useThrottleFn": true,
|
||||
"useThrottledRefHistory": true,
|
||||
"useTimeAgo": true,
|
||||
"useTimeout": true,
|
||||
"useTimeoutFn": true,
|
||||
"useTimeoutPoll": true,
|
||||
"useTimestamp": true,
|
||||
"useTitle": true,
|
||||
"useToNumber": true,
|
||||
"useToString": true,
|
||||
"useToggle": true,
|
||||
"useTransition": true,
|
||||
"useUrlSearchParams": true,
|
||||
"useUserMedia": true,
|
||||
"useTemplateRef": true,
|
||||
"useVModel": true,
|
||||
"useVModels": true,
|
||||
"useVibrate": true,
|
||||
"useVirtualList": true,
|
||||
"useWakeLock": true,
|
||||
"useWebNotification": true,
|
||||
"useWebSocket": true,
|
||||
"useWebWorker": true,
|
||||
"useWebWorkerFn": true,
|
||||
"useWindowFocus": true,
|
||||
"useWindowScroll": true,
|
||||
"useWindowSize": true,
|
||||
"watch": true,
|
||||
"watchArray": true,
|
||||
"watchAtMost": true,
|
||||
"watchDebounced": true,
|
||||
"watchEffect": true,
|
||||
"watchIgnorable": true,
|
||||
"watchOnce": true,
|
||||
"watchPausable": true,
|
||||
"watchPostEffect": true,
|
||||
"watchSyncEffect": true,
|
||||
"watchThrottled": true,
|
||||
"watchTriggerable": true,
|
||||
"watchWithFilter": true,
|
||||
"whenever": true
|
||||
"watchSyncEffect": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
parser: "vue-eslint-parser", // https://eslint.vuejs.org/user-guide/#bundle-configurations
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/vue3-essential",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"./.eslintrc-auto-import.json",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
parser: "@typescript-eslint/parser",
|
||||
},
|
||||
plugins: ["vue", "@typescript-eslint"],
|
||||
rules: {
|
||||
"vue/multi-word-component-names": "off", // Disable multi-word component name requirement: https://eslint.vuejs.org/rules/multi-word-component-names.html
|
||||
"@typescript-eslint/no-empty-function": "off", // Disable empty function checks
|
||||
"@typescript-eslint/no-explicit-any": "off", // Disable warnings for any
|
||||
"vue/no-v-model-argument": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
}, // https://eslint.org/docs/latest/use/configure/language-options#specifying-globals
|
||||
globals: {
|
||||
DialogType: "readonly",
|
||||
OptionType: "readonly",
|
||||
},
|
||||
};
|
||||
@@ -25,6 +25,12 @@ module.exports = {
|
||||
"custom-property-pattern": null, // Custom property naming rule
|
||||
"keyframes-name-pattern": null, // Keyframe naming rule
|
||||
"no-descending-specificity": null, // Allow non-descending specificity
|
||||
// Пустая строка `//` внутри блока комментария — абзацный разделитель, а не
|
||||
// забытый комментарий. Правило появилось в stylelint-config-recommended-scss
|
||||
// 17 и требует либо склеивать абзацы, либо разрывать блок пустой строкой;
|
||||
// и то и другое ухудшает читаемость объяснений, ради которых эти
|
||||
// комментарии написаны.
|
||||
"scss/comment-no-empty": null,
|
||||
// Allow global, export and deep pseudo classes
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import vueParser from "vue-eslint-parser";
|
||||
|
||||
/**
|
||||
* Конфигурация ESLint во flat-формате.
|
||||
*
|
||||
* Заменяет `.eslintrc.cjs`. Причина не в моде на формат: eslint 8 объявлен
|
||||
* EOL, перестал получать исправления и оставался последним источником десятка
|
||||
* предупреждений `pnpm audit` в проекте (minimatch, brace-expansion, js-yaml,
|
||||
* flatted — всё в его собственном графе). Eslintrc-формат в eslint 9 работает
|
||||
* только через переменную окружения, а в 10 удалён вовсе, поэтому обновление
|
||||
* версии без перехода на flat-config было бы отсрочкой на один релиз.
|
||||
*
|
||||
* Набор правил сохранён ровно тем же, что был в `.eslintrc.cjs`: перевод
|
||||
* формата не место для изменения политики линтинга.
|
||||
*/
|
||||
|
||||
// Глобальные имена авто-импорта генерирует unplugin-auto-import в
|
||||
// eslintrc-формате (`{ globals: { ref: true, ... } }`). Flat-config такой файл
|
||||
// через `extends` не подключает, поэтому список читается напрямую — источник
|
||||
// истины остаётся один, и он по-прежнему пересоздаётся сборкой.
|
||||
const require = createRequire(import.meta.url);
|
||||
const autoImportGlobals = require("./.eslintrc-auto-import.json").globals;
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// Перенесено из .eslintignore: eslint 9 этот файл больше не читает, и
|
||||
// список исключений обязан жить в самой конфигурации.
|
||||
//
|
||||
// src/assets — SVG и изображения; src/types/*.d.ts — объявления, которые
|
||||
// генерируют unplugin-auto-import и unplugin-vue-components, править их
|
||||
// вручную нельзя, а замечания линтера по ним только шумят.
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"public/**",
|
||||
"src/assets/**",
|
||||
"src/types/*.d.ts",
|
||||
],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginVue.configs["flat/essential"],
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...autoImportGlobals,
|
||||
// Объявлены в src/types/global.d.ts и используются как глобальные типы.
|
||||
DialogType: "readonly",
|
||||
OptionType: "readonly",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Имя компонента из одного слова — устоявшееся соглашение этого проекта.
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-v-model-argument": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
// .vue разбирает vue-eslint-parser, а <script lang="ts"> внутри него —
|
||||
// парсер TypeScript. Без второй половины содержимое блока не разбирается.
|
||||
files: ["**/*.vue"],
|
||||
languageOptions: {
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Последним: отключает правила форматирования, которыми владеет prettier.
|
||||
//
|
||||
// Ни один из включённых наборов таких правил сейчас не содержит, так что это
|
||||
// страховка, а не исправление: форматированием занимается отдельный скрипт
|
||||
// lint:prettier, и правило-дубликат в линтере привело бы к двум источникам
|
||||
// истины о стиле.
|
||||
prettier
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
+55
-42
@@ -2,67 +2,80 @@
|
||||
"name": "hy2xs-admin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "HY2XS admin",
|
||||
"author": "HY2XS",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
"dev": "vite serve --mode development",
|
||||
"build:prod": "vite build --mode production && vue-tsc --noEmit",
|
||||
"lint:eslint": "eslint --fix --ext .ts,.js,.vue ./src ",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build:prod": "vite build --mode production",
|
||||
"verify": "pnpm run typecheck && pnpm run build:prod",
|
||||
"lint:eslint": "eslint --fix ./src",
|
||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^1.0.0",
|
||||
"@vueuse/core": "^9.1.1",
|
||||
"axios": "^1.3.4",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"axios": "^1.20.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"echarts": "^5.5.1",
|
||||
"element-plus": "^2.3.1",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"pinia": "^2.0.33",
|
||||
"pinia": "^2.3.1",
|
||||
"qrcode.vue": "3.4.1",
|
||||
"vue": "^3.2.45",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "^4.1.6",
|
||||
"vuedraggable": "^4.1.0"
|
||||
"vue": "^3.5.42",
|
||||
"vue-echarts": "^8.1.0",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/ep": "^1.1.8",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@iconify-json/ep": "^1.2.4",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/path-browserify": "^1.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.9.0",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-html": "^1.5.0",
|
||||
"postcss-scss": "^4.0.6",
|
||||
"prettier": "^2.8.7",
|
||||
"sass": "^1.58.3",
|
||||
"stylelint": "^15.5.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-recess-order": "^4.0.0",
|
||||
"stylelint-config-recommended-scss": "^9.0.1",
|
||||
"stylelint-config-recommended-vue": "^1.4.0",
|
||||
"stylelint-config-standard": "^32.0.0",
|
||||
"stylelint-config-standard-scss": "^8.0.0",
|
||||
"typescript": "^4.9.3",
|
||||
"unocss": "^0.50.1",
|
||||
"unplugin-auto-import": "^0.13.0",
|
||||
"unplugin-icons": "^0.15.1",
|
||||
"unplugin-vue-components": "^0.23.0",
|
||||
"vite": "^4.3.1",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vue-tsc": "^0.35.0"
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.10.0",
|
||||
"globals": "17.11.0",
|
||||
"postcss": "^8.5.26",
|
||||
"postcss-html": "^2.0.0",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"prettier": "^3.9.6",
|
||||
"sass": "^1.103.1",
|
||||
"stylelint": "^17.14.1",
|
||||
"stylelint-config-html": "^2.0.0",
|
||||
"stylelint-config-recess-order": "^7.8.0",
|
||||
"stylelint-config-recommended-scss": "^17.0.1",
|
||||
"stylelint-config-recommended-vue": "^2.0.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"stylelint-config-standard-scss": "^17.0.0",
|
||||
"stylelint-order": "8.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"unocss": "^66.8.1",
|
||||
"unplugin-auto-import": "^21.1.0",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^7.3.6",
|
||||
"vue-eslint-parser": "10.4.1",
|
||||
"vue-tsc": "^3.3.11"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"lodash": ">=4.18.1",
|
||||
"lodash-es": ">=4.18.1",
|
||||
"brace-expansion": ">=1.1.18",
|
||||
"picomatch": ">=2.3.2",
|
||||
"js-yaml": ">=4.3.1",
|
||||
"table>ajv": ">=8.18.0"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2845
-4123
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
Hysteria2ServerConfig,
|
||||
Hysteria2ServerConfigOutbound,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Нормализация конфига Hysteria на границе API.
|
||||
*
|
||||
* Зачем этот файл существует.
|
||||
*
|
||||
* `Hysteria2ServerConfig` описывает то, что РЕАЛЬНО приходит по сети, и почти
|
||||
* все его секции необязательны — потому что необязательны они и в upstream
|
||||
* YAML. Панель при этом показывает их как обычную форму: `dataForm.tls.cert`,
|
||||
* `dataForm.acme.dns.config`, `dataForm.resolver.https.sni`.
|
||||
*
|
||||
* Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
|
||||
* Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` в двух файлах — и он
|
||||
* прав: обращение через возможно отсутствующий объект в рантайме падает.
|
||||
* Спасало только то, что форма строится merge'ем поверх полного объекта
|
||||
* значений по умолчанию, то есть инвариант «секция есть всегда» существовал,
|
||||
* но держался на порядке присваиваний внутри компонента и нигде не был
|
||||
* выражен типом.
|
||||
*
|
||||
* Два способа это закрыть неверны:
|
||||
*
|
||||
* `?.` в 141 месте шаблона — прячет вопрос «а что показывать, если секции
|
||||
* нет», не отвечая на него, и делает шаблон нечитаемым;
|
||||
*
|
||||
* `as any` — выключает ровно ту проверку, ради которой обновлялся
|
||||
* typechecker.
|
||||
*
|
||||
* Здесь выбран третий: одно преобразование на входе. Ответ приходит в
|
||||
* `Hysteria2ServerConfig` (как есть, с необязательными секциями), а форма
|
||||
* работает с `Hysteria2ServerConfigView`, где присутствие каждой секции —
|
||||
* свойство типа. Шаблону больше не нужно знать ни одного нюанса
|
||||
* необязательности upstream-схемы.
|
||||
*
|
||||
* Важно, чего этот слой НЕ делает: он не участвует в экспорте. Выгрузка
|
||||
* серверного конфига идёт на backend от исходного YAML и сохраняет поля, о
|
||||
* которых HY2XS ещё не знает (см. docs/04). View-модель — только для
|
||||
* отображения, поэтому потеря неизвестных полей здесь безвредна.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DeepRequired делает обязательными все поля на всех уровнях.
|
||||
*
|
||||
* Массивы обрабатываются отдельно: без этой ветки `T[]` попал бы в `object` и
|
||||
* маппинг прошёлся бы по свойствам самого массива.
|
||||
*/
|
||||
type DeepRequired<T> = T extends (infer U)[]
|
||||
? DeepRequired<U>[]
|
||||
: T extends object
|
||||
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
|
||||
: T;
|
||||
|
||||
/** Конфиг Hysteria в том виде, в котором его показывает панель. */
|
||||
export type Hysteria2ServerConfigView = DeepRequired<Hysteria2ServerConfig>;
|
||||
|
||||
/** Один outbound в том же виде. */
|
||||
export type Hysteria2ServerConfigOutboundView =
|
||||
DeepRequired<Hysteria2ServerConfigOutbound>;
|
||||
|
||||
/**
|
||||
* Полное значение по умолчанию: каждая секция заполнена.
|
||||
*
|
||||
* Тип здесь не декоративный. `Hysteria2ServerConfigView` требует все поля, и
|
||||
* добавление секции в `Hysteria2ServerConfig` сломает компиляцию ровно здесь —
|
||||
* то есть новое поле upstream нельзя молча не отобразить.
|
||||
*/
|
||||
export const defaultHysteria2ServerConfigView: Hysteria2ServerConfigView = {
|
||||
listen: ":443",
|
||||
tls: {
|
||||
cert: "",
|
||||
key: "",
|
||||
sniGuard: "",
|
||||
clientCA: "",
|
||||
},
|
||||
ech: {
|
||||
keyPath: "",
|
||||
},
|
||||
acme: {
|
||||
domains: [],
|
||||
email: "",
|
||||
ca: "letsencrypt",
|
||||
listenHost: "0.0.0.0",
|
||||
dir: "/var/lib/hysteria/acme",
|
||||
type: "",
|
||||
http: {
|
||||
altPort: 8888,
|
||||
},
|
||||
tls: {
|
||||
altPort: 44333,
|
||||
},
|
||||
dns: {
|
||||
name: "cloudflare",
|
||||
config: {},
|
||||
},
|
||||
disableHTTP: false,
|
||||
disableTLSALPN: false,
|
||||
altHTTPPort: 80,
|
||||
altTLSALPNPort: 443,
|
||||
},
|
||||
obfs: {
|
||||
type: "gecko",
|
||||
salamander: {
|
||||
password: "",
|
||||
},
|
||||
gecko: {
|
||||
password: "",
|
||||
minPacketSize: 512,
|
||||
maxPacketSize: 1200,
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: 8388608,
|
||||
maxStreamReceiveWindow: 8388608,
|
||||
initConnReceiveWindow: 20971520,
|
||||
maxConnReceiveWindow: 20971520,
|
||||
maxIdleTimeout: "30s",
|
||||
maxIncomingStreams: 1024,
|
||||
disablePathMTUDiscovery: false,
|
||||
disableStatelessReset: false,
|
||||
},
|
||||
bandwidth: {
|
||||
up: "50 mbps",
|
||||
down: "50 mbps",
|
||||
disableLossCompensation: false,
|
||||
},
|
||||
congestion: {
|
||||
type: "bbr",
|
||||
bbrProfile: "standard",
|
||||
},
|
||||
ignoreClientBandwidth: false,
|
||||
speedTest: false,
|
||||
disableUDP: false,
|
||||
udpIdleTimeout: "60s",
|
||||
resolver: {
|
||||
type: "",
|
||||
tcp: {
|
||||
addr: "8.8.8.8:53",
|
||||
timeout: "4s",
|
||||
},
|
||||
udp: {
|
||||
addr: "8.8.4.4:53",
|
||||
timeout: "4s",
|
||||
},
|
||||
tls: {
|
||||
addr: "1.1.1.1:853",
|
||||
timeout: "10s",
|
||||
sni: "cloudflare-dns.com",
|
||||
insecure: false,
|
||||
},
|
||||
https: {
|
||||
addr: "1.1.1.1:443",
|
||||
timeout: "10s",
|
||||
sni: "cloudflare-dns.com",
|
||||
insecure: false,
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: true,
|
||||
timeout: "2s",
|
||||
rewriteDomain: false,
|
||||
tcpPorts: "80,443,8000-9000",
|
||||
udpPorts: "all",
|
||||
},
|
||||
acl: {
|
||||
file: "",
|
||||
inline: [],
|
||||
geoip: "",
|
||||
geosite: "",
|
||||
geoUpdateInterval: "168h",
|
||||
},
|
||||
outbounds: [],
|
||||
trafficStats: {
|
||||
listen: ":9999",
|
||||
},
|
||||
masquerade: {
|
||||
type: "",
|
||||
file: {
|
||||
dir: "",
|
||||
},
|
||||
proxy: {
|
||||
url: "",
|
||||
rewriteHost: true,
|
||||
insecure: false,
|
||||
xForwarded: false,
|
||||
},
|
||||
string: {
|
||||
content: "hello stupid world",
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
},
|
||||
listenHTTP: ":80",
|
||||
listenHTTPS: ":443",
|
||||
forceHTTPS: true,
|
||||
},
|
||||
mimic: {
|
||||
enabled: false,
|
||||
interface: "",
|
||||
xdpMode: "",
|
||||
path: "",
|
||||
extraArgs: [],
|
||||
},
|
||||
realm: {
|
||||
stunServers: [],
|
||||
stunTimeout: "",
|
||||
punchTimeout: "",
|
||||
heartbeatInterval: "",
|
||||
insecure: false,
|
||||
ipMode: "",
|
||||
portMapping: {
|
||||
enabled: false,
|
||||
timeout: "",
|
||||
lifetime: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивное наложение ответа сервера на значение по умолчанию.
|
||||
*
|
||||
* `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и
|
||||
* секция со значением `null` означают одно и то же — «не задано», — и обе
|
||||
* обязаны оставить значение по умолчанию, а не обнулить поле формы.
|
||||
*
|
||||
* Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил
|
||||
* или outbounds с сервера — это весь список, а не патч к дефолтному.
|
||||
*/
|
||||
function mergeInto(target: Record<string, unknown>, source: unknown): void {
|
||||
if (!isPlainObject(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const existing = target[key];
|
||||
if (!isPlainObject(existing)) {
|
||||
target[key] = {};
|
||||
}
|
||||
mergeInto(target[key] as Record<string, unknown>, value);
|
||||
continue;
|
||||
}
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneDefaults(): Hysteria2ServerConfigView {
|
||||
// structuredClone есть во всех целевых браузерах и, в отличие от
|
||||
// JSON.parse(JSON.stringify(...)), не тратит проход на сериализацию.
|
||||
return structuredClone(defaultHysteria2ServerConfigView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит ответ сервера к модели, с которой работает форма.
|
||||
*
|
||||
* Пустой или отсутствующий ответ даёт полное значение по умолчанию: это то же
|
||||
* состояние, в котором форма находится до первого запроса.
|
||||
*/
|
||||
export function normalizeHysteriaViewModel(
|
||||
raw: Hysteria2ServerConfig | null | undefined
|
||||
): Hysteria2ServerConfigView {
|
||||
const view = cloneDefaults();
|
||||
mergeInto(view as unknown as Record<string, unknown>, raw);
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Значение по умолчанию для одного outbound. */
|
||||
export const defaultHysteria2ServerConfigOutboundView: Hysteria2ServerConfigOutboundView =
|
||||
{
|
||||
name: "",
|
||||
type: "socks5",
|
||||
socks5: {
|
||||
addr: "",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
http: {
|
||||
url: "",
|
||||
insecure: false,
|
||||
},
|
||||
direct: {
|
||||
mode: "auto",
|
||||
bindIPv4: "",
|
||||
bindIPv6: "",
|
||||
bindDevice: "",
|
||||
fastOpen: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Тот же приём для одного outbound: список приходит с необязательными
|
||||
* подблоками, а карточка показывает их как обычные поля.
|
||||
*/
|
||||
export function normalizeOutboundViewModel(
|
||||
raw: Hysteria2ServerConfigOutbound | null | undefined
|
||||
): Hysteria2ServerConfigOutboundView {
|
||||
const view = structuredClone(defaultHysteria2ServerConfigOutboundView);
|
||||
mergeInto(view as unknown as Record<string, unknown>, raw);
|
||||
return view;
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { AxiosPromise } from "axios";
|
||||
import request from "@/utils/request";
|
||||
import {
|
||||
ConfigDto,
|
||||
ConfigsDto,
|
||||
ConfigUpdateDto,
|
||||
ConfigVo,
|
||||
Hysteria2AcmePathVo,
|
||||
Hysteria2ServerConfig,
|
||||
} from "@/api/config/types";
|
||||
|
||||
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
|
||||
// им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и
|
||||
// загрузки сертификатов здесь нет — соответствующих маршрутов не существует.
|
||||
//
|
||||
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
|
||||
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
|
||||
//
|
||||
// getConfigApi удалён вместе с маршрутом: он принимал произвольный ключ той же
|
||||
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
|
||||
// listConfigApi, на стороне сервера — по allowlist.
|
||||
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
||||
return request({
|
||||
url: "/config/getHysteria2Config",
|
||||
@@ -16,24 +25,6 @@ export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateHysteria2ConfigApi(
|
||||
data: Hysteria2ServerConfig
|
||||
): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/updateHysteria2Config",
|
||||
method: "post",
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function getConfigApi(data: ConfigDto): AxiosPromise<ConfigVo> {
|
||||
return request({
|
||||
url: "/config/getConfig",
|
||||
method: "get",
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function listConfigApi(data: ConfigsDto): AxiosPromise<Array<ConfigVo>> {
|
||||
return request({
|
||||
url: "/config/listConfig",
|
||||
@@ -50,25 +41,6 @@ export function updateConfigsApi(data: ConfigUpdateDto): AxiosPromise {
|
||||
});
|
||||
}
|
||||
|
||||
export function exportConfigApi(): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/exportConfig",
|
||||
method: "post",
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
export function importConfigApi(data: FormData): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/importConfig",
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function exportHysteria2ConfigApi(): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/exportHysteria2Config",
|
||||
@@ -76,37 +48,3 @@ export function exportHysteria2ConfigApi(): AxiosPromise {
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
export function importHysteria2ConfigApi(data: FormData): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/importHysteria2Config",
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function hysteria2AcmePathApi(): AxiosPromise<Hysteria2AcmePathVo> {
|
||||
return request({
|
||||
url: "/config/hysteria2AcmePath",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function restartServerApi(): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/restartServer",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadCertFileApi(data: FormData): AxiosPromise<string> {
|
||||
return request({
|
||||
url: "/config/uploadCertFile",
|
||||
method: "post",
|
||||
data,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export interface ConfigDto {
|
||||
key: string;
|
||||
}
|
||||
// ConfigDto удалён вместе с маршрутом getConfig: чтение произвольного ключа
|
||||
// таблицы `config` было входом к JWT- и peer-ключам.
|
||||
|
||||
export interface ConfigsDto {
|
||||
keys: Array<string>;
|
||||
@@ -20,12 +19,27 @@ export interface ConfigUpdateDto {
|
||||
configUpdateDtos: Array<ConfigsUpdateDto>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфиг Hysteria в том виде, в котором он ПРИХОДИТ ПО СЕТИ.
|
||||
*
|
||||
* Необязательность секций здесь не оплошность: ровно так устроен upstream YAML,
|
||||
* и модель обязана его повторять, иначе она врала бы об ответе сервера.
|
||||
*
|
||||
* Форма панели работает не с этим типом, а с `Hysteria2ServerConfigView` из
|
||||
* `hysteriaViewModel.ts`, где все секции обязательны. Значения по умолчанию
|
||||
* живут там же: пока они лежали здесь, рядом с описанием ответа сервера, они
|
||||
* выглядели частью протокола, хотя относятся исключительно к отображению.
|
||||
*/
|
||||
export interface Hysteria2ServerConfig {
|
||||
listen: string;
|
||||
tls?: {
|
||||
cert: string;
|
||||
key: string;
|
||||
sniGuard?: string;
|
||||
clientCA?: string;
|
||||
};
|
||||
ech?: {
|
||||
keyPath?: string;
|
||||
};
|
||||
acme?: {
|
||||
domains: string[];
|
||||
@@ -51,9 +65,14 @@ export interface Hysteria2ServerConfig {
|
||||
};
|
||||
obfs?: {
|
||||
type: string;
|
||||
salamander: {
|
||||
salamander?: {
|
||||
password: string;
|
||||
};
|
||||
gecko?: {
|
||||
password: string;
|
||||
minPacketSize?: number;
|
||||
maxPacketSize?: number;
|
||||
};
|
||||
};
|
||||
quic?: {
|
||||
initStreamReceiveWindow?: number;
|
||||
@@ -63,10 +82,16 @@ export interface Hysteria2ServerConfig {
|
||||
maxIdleTimeout?: string;
|
||||
maxIncomingStreams?: number;
|
||||
disablePathMTUDiscovery?: boolean;
|
||||
disableStatelessReset?: boolean;
|
||||
};
|
||||
bandwidth?: {
|
||||
up: string;
|
||||
down: string;
|
||||
disableLossCompensation?: boolean;
|
||||
};
|
||||
congestion?: {
|
||||
type?: string;
|
||||
bbrProfile?: string;
|
||||
};
|
||||
ignoreClientBandwidth?: boolean;
|
||||
speedTest?: boolean;
|
||||
@@ -122,6 +147,7 @@ export interface Hysteria2ServerConfig {
|
||||
url: string;
|
||||
rewriteHost: boolean;
|
||||
insecure: boolean;
|
||||
xForwarded?: boolean;
|
||||
};
|
||||
string?: {
|
||||
content: string;
|
||||
@@ -132,122 +158,28 @@ export interface Hysteria2ServerConfig {
|
||||
listenHTTPS?: string;
|
||||
forceHTTPS?: boolean;
|
||||
};
|
||||
mimic?: {
|
||||
enabled?: boolean;
|
||||
interface?: string;
|
||||
xdpMode?: string;
|
||||
path?: string;
|
||||
extraArgs?: string[];
|
||||
};
|
||||
realm?: {
|
||||
stunServers?: string[];
|
||||
stunTimeout?: string;
|
||||
punchTimeout?: string;
|
||||
heartbeatInterval?: string;
|
||||
insecure?: boolean;
|
||||
ipMode?: string;
|
||||
portMapping?: {
|
||||
enabled?: boolean;
|
||||
timeout?: string;
|
||||
lifetime?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
|
||||
listen: ":443",
|
||||
tls: {
|
||||
cert: "",
|
||||
key: "",
|
||||
sniGuard: "",
|
||||
},
|
||||
acme: {
|
||||
domains: [],
|
||||
email: "",
|
||||
ca: "zerossl",
|
||||
listenHost: "0.0.0.0",
|
||||
dir: "my_acme_dir",
|
||||
type: "",
|
||||
http: {
|
||||
altPort: 8888,
|
||||
},
|
||||
tls: {
|
||||
altPort: 44333,
|
||||
},
|
||||
dns: {
|
||||
name: "gomommy",
|
||||
config: {},
|
||||
},
|
||||
disableHTTP: false,
|
||||
disableTLSALPN: false,
|
||||
altHTTPPort: 80,
|
||||
altTLSALPNPort: 443,
|
||||
},
|
||||
obfs: {
|
||||
type: "salamander",
|
||||
salamander: {
|
||||
password: "cry_me_a_r1ver",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: 8388608,
|
||||
maxStreamReceiveWindow: 8388608,
|
||||
initConnReceiveWindow: 20971520,
|
||||
maxConnReceiveWindow: 20971520,
|
||||
maxIdleTimeout: "30s",
|
||||
maxIncomingStreams: 1024,
|
||||
disablePathMTUDiscovery: false,
|
||||
},
|
||||
bandwidth: {
|
||||
up: "1 gbps",
|
||||
down: "1 gbps",
|
||||
},
|
||||
ignoreClientBandwidth: false,
|
||||
speedTest: false,
|
||||
disableUDP: false,
|
||||
udpIdleTimeout: "60s",
|
||||
resolver: {
|
||||
type: "",
|
||||
tcp: {
|
||||
addr: "8.8.8.8:53",
|
||||
timeout: "4s",
|
||||
},
|
||||
udp: {
|
||||
addr: "8.8.4.4:53",
|
||||
timeout: "4s",
|
||||
},
|
||||
tls: {
|
||||
addr: "1.1.1.1:853",
|
||||
timeout: "10s",
|
||||
sni: "cloudflare-dns.com",
|
||||
insecure: false,
|
||||
},
|
||||
https: {
|
||||
addr: "1.1.1.1:443",
|
||||
timeout: "10s",
|
||||
sni: "cloudflare-dns.com",
|
||||
insecure: false,
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: true,
|
||||
timeout: "2s",
|
||||
rewriteDomain: false,
|
||||
tcpPorts: "80,443,8000-9000",
|
||||
udpPorts: "all",
|
||||
},
|
||||
acl: {
|
||||
file: "",
|
||||
inline: [],
|
||||
geoip: "",
|
||||
geosite: "",
|
||||
geoUpdateInterval: "168h",
|
||||
},
|
||||
outbounds: [],
|
||||
trafficStats: {
|
||||
listen: ":9999",
|
||||
},
|
||||
masquerade: {
|
||||
type: "",
|
||||
file: {
|
||||
dir: "",
|
||||
},
|
||||
proxy: {
|
||||
url: "",
|
||||
rewriteHost: true,
|
||||
insecure: false,
|
||||
},
|
||||
string: {
|
||||
content: "hello stupid world",
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
},
|
||||
listenHTTP: ":80",
|
||||
listenHTTPS: ":443",
|
||||
forceHTTPS: true,
|
||||
},
|
||||
};
|
||||
|
||||
export interface Hysteria2ServerConfigOutbound {
|
||||
name: string;
|
||||
type: string;
|
||||
@@ -269,34 +201,7 @@ export interface Hysteria2ServerConfigOutbound {
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultHysteria2ServerConfigOutbound: Hysteria2ServerConfigOutbound =
|
||||
{
|
||||
name: "",
|
||||
type: "socks5",
|
||||
socks5: {
|
||||
addr: "",
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
},
|
||||
http: {
|
||||
url: "",
|
||||
insecure: false,
|
||||
},
|
||||
direct: {
|
||||
mode: "auto",
|
||||
bindIPv4: undefined,
|
||||
bindIPv6: undefined,
|
||||
bindDevice: undefined,
|
||||
fastOpen: false,
|
||||
},
|
||||
};
|
||||
|
||||
export interface Tab {
|
||||
name: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface Hysteria2AcmePathVo {
|
||||
crtPath: string;
|
||||
keyPath: string;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ export interface LogSystemVo {
|
||||
level: string;
|
||||
msg: string;
|
||||
reqMethod: string;
|
||||
reqUri: string;
|
||||
// Путь без query string: в query по машинному endpoint'у приезжает
|
||||
// действующий machine token, и в журнале его быть не должно.
|
||||
reqPath: string;
|
||||
// Только имена переданных query-параметров, без значений.
|
||||
reqQueryKeys: string;
|
||||
statusCode: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
@@ -89,10 +89,26 @@ export function importPeerApi(data: FormData): AxiosPromise {
|
||||
});
|
||||
}
|
||||
|
||||
export function exportPeerApi(): AxiosPromise {
|
||||
/**
|
||||
* Экспорт пиров в двух режимах, и это продуктовое различие, а не флаг.
|
||||
*
|
||||
* Без секретов — список настроек пиров. Файл безопасно хранить, но при импорте
|
||||
* на другой сервер записям с пустым секретом выдаются НОВЫЕ секреты, то есть
|
||||
* прежние клиентские ссылки перестают работать.
|
||||
*
|
||||
* С секретами — резервная копия, сохраняющая работоспособность существующих
|
||||
* ссылок. Файл содержит действующие учётные данные доступа, поэтому запускается
|
||||
* только через явное подтверждение оператора.
|
||||
*
|
||||
* Раньше кнопка была одна и всегда звала маршрут без includeSecrets, а
|
||||
* документация называла эту пару механизмом переноса пиров. Оператор
|
||||
* переносил пиров и обнаруживал, что все клиенты отвалились.
|
||||
*/
|
||||
export function exportPeerApi(includeSecrets = false): AxiosPromise {
|
||||
return request({
|
||||
url: "/peer-export",
|
||||
method: "post",
|
||||
params: includeSecrets ? { includeSecrets: "true" } : undefined,
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,28 @@ export interface PeerSaveDto {
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface PeerVo extends IdDto {
|
||||
/**
|
||||
* Строка списка пиров.
|
||||
*
|
||||
* Объявлена через `type`, а не `interface`, и это не стилистика.
|
||||
*
|
||||
* `el-table-column` типизирует scope своего default-слота как
|
||||
* `{ row: DefaultRow, ... }`, где `DefaultRow = Record<PropertyKey, any>`:
|
||||
* колонка — отдельный компонент, и тип строки из `:data` родительской таблицы
|
||||
* в неё не попадает. Значит, где-то на пути от слота к обработчику нужен ровно
|
||||
* один переход `DefaultRow -> PeerVo` (см. `peerRow` в списке пиров).
|
||||
*
|
||||
* TypeScript даёт неявную индексную сигнатуру псевдонимам типов, но НЕ
|
||||
* интерфейсам и не пересечениям с ними. Пока `PeerVo` был `interface`, он не
|
||||
* был совместим с `DefaultRow` ни в одну сторону, и этот переход требовал
|
||||
* двойного приведения через `unknown` — то есть утверждения, которое компилятор
|
||||
* вообще не проверяет. С псевдонимом типа переход остаётся одним обычным
|
||||
* приведением между связанными типами.
|
||||
*/
|
||||
export type PeerVo = {
|
||||
// `id` объявлен здесь, а не унаследован от `IdDto`: пересечение с интерфейсом
|
||||
// неявную индексную сигнатуру не получает.
|
||||
id: number;
|
||||
name: string;
|
||||
remark: string;
|
||||
authId: string;
|
||||
@@ -39,7 +60,7 @@ export interface PeerVo extends IdDto {
|
||||
createTime: string;
|
||||
online: boolean;
|
||||
onlineDevices: number;
|
||||
}
|
||||
};
|
||||
|
||||
export interface PeerClientConfigVo {
|
||||
url: string;
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
<template>
|
||||
<draggable class="flex gap-2" :list="tags" item-key="id" animation="200">
|
||||
<template #item="{ element }">
|
||||
<el-tag closable @close="handleClose(element)" size="large">
|
||||
{{ element }}
|
||||
<div class="flex gap-2">
|
||||
<el-tag v-for="item in tags" :key="item" size="large">
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-input
|
||||
v-if="inputVisible"
|
||||
ref="inputRef"
|
||||
v-model="tag"
|
||||
class="w-50"
|
||||
@keyup.enter="handleConfirm"
|
||||
@blur="handleConfirm"
|
||||
/>
|
||||
<el-button v-else @click="showInput">+</el-button>
|
||||
</template>
|
||||
</draggable>
|
||||
<el-text v-if="!tags.length" type="info">—</el-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
import { ElInput } from "element-plus";
|
||||
import { PropType } from "vue";
|
||||
|
||||
/**
|
||||
* Список строковых значений конфигурации: ACME-домены, inline-правила ACL.
|
||||
*
|
||||
* Компонент только показывает список, и это приведение UI в соответствие с тем,
|
||||
* что продукт умеет, а не сокращение возможностей.
|
||||
*
|
||||
* Что было. Здесь жил редактор на `vuedraggable`: перетаскивание тегов,
|
||||
* closable-теги с удалением, поле ввода нового значения и
|
||||
* `emit("update:tags")`. Сохранить он ничего не мог — единственное место
|
||||
* использования — форма конфига Hysteria, отрисованная с `:disabled="true"`,
|
||||
* причём значения передаются как `:tags="dataForm.acme.domains"`, без v-model,
|
||||
* то есть слушателя у события не существует. Маршрутов записи серверного
|
||||
* конфига в API нет.
|
||||
*
|
||||
* Цена этого редактора была измеримой. `vuedraggable` поставляется UMD-сборкой,
|
||||
* поэтому её `require("vue")` разрешался в `vue/dist/vue.cjs.prod.js` — полную
|
||||
* сборку Vue вместе с рантайм-компилятором шаблонов. В бандл уезжало:
|
||||
*
|
||||
* vuedraggable ~155 КБ
|
||||
* sortablejs ~115 КБ
|
||||
* @vue/compiler-core + compiler-dom ~230 КБ
|
||||
*
|
||||
* то есть около полумегабайта исходников ради перетаскивания тегов в форме,
|
||||
* которая недоступна для редактирования.
|
||||
*/
|
||||
const props = defineProps({
|
||||
tags: {
|
||||
required: false,
|
||||
@@ -32,42 +43,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:tags", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const tags = useVModel(props, "tags", emit);
|
||||
|
||||
const inputRef = ref(ElInput);
|
||||
|
||||
const state = reactive({
|
||||
tag: "",
|
||||
inputVisible: false,
|
||||
});
|
||||
|
||||
const { tag, inputVisible } = toRefs(state);
|
||||
|
||||
const showInput = () => {
|
||||
state.inputVisible = true;
|
||||
nextTick(() => {
|
||||
inputRef.value!.input!.focus();
|
||||
});
|
||||
};
|
||||
const handleConfirm = (): void => {
|
||||
const newTag = state.tag.trim();
|
||||
if (newTag && !tags.value?.includes(newTag)) {
|
||||
tags.value?.push(newTag);
|
||||
state.tag = "";
|
||||
}
|
||||
state.inputVisible = false;
|
||||
};
|
||||
|
||||
const handleClose = (tag: string): void => {
|
||||
const index = tags.value?.indexOf(tag.trim());
|
||||
if (index !== -1) {
|
||||
tags.value?.splice(index, 1);
|
||||
}
|
||||
};
|
||||
const tags = computed(() => props.tags ?? []);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,71 +1,9 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag
|
||||
:key="key"
|
||||
v-for="(value, key) in mapObject"
|
||||
@close="handleClose(key)"
|
||||
@click="handleInfo(key)"
|
||||
size="large"
|
||||
closable
|
||||
>
|
||||
{{ key }}
|
||||
<el-tag v-for="entry in entries" :key="entry.key" size="large">
|
||||
{{ entry.key }}: {{ entry.value }}
|
||||
</el-tag>
|
||||
|
||||
<el-button @click="handleAdd">+</el-button>
|
||||
|
||||
<el-dialog
|
||||
:title="dialog.title"
|
||||
v-model="dialog.visible"
|
||||
width="600px"
|
||||
append-to-body
|
||||
@close="closeDialog"
|
||||
>
|
||||
<el-form
|
||||
ref="dataFormRef"
|
||||
:rules="dataFormRules"
|
||||
label-position="top"
|
||||
:model="dataForm"
|
||||
>
|
||||
<el-form-item label="key" prop="key">
|
||||
<el-input v-model="dataForm.key" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="value" prop="value">
|
||||
<el-input v-model="dataForm.value" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm"
|
||||
>{{ $t("common.confirm") }}
|
||||
</el-button>
|
||||
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
:title="infoDialog.title"
|
||||
v-model="infoDialog.visible"
|
||||
width="600px"
|
||||
append-to-body
|
||||
@close="infoDialog.visible = false"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="key" prop="key">
|
||||
<el-tag>{{ dataInfo.key }}</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="value" prop="value">
|
||||
<el-tag>{{ dataInfo.value }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="infoDialog.visible = false"
|
||||
>{{ $t("common.cancel") }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-text v-if="!entries.length" type="info">—</el-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,13 +15,22 @@ export default {
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
interface Form {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Словарь «ключ — значение» из конфигурации: параметры ACME DNS-провайдера,
|
||||
* заголовки masquerade.
|
||||
*
|
||||
* Как и ImputMultiple, компонент только показывает содержимое. Здесь был
|
||||
* редактор с диалогом добавления, closable-тегами и `emit("update:mapObject")`,
|
||||
* и он ничего не сохранял по тем же трём причинам: форма конфига Hysteria
|
||||
* отрисована с `:disabled="true"`, значения передаются как
|
||||
* `:map-object="dataForm.acme.dns.config"` без v-model, маршрутов записи
|
||||
* серверного конфига в API нет.
|
||||
*
|
||||
* Значение показывается рядом с ключом: прежний вариант прятал его во второй
|
||||
* диалог по клику на тег, что для read-only страницы лишний шаг — увидеть
|
||||
* настройку сервера нужно сразу.
|
||||
*/
|
||||
const props = defineProps({
|
||||
mapObject: {
|
||||
required: false,
|
||||
@@ -92,87 +39,9 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:mapObject", value: { [key: string]: string }): void;
|
||||
}>();
|
||||
|
||||
const mapObject = useVModel(props, "mapObject", emit);
|
||||
const { t } = useI18n();
|
||||
|
||||
const dataFormRef = ref(ElForm);
|
||||
|
||||
const dataFormRules = {
|
||||
key: [
|
||||
{
|
||||
required: true,
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
value: [
|
||||
{
|
||||
required: true,
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
dataForm: {
|
||||
key: "",
|
||||
value: "",
|
||||
} as Form,
|
||||
dialog: {
|
||||
title: "Add",
|
||||
visible: false,
|
||||
} as DialogType,
|
||||
infoDialog: {
|
||||
title: "Info",
|
||||
visible: false,
|
||||
},
|
||||
dataInfo: {
|
||||
key: "",
|
||||
value: "",
|
||||
} as Form,
|
||||
});
|
||||
|
||||
const { dataForm, dialog, infoDialog, dataInfo } = toRefs(state);
|
||||
|
||||
const handleAdd = () => {
|
||||
state.dialog.visible = true;
|
||||
};
|
||||
|
||||
const handleClose = (key: string): void => {
|
||||
delete mapObject.value[key];
|
||||
};
|
||||
|
||||
const handleInfo = (key: string) => {
|
||||
state.dataInfo = {
|
||||
key: key,
|
||||
value: mapObject.value[key] || "",
|
||||
};
|
||||
state.infoDialog.visible = true;
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
dataFormRef.value.validate((valid: any) => {
|
||||
if (valid) {
|
||||
if (mapObject.value[state.dataForm.key]) {
|
||||
ElMessage.error(t("common.invalid"));
|
||||
return;
|
||||
}
|
||||
mapObject.value[state.dataForm.key] = state.dataForm.value;
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closeDialog = (): void => {
|
||||
state.dialog.visible = false;
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
};
|
||||
const entries = computed(() =>
|
||||
Object.entries(props.mapObject ?? {}).map(([key, value]) => ({ key, value }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Спрайт локальных SVG-иконок.
|
||||
*
|
||||
* Заменяет `vite-plugin-svg-icons` и его `virtual:svg-icons-register`.
|
||||
*
|
||||
* Почему плагин убран. Он не обновлялся с 2022 года и тянул за собой цепочку,
|
||||
* которую починить нельзя: `svgo@2.8.0`, `postcss@5.2.18`, `braces@2.3.2` и
|
||||
* `image-size@0.5.5` — у последней в advisory прямо указано `Patched versions:
|
||||
* <0.0.0`, то есть исправленной версии не существует. Один build-плагин
|
||||
* оставался единственным источником половины оставшихся предупреждений
|
||||
* `pnpm audit` во всём проекте.
|
||||
*
|
||||
* Задача, которую он решал, здесь занимает сорок строк: собрать семнадцать
|
||||
* файлов в один скрытый `<svg>` со `<symbol>` внутри, чтобы `<use href="#...">`
|
||||
* находил их по имени. Имя часто вычисляется в рантайме
|
||||
* (`onlyOneChild.meta.icon`, `isFullscreen ? 'exit-fullscreen' : 'fullscreen'`),
|
||||
* поэтому подход «компонент на иконку», как у unplugin-icons, здесь не
|
||||
* подходит — спрайт с поиском по id нужен именно для этого.
|
||||
*
|
||||
* Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных
|
||||
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в
|
||||
* сборке.
|
||||
*/
|
||||
|
||||
const SYMBOL_PREFIX = "icon";
|
||||
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
|
||||
|
||||
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
|
||||
// запросов за иконками не возникает.
|
||||
const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
|
||||
function iconName(filePath: string): string {
|
||||
return filePath.replace(/^.*\//, "").replace(/\.svg$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Превращает содержимое файла в `<symbol>`.
|
||||
*
|
||||
* Отбрасываются XML-пролог и DOCTYPE: внутри уже существующего документа они
|
||||
* не только бесполезны, но и делают разметку невалидной. `width` и `height`
|
||||
* тоже отбрасываются — размер задаёт компонент.
|
||||
*
|
||||
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
|
||||
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
|
||||
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
|
||||
* задают только width/height, поэтому viewBox для них синтезируется — ровно
|
||||
* так же, как это делал заменённый плагин.
|
||||
*/
|
||||
function toSymbol(raw: string, name: string): string {
|
||||
const withoutProlog = raw
|
||||
.replace(/<\?xml[\s\S]*?\?>/gi, "")
|
||||
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.trim();
|
||||
|
||||
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
|
||||
if (!openTag) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const body = withoutProlog
|
||||
.replace(/^<svg\b[^>]*>/i, "")
|
||||
.replace(/<\/svg>\s*$/i, "");
|
||||
|
||||
const viewBoxAttr = resolveViewBox(openTag[0]);
|
||||
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
|
||||
}
|
||||
|
||||
function resolveViewBox(openTag: string): string {
|
||||
const declared = openTag.match(/viewBox="([^"]+)"/i);
|
||||
if (declared) {
|
||||
return ` viewBox="${declared[1]}"`;
|
||||
}
|
||||
|
||||
const width = numericAttribute(openTag, "width");
|
||||
const height = numericAttribute(openTag, "height");
|
||||
if (width !== null && height !== null) {
|
||||
return ` viewBox="0 0 ${width} ${height}"`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
|
||||
function numericAttribute(openTag: string, name: string): number | null {
|
||||
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const value = Number.parseFloat(match[1]);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
|
||||
* содержимое, а не добавляет второй элемент с теми же id.
|
||||
*/
|
||||
export function registerSvgIcons(): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const symbols = Object.entries(sources)
|
||||
.map(([filePath, raw]) => toSymbol(raw, iconName(filePath)))
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
|
||||
// Тип уточняется явно: getElementById возвращает HTMLElement | null, а
|
||||
// спрайт — SVGSVGElement, и без этого он не примет ни собственный
|
||||
// createElementNS, ни последующие обращения.
|
||||
let sprite = document.getElementById(
|
||||
SPRITE_ELEMENT_ID
|
||||
) as SVGSVGElement | null;
|
||||
|
||||
if (!sprite) {
|
||||
sprite = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
sprite.id = SPRITE_ELEMENT_ID;
|
||||
sprite.setAttribute("aria-hidden", "true");
|
||||
// Скрываем так же, как это делал плагин: элемент обязан остаться в
|
||||
// документе, иначе `<use>` не найдёт symbol, поэтому display:none не
|
||||
// подходит — нужен нулевой размер.
|
||||
sprite.setAttribute(
|
||||
"style",
|
||||
"position:absolute;width:0;height:0;overflow:hidden"
|
||||
);
|
||||
document.body.insertBefore(sprite, document.body.firstChild);
|
||||
}
|
||||
|
||||
sprite.innerHTML = symbols;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const hasRole: Directive = {
|
||||
});
|
||||
|
||||
if (!hasRole) {
|
||||
el.parentNode && el.parentNode.removeChild(el);
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
} else {
|
||||
throw new Error("need roles! Like v-has-role=\"['admin', 'user']\"");
|
||||
|
||||
@@ -80,6 +80,7 @@ export default {
|
||||
add: "Add",
|
||||
confirm: "Confirm",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
copySuccess: "Copy successful",
|
||||
nodeUrl: "Node URL",
|
||||
nodeQrCode: "Node QR Code",
|
||||
@@ -97,8 +98,6 @@ export default {
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
securityRisk: "Security Risks",
|
||||
defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/admin/change-password" style="color: #00BFFF">Click here</a> to change`,
|
||||
noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`,
|
||||
required: "Required",
|
||||
warning: "Warning",
|
||||
fileFormatUnsupported: "File format not supported",
|
||||
@@ -128,6 +127,17 @@ export default {
|
||||
name: "Peer",
|
||||
remark: "Remark",
|
||||
secret: "Secret",
|
||||
form: {
|
||||
namePlaceholder: "e.g. ivan-laptop",
|
||||
nameHint:
|
||||
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.",
|
||||
remarkPlaceholder: "e.g. Ivan's laptop, sales team",
|
||||
remarkHint: "Optional operator note. It is never shown to the client.",
|
||||
secretPlaceholder: "leave empty to generate automatically",
|
||||
secretHint:
|
||||
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
|
||||
quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
|
||||
},
|
||||
maxDevices: "Max devices",
|
||||
disabled: "Disabled",
|
||||
status: "Status",
|
||||
@@ -141,6 +151,13 @@ export default {
|
||||
unlimited: "Unlimited",
|
||||
secretRotateConfirm:
|
||||
"Rotate peer secret? Existing client configurations will stop working until updated.",
|
||||
exportSettings: "Export settings",
|
||||
exportBackup: "Backup",
|
||||
exportSettingsHint:
|
||||
"Peer list without secrets. Safe to store and share, but after importing on another server the peers get new secrets, so existing client links stop working.",
|
||||
exportBackupConfirm:
|
||||
"A backup contains the LIVE connection secrets of every peer in plain text: anyone who gets this file gets VPN access. Treat it like a password and delete it once the migration is done. Only this kind of copy keeps existing client links working after moving to another server.",
|
||||
exportBackupConfirmButton: "I understand, download with secrets",
|
||||
quota: "Quota",
|
||||
download: "Download",
|
||||
upload: "Upload",
|
||||
@@ -169,29 +186,13 @@ export default {
|
||||
},
|
||||
config: {
|
||||
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
|
||||
huiWebPort: "HY2XS admin Web Port",
|
||||
huiWebContext: "HY2XS admin Web Context",
|
||||
hysteria2TrafficTime: "Hysteria2 Traffic Time",
|
||||
huiCrtPath: "HY2XS admin CRT File Path",
|
||||
huiKeyPath: "HY2XS admin KEY File Path",
|
||||
uploadCrtFile: "Upload CRT File",
|
||||
uploadKeyFile: "Upload KEY File",
|
||||
restartServer: "Restart Panel",
|
||||
restartTip: "Restarting, please refresh",
|
||||
useHysteria2Cert: "Use Hysteria2 cert",
|
||||
huiHttps: "Open https on the panel",
|
||||
resetTrafficCron: "Reset traffic schedule task",
|
||||
resetTrafficCronTip:
|
||||
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3",
|
||||
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3. An empty value disables the automatic reset",
|
||||
resetTrafficMonth: "Run once a month, midnight, first of month",
|
||||
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
|
||||
mustBeInteger: "Field must be an integer",
|
||||
invalidWebContext:
|
||||
"Field must start with / and contain only lowercase letters (a-z) and numbers (0-9)",
|
||||
invalidTrafficTime: "Field must be a number with up to one decimal place",
|
||||
},
|
||||
monitor: {
|
||||
huiVersion: "HY2XS admin Version",
|
||||
cpuPercent: "CPU Usage",
|
||||
memPercent: "Memory Usage",
|
||||
diskPercent: "Disk Usage",
|
||||
@@ -226,14 +227,13 @@ export default {
|
||||
addConfigItem: "Add Config Item",
|
||||
hysteria2Version: "Hysteria2 Version",
|
||||
hysteria2Running: "Hysteria2 Status",
|
||||
hysteria2ChangeVersion: "Change",
|
||||
addOutbound: "Add Outbound",
|
||||
extension: "Extension",
|
||||
outboundsEmpty: "No outbounds are configured on the server",
|
||||
listen: "Listen",
|
||||
tls: "TLS",
|
||||
obfs: "Obfuscation",
|
||||
quic: "QUIC parameters",
|
||||
bandwidth: "Bandwidth",
|
||||
congestion: "Congestion control",
|
||||
speedTest: "Speed Test",
|
||||
udp: "UDP",
|
||||
resolver: "Resolver",
|
||||
@@ -243,8 +243,6 @@ export default {
|
||||
http: "Traffic Stats API (HTTP)",
|
||||
masquerade: "Masquerade",
|
||||
config: {
|
||||
enable: "Enable/Disable",
|
||||
remark: "Remark",
|
||||
listen:
|
||||
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
|
||||
tlsType: "TLS type",
|
||||
@@ -282,10 +280,17 @@ export default {
|
||||
"Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)",
|
||||
},
|
||||
obfs: {
|
||||
type: "Type",
|
||||
type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).",
|
||||
salamander: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
},
|
||||
gecko: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
minPacketSize:
|
||||
"Minimum QUIC handshake fragment size. HY2XS baseline: 512.",
|
||||
maxPacketSize:
|
||||
"Maximum QUIC handshake fragment size. HY2XS baseline: 1200, upstream limit: 2048.",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
|
||||
@@ -299,10 +304,18 @@ export default {
|
||||
maxIncomingStreams:
|
||||
"The maximum number of concurrent incoming streams.",
|
||||
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
|
||||
disableStatelessReset:
|
||||
"Disable QUIC stateless reset. Kept off in the HY2XS baseline: stateless reset lets a client with a stale connection reconnect immediately after a server restart or device sleep.",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
disableLossCompensation:
|
||||
"Disable loss compensation. Kept off in the HY2XS baseline, so compensation stays active.",
|
||||
},
|
||||
congestion: {
|
||||
type: "Fallback congestion controller: bbr or reno. Used when Brutal bandwidth is not negotiated by both sides.",
|
||||
bbrProfile: "BBR profile: standard, conservative or aggressive.",
|
||||
},
|
||||
ignoreClientBandwidth:
|
||||
"When enabled, makes the server to disregard any bandwidth hints set by clients",
|
||||
|
||||
@@ -77,6 +77,7 @@ export default {
|
||||
add: "Добавить",
|
||||
confirm: "Подтвердить",
|
||||
cancel: "Отмена",
|
||||
close: "Закрыть",
|
||||
copySuccess: "Скопировано",
|
||||
nodeUrl: "URL узла",
|
||||
nodeQrCode: "QR узла",
|
||||
@@ -94,8 +95,6 @@ export default {
|
||||
yes: "Да",
|
||||
no: "Нет",
|
||||
securityRisk: "Риски безопасности",
|
||||
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/admin/change-password" style="color: #00BFFF">Перейти к смене</a>`,
|
||||
noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`,
|
||||
required: "Обязательное поле",
|
||||
warning: "Внимание",
|
||||
fileFormatUnsupported: "Формат файла не поддерживается",
|
||||
@@ -124,6 +123,17 @@ export default {
|
||||
name: "Пир",
|
||||
remark: "Комментарий",
|
||||
secret: "Секрет",
|
||||
form: {
|
||||
namePlaceholder: "например, ivan-laptop",
|
||||
nameHint:
|
||||
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
||||
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
|
||||
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
||||
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
|
||||
secretHint:
|
||||
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
|
||||
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
|
||||
},
|
||||
maxDevices: "Лимит устройств",
|
||||
disabled: "Отключён",
|
||||
status: "Статус",
|
||||
@@ -137,6 +147,13 @@ export default {
|
||||
unlimited: "Безлимит",
|
||||
secretRotateConfirm:
|
||||
"Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.",
|
||||
exportSettings: "Экспорт настроек",
|
||||
exportBackup: "Резервная копия",
|
||||
exportSettingsHint:
|
||||
"Список пиров без секретов. Безопасно хранить и передавать, но после импорта на другой сервер пирам будут выданы новые секреты, и прежние клиентские ссылки работать не будут.",
|
||||
exportBackupConfirm:
|
||||
"Резервная копия содержит ДЕЙСТВУЮЩИЕ секреты подключения всех пиров в открытом виде: любой, кто получит этот файл, получит доступ к VPN. Храните его как пароль и удалите, когда перенос завершён. Только такая копия сохраняет работоспособность существующих клиентских ссылок после переноса на другой сервер.",
|
||||
exportBackupConfirmButton: "Понимаю, скачать копию с секретами",
|
||||
quota: "Квота",
|
||||
download: "Скачано",
|
||||
upload: "Отдано",
|
||||
@@ -165,29 +182,13 @@ export default {
|
||||
},
|
||||
config: {
|
||||
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
|
||||
huiWebPort: "Порт HY2XS admin",
|
||||
huiWebContext: "Web-контекст HY2XS admin",
|
||||
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
|
||||
huiCrtPath: "Путь к CRT HY2XS admin",
|
||||
huiKeyPath: "Путь к KEY HY2XS admin",
|
||||
uploadCrtFile: "Загрузить CRT",
|
||||
uploadKeyFile: "Загрузить KEY",
|
||||
restartServer: "Перезапустить панель",
|
||||
restartTip: "Перезапуск, обновите страницу",
|
||||
useHysteria2Cert: "Использовать сертификат Hysteria2",
|
||||
huiHttps: "Включить HTTPS панели",
|
||||
resetTrafficCron: "Расписание сброса трафика",
|
||||
resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
|
||||
resetTrafficCronTip:
|
||||
"Cron-выражение для планового сброса трафика. Пустое значение отключает автоматический сброс",
|
||||
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
|
||||
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
|
||||
mustBeInteger: "Поле должно быть целым числом",
|
||||
invalidWebContext:
|
||||
"Поле должно начинаться с / и содержать только строчные буквы (a-z) и цифры (0-9)",
|
||||
invalidTrafficTime:
|
||||
"Поле должно быть числом максимум с одним знаком после запятой",
|
||||
},
|
||||
monitor: {
|
||||
huiVersion: "Версия HY2XS admin",
|
||||
cpuPercent: "CPU",
|
||||
memPercent: "Память",
|
||||
diskPercent: "Диск",
|
||||
@@ -222,14 +223,15 @@ export default {
|
||||
addConfigItem: "Добавить параметр",
|
||||
hysteria2Version: "Версия Hysteria2",
|
||||
hysteria2Running: "Статус Hysteria2",
|
||||
hysteria2ChangeVersion: "Сменить",
|
||||
addOutbound: "Добавить outbound",
|
||||
extension: "Расширение",
|
||||
// addOutbound удалён вместе с редактором outbounds: страница read-only,
|
||||
// маршрутов записи серверного конфига в API нет.
|
||||
outboundsEmpty: "Outbounds в конфигурации сервера не заданы",
|
||||
listen: "Адрес прослушивания",
|
||||
tls: "TLS",
|
||||
obfs: "Маскировка",
|
||||
quic: "Параметры QUIC",
|
||||
bandwidth: "Полоса",
|
||||
congestion: "Congestion control",
|
||||
speedTest: "Тест скорости",
|
||||
udp: "UDP",
|
||||
resolver: "DNS",
|
||||
@@ -239,8 +241,6 @@ export default {
|
||||
http: "Traffic Stats API (HTTP)",
|
||||
masquerade: "Masquerade",
|
||||
config: {
|
||||
enable: "Включить/отключить",
|
||||
remark: "Комментарий",
|
||||
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
|
||||
tlsType: "Тип TLS",
|
||||
tls: {
|
||||
@@ -264,8 +264,15 @@ export default {
|
||||
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
|
||||
},
|
||||
obfs: {
|
||||
type: "Тип",
|
||||
type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
|
||||
salamander: { password: "Сильный пароль Salamander" },
|
||||
gecko: {
|
||||
password: "Сильный пароль Gecko",
|
||||
minPacketSize:
|
||||
"Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
|
||||
maxPacketSize:
|
||||
"Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
|
||||
@@ -275,8 +282,19 @@ export default {
|
||||
maxIdleTimeout: "Максимальный idle timeout",
|
||||
maxIncomingStreams: "Максимум входящих stream",
|
||||
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
|
||||
disableStatelessReset:
|
||||
"Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Вверх",
|
||||
down: "Вниз",
|
||||
disableLossCompensation:
|
||||
"Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает",
|
||||
},
|
||||
congestion: {
|
||||
type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами",
|
||||
bbrProfile: "Профиль BBR: standard, conservative или aggressive",
|
||||
},
|
||||
bandwidth: { up: "Вверх", down: "Вниз" },
|
||||
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
|
||||
speedTest: "Встроенный сервер теста скорости",
|
||||
disableUDP: "Отключить UDP forwarding",
|
||||
|
||||
@@ -72,8 +72,8 @@ function handleOutsideClick() {
|
||||
<style lang="scss" scoped>
|
||||
.app-wrapper {
|
||||
&::after {
|
||||
display: table;
|
||||
clear: both;
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { setupDirective } from "@/directive";
|
||||
import "@/permission";
|
||||
|
||||
// Локальные SVG-иконки
|
||||
import "virtual:svg-icons-register";
|
||||
import { registerSvgIcons } from "@/components/SvgIcon/sprite";
|
||||
|
||||
// Локализация
|
||||
import i18n from "@/lang/index";
|
||||
@@ -16,6 +16,8 @@ import i18n from "@/lang/index";
|
||||
import "@/styles/index.scss";
|
||||
import "uno.css";
|
||||
|
||||
registerSvgIcons();
|
||||
|
||||
const app = createApp(App);
|
||||
// Глобальная регистрация пользовательских директив
|
||||
setupDirective(app);
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useAdminStoreHook } from "@/store/modules/admin";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
|
||||
import NProgress from "nprogress";
|
||||
// Порядок важен: сначала vendor-база, затем тема HY2XS поверх неё.
|
||||
import "nprogress/nprogress.css";
|
||||
import "@/styles/nprogress.scss";
|
||||
|
||||
NProgress.configure({ showSpinner: false }); // Индикатор загрузки
|
||||
|
||||
@@ -34,7 +36,11 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
// Если маршрут не найден, перейти на 404
|
||||
if (to.matched.length === 0) {
|
||||
from.name ? next({ name: from.name }) : next("/404");
|
||||
if (from.name) {
|
||||
next({ name: from.name });
|
||||
} else {
|
||||
next("/404");
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
@@ -56,7 +62,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
return;
|
||||
}
|
||||
next({ ...to, replace: true });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Удалить token и перейти на страницу входа
|
||||
await adminStore.resetToken();
|
||||
next(`/login?redirect=${to.path}`);
|
||||
|
||||
@@ -26,9 +26,14 @@
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
// Цвет фона выбранной строки
|
||||
// Цвет фона выбранной строки.
|
||||
//
|
||||
// Раньше здесь стоял литерал `#e1f3d8b5` — бледно-зелёный из исходного
|
||||
// admin-шаблона, не имеющий отношения к палитре HY2XS. Подсветка выделения —
|
||||
// это семантика «активный элемент», поэтому берётся самый светлый оттенок
|
||||
// брендового primary.
|
||||
.el-table__body tr.current-row td {
|
||||
background-color: #e1f3d8b5 !important;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
// Единая высота header у card
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Тема NProgress.
|
||||
//
|
||||
// Vendor-стили nprogress приходят со своим фирменным синим `#29d`. Он никогда
|
||||
// не переопределялся, поэтому индикатор загрузки был единственным элементом
|
||||
// интерфейса вне брендовой палитры HY2XS.
|
||||
//
|
||||
// Здесь сознательно не пишется `#ff4e2f`: второй литерал того же цвета сразу
|
||||
// стал бы вторым источником истины. Индикатор привязан к той же переменной,
|
||||
// что и весь остальной интерфейс, и поменяется вместе с ней.
|
||||
#nprogress {
|
||||
.bar {
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.peg {
|
||||
box-shadow:
|
||||
0 0 10px var(--el-color-primary),
|
||||
0 0 5px var(--el-color-primary);
|
||||
}
|
||||
|
||||
// Spinner сейчас выключен через NProgress.configure({ showSpinner: false }),
|
||||
// но правило оставлено: если его когда-нибудь включат, синий не вернётся.
|
||||
.spinner-icon {
|
||||
border-top-color: var(--el-color-primary);
|
||||
border-left-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
|
||||
font-family:
|
||||
"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", Arial, sans-serif;
|
||||
line-height: inherit;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
|
||||
// reset element-ui css
|
||||
.horizontal-collapse-transition {
|
||||
transition: 0s width ease-in-out, 0s padding-left ease-in-out,
|
||||
transition:
|
||||
0s width ease-in-out,
|
||||
0s padding-left ease-in-out,
|
||||
0s padding-right ease-in-out;
|
||||
}
|
||||
|
||||
@@ -178,10 +180,10 @@
|
||||
& > .el-sub-menu__title {
|
||||
& > span {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,8 +208,8 @@
|
||||
&.hideSidebar {
|
||||
.sidebar-container {
|
||||
pointer-events: none;
|
||||
transition-duration: 0.3s;
|
||||
transform: translate3d(-$sideBarWidth, 0, 0);
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+145
-868
File diff suppressed because it is too large
Load Diff
+61
-58
@@ -1,65 +1,68 @@
|
||||
// generated by unplugin-vue-components
|
||||
// We suggest you to commit this file into source control
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import "@vue/runtime-core";
|
||||
|
||||
export {};
|
||||
export {}
|
||||
|
||||
declare module "@vue/runtime-core" {
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import("element-plus/es")["ElAlert"];
|
||||
ElAvatar: typeof import("element-plus/es")["ElAvatar"];
|
||||
ElButton: typeof import("element-plus/es")["ElButton"];
|
||||
ElCard: typeof import("element-plus/es")["ElCard"];
|
||||
ElCol: typeof import("element-plus/es")["ElCol"];
|
||||
ElDatePicker: typeof import("element-plus/es")["ElDatePicker"];
|
||||
ElDialog: typeof import("element-plus/es")["ElDialog"];
|
||||
ElDrawer: typeof import("element-plus/es")["ElDrawer"];
|
||||
ElDropdown: typeof import("element-plus/es")["ElDropdown"];
|
||||
ElDropdownItem: typeof import("element-plus/es")["ElDropdownItem"];
|
||||
ElDropdownMenu: typeof import("element-plus/es")["ElDropdownMenu"];
|
||||
ElEmpty: typeof import("element-plus/es")["ElEmpty"];
|
||||
ElForm: typeof import("element-plus/es")["ElForm"];
|
||||
ElFormItem: typeof import("element-plus/es")["ElFormItem"];
|
||||
ElInput: typeof import("element-plus/es")["ElInput"];
|
||||
ElInputNumber: typeof import("element-plus/es")["ElInputNumber"];
|
||||
ElMenu: typeof import("element-plus/es")["ElMenu"];
|
||||
ElMenuItem: typeof import("element-plus/es")["ElMenuItem"];
|
||||
ElOption: typeof import("element-plus/es")["ElOption"];
|
||||
ElPagination: typeof import("element-plus/es")["ElPagination"];
|
||||
ElProgress: typeof import("element-plus/es")["ElProgress"];
|
||||
ElRadioButton: typeof import("element-plus/es")["ElRadioButton"];
|
||||
ElRadioGroup: typeof import("element-plus/es")["ElRadioGroup"];
|
||||
ElRow: typeof import("element-plus/es")["ElRow"];
|
||||
ElScrollbar: typeof import("element-plus/es")["ElScrollbar"];
|
||||
ElSelect: typeof import("element-plus/es")["ElSelect"];
|
||||
ElSubMenu: typeof import("element-plus/es")["ElSubMenu"];
|
||||
ElSwitch: typeof import("element-plus/es")["ElSwitch"];
|
||||
ElTable: typeof import("element-plus/es")["ElTable"];
|
||||
ElTableColumn: typeof import("element-plus/es")["ElTableColumn"];
|
||||
ElTabPane: typeof import("element-plus/es")["ElTabPane"];
|
||||
ElTabs: typeof import("element-plus/es")["ElTabs"];
|
||||
ElTag: typeof import("element-plus/es")["ElTag"];
|
||||
ElTooltip: typeof import("element-plus/es")["ElTooltip"];
|
||||
ElUpload: typeof import("element-plus/es")["ElUpload"];
|
||||
Hamburger: typeof import("./../components/Hamburger/index.vue")["default"];
|
||||
IEpCaretBottom: typeof import("~icons/ep/caret-bottom")["default"];
|
||||
IEpDownload: typeof import("~icons/ep/download")["default"];
|
||||
IEpMoreFilled: typeof import("~icons/ep/more-filled")["default"];
|
||||
IEpRefresh: typeof import("~icons/ep/refresh")["default"];
|
||||
IEpRefreshRight: typeof import("~icons/ep/refresh-right")["default"];
|
||||
IEpUpload: typeof import("~icons/ep/upload")["default"];
|
||||
IEpUserFilled: typeof import("~icons/ep/user-filled")["default"];
|
||||
ImputMultiple: typeof import("./../components/ImputMultiple/index.vue")["default"];
|
||||
LangSelect: typeof import("./../components/LangSelect/index.vue")["default"];
|
||||
MapAdd: typeof import("./../components/MapAdd/index.vue")["default"];
|
||||
Pagination: typeof import("./../components/Pagination/index.vue")["default"];
|
||||
RouterLink: typeof import("vue-router")["RouterLink"];
|
||||
RouterView: typeof import("vue-router")["RouterView"];
|
||||
SvgIcon: typeof import("./../components/SvgIcon/index.vue")["default"];
|
||||
UnitSelect: typeof import("./../components/UnitSelect/index.vue")["default"];
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
|
||||
IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default']
|
||||
IEpDownload: typeof import('~icons/ep/download')['default']
|
||||
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
|
||||
IEpRefresh: typeof import('~icons/ep/refresh')['default']
|
||||
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
|
||||
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
|
||||
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
|
||||
MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
|
||||
Pagination: typeof import('./../components/Pagination/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
|
||||
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import("element-plus/es")["ElLoadingDirective"];
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
-2
@@ -2,8 +2,12 @@
|
||||
|
||||
declare module "*.vue" {
|
||||
import { DefineComponent } from "vue";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
// `object` вместо `{}`: последний допускает любое ненулевое значение,
|
||||
// включая 0 и "", то есть не ограничивает ничего. Правило ban-types, под
|
||||
// которое здесь стояло подавление, в @typescript-eslint 8 удалено и
|
||||
// разделено на точечные — само подавление ссылалось на несуществующее имя.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const component: DefineComponent<object, object, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useAdminStoreHook } from "@/store/modules/admin";
|
||||
import i18n from "@/lang/index";
|
||||
|
||||
const dynamicBase = (window as any).__dynamic_base__ || "";
|
||||
const API_BASE = "/hui";
|
||||
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
|
||||
// под ним лежали и machine-to-machine auth Hysteria, и JWT-защищённый
|
||||
// админский API, хотя middleware у них разные.
|
||||
// Значение синхронизировано с constant.AdminAPIBase в админке и
|
||||
// ADMIN_API_BASE в оркестраторе.
|
||||
const API_BASE = "/api";
|
||||
const t = i18n.global.t;
|
||||
// Создание axios instance
|
||||
const service = axios.create({
|
||||
|
||||
@@ -7,39 +7,6 @@
|
||||
{{ $t("common.save") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleRestartServer">
|
||||
<template #icon>
|
||||
<i-ep-refreshRight />
|
||||
</template>
|
||||
{{ $t("config.restartServer") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
:http-request="handleImport"
|
||||
:show-file-list="false"
|
||||
accept=".json"
|
||||
:limit="1"
|
||||
:before-upload="beforeImport"
|
||||
>
|
||||
<el-button>
|
||||
<template #icon>
|
||||
<i-ep-upload />
|
||||
</template>
|
||||
{{ $t("common.import") }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -57,73 +24,6 @@
|
||||
:model="dataForm"
|
||||
label-position="top"
|
||||
>
|
||||
<el-form-item :label="$t('config.huiWebPort')" prop="huiWebPort">
|
||||
<el-input
|
||||
v-model="dataForm.huiWebPort"
|
||||
:placeholder="$t('config.huiWebPort')"
|
||||
disabled
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('config.huiWebContext')" prop="huiWebContext">
|
||||
<el-input
|
||||
v-model="dataForm.huiWebContext"
|
||||
:placeholder="$t('config.huiWebContext')"
|
||||
disabled
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('config.hysteria2TrafficTime')"
|
||||
prop="hysteria2TrafficTime"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.hysteria2TrafficTime"
|
||||
:placeholder="$t('config.hysteria2TrafficTime')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('config.huiHttps')" prop="huiHttps">
|
||||
<el-select
|
||||
v-model="huiHttps"
|
||||
style="width: 50%"
|
||||
ref="huiHttpsRef"
|
||||
disabled
|
||||
>
|
||||
<el-option
|
||||
v-for="item in huiHttpsList"
|
||||
:key="item.key"
|
||||
:label="item.key"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="huiHttps"
|
||||
:label="$t('config.huiCrtPath')"
|
||||
prop="huiCrtPath"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.huiCrtPath"
|
||||
:placeholder="$t('config.huiCrtPath')"
|
||||
style="width: 50%"
|
||||
disabled
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="huiHttps"
|
||||
:label="$t('config.huiKeyPath')"
|
||||
prop="huiKeyPath"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.huiKeyPath"
|
||||
:placeholder="$t('config.huiKeyPath')"
|
||||
style="width: 50%"
|
||||
disabled
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-tooltip
|
||||
:content="$t('config.resetTrafficCronTip')"
|
||||
placement="bottom"
|
||||
@@ -161,104 +61,60 @@ export default {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Сетевые настройки самой панели (порт, web-контекст, HTTPS, пути к
|
||||
// сертификатам) здесь отсутствуют намеренно: ими владеет install-оркестратор
|
||||
// через /etc/hy2xs/hy2xs.env и systemd-юнит. Раньше они лежали в SQLite и
|
||||
// показывались тут в disabled-виде — второй источник истины, из которого
|
||||
// ничего нельзя было изменить.
|
||||
//
|
||||
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
|
||||
// целиком, вместе с JWT- и peer-ключами.
|
||||
//
|
||||
// Поля «Период учёта трафика Hysteria2» здесь тоже больше нет. Ключ
|
||||
// HYSTERIA2_TRAFFIC_TIME не имел в рантайме ни одного потребителя: интервал
|
||||
// сбора метрик задан в коде админки, а traffic accounting его не читал.
|
||||
// Настройка меняла строку в базе и ничего больше — то есть панель обещала
|
||||
// оператору управление, которого не существовало.
|
||||
//
|
||||
// Осталась ровно одна настройка, которой панель действительно владеет.
|
||||
import { Select } from "@element-plus/icons-vue";
|
||||
import {
|
||||
exportConfigApi,
|
||||
importConfigApi,
|
||||
listConfigApi,
|
||||
restartServerApi,
|
||||
updateConfigsApi,
|
||||
} from "@/api/config";
|
||||
import { listConfigApi, updateConfigsApi } from "@/api/config";
|
||||
import { ConfigsUpdateDto } from "@/api/config/types";
|
||||
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const dataFormRef = ref(ElForm);
|
||||
const huiHttpsRef = ref<any>(null);
|
||||
|
||||
const huiWebPortKey = "H_UI_WEB_PORT";
|
||||
const huiWebContext = "H_UI_WEB_CONTEXT";
|
||||
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
|
||||
const huiCrtPathKey = "H_UI_CRT_PATH";
|
||||
const huiKeyPathKey = "H_UI_KEY_PATH";
|
||||
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
|
||||
|
||||
const huiHttpsList = [
|
||||
{ key: t("common.yes"), value: 1 },
|
||||
{ key: t("common.no"), value: 0 },
|
||||
];
|
||||
|
||||
const cronResetTraffic = [
|
||||
{ key: t("config.resetTrafficMonth"), value: "@monthly" },
|
||||
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
|
||||
];
|
||||
|
||||
const dataFormRules = {
|
||||
huiWebPort: [
|
||||
{
|
||||
required: true,
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^\d+$/,
|
||||
message: t("config.mustBeInteger"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
huiWebContext: [
|
||||
{
|
||||
required: true,
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^\/([a-z0-9]+(\/[a-z0-9]+)*)?$/,
|
||||
message: t("config.invalidWebContext"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
hysteria2TrafficTime: [
|
||||
{
|
||||
required: true,
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^\d+(\.\d)?$/,
|
||||
message: t("config.invalidTrafficTime"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
};
|
||||
// Правил формы здесь нет намеренно.
|
||||
//
|
||||
// Поле — el-select с allow-create, то есть значение произвольное, и
|
||||
// единственная осмысленная проверка cron-выражения — разбор его тем же
|
||||
// парсером, которым пользуется планировщик. Такой парсер живёт на сервере;
|
||||
// вторая, приблизительная реализация на фронтенде расходилась бы с ним и
|
||||
// давала бы либо ложные отказы, либо ложные разрешения. Пустое значение
|
||||
// легально и означает «автоматический сброс выключен».
|
||||
const dataFormRules = {};
|
||||
|
||||
const state = reactive({
|
||||
dataForm: {
|
||||
huiWebPort: "8081",
|
||||
huiWebContext: "/",
|
||||
hysteria2TrafficTime: "1",
|
||||
huiCrtPath: "",
|
||||
huiKeyPath: "",
|
||||
resetTrafficCron: "",
|
||||
},
|
||||
huiHttps: 0,
|
||||
fileList: [] as UploadFile[],
|
||||
});
|
||||
|
||||
const { dataForm, huiHttps, fileList } = toRefs(state);
|
||||
const { dataForm } = toRefs(state);
|
||||
|
||||
const submitForm = () => {
|
||||
dataFormRef.value.validate((valid: boolean) => {
|
||||
if (valid) {
|
||||
let configs: ConfigsUpdateDto[] = [
|
||||
{
|
||||
key: hysteria2TrafficTimeKey,
|
||||
value: state.dataForm.hysteria2TrafficTime,
|
||||
},
|
||||
{
|
||||
key: resetTrafficCronKey,
|
||||
value: state.dataForm.resetTrafficCron,
|
||||
@@ -274,109 +130,18 @@ const submitForm = () => {
|
||||
|
||||
const setConfig = async () => {
|
||||
const { data } = await listConfigApi({
|
||||
keys: [
|
||||
huiCrtPathKey,
|
||||
huiWebContext,
|
||||
huiKeyPathKey,
|
||||
huiWebPortKey,
|
||||
hysteria2TrafficTimeKey,
|
||||
resetTrafficCronKey,
|
||||
],
|
||||
keys: [resetTrafficCronKey],
|
||||
});
|
||||
|
||||
data.forEach((configVo) => {
|
||||
if (configVo.key === huiWebPortKey) {
|
||||
state.dataForm.huiWebPort = configVo.value;
|
||||
} else if (configVo.key === huiWebContext) {
|
||||
state.dataForm.huiWebContext = configVo.value;
|
||||
} else if (configVo.key === hysteria2TrafficTimeKey) {
|
||||
state.dataForm.hysteria2TrafficTime = configVo.value;
|
||||
} else if (configVo.key === huiCrtPathKey) {
|
||||
state.dataForm.huiCrtPath = configVo.value;
|
||||
} else if (configVo.key === huiKeyPathKey) {
|
||||
state.dataForm.huiKeyPath = configVo.value;
|
||||
} else if (configVo.key === resetTrafficCronKey) {
|
||||
if (configVo.key === resetTrafficCronKey) {
|
||||
state.dataForm.resetTrafficCron = configVo.value;
|
||||
}
|
||||
});
|
||||
|
||||
if (state.dataForm.huiCrtPath != "" && state.dataForm.huiKeyPath != "") {
|
||||
state.huiHttps = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (params: UploadRequestOptions) => {
|
||||
if (state.fileList.length > 0) {
|
||||
try {
|
||||
let formData = new FormData();
|
||||
formData.append("file", params.file);
|
||||
await importConfigApi(formData);
|
||||
ElMessage.success(t("common.success"));
|
||||
state.fileList = [];
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
} finally {
|
||||
await setConfig();
|
||||
}
|
||||
}
|
||||
};
|
||||
const beforeImport = (file: UploadRawFile) => {
|
||||
if (!file.name.endsWith(".json")) {
|
||||
ElMessage.error(t("common.fileFormatUnsupported"));
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 2) {
|
||||
ElMessage.error(t("common.fileTooLarge"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
let response = await exportConfigApi();
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
let url = window.URL.createObjectURL(blob);
|
||||
let a = document.createElement("a");
|
||||
document.body.appendChild(a);
|
||||
a.href = url;
|
||||
let dis = response.headers["content-disposition"];
|
||||
a.download = dis.split("attachment; filename=")[1];
|
||||
// Имитация клика для скачивания
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartServer = async () => {
|
||||
try {
|
||||
ElMessageBox.confirm(t("config.restartTip"), t("common.warning"), {
|
||||
confirmButtonText: t("common.confirm"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
restartServerApi();
|
||||
ElMessage.success(t("config.restartTip"));
|
||||
});
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setConfig();
|
||||
if (route.query.focus === "huiHttps") {
|
||||
nextTick(() => {
|
||||
const input = huiHttpsRef.value.$el.querySelector(".el-input__inner");
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ const loadDashboard = async () => {
|
||||
securityRisks.value = secRes.data;
|
||||
lastSuccessAt.value = Date.now();
|
||||
loadError.value = "";
|
||||
} catch (error) {
|
||||
} catch {
|
||||
loadError.value = t("dashboard.refreshFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
@@ -61,7 +61,7 @@ function back() {
|
||||
|
||||
.pan-back-btn {
|
||||
color: #fff;
|
||||
background: #008489;
|
||||
background: var(--el-color-primary);
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ function back() {
|
||||
}
|
||||
|
||||
a {
|
||||
color: #008489;
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -209,7 +209,7 @@ function message() {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 40px;
|
||||
color: #1482f0;
|
||||
color: var(--el-color-primary);
|
||||
opacity: 0;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
@@ -242,8 +242,8 @@ function message() {
|
||||
}
|
||||
|
||||
&__return-home {
|
||||
display: block;
|
||||
float: left;
|
||||
display: block;
|
||||
width: 110px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
@@ -251,7 +251,7 @@ function message() {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: #1482f0;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 100px;
|
||||
opacity: 0;
|
||||
animation-name: slideUp;
|
||||
|
||||
@@ -1,152 +1,17 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag
|
||||
:key="item"
|
||||
v-for="item in outbounds"
|
||||
@close="handleClose(item)"
|
||||
:key="item.name"
|
||||
@click="handleInfo(item)"
|
||||
size="large"
|
||||
closable
|
||||
>
|
||||
{{ item.name }}
|
||||
</el-tag>
|
||||
|
||||
<el-button @click="handleAdd">+</el-button>
|
||||
|
||||
<el-dialog
|
||||
:title="dialog.title"
|
||||
v-model="dialog.visible"
|
||||
width="600px"
|
||||
append-to-body
|
||||
@close="closeDialog"
|
||||
>
|
||||
<el-form ref="dataFormRef" label-position="top" :model="dataForm">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.name')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="name" prop="name">
|
||||
<el-input v-model="dataForm.name" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.type')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="type" prop="type">
|
||||
<el-select v-model="dataForm.type" style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in outboundTypes"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<template v-if="dataForm.type === 'socks5'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.addr')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.addr" prop="socks5.addr">
|
||||
<el-input v-model="dataForm.socks5.addr" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.username')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.username" prop="socks5.username">
|
||||
<el-input v-model="dataForm.socks5.username" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.password" prop="socks5.password">
|
||||
<el-input v-model="dataForm.socks5.password" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="dataForm.type === 'http'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.http.url')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="http.url" prop="http.url">
|
||||
<el-input v-model="dataForm.http.url" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.http.insecure')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="http.insecure" prop="http.insecure">
|
||||
<el-switch v-model="dataForm.http.insecure" />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="dataForm.type === 'direct'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.mode')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.mode" prop="direct.mode">
|
||||
<el-select v-model="dataForm.direct.mode" style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in outboundDirectModes"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindIPv4')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindIPv4" prop="direct.bindIPv4">
|
||||
<el-input v-model="dataForm.direct.bindIPv4" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindIPv6')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindIPv6" prop="direct.bindIPv6">
|
||||
<el-input v-model="dataForm.direct.bindIPv6" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindDevice')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindDevice" prop="direct.bindDevice">
|
||||
<el-input v-model="dataForm.direct.bindDevice" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.fastOpen')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.fastOpen" prop="direct.fastOpen">
|
||||
<el-switch v-model="dataForm.direct.fastOpen" />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm"
|
||||
>{{ $t("common.confirm") }}
|
||||
</el-button>
|
||||
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-text v-if="!outbounds.length" type="info">
|
||||
{{ $t("hysteria.outboundsEmpty") }}
|
||||
</el-text>
|
||||
|
||||
<el-dialog
|
||||
:title="outboundInfoDialog.title"
|
||||
@@ -185,7 +50,7 @@
|
||||
:content="$t('hysteria.config.outbounds.socks5.username')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.username" prop="outboundInfo.username">
|
||||
<el-form-item label="socks5.username" prop="socks5.username">
|
||||
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
@@ -262,7 +127,7 @@
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="outboundInfoDialog.visible = false"
|
||||
>{{ $t("common.cancel") }}
|
||||
>{{ $t("common.close") }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -277,16 +142,36 @@ export default {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Hysteria2ServerConfigOutbound } from "@/api/config/types";
|
||||
import {
|
||||
defaultHysteria2ServerConfigOutbound,
|
||||
Hysteria2ServerConfigOutbound,
|
||||
} from "@/api/config/types";
|
||||
defaultHysteria2ServerConfigOutboundView,
|
||||
normalizeOutboundViewModel,
|
||||
type Hysteria2ServerConfigOutboundView,
|
||||
} from "@/api/config/hysteriaViewModel";
|
||||
import { PropType } from "vue";
|
||||
import { deepCopy } from "@/utils/copy";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Компонент только показывает outbounds, и это не сужение возможностей, а
|
||||
// приведение UI в соответствие с тем, что продукт умеет.
|
||||
//
|
||||
// Что было. Здесь жил полноценный редактор: кнопка «+», диалог создания,
|
||||
// submitForm с валидацией, closable-теги с удалением и `emit("update:outbounds")`.
|
||||
// Ни одно из этих действий не могло ничего сохранить:
|
||||
//
|
||||
// родительская форма отрисована с `:disabled="true"` — страница read-only и
|
||||
// прямо сообщает, что конфигом Hysteria владеет `hy2xs-orchestrator reconfigure`;
|
||||
//
|
||||
// родитель передаёт `:outbounds="dataForm.outbounds"` БЕЗ v-model, то есть
|
||||
// слушателя у `update:outbounds` не существует;
|
||||
//
|
||||
// маршрутов записи серверного конфига в API нет — они удалены вместе с
|
||||
// мёртвым updater/config-write слоем.
|
||||
//
|
||||
// То есть оператор мог добавить outbound, увидеть его в списке и уйти в
|
||||
// уверенности, что изменил конфигурацию сервера. Изменения не переживали даже
|
||||
// перехода на другую вкладку.
|
||||
const props = defineProps({
|
||||
outbounds: {
|
||||
required: true,
|
||||
@@ -295,84 +180,27 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: "update:outbounds",
|
||||
value: Array<Hysteria2ServerConfigOutbound>
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const outbounds = useVModel(props, "outbounds", emit);
|
||||
|
||||
const dataFormRef = ref(ElForm);
|
||||
const outbounds = computed(() => props.outbounds ?? []);
|
||||
|
||||
const state = reactive({
|
||||
dataForm: {
|
||||
...defaultHysteria2ServerConfigOutbound,
|
||||
} as Hysteria2ServerConfigOutbound,
|
||||
dialog: {
|
||||
title: t("hysteria.addOutbound"),
|
||||
visible: false,
|
||||
} as DialogType,
|
||||
outboundInfoDialog: {
|
||||
title: t("hysteria.outbounds"),
|
||||
visible: false,
|
||||
},
|
||||
outboundInfo: {} as Hysteria2ServerConfigOutbound,
|
||||
// Карточка работает с нормализованной моделью по той же причине, что и
|
||||
// основная форма: подблоки socks5/http/direct в схеме необязательны, а
|
||||
// шаблон обращается к их полям напрямую.
|
||||
outboundInfo: {
|
||||
...defaultHysteria2ServerConfigOutboundView,
|
||||
} as Hysteria2ServerConfigOutboundView,
|
||||
});
|
||||
|
||||
const { dataForm, dialog, outboundInfoDialog, outboundInfo } = toRefs(state);
|
||||
|
||||
const outboundTypes = ["socks5", "http", "direct"];
|
||||
|
||||
const outboundDirectModes = ["auto", "64", "46", "6", "4"];
|
||||
|
||||
const handleAdd = () => {
|
||||
state.dialog.visible = true;
|
||||
};
|
||||
|
||||
const handleClose = (outbound: Hysteria2ServerConfigOutbound): void => {
|
||||
const index = outbounds.value.indexOf(outbound);
|
||||
if (index !== -1) {
|
||||
outbounds.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
const { outboundInfoDialog, outboundInfo } = toRefs(state);
|
||||
|
||||
const handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
|
||||
state.outboundInfo = outbound;
|
||||
state.outboundInfo = normalizeOutboundViewModel(outbound);
|
||||
state.outboundInfoDialog.visible = true;
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
dataFormRef.value.validate((valid: any) => {
|
||||
if (valid) {
|
||||
if (outbounds.value.some((item) => item.name === state.dataForm.name)) {
|
||||
ElMessage.error(t("common.invalid"));
|
||||
return;
|
||||
}
|
||||
if (state.dataForm.type === "socks5") {
|
||||
state.dataForm.http = undefined;
|
||||
state.dataForm.direct = undefined;
|
||||
} else if (state.dataForm.type === "http") {
|
||||
state.dataForm.socks5 = undefined;
|
||||
state.dataForm.direct = undefined;
|
||||
} else if (state.dataForm.type === "direct") {
|
||||
state.dataForm.socks5 = undefined;
|
||||
state.dataForm.http = undefined;
|
||||
}
|
||||
|
||||
let outbound = deepCopy(state.dataForm);
|
||||
outbounds.value.push(outbound);
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closeDialog = (): void => {
|
||||
state.dialog.visible = false;
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -51,16 +51,6 @@
|
||||
:disabled="true"
|
||||
>
|
||||
<el-tabs v-model="activeName" class="tabs">
|
||||
<el-tab-pane :label="$t('hysteria.extension')" name="extension">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.remark')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="remark" prop="remark">
|
||||
<el-input v-model="configForm.remark" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('hysteria.listen')" name="listen">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.listen')"
|
||||
@@ -95,28 +85,9 @@
|
||||
<el-form-item label="cert" prop="tls.cert">
|
||||
<el-input
|
||||
v-model="dataForm.tls.cert"
|
||||
style="width: 50%"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
/>
|
||||
<el-upload
|
||||
style="height: 32px"
|
||||
ref="uploadCrtFile"
|
||||
action=""
|
||||
:file-list="crtFileList"
|
||||
:http-request="uploadCertFile"
|
||||
accept=".crt"
|
||||
:before-upload="
|
||||
() => {
|
||||
crtFileList = [];
|
||||
}
|
||||
"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
>
|
||||
<template #trigger>
|
||||
<el-button>{{ t("config.uploadCrtFile") }}</el-button>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
@@ -127,28 +98,9 @@
|
||||
<el-form-item label="key" prop="tls.key">
|
||||
<el-input
|
||||
v-model="dataForm.tls.key"
|
||||
style="width: 50%"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
/>
|
||||
<el-upload
|
||||
style="height: 32px"
|
||||
ref="uploadKeyFile"
|
||||
action=""
|
||||
:file-list="keyFileList"
|
||||
:http-request="uploadCertFile"
|
||||
accept=".key"
|
||||
:before-upload="
|
||||
() => {
|
||||
keyFileList = [];
|
||||
}
|
||||
"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
>
|
||||
<template #trigger>
|
||||
<el-button>{{ t("config.uploadKeyFile") }}</el-button>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
@@ -361,8 +313,8 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<template v-if="dataForm.obfs.type === 'salamander'">
|
||||
<el-tooltip
|
||||
v-if="dataForm.obfs.type === 'salamander'"
|
||||
:content="$t('hysteria.config.obfs.salamander.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
@@ -376,6 +328,48 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="dataForm.obfs.type === 'gecko'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.password"
|
||||
prop="obfs.gecko.password"
|
||||
>
|
||||
<el-input v-model="dataForm.obfs.gecko.password" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.minPacketSize')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.minPacketSize"
|
||||
prop="obfs.gecko.minPacketSize"
|
||||
>
|
||||
<el-input
|
||||
v-model.number="dataForm.obfs.gecko.minPacketSize"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.obfs.gecko.maxPacketSize')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="obfs.gecko.maxPacketSize"
|
||||
prop="obfs.gecko.maxPacketSize"
|
||||
>
|
||||
<el-input
|
||||
v-model.number="dataForm.obfs.gecko.maxPacketSize"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('hysteria.quic')" name="quic" v-if="quic">
|
||||
<el-tooltip
|
||||
@@ -470,6 +464,17 @@
|
||||
<el-switch v-model="dataForm.quic.disablePathMTUDiscovery" />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.quic.disableStatelessReset')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="quic.disableStatelessReset"
|
||||
prop="quic.disableStatelessReset"
|
||||
>
|
||||
<el-switch v-model="dataForm.quic.disableStatelessReset" />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="$t('hysteria.bandwidth')"
|
||||
@@ -494,6 +499,19 @@
|
||||
<el-input v-model="dataForm.bandwidth.down" clearable />
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.bandwidth.disableLossCompensation')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="bandwidth.disableLossCompensation"
|
||||
prop="bandwidth.disableLossCompensation"
|
||||
>
|
||||
<el-switch
|
||||
v-model="dataForm.bandwidth.disableLossCompensation"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.ignoreClientBandwidth')"
|
||||
placement="bottom"
|
||||
@@ -506,6 +524,53 @@
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="$t('hysteria.congestion')"
|
||||
name="congestion"
|
||||
v-if="congestion && dataForm.congestion"
|
||||
>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.congestion.type')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="congestion.type" prop="congestion.type">
|
||||
<el-select
|
||||
v-model="dataForm.congestion.type"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="item in congestionTypes"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.congestion.bbrProfile')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item
|
||||
label="congestion.bbrProfile"
|
||||
prop="congestion.bbrProfile"
|
||||
>
|
||||
<el-select
|
||||
v-model="dataForm.congestion.bbrProfile"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="item in bbrProfiles"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="$t('hysteria.speedTest')"
|
||||
name="speedTest"
|
||||
@@ -977,25 +1042,24 @@ export default {
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defaultHysteria2ServerConfig,
|
||||
Hysteria2ServerConfig,
|
||||
} from "@/api/config/types";
|
||||
defaultHysteria2ServerConfigView,
|
||||
normalizeHysteriaViewModel,
|
||||
type Hysteria2ServerConfigView,
|
||||
} from "@/api/config/hysteriaViewModel";
|
||||
import Outbounds from "./components/Outbounds/index.vue";
|
||||
import {
|
||||
exportHysteria2ConfigApi,
|
||||
getHysteria2ConfigApi,
|
||||
listConfigApi,
|
||||
uploadCertFileApi,
|
||||
} from "@/api/config";
|
||||
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { assignWith, deepCopy } from "@/utils/copy";
|
||||
import { UploadRequestOptions } from "element-plus";
|
||||
import { deepCopy } from "@/utils/copy";
|
||||
import { dashboardSummaryApi } from "@/api/dashboard";
|
||||
import { UploadUserFile } from "element-plus";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hysteria2Remark = "HYSTERIA2_CONFIG_REMARK";
|
||||
// Вкладки «extension» с полем remark здесь больше нет.
|
||||
//
|
||||
// Она показывала HYSTERIA2_CONFIG_REMARK — пустую строку в таблице `config`,
|
||||
// которую clean install создавал один раз и которую не записывал никто. Имя
|
||||
// профиля в клиентской ссылке теперь выводится из имени пира на сервере
|
||||
// (см. service/hysteria2_api.go), поэтому настраивать здесь нечего.
|
||||
const dataFormRef = ref(ElForm);
|
||||
|
||||
const dataFormRules = {
|
||||
@@ -1020,29 +1084,36 @@ const tlsSniGuards = ref<string[]>(["dns-san", "disable", "strict"]);
|
||||
const aclTypes = ref<string[]>(["file", "inline"]);
|
||||
const acmeCas = ref<string[]>(["zerossl", "letsencrypt"]);
|
||||
const acmeTypes = ref<string[]>(["http", "tls", "dns"]);
|
||||
// Актуальный upstream-реестр ACME DNS-провайдеров.
|
||||
// `namedotcom` удалён в Hysteria 2.11.0 вместе с переписанным ACME-стеком:
|
||||
// конфигурация с ним больше не запускается.
|
||||
const dnsNames = ref<string[]>([
|
||||
"cloudflare",
|
||||
"duckdns",
|
||||
"gandi",
|
||||
"godaddy",
|
||||
"namedotcom",
|
||||
"namecheap",
|
||||
"njalla",
|
||||
"porkbun",
|
||||
"vultr",
|
||||
]);
|
||||
const obfsTypes = ref<string[]>(["salamander"]);
|
||||
const obfsTypes = ref<string[]>(["gecko", "salamander"]);
|
||||
const congestionTypes = ref<string[]>(["bbr", "reno"]);
|
||||
const bbrProfiles = ref<string[]>(["standard", "conservative", "aggressive"]);
|
||||
const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
|
||||
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
|
||||
|
||||
const state = reactive({
|
||||
configForm: {
|
||||
remark: "",
|
||||
},
|
||||
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
|
||||
activeName: "extension",
|
||||
dataForm: deepCopy(
|
||||
defaultHysteria2ServerConfigView
|
||||
) as Hysteria2ServerConfigView,
|
||||
activeName: "listen",
|
||||
tlsType: "acme",
|
||||
aclType: "inline",
|
||||
obfs: false,
|
||||
quic: false,
|
||||
bandwidth: false,
|
||||
congestion: false,
|
||||
speedTest: false,
|
||||
udp: false,
|
||||
resolver: false,
|
||||
@@ -1054,12 +1125,9 @@ const state = reactive({
|
||||
version: "",
|
||||
running: false,
|
||||
},
|
||||
crtFileList: [] as UploadUserFile[],
|
||||
keyFileList: [] as UploadUserFile[],
|
||||
});
|
||||
|
||||
const {
|
||||
configForm,
|
||||
activeName,
|
||||
dataForm,
|
||||
tlsType,
|
||||
@@ -1067,6 +1135,7 @@ const {
|
||||
obfs,
|
||||
quic,
|
||||
bandwidth,
|
||||
congestion,
|
||||
speedTest,
|
||||
udp,
|
||||
resolver,
|
||||
@@ -1075,8 +1144,6 @@ const {
|
||||
outbounds,
|
||||
masquerade,
|
||||
hysteria2Monitor,
|
||||
crtFileList,
|
||||
keyFileList,
|
||||
} = toRefs(state);
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -1100,73 +1167,53 @@ const handleExport = async () => {
|
||||
};
|
||||
|
||||
const setConfig = () => {
|
||||
listConfigApi({
|
||||
keys: [hysteria2Remark],
|
||||
}).then((response) => {
|
||||
const data = response.data;
|
||||
data.forEach((configVo) => {
|
||||
if (configVo.key === hysteria2Remark) {
|
||||
state.configForm.remark = configVo.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
getHysteria2ConfigApi().then((response) => {
|
||||
const data = response.data;
|
||||
if (data) {
|
||||
state.tlsType = data?.tls?.cert && data?.tls?.key ? "tls" : "acme";
|
||||
state.aclType = data?.acl?.inline ? "inline" : "file";
|
||||
|
||||
state.obfs = !!data?.obfs;
|
||||
state.quic = !!data?.quic;
|
||||
state.bandwidth = !!data?.bandwidth;
|
||||
state.speedTest = !!data?.speedTest;
|
||||
state.udp = !!data?.disableUDP || !!data?.udpIdleTimeout;
|
||||
state.resolver = !!data?.resolver;
|
||||
state.sniff = !!data?.sniff;
|
||||
state.acl = !!data?.acl;
|
||||
state.outbounds = !!data?.outbounds;
|
||||
state.masquerade = !!data?.masquerade;
|
||||
|
||||
state.dataForm = deepCopy(defaultHysteria2ServerConfig);
|
||||
assignWith(state.dataForm, data);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Какие вкладки показывать, решает ОТВЕТ СЕРВЕРА, а не форма: в форме после
|
||||
// нормализации есть все секции, потому что это её инвариант. Эти флаги —
|
||||
// единственное место, где присутствие секции в исходном конфиге ещё имеет
|
||||
// значение, и поэтому они читаются из `data`, а не из `state.dataForm`.
|
||||
state.tlsType = data.tls?.cert && data.tls?.key ? "tls" : "acme";
|
||||
state.aclType = data.acl?.inline ? "inline" : "file";
|
||||
|
||||
state.obfs = !!data.obfs;
|
||||
state.quic = !!data.quic;
|
||||
state.bandwidth = !!data.bandwidth;
|
||||
state.congestion = !!data.congestion;
|
||||
state.speedTest = !!data.speedTest;
|
||||
state.udp = !!data.disableUDP || !!data.udpIdleTimeout;
|
||||
state.resolver = !!data.resolver;
|
||||
state.sniff = !!data.sniff;
|
||||
state.acl = !!data.acl;
|
||||
state.outbounds = !!data.outbounds;
|
||||
state.masquerade = !!data.masquerade;
|
||||
|
||||
state.dataForm = normalizeHysteriaViewModel(data);
|
||||
});
|
||||
};
|
||||
|
||||
// dropInactiveObfsSubtype здесь больше нет.
|
||||
//
|
||||
// Функция удаляла из формы неактивную ветку obfs, потому что форма строилась
|
||||
// merge'ем поверх дефолта и показала бы блок, которого нет в конфиге сервера.
|
||||
// Но какой блок показывать, всегда однозначно определял `obfs.type` — а он
|
||||
// приходит с сервера. Проверка присутствия дублировала проверку типа и была
|
||||
// единственной причиной, по которой шаблону приходилось писать
|
||||
// `v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"`.
|
||||
//
|
||||
// Теперь ветку выбирает только тип, а присутствие обеих обеспечивает
|
||||
// нормализация.
|
||||
|
||||
const setHysteria2Monitor = async () => {
|
||||
const { data } = await dashboardSummaryApi();
|
||||
state.hysteria2Monitor.version = data.hysteria.version;
|
||||
state.hysteria2Monitor.running = data.hysteria.running;
|
||||
};
|
||||
|
||||
const uploadCertFile = async (params: UploadRequestOptions) => {
|
||||
try {
|
||||
if (!state.dataForm.tls) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!params.file.name.endsWith(".crt") &&
|
||||
!params.file.name.endsWith(".key")
|
||||
) {
|
||||
ElMessage.error(t("common.fileFormatUnsupported"));
|
||||
}
|
||||
if (params.file.size > 1024 * 1024) {
|
||||
ElMessage.error(t("common.fileTooLarge"));
|
||||
}
|
||||
let formData = new FormData();
|
||||
formData.append("file", params.file);
|
||||
const { data } = await uploadCertFileApi(formData);
|
||||
if (params.file.name.endsWith(".crt")) {
|
||||
state.dataForm.tls.cert = data;
|
||||
} else if (params.file.name.endsWith(".key")) {
|
||||
state.dataForm.tls.key = data;
|
||||
}
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setConfig();
|
||||
setHysteria2Monitor();
|
||||
|
||||
@@ -222,10 +222,10 @@ const handleLogin = () => {
|
||||
|
||||
.el-input__inner {
|
||||
color: #fff;
|
||||
caret-color: #fff;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
caret-color: #fff;
|
||||
|
||||
&:-webkit-autofill {
|
||||
box-shadow: 0 0 0 1000px transparent inset !important;
|
||||
@@ -237,7 +237,9 @@ const handleLogin = () => {
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus,
|
||||
&:-webkit-autofill:active {
|
||||
transition: color 99999s ease-out, background-color 99999s ease-out;
|
||||
transition:
|
||||
color 99999s ease-out,
|
||||
background-color 99999s ease-out;
|
||||
transition-delay: 99999s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,19 @@
|
||||
>
|
||||
<el-button>{{ $t("common.import") }}</el-button>
|
||||
</el-upload>
|
||||
<el-tooltip :content="$t('peer.exportSettingsHint')" placement="bottom">
|
||||
<el-button class="ml-2" @click="handleExport">{{
|
||||
$t("common.export")
|
||||
$t("peer.exportSettings")
|
||||
}}</el-button>
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
class="ml-2"
|
||||
type="warning"
|
||||
plain
|
||||
@click="handleExportBackup"
|
||||
>
|
||||
{{ $t("peer.exportBackup") }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="records">
|
||||
<el-table-column :label="$t('peer.name')" min-width="220">
|
||||
@@ -65,17 +75,15 @@
|
||||
<el-table-column :label="$t('peer.traffic')" min-width="260">
|
||||
<template #default="scope">
|
||||
<div>
|
||||
{{
|
||||
formatBytes(scope.row.downloadBytes + scope.row.uploadBytes)
|
||||
}}
|
||||
{{ formatBytes(scope.row.downloadBytes + scope.row.uploadBytes) }}
|
||||
/ {{ quotaText(scope.row.quotaBytes) }}
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="trafficPercent(scope.row)"
|
||||
:percentage="trafficPercent(peerRow(scope.row))"
|
||||
:status="
|
||||
scope.row.quotaBytes < 0
|
||||
? undefined
|
||||
: trafficPercent(scope.row) >= 90
|
||||
: trafficPercent(peerRow(scope.row)) >= 90
|
||||
? 'exception'
|
||||
: undefined
|
||||
"
|
||||
@@ -112,36 +120,45 @@
|
||||
>
|
||||
<template #default="scope">
|
||||
<div class="peer-actions">
|
||||
<el-button link type="primary" @click="openOverview(scope.row)">{{
|
||||
$t("peer.overview")
|
||||
}}</el-button>
|
||||
<el-button link type="primary" @click="copyUri(scope.row)">{{
|
||||
$t("peer.copyUri")
|
||||
}}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openOverview(peerRow(scope.row))"
|
||||
>{{ $t("peer.overview") }}</el-button
|
||||
>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="copyUri(peerRow(scope.row))"
|
||||
>{{ $t("peer.copyUri") }}</el-button
|
||||
>
|
||||
<el-dropdown trigger="click" placement="bottom-end">
|
||||
<el-button text circle class="peer-more-btn" @click.stop>
|
||||
<i-ep-more-filled />
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="showQr(scope.row)">{{
|
||||
<el-dropdown-item @click="showQr(peerRow(scope.row))">{{
|
||||
$t("peer.clientQr")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleUpdate(scope.row)">{{
|
||||
$t("common.edit")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleResetTraffic(scope.row)">{{
|
||||
$t("common.resetTraffic")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleKick(scope.row)">{{
|
||||
<el-dropdown-item
|
||||
@click="handleUpdate(peerRow(scope.row))"
|
||||
>{{ $t("common.edit") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item
|
||||
@click="handleResetTraffic(peerRow(scope.row))"
|
||||
>{{ $t("common.resetTraffic") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item @click="handleKick(peerRow(scope.row))">{{
|
||||
$t("peer.kick")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleReleaseKick(scope.row)">{{
|
||||
$t("peer.releaseKick")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="handleReleaseKick(peerRow(scope.row))"
|
||||
>{{ $t("peer.releaseKick") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item
|
||||
divided
|
||||
@click="handleDelete(scope.row)"
|
||||
@click="handleDelete(peerRow(scope.row))"
|
||||
>{{ $t("common.delete") }}</el-dropdown-item
|
||||
>
|
||||
</el-dropdown-menu>
|
||||
@@ -167,18 +184,32 @@
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
>
|
||||
<el-form-item :label="$t('peer.name')" prop="name"
|
||||
><el-input v-model="dataForm.name"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.remark')"
|
||||
><el-input v-model="dataForm.remark"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.secret')" prop="secret"
|
||||
><el-input v-model="dataForm.secret" show-password
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.quota')"
|
||||
><el-input-number v-model="dataForm.quotaBytes" :min="-1"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.name')" prop="name">
|
||||
<el-input
|
||||
v-model="dataForm.name"
|
||||
:placeholder="$t('peer.form.namePlaceholder')"
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.remark')">
|
||||
<el-input
|
||||
v-model="dataForm.remark"
|
||||
:placeholder="$t('peer.form.remarkPlaceholder')"
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.secret')" prop="secret">
|
||||
<el-input
|
||||
v-model="dataForm.secret"
|
||||
show-password
|
||||
:placeholder="$t('peer.form.secretPlaceholder')"
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.quota')">
|
||||
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
|
||||
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.expireTime')"
|
||||
><el-date-picker
|
||||
v-model="dataForm.expiresAt"
|
||||
@@ -339,6 +370,28 @@ import {
|
||||
} from "@/api/peer/types";
|
||||
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||
|
||||
/**
|
||||
* Единственный переход от строки слота таблицы к модели пира.
|
||||
*
|
||||
* `el-table-column` — отдельный компонент, и тип строки из `:data` родительской
|
||||
* таблицы в его слот не попадает: scope всегда типизирован как
|
||||
* `{ row: DefaultRow, ... }`, где `DefaultRow = Record<PropertyKey, any>`.
|
||||
* Обобщённость `el-table` здесь не помогает, а аннотировать слот нельзя —
|
||||
* `DefaultRow` не сужается до `PeerVo` контравариантно.
|
||||
*
|
||||
* Поэтому переход нужен, и вопрос только в том, где он стоит. Здесь он ровно
|
||||
* один, назван и объяснён; альтернативой были семь `as any` прямо в шаблоне,
|
||||
* каждый из которых отключал бы проверку и всего остального выражения.
|
||||
*
|
||||
* Утверждение опирается на соседнюю строку шаблона: `:data="records"`, где
|
||||
* `records` объявлен как `Ref<PeerVo[]>`. Таблица не получает никаких других
|
||||
* данных, поэтому в `row` не может оказаться ничего другого. Смена типа
|
||||
* `records` сломает компиляцию здесь же: `PeerVo` объявлен псевдонимом типа и
|
||||
* потому совместим с `DefaultRow`, так что это обычное приведение между
|
||||
* связанными типами, а не `as unknown as`.
|
||||
*/
|
||||
const peerRow = (row: Record<PropertyKey, any>): PeerVo => row as PeerVo;
|
||||
|
||||
const { t } = useI18n();
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
@@ -546,9 +599,9 @@ function beforeImport(file: UploadRawFile) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
async function downloadExport(includeSecrets: boolean) {
|
||||
try {
|
||||
const response = await exportPeerApi();
|
||||
const response = await exportPeerApi(includeSecrets);
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
@@ -566,6 +619,28 @@ async function handleExport() {
|
||||
}
|
||||
}
|
||||
|
||||
// Обычный экспорт: настройки пиров без секретов.
|
||||
async function handleExport() {
|
||||
await downloadExport(false);
|
||||
}
|
||||
|
||||
// Резервная копия: файл содержит действующие секреты подключения всех пиров.
|
||||
// Подтверждение здесь обязательно — это единственное место в панели, где
|
||||
// оператор осознанно выгружает наружу работающие учётные данные доступа.
|
||||
async function handleExportBackup() {
|
||||
await ElMessageBox.confirm(
|
||||
t("peer.exportBackupConfirm"),
|
||||
t("common.securityRisk"),
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: t("peer.exportBackupConfirmButton"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
confirmButtonClass: "el-button--danger",
|
||||
}
|
||||
);
|
||||
await downloadExport(true);
|
||||
}
|
||||
|
||||
onMounted(handleQuery);
|
||||
</script>
|
||||
|
||||
@@ -573,23 +648,35 @@ onMounted(handleQuery);
|
||||
.peer-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.peer-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.peer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.peer-more-btn {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.qr-dialog-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0 20px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,8 +9,6 @@ import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
import Icons from "unplugin-icons/vite";
|
||||
import IconsResolver from "unplugin-icons/resolver";
|
||||
|
||||
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
|
||||
|
||||
import UnoCSS from "unocss/vite";
|
||||
|
||||
import path from "path";
|
||||
@@ -21,7 +19,8 @@ const pathSrc = path.resolve(__dirname, "src");
|
||||
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g;
|
||||
const DRIVE_LETTER_REGEX = /^[a-z]:/i;
|
||||
const DEV_SERVER_PORT = 8080;
|
||||
const API_BASE = "/hui";
|
||||
// Синхронизировано с src/utils/request.ts и constant.AdminAPIBase.
|
||||
const API_BASE = "/api";
|
||||
|
||||
export default defineConfig((): UserConfig => {
|
||||
return {
|
||||
@@ -57,14 +56,42 @@ export default defineConfig((): UserConfig => {
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
UnoCSS({
|
||||
/* options */
|
||||
}),
|
||||
UnoCSS({/* options */}),
|
||||
AutoImport({
|
||||
// Auto import Vue helpers: ref, reactive, toRef, etc.
|
||||
imports: ["vue", "@vueuse/core"],
|
||||
//
|
||||
// Из @vueuse/core берутся ПОИМЁННО только те функции, которые
|
||||
// действительно используются без явного импорта, а не весь пакет.
|
||||
//
|
||||
// Причина не стилистическая. С `"@vueuse/core"` целиком в список
|
||||
// авто-импорта попадает вся его публичная поверхность — более пятисот
|
||||
// имён, — и на VueUse 13 сборка падает:
|
||||
//
|
||||
// [auto-import] identifier toRef already defined with vue
|
||||
//
|
||||
// VueUse реэкспортирует `toRef` и `toValue`, и они конфликтуют с
|
||||
// одноимёнными функциями Vue. Разрешать конфликт порядком записей в
|
||||
// массиве значило бы решать вопрос «какой из двух toRef здесь имеется в
|
||||
// виду» невидимо для читателя файла.
|
||||
//
|
||||
// Побочный эффект — src/types/auto-imports.d.ts сокращается с 526 строк
|
||||
// до размера, который можно прочитать глазами.
|
||||
imports: [
|
||||
"vue",
|
||||
{
|
||||
"@vueuse/core": ["useVModel", "useFullscreen"],
|
||||
},
|
||||
],
|
||||
eslintrc: {
|
||||
enabled: false, // Default `false`
|
||||
// Включено намеренно. Файл перечисляет глобальные имена для eslint и
|
||||
// подключается через `extends` в .eslintrc.cjs, но генерация была
|
||||
// выключена — то есть список жил своей жизнью и расходился со
|
||||
// списком авто-импортов при каждом его изменении.
|
||||
//
|
||||
// Устаревшие записи здесь не безобидны: eslint перестаёт сообщать об
|
||||
// обращении к имени, которого больше нет в авто-импорте, и ошибка
|
||||
// доезжает до typecheck или до рантайма вместо линтера.
|
||||
enabled: true,
|
||||
filepath: "./.eslintrc-auto-import.json", // Default `./.eslintrc-auto-import.json`
|
||||
globalsPropValue: true, // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable')
|
||||
},
|
||||
@@ -94,13 +121,6 @@ export default defineConfig((): UserConfig => {
|
||||
// Auto install icon collections
|
||||
autoInstall: true,
|
||||
}),
|
||||
|
||||
createSvgIconsPlugin({
|
||||
// Icon folders to cache
|
||||
iconDirs: [path.resolve(pathSrc, "assets/icons")],
|
||||
// symbolId format
|
||||
symbolId: "icon-[dir]-[name]",
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
|
||||
+58
-39
@@ -1,67 +1,86 @@
|
||||
module hy2xs-admin
|
||||
|
||||
go 1.21
|
||||
// Языковая версия держится ниже GO_VERSION из versions.env: сборка проверяет
|
||||
// это шагом verify_go_toolchain_contract. 1.21 здесь стояла с поколения, в
|
||||
// котором она ещё поддерживалась; к v1 её линия давно вне поддержки, а вместе
|
||||
// с ней в бинарь попадала соответствующая stdlib.
|
||||
go 1.25.0
|
||||
|
||||
// toolchain объявляет, каким компилятором проект собирается на самом деле.
|
||||
//
|
||||
// Директива `go` выше — это языковой baseline модуля, и она НЕ выбирает
|
||||
// компилятор: с ней одной локальный `go build` на 1.25 проходил успешно, хотя
|
||||
// релизный бинарь собирается на 1.26.7 и наследует её stdlib. То есть
|
||||
// разработчик и сборка проверяли разный код, а расхождение не было видно ни в
|
||||
// одном из выводов.
|
||||
//
|
||||
// Значение обязано совпадать с GO_VERSION из versions.env; это проверяет
|
||||
// verify_go_toolchain_contract, а `tools/dev/doctor` показывает то же
|
||||
// расхождение локально, до сборки.
|
||||
toolchain go1.26.7
|
||||
|
||||
require (
|
||||
github.com/didip/tollbooth v4.0.2+incompatible
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-playground/validator/v10 v10.14.0
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/spf13/cobra v1.8.1
|
||||
golang.org/x/crypto v0.18.0
|
||||
github.com/sirupsen/logrus v1.10.2
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.55.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/gorm v1.25.9
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.23.0 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.61.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.13 // indirect
|
||||
github.com/tklauser/numcpus v0.7.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/ugorji/go/codec v1.3.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect
|
||||
golang.org/x/arch v0.30.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
modernc.org/libc v1.75.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
modernc.org/sqlite v1.57.0 // indirect
|
||||
)
|
||||
|
||||
+134
-97
@@ -1,11 +1,12 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
|
||||
github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
|
||||
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
|
||||
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -14,39 +15,42 @@ github.com/didip/tollbooth v4.0.2+incompatible h1:fVSa33JzSz0hoh2NxpwZtksAzAgd7z
|
||||
github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
|
||||
github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -55,106 +59,139 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
|
||||
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4=
|
||||
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0=
|
||||
github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4=
|
||||
github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
|
||||
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
|
||||
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
|
||||
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/service"
|
||||
"time"
|
||||
)
|
||||
|
||||
func InitCron() error {
|
||||
loc := time.Now().Location()
|
||||
c := cron.New(cron.WithLocation(loc))
|
||||
_, err := c.AddFunc("@every 30s", service.CronHandleAccount)
|
||||
if err != nil {
|
||||
logrus.Errorf("cron add func CronHandleAccount err: %v", err)
|
||||
return errors.New("cron add func CronHandleAccount err")
|
||||
}
|
||||
_, err = c.AddFunc("@every 10s", service.CollectMetricsSnapshot)
|
||||
if err != nil {
|
||||
logrus.Errorf("cron add func CollectMetricsSnapshot err: %v", err)
|
||||
return errors.New("cron add func CollectMetricsSnapshot err")
|
||||
}
|
||||
_, err = c.AddFunc("@every 1h", service.CleanupStatsRetention)
|
||||
if err != nil {
|
||||
logrus.Errorf("cron add func CleanupStatsRetention err: %v", err)
|
||||
return errors.New("cron add func CleanupStatsRetention err")
|
||||
}
|
||||
resetTrafficCron, err := dao.GetConfig("key = ?", constant.ResetTrafficCron)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *resetTrafficCron.Value != "" {
|
||||
_, err := c.AddFunc(*resetTrafficCron.Value, service.CronResetTraffic)
|
||||
if err != nil {
|
||||
logrus.Errorf("cron add func CronResetTraffic err: %v", err)
|
||||
}
|
||||
}
|
||||
go service.CleanupStatsRetention()
|
||||
c.Start()
|
||||
return nil
|
||||
}
|
||||
+46
-4
@@ -1,11 +1,14 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
"hy2xs-admin/model/constant"
|
||||
"time"
|
||||
)
|
||||
|
||||
func InitLog() {
|
||||
@@ -21,6 +24,40 @@ func InitLog() {
|
||||
logrus.SetLevel(logrus.InfoLevel)
|
||||
}
|
||||
|
||||
// requestQueryKeys возвращает ИМЕНА query-параметров запроса, без значений.
|
||||
//
|
||||
// Для диагностики важно, был ли параметр передан, а не что в нём лежало.
|
||||
// Список сортируется, чтобы одинаковые запросы давали одинаковую строку в
|
||||
// логе и её можно было группировать.
|
||||
func requestQueryKeys(c *gin.Context) string {
|
||||
query := c.Request.URL.Query()
|
||||
if len(query) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(query))
|
||||
for key := range query {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return strings.Join(keys, ",")
|
||||
}
|
||||
|
||||
// LogHandler пишет журнал HTTP-запросов админки.
|
||||
//
|
||||
// В лог попадает ПУТЬ, а не RequestURI.
|
||||
//
|
||||
// Разница здесь не косметическая. RequestURI включает query string, а Hysteria
|
||||
// обращается к машинному endpoint'у как
|
||||
// /internal/hysteria/auth?access_token=<machine token> — то есть при каждом
|
||||
// подключении пира. Пока логировался RequestURI, действующий machine token
|
||||
// оседал открытым текстом в /var/log/hy2xs/hy2xs-admin.log, а этот файл
|
||||
// отдаётся оператору через ExportLog и попадает в diagnostics-бандл.
|
||||
// Получался постоянный канал утечки секрета в обычной штатной работе, мимо
|
||||
// всей структурной редакции, сделанной для конфигов и env.
|
||||
//
|
||||
// Значения query-параметров не логируются вовсе: список того, «что можно», при
|
||||
// таком подходе пришлось бы поддерживать вручную и он неизбежно разошёлся бы с
|
||||
// набором маршрутов. Имена параметров сохранены — их для диагностики хватает.
|
||||
func LogHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
@@ -30,13 +67,18 @@ func LogHandler() gin.HandlerFunc {
|
||||
statusCode := c.Writer.Status()
|
||||
latencyTime := time.Since(startTime)
|
||||
|
||||
entry := logrus.WithFields(logrus.Fields{
|
||||
fields := logrus.Fields{
|
||||
"statusCode": statusCode,
|
||||
"latencyTime": latencyTime.Milliseconds(),
|
||||
"clientIP": c.ClientIP(),
|
||||
"reqMethod": c.Request.Method,
|
||||
"reqUri": c.Request.RequestURI,
|
||||
})
|
||||
"reqPath": c.Request.URL.Path,
|
||||
}
|
||||
if keys := requestQueryKeys(c); keys != "" {
|
||||
fields["reqQueryKeys"] = keys
|
||||
}
|
||||
|
||||
entry := logrus.WithFields(fields)
|
||||
|
||||
if statusCode >= 500 {
|
||||
entry.Error()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// sentinel имитирует действующий machine token: если он окажется в журнале —
|
||||
// значит секрет утёк в /var/log/hy2xs/hy2xs-admin.log, который отдаётся
|
||||
// оператору через ExportLog и попадает в diagnostics-бандл.
|
||||
const sentinel = "SUPER_SECRET_SENTINEL"
|
||||
|
||||
func captureLog(t *testing.T, request *http.Request) string {
|
||||
t.Helper()
|
||||
|
||||
previousOut := logrus.StandardLogger().Out
|
||||
previousFormatter := logrus.StandardLogger().Formatter
|
||||
t.Cleanup(func() {
|
||||
logrus.SetOutput(previousOut)
|
||||
logrus.SetFormatter(previousFormatter)
|
||||
})
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
logrus.SetOutput(buffer)
|
||||
logrus.SetFormatter(&logrus.JSONFormatter{})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(LogHandler())
|
||||
engine.POST("/internal/hysteria/auth", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
engine.GET("/api/peers", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
engine.ServeHTTP(httptest.NewRecorder(), request)
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
// Регрессия: логировался c.Request.RequestURI, то есть путь ВМЕСТЕ с query.
|
||||
// Hysteria обращается к машинному endpoint'у при каждом подключении пира, так
|
||||
// что токен оседал в журнале в штатном режиме работы.
|
||||
func TestLogHandlerNeverLogsQueryValues(t *testing.T) {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/internal/hysteria/auth?access_token="+sentinel,
|
||||
strings.NewReader("{}"),
|
||||
)
|
||||
|
||||
logged := captureLog(t, request)
|
||||
|
||||
if strings.Contains(logged, sentinel) {
|
||||
t.Fatalf("machine token попал в журнал: %s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, `"reqPath":"/internal/hysteria/auth"`) {
|
||||
t.Fatalf("в журнале нет пути запроса: %s", logged)
|
||||
}
|
||||
if strings.Contains(logged, "reqUri") {
|
||||
t.Fatalf("поле reqUri вернулось в журнал: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// Имена параметров для диагностики сохраняются: важно видеть, что параметр был
|
||||
// передан, а не что в нём лежало.
|
||||
func TestLogHandlerKeepsQueryKeyNames(t *testing.T) {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/internal/hysteria/auth?access_token="+sentinel,
|
||||
strings.NewReader("{}"),
|
||||
)
|
||||
|
||||
logged := captureLog(t, request)
|
||||
|
||||
if !strings.Contains(logged, `"reqQueryKeys":"access_token"`) {
|
||||
t.Fatalf("имя query-параметра не попало в журнал: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogHandlerOmitsQueryKeysWhenThereAreNone(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/peers", nil)
|
||||
|
||||
logged := captureLog(t, request)
|
||||
|
||||
if strings.Contains(logged, "reqQueryKeys") {
|
||||
t.Fatalf("пустой список параметров не должен появляться в журнале: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// Значение секретоподобного параметра не должно попасть в журнал ни на каком
|
||||
// маршруте, а не только на машинном.
|
||||
func TestLogHandlerRedactsQueryValuesOnOperatorRoutes(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/peers?includeSecrets=true&name="+sentinel, nil)
|
||||
|
||||
logged := captureLog(t, request)
|
||||
|
||||
if strings.Contains(logged, sentinel) {
|
||||
t.Fatalf("значение query-параметра попало в журнал: %s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, `"reqPath":"/api/peers"`) {
|
||||
t.Fatalf("в журнале нет пути запроса: %s", logged)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -38,7 +39,11 @@ func MachineAuthHandler() gin.HandlerFunc {
|
||||
provided = strings.TrimSpace(c.GetHeader("X-HY2XS-Machine-Token"))
|
||||
}
|
||||
|
||||
if provided == "" || provided != expected {
|
||||
// Сравнение постоянного времени: обычное `!=` завершается на первом
|
||||
// несовпавшем байте. Endpoint слушает только loopback, но локальный
|
||||
// непривилегированный процесс — ровно та позиция, из которой такой
|
||||
// замер и делается, а ценой ошибки здесь является machine token.
|
||||
if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"code": http.StatusForbidden,
|
||||
"type": "no",
|
||||
|
||||
+114
-1
@@ -1,12 +1,55 @@
|
||||
package bo
|
||||
|
||||
// ObfsShareConfig — та часть обфускации, которую способна описать официальная
|
||||
// схема hysteria2:// URI: только type и password.
|
||||
//
|
||||
// Gecko minPacketSize/maxPacketSize в ссылку не помещаются. Именно поэтому
|
||||
// HY2XS фиксирует их на upstream defaults 512/1200: иначе сгенерированная
|
||||
// ссылка не описывала бы подключение полностью.
|
||||
type ObfsShareConfig struct {
|
||||
Type string
|
||||
Password string
|
||||
}
|
||||
|
||||
// ObfsShare приводит любой поддерживаемый тип обфускации к единому виду для
|
||||
// генерации клиентской ссылки. Знание о подтипах живёт рядом с моделью, чтобы
|
||||
// добавление нового типа не требовало правок в слое share URI.
|
||||
func (c *Hysteria2ServerConfig) ObfsShare() ObfsShareConfig {
|
||||
if c == nil || c.Obfs == nil || c.Obfs.Type == nil {
|
||||
return ObfsShareConfig{}
|
||||
}
|
||||
|
||||
switch *c.Obfs.Type {
|
||||
case "salamander":
|
||||
if c.Obfs.Salamander != nil && c.Obfs.Salamander.Password != nil {
|
||||
return ObfsShareConfig{Type: "salamander", Password: *c.Obfs.Salamander.Password}
|
||||
}
|
||||
case "gecko":
|
||||
if c.Obfs.Gecko != nil && c.Obfs.Gecko.Password != nil {
|
||||
return ObfsShareConfig{Type: "gecko", Password: *c.Obfs.Gecko.Password}
|
||||
}
|
||||
}
|
||||
|
||||
return ObfsShareConfig{}
|
||||
}
|
||||
|
||||
// AcmeDomain возвращает первый ACME-домен, если он есть.
|
||||
func (c *Hysteria2ServerConfig) AcmeDomain() string {
|
||||
if c == nil || c.ACME == nil || len(c.ACME.Domains) == 0 {
|
||||
return ""
|
||||
}
|
||||
return c.ACME.Domains[0]
|
||||
}
|
||||
|
||||
type Hysteria2ServerConfig struct {
|
||||
Listen *string `yaml:"listen,omitempty" json:"listen" validate:"required"`
|
||||
Obfs *serverConfigObfs `yaml:"obfs,omitempty" json:"obfs" validate:"omitempty"`
|
||||
TLS *serverConfigTLS `yaml:"tls,omitempty" json:"tls" validate:"omitempty"`
|
||||
ACME *serverConfigACME `yaml:"acme,omitempty" json:"acme" validate:"omitempty"`
|
||||
ECH *serverConfigECH `yaml:"ech,omitempty" json:"ech" validate:"omitempty"`
|
||||
QUIC *serverConfigQUIC `yaml:"quic,omitempty" json:"quic" validate:"omitempty"`
|
||||
Bandwidth *serverConfigBandwidth `yaml:"bandwidth,omitempty" json:"bandwidth" validate:"omitempty"`
|
||||
Congestion *ServerConfigCongestion `yaml:"congestion,omitempty" json:"congestion" validate:"omitempty"`
|
||||
IgnoreClientBandwidth *bool `yaml:"ignoreClientBandwidth,omitempty" json:"ignoreClientBandwidth" validate:"omitempty"`
|
||||
SpeedTest *bool `yaml:"speedTest,omitempty" json:"speedTest" validate:"omitempty"`
|
||||
DisableUDP *bool `yaml:"disableUDP,omitempty" json:"disableUDP" validate:"omitempty"`
|
||||
@@ -18,21 +61,43 @@ type Hysteria2ServerConfig struct {
|
||||
Outbounds []serverConfigOutboundEntry `yaml:"outbounds,omitempty" json:"outbounds" validate:"omitempty"`
|
||||
TrafficStats *ServerConfigTrafficStats `yaml:"trafficStats,omitempty" json:"trafficStats" validate:"required"`
|
||||
Masquerade *serverConfigMasquerade `yaml:"masquerade,omitempty" json:"masquerade" validate:"omitempty"`
|
||||
Mimic *serverConfigMimic `yaml:"mimic,omitempty" json:"mimic" validate:"omitempty"`
|
||||
Realm *serverConfigRealm `yaml:"realm,omitempty" json:"realm" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigObfsSalamander struct {
|
||||
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// serverConfigObfsGecko описывает Gecko-обфускацию (Hysteria 2.9.2+).
|
||||
// Gecko достраивается поверх Salamander и дополнительно фрагментирует
|
||||
// QUIC handshake на пакеты случайного размера в диапазоне min..max.
|
||||
type serverConfigObfsGecko struct {
|
||||
Password *string `yaml:"password,omitempty" json:"password" validate:"required"`
|
||||
MinPacketSize *int `yaml:"minPacketSize,omitempty" json:"minPacketSize" validate:"omitempty"`
|
||||
MaxPacketSize *int `yaml:"maxPacketSize,omitempty" json:"maxPacketSize" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// Обе ветки опциональны: в конфиге присутствует ровно одна из них,
|
||||
// соответствующая Type.
|
||||
type serverConfigObfs struct {
|
||||
Type *string `yaml:"type,omitempty" json:"type" validate:"required"`
|
||||
Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"required"`
|
||||
Salamander *serverConfigObfsSalamander `yaml:"salamander,omitempty" json:"salamander" validate:"omitempty"`
|
||||
Gecko *serverConfigObfsGecko `yaml:"gecko,omitempty" json:"gecko" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigTLS struct {
|
||||
Cert *string `yaml:"cert,omitempty" json:"cert" validate:"required"`
|
||||
Key *string `yaml:"key,omitempty" json:"key" validate:"required"`
|
||||
SNIGuard *string `yaml:"sniGuard,omitempty" json:"sniGuard" validate:"omitempty"`
|
||||
ClientCA *string `yaml:"clientCA,omitempty" json:"clientCA" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// serverConfigECH — Encrypted Client Hello (Hysteria 2.10.0+).
|
||||
// HY2XS не генерирует ECH keypair и не включает ECH в default-профиле,
|
||||
// но обязан корректно читать и сохранять этот блок.
|
||||
type serverConfigECH struct {
|
||||
KeyPath *string `yaml:"keyPath,omitempty" json:"keyPath" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigACME struct {
|
||||
@@ -78,11 +143,25 @@ type serverConfigQUIC struct {
|
||||
MaxIdleTimeout *string `yaml:"maxIdleTimeout,omitempty" json:"maxIdleTimeout" validate:"omitempty"`
|
||||
MaxIncomingStreams *int64 `yaml:"maxIncomingStreams,omitempty" json:"maxIncomingStreams" validate:"omitempty"`
|
||||
DisablePathMTUDiscovery *bool `yaml:"disablePathMTUDiscovery,omitempty" json:"disablePathMTUDiscovery" validate:"omitempty"`
|
||||
// DisableStatelessReset появился в Hysteria 2.12.2. HY2XS оставляет
|
||||
// stateless reset включённым: клиент после перезапуска сервера или сна
|
||||
// устройства переподключается сразу, а не по таймауту.
|
||||
DisableStatelessReset *bool `yaml:"disableStatelessReset,omitempty" json:"disableStatelessReset" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigBandwidth struct {
|
||||
Up *string `yaml:"up,omitempty" json:"up" validate:"required"`
|
||||
Down *string `yaml:"down,omitempty" json:"down" validate:"required"`
|
||||
// DisableLossCompensation появился в Hysteria 2.10.0.
|
||||
DisableLossCompensation *bool `yaml:"disableLossCompensation,omitempty" json:"disableLossCompensation" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// ServerConfigCongestion — fallback congestion controller. Используется, когда
|
||||
// Brutal bandwidth не согласован сторонами. Это не то же самое, что host-level
|
||||
// BBR ядра Linux: у Hysteria собственный congestion-control контур.
|
||||
type ServerConfigCongestion struct {
|
||||
Type *string `yaml:"type,omitempty" json:"type" validate:"omitempty"`
|
||||
BBRProfile *string `yaml:"bbrProfile,omitempty" json:"bbrProfile" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type ServerConfigAuthHTTP struct {
|
||||
@@ -183,9 +262,11 @@ type serverConfigMasqueradeFile struct {
|
||||
}
|
||||
|
||||
type serverConfigMasqueradeProxy struct {
|
||||
// URL с Hysteria 2.12.2 может указывать и на unix socket.
|
||||
URL *string `yaml:"url,omitempty" json:"url" validate:"required"`
|
||||
RewriteHost *bool `yaml:"rewriteHost,omitempty" json:"rewriteHost" validate:"required"`
|
||||
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"required"`
|
||||
XForwarded *bool `yaml:"xForwarded,omitempty" json:"xForwarded" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigMasqueradeString struct {
|
||||
@@ -203,3 +284,35 @@ type serverConfigMasquerade struct {
|
||||
ListenHTTPS *string `yaml:"listenHTTPS,omitempty" json:"listenHTTPS" validate:"omitempty"`
|
||||
ForceHTTPS *bool `yaml:"forceHTTPS,omitempty" json:"forceHTTPS" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// serverConfigMimic — Mimic / fake TCP (Hysteria 2.12.0+).
|
||||
// Требует отдельный сторонний бинарник, root и eBPF/XDP, поэтому в
|
||||
// production-профиле HY2XS выключен: текущий systemd-контракт запускает
|
||||
// Hysteria под непривилегированным пользователем.
|
||||
type serverConfigMimic struct {
|
||||
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
|
||||
Interface *string `yaml:"interface,omitempty" json:"interface" validate:"omitempty"`
|
||||
XDPMode *string `yaml:"xdpMode,omitempty" json:"xdpMode" validate:"omitempty"`
|
||||
Path *string `yaml:"path,omitempty" json:"path" validate:"omitempty"`
|
||||
ExtraArgs []string `yaml:"extraArgs,omitempty" json:"extraArgs" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type serverConfigRealmPortMapping struct {
|
||||
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" validate:"omitempty"`
|
||||
Timeout *string `yaml:"timeout,omitempty" json:"timeout" validate:"omitempty"`
|
||||
Lifetime *string `yaml:"lifetime,omitempty" json:"lifetime" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// serverConfigRealm — Hysteria Realms (2.9.0+), запуск сервера за NAT через
|
||||
// STUN/hole punching. Меняет фундаментальный сетевой контракт HY2XS
|
||||
// (выделенный сервер, публичный IPv4, UDP/443, own nftables), поэтому в
|
||||
// default-профиле не используется, но должен корректно читаться.
|
||||
type serverConfigRealm struct {
|
||||
StunServers []string `yaml:"stunServers,omitempty" json:"stunServers" validate:"omitempty"`
|
||||
StunTimeout *string `yaml:"stunTimeout,omitempty" json:"stunTimeout" validate:"omitempty"`
|
||||
PunchTimeout *string `yaml:"punchTimeout,omitempty" json:"punchTimeout" validate:"omitempty"`
|
||||
HeartbeatInterval *string `yaml:"heartbeatInterval,omitempty" json:"heartbeatInterval" validate:"omitempty"`
|
||||
Insecure *bool `yaml:"insecure,omitempty" json:"insecure" validate:"omitempty"`
|
||||
IPMode *string `yaml:"ipMode,omitempty" json:"ipMode" validate:"omitempty"`
|
||||
PortMapping *serverConfigRealmPortMapping `yaml:"portMapping,omitempty" json:"portMapping" validate:"omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package constant
|
||||
|
||||
// Пространства имён HTTP API админки.
|
||||
//
|
||||
// Раньше всё жило под одним префиксом «hui» — наследие H UI, попавшее не
|
||||
// только во внутренние имена, но и в runtime-контракт: путь machine-auth
|
||||
// записывается в /etc/hysteria/config.yaml и в post-install.env, то есть
|
||||
// является частью протокола между Hysteria и админкой.
|
||||
//
|
||||
// Разделение на два пространства отражает разницу в природе маршрутов:
|
||||
//
|
||||
// AdminAPIBase — операторский API, за JWT и rate limiter;
|
||||
// HysteriaMachineAuth — не API интерфейса вовсе, а внутренний
|
||||
// IPC-подобный endpoint между Hysteria и админкой,
|
||||
// доступный только с loopback и по machine token.
|
||||
//
|
||||
// Значения обязаны совпадать с константами оркестратора
|
||||
// (orchestrator/src/config/profile.ts). Сверка выполняется на сборке шагом
|
||||
// verify_versions_contract.
|
||||
const (
|
||||
// AdminAPIBase — префикс операторского и auth API панели.
|
||||
AdminAPIBase = "/api"
|
||||
|
||||
// HysteriaMachineAuthPath — полный путь machine-auth endpoint'а,
|
||||
// который Hysteria вызывает при подключении пира.
|
||||
HysteriaMachineAuthPath = "/internal/hysteria/auth"
|
||||
)
|
||||
@@ -1,17 +1,126 @@
|
||||
package constant
|
||||
|
||||
// Ключи таблицы `config` — то, чем действительно владеет админка.
|
||||
//
|
||||
// Сетевых настроек самой панели (порт, web-контекст, пути к сертификатам)
|
||||
// здесь больше нет. Они достались от H UI, где панель конфигурировала себя
|
||||
// сама, и в архитектуре HY2XS были вторым источником истины: порт приходит из
|
||||
// HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS
|
||||
// терминируется на внешнем слое. Панель записывала в SQLite тот же порт,
|
||||
// который ей передали аргументом, и тут же читала его обратно.
|
||||
// Ключей HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME и
|
||||
// HYSTERIA2_CONFIG_REMARK здесь больше нет. Все четыре достались от предыдущего
|
||||
// поколения панели и к v1 перестали чем-либо управлять:
|
||||
//
|
||||
// HYSTERIA2_ENABLE — жизненным циклом Hysteria владеет systemd, а
|
||||
// единственным потребителем ключа была строка лога;
|
||||
// HYSTERIA2_CONFIG — второй источник истины рядом с
|
||||
// /etc/hysteria/config.yaml, причём читался ПЕРВЫМ;
|
||||
// HYSTERIA2_TRAFFIC_TIME — настройка в панели без единого потребителя в
|
||||
// рантайме: интервал сбора метрик задан в коде;
|
||||
// HYSTERIA2_CONFIG_REMARK — пустая read-only строка, которую никто не писал.
|
||||
//
|
||||
// Пустой ключ, который нельзя изменить и который ни на что не влияет, — это не
|
||||
// «задел на будущее», а состояние, которое приходится объяснять в документации
|
||||
// и защищать в allowlist. Настоящую замену получил только remark: имя профиля
|
||||
// в share URI теперь выводится из имени пира (см. service/hysteria2_api.go).
|
||||
const (
|
||||
HUIWebPort = "H_UI_WEB_PORT"
|
||||
HUIWebContext = "H_UI_WEB_CONTEXT"
|
||||
HUICrtPath = "H_UI_CRT_PATH"
|
||||
HUIKeyPath = "H_UI_KEY_PATH"
|
||||
JwtSecret = "JWT_SECRET"
|
||||
PeerSecretKey = "PEER_SECRET_KEY"
|
||||
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
|
||||
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
|
||||
Hysteria2Enable = "HYSTERIA2_ENABLE"
|
||||
Hysteria2Config = "HYSTERIA2_CONFIG"
|
||||
Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME"
|
||||
Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK"
|
||||
ResetTrafficCron = "RESET_TRAFFIC_CRON"
|
||||
|
||||
// BootstrapPeerSeeded — отметка о том, что пир установщика УЖЕ был создан
|
||||
// однажды. Не «существует сейчас», а именно «был создан».
|
||||
//
|
||||
// Различие и есть суть ключа. Раньше единственным признаком служило наличие
|
||||
// строки в таблице пиров, поэтому удаление bootstrap-admin-peer выглядело
|
||||
// отзывом доступа, но им не было: HY2XS_ADMIN_CON_PASS продолжает жить в
|
||||
// /etc/hy2xs/hy2xs.env, и ближайший `systemctl restart hy2xs-admin` или
|
||||
// перезагрузка сервера возвращали тот же самый секрет в базу. Молча.
|
||||
//
|
||||
// Отзыв учётных данных, который не переживает перезапуск, — не отзыв.
|
||||
// Маркер делает удаление окончательным: seeder читает его, а не таблицу
|
||||
// пиров, поэтому после первого успешного bootstrap он не создаёт запись
|
||||
// больше никогда.
|
||||
BootstrapPeerSeeded = "BOOTSTRAP_PEER_SEEDED"
|
||||
)
|
||||
|
||||
// Что операторский API имеет право прочитать и записать в таблице `config`.
|
||||
//
|
||||
// Список — allowlist, и это принципиально. Раньше здесь работал denylist из
|
||||
// трёх orchestrator-ключей, а `getConfig`/`listConfig` принимали произвольную
|
||||
// строку. В той же таблице лежат JWT_SECRET, PEER_SECRET_KEY,
|
||||
// PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET — то есть
|
||||
// авторизованный запрос `?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key
|
||||
// шифрования секретов пиров, а updateConfigs позволял его подменить. После
|
||||
// подмены перестают расшифровываться секреты уже существующих пиров.
|
||||
//
|
||||
// Именно ради этого удаляли generic export/import таблицы `config`, но точечный
|
||||
// API остался прежним, и дыра просто сменила размер отверстия.
|
||||
//
|
||||
// Denylist здесь неверен структурно: он требует, чтобы автор КАЖДОГО нового
|
||||
// ключа вспомнил про этот файл. Забытый ключ при denylist сразу публичен, при
|
||||
// allowlist — сразу закрыт. Отказ по умолчанию — единственное поведение,
|
||||
// которое не зависит от внимательности.
|
||||
var (
|
||||
// publicReadableConfigKeys — настройки, которые панель показывает оператору.
|
||||
publicReadableConfigKeys = map[string]struct{}{
|
||||
ResetTrafficCron: {},
|
||||
}
|
||||
|
||||
// publicWritableConfigKeys — подмножество читаемых, которые оператор
|
||||
// действительно редактирует в интерфейсе.
|
||||
//
|
||||
// После очистки мёртвых ключей оба списка совпали: единственная настройка,
|
||||
// которой владеет панель, — расписание сброса счётчиков трафика. Списки
|
||||
// намеренно не схлопнуты в один: читаемое и записываемое — разные права, и
|
||||
// первый же ключ «показываем, но менять нельзя» вернёт разницу.
|
||||
publicWritableConfigKeys = map[string]struct{}{
|
||||
ResetTrafficCron: {},
|
||||
}
|
||||
)
|
||||
|
||||
// IsPublicReadableConfigKey сообщает, можно ли отдать значение ключа наружу.
|
||||
func IsPublicReadableConfigKey(key string) bool {
|
||||
_, ok := publicReadableConfigKeys[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsPublicWritableConfigKey сообщает, можно ли принять значение ключа снаружи.
|
||||
func IsPublicWritableConfigKey(key string) bool {
|
||||
_, ok := publicWritableConfigKeys[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// PublicReadableConfigKeys возвращает копию списка читаемых ключей.
|
||||
// Используется тестами и текстами ошибок.
|
||||
func PublicReadableConfigKeys() []string {
|
||||
return keysOf(publicReadableConfigKeys)
|
||||
}
|
||||
|
||||
// PublicWritableConfigKeys возвращает копию списка записываемых ключей.
|
||||
func PublicWritableConfigKeys() []string {
|
||||
return keysOf(publicWritableConfigKeys)
|
||||
}
|
||||
|
||||
// InternalConfigKeys — ключи, которые не покидают процесс ни при каком
|
||||
// сценарии. Объявлены явно, чтобы тест мог проверить их поимённо, а не
|
||||
// полагаться на то, что их «случайно нет» в allowlist.
|
||||
func InternalConfigKeys() []string {
|
||||
return []string{
|
||||
JwtSecret,
|
||||
PeerSecretKey,
|
||||
PeerSecretEncryptionKey,
|
||||
Hysteria2TrafficStatsSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(set map[string]struct{}) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package constant
|
||||
|
||||
import "testing"
|
||||
|
||||
// Allowlist существует ровно для того, чтобы забытый ключ был закрыт, а не
|
||||
// открыт. Тест закрепляет обе стороны этого утверждения поимённо.
|
||||
func TestInternalKeysAreNeitherReadableNorWritable(t *testing.T) {
|
||||
for _, key := range InternalConfigKeys() {
|
||||
if IsPublicReadableConfigKey(key) {
|
||||
t.Errorf("внутренний ключ %s доступен на чтение через API", key)
|
||||
}
|
||||
if IsPublicWritableConfigKey(key) {
|
||||
t.Errorf("внутренний ключ %s доступен на запись через API", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretKeysAreListedAsInternal(t *testing.T) {
|
||||
required := []string{
|
||||
JwtSecret,
|
||||
PeerSecretKey,
|
||||
PeerSecretEncryptionKey,
|
||||
Hysteria2TrafficStatsSecret,
|
||||
}
|
||||
internal := make(map[string]struct{}, len(InternalConfigKeys()))
|
||||
for _, key := range InternalConfigKeys() {
|
||||
internal[key] = struct{}{}
|
||||
}
|
||||
for _, key := range required {
|
||||
if _, ok := internal[key]; !ok {
|
||||
t.Errorf("секрет %s не объявлен внутренним ключом", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorSettingsRemainAvailable(t *testing.T) {
|
||||
if !IsPublicReadableConfigKey(ResetTrafficCron) {
|
||||
t.Errorf("пользовательская настройка %s недоступна на чтение", ResetTrafficCron)
|
||||
}
|
||||
if !IsPublicWritableConfigKey(ResetTrafficCron) {
|
||||
t.Errorf("пользовательская настройка %s недоступна на запись", ResetTrafficCron)
|
||||
}
|
||||
}
|
||||
|
||||
// Ключи, удалённые как мёртвое состояние, не должны вернуться в API «по
|
||||
// инерции» вместе с восстановлением константы. Проверка идёт по строковым
|
||||
// литералам: соответствующих констант в коде уже нет, и появиться они не
|
||||
// должны.
|
||||
func TestRemovedKeysStayClosed(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"HYSTERIA2_ENABLE",
|
||||
"HYSTERIA2_CONFIG",
|
||||
"HYSTERIA2_TRAFFIC_TIME",
|
||||
"HYSTERIA2_CONFIG_REMARK",
|
||||
} {
|
||||
if IsPublicReadableConfigKey(key) {
|
||||
t.Errorf("удалённый ключ %s снова доступен на чтение", key)
|
||||
}
|
||||
if IsPublicWritableConfigKey(key) {
|
||||
t.Errorf("удалённый ключ %s снова доступен на запись", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Право записи без права чтения означает настройку, которую оператор может
|
||||
// изменить, но не может увидеть.
|
||||
func TestWritableIsSubsetOfReadable(t *testing.T) {
|
||||
for _, key := range PublicWritableConfigKeys() {
|
||||
if !IsPublicReadableConfigKey(key) {
|
||||
t.Errorf("ключ %s можно записать, но нельзя прочитать", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownKeyIsClosedByDefault(t *testing.T) {
|
||||
if IsPublicReadableConfigKey("SOME_FUTURE_KEY") || IsPublicWritableConfigKey("SOME_FUTURE_KEY") {
|
||||
t.Fatal("неизвестный ключ обязан быть закрыт по умолчанию")
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,23 @@ func getenv(name string, fallback string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// Пути читаются напрямую из canonical env HY2XS.
|
||||
//
|
||||
// Раньше здесь стояли HUI_DATA/HUI_LOG, а systemd-юнит перекладывал в них
|
||||
// значения из HY2XS_DATA_DIR/HY2XS_LOG_DIR. Этот мост от старого H UI не давал
|
||||
// ничего, кроме второго имени у той же величины, и делал юнит обязательным
|
||||
// участником контракта конфигурации.
|
||||
var (
|
||||
DataRootDir = getenv("HUI_DATA", "/var/lib/hy2xs-admin")
|
||||
LogDir = getenv("HUI_LOG", "/var/log/hy2xs")
|
||||
DataRootDir = getenv("HY2XS_DATA_DIR", "/var/lib/hy2xs-admin")
|
||||
LogDir = getenv("HY2XS_LOG_DIR", "/var/log/hy2xs")
|
||||
SqliteDBDir = DataRootDir
|
||||
BinDir = filepath.Join(DataRootDir, "bin")
|
||||
ExportPathDir = filepath.Join(DataRootDir, "export")
|
||||
|
||||
SqliteDBPath = filepath.Join(DataRootDir, "h_ui.db")
|
||||
// Каталога для выгрузок здесь нет намеренно: артефакты экспорта
|
||||
// формируются в памяти и отдаются прямо в ответ. Персистентный
|
||||
// export-каталог накапливал на диске JSON с секретами.
|
||||
|
||||
SqliteDBPath = filepath.Join(DataRootDir, "hy2xs-admin.db")
|
||||
|
||||
Hysteria2ConfigPath = "/etc/hysteria/config.yaml"
|
||||
Hysteria2BinPath = "/usr/local/bin/hysteria"
|
||||
@@ -30,5 +39,15 @@ var (
|
||||
|
||||
const (
|
||||
TokenType = "Bearer"
|
||||
Version = "v0.0.22"
|
||||
)
|
||||
|
||||
// Version — версия пакета HY2XS, из которого собран этот бинарник.
|
||||
//
|
||||
// Значение проставляется на сборке через
|
||||
// `-ldflags -X hy2xs-admin/model/constant.Version=v${HY2XS_VERSION}`,
|
||||
// где HY2XS_VERSION приходит из корневого versions.env. Собственной
|
||||
// константы здесь нет намеренно: захардкоженная версия админки уже успела
|
||||
// разъехаться с версией пакета.
|
||||
//
|
||||
// "dev" остаётся только для сборок вне tools/build/build.sh.
|
||||
var Version = "dev"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package dto
|
||||
|
||||
type ConfigDto struct {
|
||||
Key *string `json:"key" form:"key" validate:"required,min=1,max=128"`
|
||||
}
|
||||
// ConfigDto удалён вместе с маршрутом GET /config/getConfig: чтение
|
||||
// произвольного ключа таблицы `config` было точечным входом к JWT- и
|
||||
// peer-ключам. Чтение идёт через ConfigsDto и allowlist.
|
||||
|
||||
type ConfigsDto struct {
|
||||
Keys []string `json:"keys" form:"keys" validate:"required"`
|
||||
|
||||
@@ -5,7 +5,3 @@ type Hysteria2AuthDto struct {
|
||||
Auth *string `json:"auth" form:"auth" validate:"required"`
|
||||
Tx *int64 `json:"tx" form:"tx" validate:"required"`
|
||||
}
|
||||
|
||||
type Hysteria2VersionDto struct {
|
||||
Version *string `json:"version" form:"version" validate:"required,min=1,max=10"`
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package entity
|
||||
|
||||
type LegacyAccount struct {
|
||||
Username *string `gorm:"column:username;default:''" json:"username"`
|
||||
Pass *string `gorm:"column:pass;default:''" json:"pass"`
|
||||
ConPass *string `gorm:"column:con_pass;default:''" json:"conPass"`
|
||||
Quota *int64 `gorm:"column:quota;default:0" json:"quota"`
|
||||
Download *int64 `gorm:"column:download;default:0" json:"download"`
|
||||
Upload *int64 `gorm:"column:upload;default:0" json:"upload"`
|
||||
ExpireTime *int64 `gorm:"column:expire_time;default:0" json:"expireTime"`
|
||||
KickUtilTime *int64 `gorm:"column:kick_util_time;default:0" json:"kickUtilTime"`
|
||||
DeviceNo *int64 `gorm:"column:device_no;default:3" json:"deviceNo"`
|
||||
Role *string `gorm:"column:role;default:'user'" json:"role"`
|
||||
Deleted *int64 `gorm:"column:deleted;default:0" json:"deleted"`
|
||||
BaseEntity `gorm:"embedded"`
|
||||
|
||||
LoginAt *int64 `gorm:"column:login_at;default:0" json:"loginAt"`
|
||||
ConAt *int64 `gorm:"column:con_at;default:0" json:"conAt"`
|
||||
Remark *string `gorm:"column:remark;default:''" json:"remark"`
|
||||
ForcePasswordChange *int64 `gorm:"column:force_password_change;default:0" json:"forcePasswordChange"`
|
||||
}
|
||||
|
||||
func (LegacyAccount) TableName() string {
|
||||
return "account"
|
||||
}
|
||||
@@ -30,13 +30,3 @@ func Hysteria2AuthBadRequest(c *gin.Context) {
|
||||
Id: "",
|
||||
})
|
||||
}
|
||||
|
||||
type Hysteria2UrlVo struct {
|
||||
Url string `json:"url"`
|
||||
QrCode []byte `json:"qrCode"`
|
||||
}
|
||||
|
||||
type Hysteria2AcmePathVo struct {
|
||||
CrtPath string `json:"crtPath"`
|
||||
KeyPath string `json:"keyPath"`
|
||||
}
|
||||
|
||||
@@ -5,13 +5,20 @@ type LogSystemPage[T LogSystemVo | LogHysteria2Vo] struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// LogSystemVo — строка журнала админки в том виде, в каком её пишет
|
||||
// middleware.LogHandler.
|
||||
//
|
||||
// Поля reqUri здесь нет намеренно: RequestURI несёт query string, а по
|
||||
// машинному endpoint'у в ней приезжает действующий machine token. Логируется
|
||||
// путь и, отдельно, имена переданных query-параметров без значений.
|
||||
type LogSystemVo struct {
|
||||
ClientIP string `json:"clientIp"`
|
||||
LatencyTime int64 `json:"latencyTime"`
|
||||
Level string `json:"level"`
|
||||
Msg string `json:"msg"`
|
||||
ReqMethod string `json:"reqMethod"`
|
||||
ReqUri string `json:"reqUri"`
|
||||
ReqPath string `json:"reqPath"`
|
||||
ReqQueryKeys string `json:"reqQueryKeys"`
|
||||
StatusCode int64 `json:"statusCode"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
+15
-8
@@ -5,20 +5,27 @@ import (
|
||||
"hy2xs-admin/controller"
|
||||
)
|
||||
|
||||
// Серверный конфиг Hysteria в HY2XS доступен только на чтение и на выгрузку:
|
||||
// его владелец — install-оркестратор. Маршруты записи (updateHysteria2Config,
|
||||
// importHysteria2Config), перезапуска панели и загрузки сертификатов удалены,
|
||||
// а не оставлены заглушками: маршрут, который всегда отвечает «feature
|
||||
// disabled», вводит в заблуждение и остаётся точкой входа.
|
||||
//
|
||||
// По той же причине здесь нет generic exportConfig/importConfig: они отдавали
|
||||
// и принимали таблицу `config` целиком, вместе с JWT- и peer-ключами.
|
||||
// См. комментарий в controller/config.go.
|
||||
//
|
||||
// Маршрута getConfig тоже нет. Он принимал произвольный ключ и был вторым
|
||||
// входом в ту же таблицу секретов, только точечным: `?key=JWT_SECRET`
|
||||
// возвращал подписывающий ключ, `?key=PEER_SECRET_ENCRYPTION_KEY` — master-key
|
||||
// шифрования секретов пиров. Потребителей у маршрута не было ни одного:
|
||||
// панель читает настройки через listConfig, у которого теперь allowlist.
|
||||
func initConfigRouter(configApi *gin.RouterGroup) {
|
||||
config := configApi.Group("/config")
|
||||
{
|
||||
config.POST("/updateConfigs", controller.UpdateConfigs)
|
||||
config.GET("/getConfig", controller.GetConfig)
|
||||
config.POST("/listConfig", controller.ListConfig)
|
||||
config.GET("/getHysteria2Config", controller.GetHysteria2Config)
|
||||
config.POST("/updateHysteria2Config", controller.UpdateHysteria2Config)
|
||||
config.POST("/exportHysteria2Config", controller.ExportHysteria2Config)
|
||||
config.POST("/importHysteria2Config", controller.ImportHysteria2Config)
|
||||
config.POST("/exportConfig", controller.ExportConfig)
|
||||
config.POST("/importConfig", controller.ImportConfig)
|
||||
config.GET("/hysteria2AcmePath", controller.Hysteria2AcmePath)
|
||||
config.POST("/restartServer", controller.RestartServer)
|
||||
config.POST("/uploadCertFile", controller.UploadCertFile)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-13
@@ -5,17 +5,14 @@ import (
|
||||
"hy2xs-admin/controller"
|
||||
)
|
||||
|
||||
func initHysteria2MachineAuthRouter(hysteria2Api *gin.RouterGroup) {
|
||||
hysteria2 := hysteria2Api.Group("/hysteria2")
|
||||
{
|
||||
hysteria2.POST("/auth", controller.Hysteria2Auth)
|
||||
}
|
||||
}
|
||||
|
||||
func initHysteria2Router(hysteria2Api *gin.RouterGroup) {
|
||||
hysteria2 := hysteria2Api.Group("/hysteria2")
|
||||
{
|
||||
hysteria2.POST("/hysteria2ChangeVersion", controller.Hysteria2ChangeVersion)
|
||||
hysteria2.GET("/listRelease", controller.ListRelease)
|
||||
}
|
||||
// Панель HY2XS не управляет жизненным циклом Hysteria: установка, обновление
|
||||
// и перезапуск выполняются install-оркестратором. Маршрутов updater'а здесь
|
||||
// нет намеренно — API-контракт не должен обещать операцию, которой у продукта
|
||||
// принципиально не существует, и не должен нести лишнюю attack surface.
|
||||
//
|
||||
// Единственный маршрут этой группы — machine-auth. Группа уже смонтирована на
|
||||
// constant.HysteriaMachineAuthPath, поэтому относительный путь здесь пустой:
|
||||
// полный путь объявлен ровно в одном месте.
|
||||
func initHysteria2MachineAuthRouter(machineAuthAPI *gin.RouterGroup) {
|
||||
machineAuthAPI.POST("", controller.Hysteria2Auth)
|
||||
}
|
||||
|
||||
+10
-3
@@ -19,9 +19,16 @@ func initPeerRouter(peerApi *gin.RouterGroup) {
|
||||
peers.PATCH("/:id/kick", controller.KickPeer)
|
||||
peers.GET("/:id/client-config", controller.PeerClientConfig)
|
||||
|
||||
// aliases на один миграционный релиз
|
||||
peers.GET("/:id/client-url", controller.PeerClientConfig)
|
||||
peers.GET("/:id/qr", controller.PeerClientConfig)
|
||||
// Алиасов /:id/client-url и /:id/qr здесь больше нет.
|
||||
//
|
||||
// Они были оставлены «на один миграционный релиз», но миграционного
|
||||
// релиза у продукта не существует: HY2XS v1 ставится только на чистый
|
||||
// хост, база предыдущего поколения не переносится, а клиентов у этих
|
||||
// путей нет — панель ходит в /:id/client-config. Дожив до 1.0.0, они
|
||||
// стали бы частью публичного API v1, и убирать их пришлось бы уже со
|
||||
// сломом совместимости.
|
||||
//
|
||||
// Отсутствие закреплено TestRouterHasNoRemovedRoutes.
|
||||
}
|
||||
|
||||
peerApi.POST("/peer-import", controller.ImportPeer)
|
||||
|
||||
+26
-23
@@ -7,10 +7,16 @@ import (
|
||||
"hy2xs-admin/middleware"
|
||||
"hy2xs-admin/model/constant"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Router(router *gin.Engine, huiWebContext *string) {
|
||||
// Router собирает HTTP-контур админки.
|
||||
//
|
||||
// Панель всегда живёт в корне: настраиваемый web-контекст был возможностью H UI
|
||||
// разворачивать панель по произвольному пути, а HY2XS слушает только loopback
|
||||
// и отдаёт панель через SSH-туннель или reverse proxy. Настройка, которую UI
|
||||
// уже не позволял менять, а backend продолжал читать из SQLite, — это не
|
||||
// точка расширения, а лишний источник истины.
|
||||
func Router(router *gin.Engine) {
|
||||
router.GET("/healthz", func(c *gin.Context) {
|
||||
sqliteReady := dao.IsSqliteReady()
|
||||
configReadable := false
|
||||
@@ -36,35 +42,32 @@ func Router(router *gin.Engine, huiWebContext *string) {
|
||||
})
|
||||
})
|
||||
|
||||
relativePath := "/"
|
||||
if huiWebContext != nil && strings.HasPrefix(*huiWebContext, "/") {
|
||||
relativePath = *huiWebContext
|
||||
}
|
||||
frontend.InitFrontend(router, "/")
|
||||
|
||||
frontend.InitFrontend(router, relativePath)
|
||||
// Machine-контур. Не часть операторского API: это внутренний канал между
|
||||
// Hysteria и админкой, поэтому у него отдельное пространство имён,
|
||||
// собственный middleware и никакого JWT.
|
||||
machineAPI := router.Group(constant.HysteriaMachineAuthPath)
|
||||
machineAPI.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler())
|
||||
initHysteria2MachineAuthRouter(machineAPI)
|
||||
|
||||
globalGroup := router.Group(relativePath)
|
||||
api := router.Group(constant.AdminAPIBase)
|
||||
|
||||
machineApi := globalGroup.Group("/hui")
|
||||
machineApi.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler())
|
||||
initHysteria2MachineAuthRouter(machineApi)
|
||||
authAPI := api.Group("")
|
||||
authAPI.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler())
|
||||
initAuthRouter(authAPI)
|
||||
|
||||
authApi := globalGroup.Group("/hui")
|
||||
authApi.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler())
|
||||
initAuthRouter(authApi)
|
||||
|
||||
huiAdminApi := globalGroup.Group("/hui")
|
||||
huiAdminApi.Use(
|
||||
adminAPI := api.Group("")
|
||||
adminAPI.Use(
|
||||
middleware.FilterHandler(),
|
||||
middleware.LogHandler(),
|
||||
middleware.RateLimiterHandler(),
|
||||
middleware.JWTHandler(),
|
||||
middleware.AdminHandler(),
|
||||
)
|
||||
initAdminRouter(huiAdminApi)
|
||||
initDashboardRouter(huiAdminApi)
|
||||
initPeerRouter(huiAdminApi)
|
||||
initConfigRouter(huiAdminApi)
|
||||
initHysteria2Router(huiAdminApi)
|
||||
initLogRouter(huiAdminApi)
|
||||
initAdminRouter(adminAPI)
|
||||
initDashboardRouter(adminAPI)
|
||||
initPeerRouter(adminAPI)
|
||||
initConfigRouter(adminAPI)
|
||||
initLogRouter(adminAPI)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Регистрация маршрутов — единственное место, где ошибка проявляется паникой
|
||||
// при старте сервиса, а не ответом с кодом. Смена пространства имён API как
|
||||
// раз относится к таким изменениям: конфликт с wildcard-маршрутом фронтенда
|
||||
// или дублирующая регистрация обнаружились бы только на живом сервере.
|
||||
func buildRoutes(t *testing.T) gin.RoutesInfo {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
engine := gin.New()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("сборка маршрутов паникует: %v", r)
|
||||
}
|
||||
}()
|
||||
Router(engine)
|
||||
return engine.Routes()
|
||||
}
|
||||
|
||||
func TestRouterRegistersMachineAuthUnderInternalNamespace(t *testing.T) {
|
||||
routes := buildRoutes(t)
|
||||
|
||||
found := false
|
||||
for _, route := range routes {
|
||||
if route.Method == "POST" && route.Path == constant.HysteriaMachineAuthPath {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("machine-auth endpoint не зарегистрирован на %s", constant.HysteriaMachineAuthPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterKeepsAdminAndAuthUnderApiNamespace(t *testing.T) {
|
||||
routes := buildRoutes(t)
|
||||
|
||||
expected := []string{
|
||||
constant.AdminAPIBase + "/auth/login",
|
||||
constant.AdminAPIBase + "/peers",
|
||||
constant.AdminAPIBase + "/config/listConfig",
|
||||
constant.AdminAPIBase + "/peer-import",
|
||||
constant.AdminAPIBase + "/peer-export",
|
||||
}
|
||||
present := make(map[string]bool, len(routes))
|
||||
for _, route := range routes {
|
||||
present[route.Path] = true
|
||||
}
|
||||
for _, path := range expected {
|
||||
if !present[path] {
|
||||
t.Errorf("маршрут %s не зарегистрирован", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Старое пространство имён — часть runtime-контракта, а не внутреннее имя:
|
||||
// его возвращение сломало бы конфиг Hysteria на уже установленных серверах.
|
||||
func TestRouterHasNoLegacyNamespace(t *testing.T) {
|
||||
for _, route := range buildRoutes(t) {
|
||||
if strings.HasPrefix(route.Path, "/hui") {
|
||||
t.Errorf("вернулось legacy-пространство имён: %s %s", route.Method, route.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Маршруты, операциями которых продукт не владеет, удалены, а не оставлены
|
||||
// отвечающими «feature disabled».
|
||||
func TestRouterHasNoRemovedRoutes(t *testing.T) {
|
||||
removed := []string{
|
||||
"exportConfig",
|
||||
"importConfig",
|
||||
"updateHysteria2Config",
|
||||
"importHysteria2Config",
|
||||
"hysteria2ChangeVersion",
|
||||
"listRelease",
|
||||
"restartServer",
|
||||
"uploadCertFile",
|
||||
"hysteria2AcmePath",
|
||||
// getConfig принимал произвольный ключ таблицы `config` и был точечным
|
||||
// входом к JWT_SECRET, PEER_SECRET_KEY и PEER_SECRET_ENCRYPTION_KEY.
|
||||
// Потребителей у него не было ни одного.
|
||||
"getConfig",
|
||||
// Алиасы клиентской конфигурации, оставленные «на один миграционный
|
||||
// релиз». Миграционного релиза у clean-install-only продукта нет, а в
|
||||
// 1.0.0 они стали бы частью публичного API.
|
||||
"client-url",
|
||||
"/qr",
|
||||
}
|
||||
for _, route := range buildRoutes(t) {
|
||||
for _, dead := range removed {
|
||||
if strings.Contains(route.Path, dead) {
|
||||
t.Errorf("удалённый маршрут вернулся: %s %s", route.Method, route.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Оставшиеся маршруты /config — это чтение по allowlist, запись по allowlist и
|
||||
// санитизирующая выгрузка серверного конфига. Никакого generic-доступа к
|
||||
// таблице `config` у API быть не должно.
|
||||
func TestRouterConfigNamespaceIsClosed(t *testing.T) {
|
||||
allowed := map[string]bool{
|
||||
constant.AdminAPIBase + "/config/updateConfigs": true,
|
||||
constant.AdminAPIBase + "/config/listConfig": true,
|
||||
constant.AdminAPIBase + "/config/getHysteria2Config": true,
|
||||
constant.AdminAPIBase + "/config/exportHysteria2Config": true,
|
||||
}
|
||||
|
||||
for _, route := range buildRoutes(t) {
|
||||
if !strings.HasPrefix(route.Path, constant.AdminAPIBase+"/config") {
|
||||
continue
|
||||
}
|
||||
if !allowed[route.Path] {
|
||||
t.Errorf("в пространстве /config появился неожиданный маршрут: %s %s", route.Method, route.Path)
|
||||
}
|
||||
}
|
||||
|
||||
present := make(map[string]bool)
|
||||
for _, route := range buildRoutes(t) {
|
||||
present[route.Path] = true
|
||||
}
|
||||
for path := range allowed {
|
||||
if !present[path] {
|
||||
t.Errorf("маршрут %s пропал", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterExposesHealthz(t *testing.T) {
|
||||
found := false
|
||||
for _, route := range buildRoutes(t) {
|
||||
if route.Method == "GET" && route.Path == "/healthz" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("/healthz не зарегистрирован")
|
||||
}
|
||||
}
|
||||
+20
-16
@@ -30,16 +30,9 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
verified, legacy := util.VerifyPassword(plainPassword, *admin.PasswordHash)
|
||||
if !verified {
|
||||
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
|
||||
return "", false, errors.New(constant.WrongPassword)
|
||||
}
|
||||
if legacy {
|
||||
hash, hashErr := util.HashPassword(plainPassword)
|
||||
if hashErr == nil {
|
||||
_ = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{"password_hash": hash})
|
||||
}
|
||||
}
|
||||
tokenVersion := int64(1)
|
||||
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
||||
tokenVersion = *admin.TokenVersion
|
||||
@@ -49,21 +42,33 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
||||
if tokenErr != nil {
|
||||
return "", false, tokenErr
|
||||
}
|
||||
requirePasswordChange := legacy
|
||||
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
|
||||
// с поддержкой самого формата: единственный источник этого требования —
|
||||
// флаг force_password_change, который ставит установка.
|
||||
requirePasswordChange := false
|
||||
if admin.ForcePasswordChange != nil {
|
||||
requirePasswordChange = *admin.ForcePasswordChange != 0
|
||||
}
|
||||
return token, requirePasswordChange, nil
|
||||
}
|
||||
|
||||
// GetAdminInfo читает учётные данные ТОЛЬКО из контекста запроса.
|
||||
//
|
||||
// Резервной ветки «если в контексте пусто — разобрать токен самостоятельно»
|
||||
// здесь больше нет. Она была недостижима: все маршруты, ведущие сюда, стоят за
|
||||
// middleware.JWTHandler, который разбирает токен, сверяет token_version и
|
||||
// кладёт claims в контекст. При этом ветка несла собственный разбор заголовка
|
||||
// Authorization через service.GetToken, где strings.SplitN(...)[1] паниковал
|
||||
// на заголовке без пробела.
|
||||
//
|
||||
// Хуже недостижимости было бы её оживление: этот путь не проверял ни статус
|
||||
// учётной записи, ни версию токена, то есть отозванный токен прошёл бы здесь,
|
||||
// но не прошёл бы через middleware. Второй способ аутентификации, слабее
|
||||
// первого, — не запасной вариант, а обход.
|
||||
func GetAdminInfo(c *gin.Context) (vo.AdminInfoVo, error) {
|
||||
claims, ok := adminClaimsFromContext(c)
|
||||
if !ok {
|
||||
myClaims, err := ParseToken(GetToken(c))
|
||||
if err != nil {
|
||||
return vo.AdminInfoVo{}, err
|
||||
}
|
||||
claims = myClaims.Admin
|
||||
return vo.AdminInfoVo{}, errors.New(constant.UnauthorizedError)
|
||||
}
|
||||
admin, err := dao.GetAdminUser("id = ?", claims.Id)
|
||||
if err != nil {
|
||||
@@ -92,8 +97,7 @@ func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string)
|
||||
if admin.PasswordHash == nil {
|
||||
return errors.New(constant.SysError)
|
||||
}
|
||||
verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash)
|
||||
if !verified {
|
||||
if !util.VerifyPassword(oldPassword, *admin.PasswordHash) {
|
||||
return errors.New(constant.WrongPassword)
|
||||
}
|
||||
hash, hashErr := util.HashPassword(newPassword)
|
||||
|
||||
+62
-134
@@ -10,16 +10,58 @@ import (
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConfigUpdate — одна пара «ключ/значение» из партии операторского API.
|
||||
type ConfigUpdate struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func UpdateConfig(key string, value string) error {
|
||||
return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value})
|
||||
}
|
||||
|
||||
// UpdateConfigs применяет партию настроек целиком или не применяет вовсе.
|
||||
//
|
||||
// Проверка значений в неё не входит намеренно: партия обязана быть проверена
|
||||
// ПОЛНОСТЬЮ до открытия транзакции. Транзакция, внутри которой ещё может
|
||||
// возникнуть отказ по содержимому, — это откат вместо отказа, то есть лишняя
|
||||
// работа с базой на каждом неверно заполненном поле формы.
|
||||
func UpdateConfigs(updates []ConfigUpdate) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.WithConfigTx(func(tx dao.ConfigTx) error {
|
||||
for _, item := range updates {
|
||||
if err := tx.UpdateConfig([]string{item.Key}, map[string]interface{}{"value": item.Value}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateConfigValue проверяет ЗНАЧЕНИЕ настройки до записи в базу.
|
||||
//
|
||||
// Раньше такой проверки не было вовсе: DTO ограничивал длину 128 символами, а
|
||||
// поле расписания в панели — el-select с allow-create, то есть строка
|
||||
// произвольная. Невалидное cron-выражение сохранялось, API отвечал успехом, а
|
||||
// планировщик при следующем старте просто логировал ошибку и продолжал без
|
||||
// джобы. Для оператора это выглядело как «настройка принята, сброс трафика
|
||||
// молча перестал работать».
|
||||
func ValidateConfigValue(key string, value string) error {
|
||||
switch key {
|
||||
case constant.ResetTrafficCron:
|
||||
return ValidateResetTrafficCron(value)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func GetConfig(key string) (entity.Config, error) {
|
||||
return dao.GetConfig("key = ?", key)
|
||||
}
|
||||
@@ -28,90 +70,29 @@ func ListConfig(keys []string) ([]entity.Config, error) {
|
||||
return dao.ListConfig("key in ?", keys)
|
||||
}
|
||||
|
||||
func ListConfigNotIn(keys []string) ([]entity.Config, error) {
|
||||
return dao.ListConfig("key not in ?", keys)
|
||||
}
|
||||
|
||||
// GetHysteria2Config читает ТОЛЬКО /etc/hysteria/config.yaml.
|
||||
//
|
||||
// Ветки «сначала посмотреть в SQLite, а если пусто — в файл» здесь больше нет.
|
||||
// Ключ HYSTERIA2_CONFIG остался от предыдущего поколения, где панель сама
|
||||
// владела серверным конфигом. В HY2XS владелец ровно один — install-оркестратор,
|
||||
// маршруты записи конфига удалены, и писателя у этого ключа не осталось: clean
|
||||
// install создавал его пустым, и пустым он оставался навсегда.
|
||||
//
|
||||
// Опасна была не бесполезность, а приоритет: БД читалась ПЕРВОЙ. Любое
|
||||
// значение, попавшее в неё в обход продукта, молча становилось тем, что панель
|
||||
// показывает оператору и из чего генерирует клиентские ссылки, пока Hysteria
|
||||
// продолжала работать по файлу. То есть ключ был готовым вторым источником
|
||||
// истины — ровно тем, что проект последовательно убирает.
|
||||
func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
|
||||
var serverConfig bo.Hysteria2ServerConfig
|
||||
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
|
||||
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
|
||||
if err != nil {
|
||||
return serverConfig, err
|
||||
}
|
||||
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
|
||||
content, readErr := os.ReadFile(constant.Hysteria2ConfigPath)
|
||||
if readErr != nil {
|
||||
return serverConfig, readErr
|
||||
}
|
||||
if err = yaml.Unmarshal(content, &serverConfig); err != nil {
|
||||
return serverConfig, err
|
||||
}
|
||||
return serverConfig, nil
|
||||
}
|
||||
if err = yaml.Unmarshal([]byte(*config.Value), &serverConfig); err != nil {
|
||||
return serverConfig, err
|
||||
}
|
||||
return serverConfig, nil
|
||||
}
|
||||
|
||||
func UpdateHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error {
|
||||
// Значения по умолчанию
|
||||
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.Hysteria2TrafficStatsSecret})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hUIWebPort string
|
||||
var trafficStatsSecret string
|
||||
for _, item := range config {
|
||||
if *item.Key == constant.HUIWebPort {
|
||||
hUIWebPort = *item.Value
|
||||
} else if *item.Key == constant.Hysteria2TrafficStatsSecret {
|
||||
trafficStatsSecret = *item.Value
|
||||
}
|
||||
}
|
||||
|
||||
if hUIWebPort == "" || trafficStatsSecret == "" {
|
||||
logrus.Errorf("hUIWebPort or trafficStatsSecret is nil")
|
||||
return errors.New(constant.SysError)
|
||||
}
|
||||
|
||||
authHttpUrl, err := GetAuthHttpUrl()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authType := "http"
|
||||
authHttpInsecure := true
|
||||
var auth bo.ServerConfigAuth
|
||||
auth.Type = &authType
|
||||
var http bo.ServerConfigAuthHTTP
|
||||
http.URL = &authHttpUrl
|
||||
http.Insecure = &authHttpInsecure
|
||||
auth.HTTP = &http
|
||||
hysteria2ServerConfig.Auth = &auth
|
||||
if hysteria2ServerConfig.TrafficStats == nil {
|
||||
hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{}
|
||||
}
|
||||
hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
|
||||
|
||||
yamlConfig, err := yaml.Marshal(&hysteria2ServerConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.UpdateConfig([]string{constant.Hysteria2Config}, map[string]interface{}{"value": string(yamlConfig)})
|
||||
}
|
||||
|
||||
func SetHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error {
|
||||
config, err := yaml.Marshal(&hysteria2ServerConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.UpdateConfig([]string{constant.Hysteria2Config}, map[string]interface{}{"value": string(config)})
|
||||
}
|
||||
|
||||
func UpsertConfig(configs []entity.Config) error {
|
||||
return dao.UpsertConfig(configs)
|
||||
}
|
||||
|
||||
func GetHysteria2ApiPort() (int64, error) {
|
||||
@@ -119,15 +100,18 @@ func GetHysteria2ApiPort() (int64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Сообщение передаётся аргументом, а не форматной строкой: значение
|
||||
// приходит из /etc/hysteria/config.yaml, и знак процента в нём превращал
|
||||
// запись журнала в мусор вида `%!s(MISSING)`.
|
||||
if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil {
|
||||
errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil"
|
||||
logrus.Errorf(errMsg)
|
||||
logrus.Errorf("%s", errMsg)
|
||||
return 0, errors.New(errMsg)
|
||||
}
|
||||
apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err)
|
||||
logrus.Errorf(errMsg)
|
||||
logrus.Errorf("%s", errMsg)
|
||||
return 0, errors.New(errMsg)
|
||||
}
|
||||
return apiPort, nil
|
||||
@@ -157,59 +141,3 @@ func parseTrafficStatsPort(listen string) (int64, error) {
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func GetPortAndCert() (int64, string, string, error) {
|
||||
configs, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.HUICrtPath, constant.HUIKeyPath})
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
port := ""
|
||||
crtPath := ""
|
||||
keyPath := ""
|
||||
for _, config := range configs {
|
||||
value := *config.Value
|
||||
if *config.Key == constant.HUIWebPort {
|
||||
port = value
|
||||
} else if *config.Key == constant.HUICrtPath {
|
||||
crtPath = value
|
||||
} else if *config.Key == constant.HUIKeyPath {
|
||||
keyPath = value
|
||||
}
|
||||
}
|
||||
|
||||
portInt, err := strconv.ParseInt(port, 10, 64)
|
||||
if err != nil {
|
||||
logrus.Errorf("port: %s is invalid", port)
|
||||
return 0, "", "", errors.New(fmt.Sprintf("port: %s is invalid", port))
|
||||
}
|
||||
|
||||
return portInt, crtPath, keyPath, nil
|
||||
}
|
||||
|
||||
func GetAuthHttpUrl() (string, error) {
|
||||
port, crtPath, keyPath, err := GetPortAndCert()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
protocol := "http"
|
||||
if crtPath != "" && keyPath != "" {
|
||||
protocol = "https"
|
||||
}
|
||||
config, err := dao.GetConfig("key = ?", constant.HUIWebContext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
webContext := ""
|
||||
if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") {
|
||||
webContext = *config.Value
|
||||
}
|
||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authURL := fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext)
|
||||
if trafficSecretConfig.Value != nil && strings.TrimSpace(*trafficSecretConfig.Value) != "" {
|
||||
authURL = fmt.Sprintf("%s?access_token=%s", authURL, url.QueryEscape(strings.TrimSpace(*trafficSecretConfig.Value)))
|
||||
}
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Планировщик принадлежит ПРОЦЕССУ, а не HTTP-серверу.
|
||||
//
|
||||
// Что было. InitCron жил в middleware, вызывался из runServer и на каждом
|
||||
// вызове создавал новый cron.New(). Ссылка на планировщик никуда не
|
||||
// сохранялась, а releaseResource() закрывал только SQLite и Hysteria —
|
||||
// cron.Stop() не вызывался нигде. При этом смена RESET_TRAFFIC_CRON делала
|
||||
// StopServer(), из-за чего runServer возвращался, а точка входа в cmd.go
|
||||
// крутила его в `for {}` и запускала всё заново.
|
||||
//
|
||||
// Итог: каждая смена расписания добавляла ЦЕЛЫЙ дублирующий набор джоб, а
|
||||
// старое расписание сброса трафика продолжало работать. После двух правок
|
||||
// «@monthly → @weekly → @monthly» на процессе висели три планировщика,
|
||||
// утроенный CollectMetricsSnapshot и три разных расписания сброса
|
||||
// одновременно. Плюс окно, в котором джобы старого планировщика били в уже
|
||||
// закрытое соединение SQLite: releaseResource() отрабатывал раньше, чем
|
||||
// следующий runServer успевал открыть базу.
|
||||
//
|
||||
// Правильная граница: фиксированные джобы регистрируются один раз за жизнь
|
||||
// процесса, а расписание сброса трафика перепланируется на месте по своему
|
||||
// EntryID. HTTP-сервер к смене настройки отношения не имеет вообще.
|
||||
|
||||
// cronShutdownTimeout ограничивает ожидание уже запущенных джоб при остановке.
|
||||
// systemd по умолчанию даёт юниту 90 секунд, так что запас есть.
|
||||
const cronShutdownTimeout = 10 * time.Second
|
||||
|
||||
var (
|
||||
cronMu sync.Mutex
|
||||
cronScheduler *cron.Cron
|
||||
resetTrafficEntryID cron.EntryID
|
||||
|
||||
// cronOwnedJobs учитывает джобы, запущенные ВНЕ расписания.
|
||||
//
|
||||
// Стартовая уборка статистики намеренно выполняется в фоне: на большой базе
|
||||
// она заметно долгая, и держать на ней запуск сервиса незачем. Но
|
||||
// cron.Stop() ждёт только то, что запустил сам планировщик, поэтому
|
||||
// необслуженная горутина переживала закрытие SQLite — ровно та же болезнь,
|
||||
// от которой лечится весь этот файл, только меньшего масштаба.
|
||||
cronOwnedJobs sync.WaitGroup
|
||||
)
|
||||
|
||||
// ValidateResetTrafficCron проверяет выражение ТЕМ ЖЕ парсером, которым его
|
||||
// потом будет разбирать runtime.
|
||||
//
|
||||
// cron.New() без опций собирает parser из Minute|Hour|Dom|Month|Dow|Descriptor,
|
||||
// и ровно его же использует cron.ParseStandard. Поэтому «валидно на входе API»
|
||||
// и «планируется в рантайме» здесь не могут разойтись — а разойтись они могли
|
||||
// бы, если бы валидация была написана собственной регуляркой.
|
||||
//
|
||||
// Пустая строка — легальное значение и означает «автоматический сброс
|
||||
// выключен»: в панели поле clearable, и оператор имеет право его очистить.
|
||||
func ValidateResetTrafficCron(expression string) error {
|
||||
trimmed := strings.TrimSpace(expression)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := cron.ParseStandard(trimmed); err != nil {
|
||||
return fmt.Errorf("%s: невалидное cron-выражение %q: %v", constant.ResetTrafficCron, expression, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitCron поднимает единственный планировщик процесса.
|
||||
func InitCron() error {
|
||||
cronMu.Lock()
|
||||
defer cronMu.Unlock()
|
||||
|
||||
if cronScheduler != nil {
|
||||
return errors.New("cron scheduler is already running")
|
||||
}
|
||||
|
||||
c := cron.New(cron.WithLocation(time.Now().Location()))
|
||||
|
||||
fixedJobs := []struct {
|
||||
name string
|
||||
spec string
|
||||
job func()
|
||||
}{
|
||||
{"CronHandleAccount", "@every 30s", CronHandleAccount},
|
||||
{"CollectMetricsSnapshot", "@every 10s", CollectMetricsSnapshot},
|
||||
{"CleanupStatsRetention", "@every 1h", CleanupStatsRetention},
|
||||
}
|
||||
for _, fixed := range fixedJobs {
|
||||
if _, err := c.AddFunc(fixed.spec, fixed.job); err != nil {
|
||||
logrus.Errorf("cron add func %s err: %v", fixed.name, err)
|
||||
return fmt.Errorf("cron add func %s err", fixed.name)
|
||||
}
|
||||
}
|
||||
|
||||
expression, err := storedResetTrafficCron()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if expression != "" {
|
||||
id, addErr := c.AddFunc(expression, CronResetTraffic)
|
||||
if addErr != nil {
|
||||
// Старт НЕ прерывается, и это осознанное решение.
|
||||
//
|
||||
// Панель отдаёт не только операторский UI: на ней же висит
|
||||
// /internal/hysteria/auth, куда Hysteria ходит при каждом
|
||||
// подключении пира. Отказ старта из-за испорченной строки
|
||||
// расписания положил бы подключения пользователей — цена
|
||||
// несопоставима с отключённым плановым сбросом счётчиков.
|
||||
//
|
||||
// Молчаливой деградации при этом нет: запись через API теперь
|
||||
// валидируется тем же парсером, поэтому попасть сюда можно только
|
||||
// правкой базы в обход продукта, и об этом пишется ERROR.
|
||||
logrus.Errorf(
|
||||
"cron: сохранённое %s=%q невалидно (%v); плановый сброс трафика выключен до исправления настройки",
|
||||
constant.ResetTrafficCron, expression, addErr,
|
||||
)
|
||||
} else {
|
||||
resetTrafficEntryID = id
|
||||
}
|
||||
}
|
||||
|
||||
cronOwnedJobs.Add(1)
|
||||
go func() {
|
||||
defer cronOwnedJobs.Done()
|
||||
CleanupStatsRetention()
|
||||
}()
|
||||
|
||||
c.Start()
|
||||
cronScheduler = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// RescheduleResetTraffic переносит джобу сброса трафика на новое расписание.
|
||||
//
|
||||
// HTTP-сервер здесь не участвует. Раньше единственным способом применить новое
|
||||
// расписание был перезапуск процесса через StopServer(), и именно он и плодил
|
||||
// планировщики.
|
||||
func RescheduleResetTraffic(expression string) error {
|
||||
if err := ValidateResetTrafficCron(expression); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cronMu.Lock()
|
||||
defer cronMu.Unlock()
|
||||
|
||||
if cronScheduler == nil {
|
||||
return errors.New("cron scheduler is not running")
|
||||
}
|
||||
|
||||
// Старая запись снимается всегда, даже если новая не будет добавлена:
|
||||
// пустое выражение означает «сброс выключен», а не «оставить как было».
|
||||
if resetTrafficEntryID != 0 {
|
||||
cronScheduler.Remove(resetTrafficEntryID)
|
||||
resetTrafficEntryID = 0
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(expression)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ошибка здесь уже невозможна: выражение прошло тот же парсер выше.
|
||||
// Проверка остаётся, чтобы расхождение двух парсеров не превратилось в
|
||||
// молча пропавшую джобу.
|
||||
id, err := cronScheduler.AddFunc(trimmed, CronResetTraffic)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: не удалось запланировать %q: %v", constant.ResetTrafficCron, expression, err)
|
||||
}
|
||||
resetTrafficEntryID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopCron останавливает планировщик и дожидается уже запущенных джоб.
|
||||
//
|
||||
// Без этого джобы продолжали работать после закрытия SQLite: каждая из них
|
||||
// ходит в базу, и остановка в обратном порядке (сначала планировщик, потом
|
||||
// соединение) — часть контракта завершения процесса.
|
||||
func StopCron() {
|
||||
cronMu.Lock()
|
||||
scheduler := cronScheduler
|
||||
cronScheduler = nil
|
||||
resetTrafficEntryID = 0
|
||||
cronMu.Unlock()
|
||||
|
||||
if scheduler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Дожидаемся обеих групп: и джоб, запущенных планировщиком, и фоновых,
|
||||
// которые он не видит. Незавершённая джоба означает работу с базой, которую
|
||||
// releaseResource закроет сразу после возврата отсюда.
|
||||
drained := make(chan struct{})
|
||||
go func() {
|
||||
<-scheduler.Stop().Done()
|
||||
cronOwnedJobs.Wait()
|
||||
close(drained)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-drained:
|
||||
case <-time.After(cronShutdownTimeout):
|
||||
logrus.Warnf("cron: запущенные джобы не завершились за %s, продолжаем остановку", cronShutdownTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ResetTrafficScheduled сообщает, запланирован ли сейчас сброс трафика.
|
||||
// Существует ради тестов: иначе проверить, что старая запись действительно
|
||||
// снята, а не просто добавлена рядом, можно было бы только по времени.
|
||||
func ResetTrafficScheduled() bool {
|
||||
cronMu.Lock()
|
||||
defer cronMu.Unlock()
|
||||
return resetTrafficEntryID != 0
|
||||
}
|
||||
|
||||
// CronEntryCount возвращает число активных записей планировщика.
|
||||
// Тоже ради тестов: накопление джоб — это именно рост этого числа.
|
||||
func CronEntryCount() int {
|
||||
cronMu.Lock()
|
||||
defer cronMu.Unlock()
|
||||
if cronScheduler == nil {
|
||||
return 0
|
||||
}
|
||||
return len(cronScheduler.Entries())
|
||||
}
|
||||
|
||||
func storedResetTrafficCron() (string, error) {
|
||||
config, err := dao.GetConfig("key = ?", constant.ResetTrafficCron)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if config.Value == nil {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(*config.Value), nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Число фиксированных джоб планировщика: CronHandleAccount,
|
||||
// CollectMetricsSnapshot, CleanupStatsRetention. Записано числом намеренно —
|
||||
// тест обязан заметить и появление новой джобы, и потерю существующей.
|
||||
const fixedCronJobCount = 3
|
||||
|
||||
func startTestCron(t *testing.T, resetTrafficExpression string) {
|
||||
t.Helper()
|
||||
|
||||
newTestDB(t)
|
||||
if err := dao.UpdateConfig(
|
||||
[]string{constant.ResetTrafficCron},
|
||||
map[string]interface{}{"value": resetTrafficExpression},
|
||||
); err != nil {
|
||||
t.Fatalf("не удалось записать %s: %v", constant.ResetTrafficCron, err)
|
||||
}
|
||||
|
||||
if err := InitCron(); err != nil {
|
||||
t.Fatalf("InitCron: %v", err)
|
||||
}
|
||||
t.Cleanup(StopCron)
|
||||
}
|
||||
|
||||
// Главная регрессия P0: смена расписания не имеет права добавлять джобы.
|
||||
//
|
||||
// Раньше применение новой настройки шло через StopServer() и повторный
|
||||
// runServer, а InitCron на каждом вызове создавал новый cron.New() и нигде его
|
||||
// не сохранял. Старый планировщик никто не останавливал, поэтому после каждой
|
||||
// правки на процессе оставался ЦЕЛЫЙ дублирующий набор джоб, а старое
|
||||
// расписание сброса трафика продолжало срабатывать.
|
||||
func TestRescheduleResetTrafficDoesNotAccumulateJobs(t *testing.T) {
|
||||
startTestCron(t, "@monthly")
|
||||
|
||||
initial := CronEntryCount()
|
||||
if initial != fixedCronJobCount+1 {
|
||||
t.Fatalf("после старта ожидалось %d записей, получено %d", fixedCronJobCount+1, initial)
|
||||
}
|
||||
|
||||
for _, expression := range []string{"@weekly", "@monthly", "0 3 * * *", "@daily"} {
|
||||
if err := RescheduleResetTraffic(expression); err != nil {
|
||||
t.Fatalf("RescheduleResetTraffic(%q): %v", expression, err)
|
||||
}
|
||||
if count := CronEntryCount(); count != initial {
|
||||
t.Fatalf("после смены расписания на %q записей стало %d, ожидалось %d", expression, count, initial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустое выражение означает «автоматический сброс выключен», а не «оставить
|
||||
// как было»: поле в панели clearable, и оператор имеет право его очистить.
|
||||
func TestRescheduleResetTrafficWithEmptyExpressionDisablesJob(t *testing.T) {
|
||||
startTestCron(t, "@monthly")
|
||||
|
||||
if !ResetTrafficScheduled() {
|
||||
t.Fatal("после старта с непустым расписанием джоба сброса не запланирована")
|
||||
}
|
||||
|
||||
if err := RescheduleResetTraffic(" "); err != nil {
|
||||
t.Fatalf("RescheduleResetTraffic(пусто): %v", err)
|
||||
}
|
||||
if ResetTrafficScheduled() {
|
||||
t.Fatal("пустое выражение не сняло джобу сброса трафика")
|
||||
}
|
||||
if count := CronEntryCount(); count != fixedCronJobCount {
|
||||
t.Fatalf("после отключения сброса ожидалось %d записей, получено %d", fixedCronJobCount, count)
|
||||
}
|
||||
|
||||
// Включение обратно должно работать без перезапуска процесса.
|
||||
if err := RescheduleResetTraffic("@weekly"); err != nil {
|
||||
t.Fatalf("повторное включение: %v", err)
|
||||
}
|
||||
if !ResetTrafficScheduled() {
|
||||
t.Fatal("джоба сброса не вернулась после повторного включения")
|
||||
}
|
||||
}
|
||||
|
||||
// Невалидное выражение не имеет права ни изменить планировщик, ни пройти
|
||||
// молча: раньше ошибка AddFunc только логировалась, и сброс трафика тихо
|
||||
// исчезал.
|
||||
func TestRescheduleResetTrafficRejectsInvalidExpression(t *testing.T) {
|
||||
startTestCron(t, "@monthly")
|
||||
|
||||
before := CronEntryCount()
|
||||
|
||||
if err := RescheduleResetTraffic("какая-нибудь ерунда"); err == nil {
|
||||
t.Fatal("невалидное выражение принято")
|
||||
}
|
||||
if after := CronEntryCount(); after != before {
|
||||
t.Fatalf("отклонённое выражение изменило планировщик: было %d записей, стало %d", before, after)
|
||||
}
|
||||
if !ResetTrafficScheduled() {
|
||||
t.Fatal("отклонённое выражение сняло действующую джобу")
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация обязана пользоваться ТЕМ ЖЕ парсером, что и планировщик. Набор
|
||||
// проверяет обе стороны: то, что cron умеет, должно приниматься, а то, что не
|
||||
// умеет, — отклоняться до записи в базу.
|
||||
func TestValidateResetTrafficCronMatchesSchedulerParser(t *testing.T) {
|
||||
valid := []string{
|
||||
"",
|
||||
" ",
|
||||
"@monthly",
|
||||
"@weekly",
|
||||
"@daily",
|
||||
"@hourly",
|
||||
"@every 1h30m",
|
||||
"0 0 * * *",
|
||||
"*/15 * * * *",
|
||||
"0 3 1 * 1",
|
||||
}
|
||||
for _, expression := range valid {
|
||||
if err := ValidateResetTrafficCron(expression); err != nil {
|
||||
t.Errorf("валидное выражение %q отклонено: %v", expression, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"какая-нибудь ерунда",
|
||||
"@yearly extra",
|
||||
"* * * *",
|
||||
"* * * * * *",
|
||||
"99 * * * *",
|
||||
"@every",
|
||||
"@every банан",
|
||||
"- - - - -",
|
||||
}
|
||||
for _, expression := range invalid {
|
||||
if err := ValidateResetTrafficCron(expression); err == nil {
|
||||
t.Errorf("невалидное выражение %q принято", expression)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Невалидное значение в базе не имеет права уронить старт: на панели висит
|
||||
// /internal/hysteria/auth, и отказ старта из-за строки расписания положил бы
|
||||
// подключения пользователей.
|
||||
func TestInitCronSurvivesInvalidStoredExpression(t *testing.T) {
|
||||
startTestCron(t, "совершенно невалидная строка")
|
||||
|
||||
if ResetTrafficScheduled() {
|
||||
t.Fatal("невалидное выражение из базы было запланировано")
|
||||
}
|
||||
if count := CronEntryCount(); count != fixedCronJobCount {
|
||||
t.Fatalf("фиксированные джобы не поднялись: ожидалось %d записей, получено %d", fixedCronJobCount, count)
|
||||
}
|
||||
|
||||
// Оператор обязан иметь возможность починить настройку через API, без
|
||||
// перезапуска сервиса.
|
||||
if err := RescheduleResetTraffic("@weekly"); err != nil {
|
||||
t.Fatalf("исправление расписания: %v", err)
|
||||
}
|
||||
if !ResetTrafficScheduled() {
|
||||
t.Fatal("исправленное расписание не применилось")
|
||||
}
|
||||
}
|
||||
|
||||
// Планировщик принадлежит процессу: второй InitCron без остановки первого —
|
||||
// это ровно тот сценарий, который и создавал дубликаты.
|
||||
func TestInitCronRefusesSecondScheduler(t *testing.T) {
|
||||
startTestCron(t, "@monthly")
|
||||
|
||||
if err := InitCron(); err == nil {
|
||||
t.Fatal("второй планировщик поднялся поверх работающего")
|
||||
}
|
||||
if count := CronEntryCount(); count != fixedCronJobCount+1 {
|
||||
t.Fatalf("отклонённый повторный старт изменил планировщик: %d записей", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopCronIsIdempotent(t *testing.T) {
|
||||
startTestCron(t, "@monthly")
|
||||
|
||||
StopCron()
|
||||
if count := CronEntryCount(); count != 0 {
|
||||
t.Fatalf("после остановки осталось %d записей", count)
|
||||
}
|
||||
if ResetTrafficScheduled() {
|
||||
t.Fatal("после остановки джоба сброса числится запланированной")
|
||||
}
|
||||
|
||||
// Второй вызов не должен паниковать: StopCron стоит в defer runServer и
|
||||
// может быть вызван повторно на пути обработки ошибки.
|
||||
StopCron()
|
||||
|
||||
if err := RescheduleResetTraffic("@weekly"); err == nil {
|
||||
t.Fatal("перепланирование на остановленном планировщике не отклонено")
|
||||
}
|
||||
}
|
||||
+14
-66
@@ -2,27 +2,27 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// Жизненный цикл Hysteria принадлежит systemd и install-оркестратору.
|
||||
// Панель умеет только наблюдать состояние: функций Start/Stop/Restart и
|
||||
// смены версии здесь нет намеренно, а не «временно отключены».
|
||||
|
||||
// InitHysteria2 проверяет единственное, что панели действительно нужно от
|
||||
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
|
||||
// у которого спрашивает состояние Hysteria2IsRunning.
|
||||
//
|
||||
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
|
||||
// в которой панель сама запускала и останавливала Hysteria: clean install
|
||||
// создавал его со значением "0", записать "1" было некому, а вся ветка
|
||||
// сводилась к строке в журнале. Настройка, которая ничем не управляет, но
|
||||
// выглядит выключателем, хуже отсутствующей.
|
||||
func InitHysteria2() error {
|
||||
if !util.Exists(util.GetHysteria2BinPath()) {
|
||||
return errors.New("systemd-managed hysteria binary not found")
|
||||
}
|
||||
|
||||
config, err := dao.GetConfig("key = ?", constant.Hysteria2Enable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *config.Value == "1" {
|
||||
logrus.Infof("hysteria2 lifecycle is managed by systemd in HY2XS production package")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,58 +31,6 @@ func Hysteria2IsRunning() bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func StartHysteria2() error {
|
||||
return errors.New("managed by orchestrator: use hy2xs-orchestrator reconfigure")
|
||||
}
|
||||
|
||||
func StopHysteria2() error {
|
||||
return errors.New("managed by orchestrator: use hy2xs-orchestrator reconfigure")
|
||||
}
|
||||
|
||||
func RestartHysteria2() error {
|
||||
return errors.New("managed by orchestrator: use hy2xs-orchestrator reconfigure")
|
||||
}
|
||||
|
||||
func ReleaseHysteria2() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func Hysteria2AcmePath() (vo.Hysteria2AcmePathVo, error) {
|
||||
hysteria2AcmePathVo := vo.Hysteria2AcmePathVo{}
|
||||
hysteria2Config, err := GetHysteria2Config()
|
||||
if err != nil {
|
||||
return hysteria2AcmePathVo, err
|
||||
}
|
||||
if hysteria2Config.TLS != nil &&
|
||||
hysteria2Config.TLS.Cert != nil && *hysteria2Config.TLS.Cert != "" &&
|
||||
hysteria2Config.TLS.Key != nil && *hysteria2Config.TLS.Key != "" {
|
||||
if util.Exists(*hysteria2Config.TLS.Cert) && util.Exists(*hysteria2Config.TLS.Key) {
|
||||
hysteria2AcmePathVo.CrtPath = *hysteria2Config.TLS.Cert
|
||||
hysteria2AcmePathVo.KeyPath = *hysteria2Config.TLS.Key
|
||||
return hysteria2AcmePathVo, nil
|
||||
}
|
||||
return hysteria2AcmePathVo, errors.New("cert not found")
|
||||
} else if hysteria2Config.ACME != nil &&
|
||||
hysteria2Config.ACME.Domains != nil &&
|
||||
len(hysteria2Config.ACME.Domains) > 0 &&
|
||||
hysteria2Config.ACME.CA != nil &&
|
||||
*hysteria2Config.ACME.CA != "" &&
|
||||
hysteria2Config.ACME.Dir != nil &&
|
||||
*hysteria2Config.ACME.Dir != "" {
|
||||
acmeDir := *hysteria2Config.ACME.Dir
|
||||
for _, domain := range hysteria2Config.ACME.Domains {
|
||||
crtPath, err := util.FindFile(acmeDir, fmt.Sprintf("%s.crt", domain))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
keyPath, err := util.FindFile(acmeDir, fmt.Sprintf("%s.key", domain))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hysteria2AcmePathVo.CrtPath = crtPath
|
||||
hysteria2AcmePathVo.KeyPath = keyPath
|
||||
return hysteria2AcmePathVo, nil
|
||||
}
|
||||
}
|
||||
return vo.Hysteria2AcmePathVo{}, errors.New("cert not found")
|
||||
}
|
||||
|
||||
+106
-33
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/proxy"
|
||||
"net"
|
||||
@@ -14,6 +15,28 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// resolveShareSni выбирает SNI для клиентской ссылки.
|
||||
//
|
||||
// ACME-домен — не единственный источник истины: в режиме tls (файловые
|
||||
// сертификаты) блока acme в конфиге нет, но домен продукта известен из
|
||||
// runtime-конфига. Публичный IPv4 в качестве SNI не используется.
|
||||
func resolveShareSni(acmeDomain string, publicHost string) string {
|
||||
if domain := strings.TrimSpace(acmeDomain); domain != "" {
|
||||
return domain
|
||||
}
|
||||
if domain := strings.TrimSpace(os.Getenv("HY2XS_DOMAIN")); domain != "" && !isIPAddress(domain) {
|
||||
return domain
|
||||
}
|
||||
if host := strings.TrimSpace(publicHost); host != "" && !isIPAddress(host) {
|
||||
return host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isIPAddress(value string) bool {
|
||||
return net.ParseIP(strings.TrimSpace(value)) != nil
|
||||
}
|
||||
|
||||
func resolvePublicEndpoint() (string, int, error) {
|
||||
host := strings.TrimSpace(os.Getenv("HY2XS_PUBLIC_HOST"))
|
||||
if host == "" || host == "0.0.0.0" {
|
||||
@@ -125,30 +148,11 @@ func Hysteria2Url(accountId int64) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
remark := ""
|
||||
if hysteria2ConfigRemark.Value != nil {
|
||||
remark = *hysteria2ConfigRemark.Value
|
||||
}
|
||||
remark := shareRemark(peer.Name, hostname)
|
||||
|
||||
obfsType := ""
|
||||
obfsPassword := ""
|
||||
if hysteria2Config.Obfs != nil &&
|
||||
hysteria2Config.Obfs.Type != nil &&
|
||||
hysteria2Config.Obfs.Salamander != nil &&
|
||||
hysteria2Config.Obfs.Salamander.Password != nil {
|
||||
obfsType = *hysteria2Config.Obfs.Type
|
||||
obfsPassword = *hysteria2Config.Obfs.Salamander.Password
|
||||
}
|
||||
|
||||
sni := ""
|
||||
if hysteria2Config.ACME != nil && len(hysteria2Config.ACME.Domains) > 0 {
|
||||
sni = hysteria2Config.ACME.Domains[0]
|
||||
}
|
||||
obfs := hysteria2Config.ObfsShare()
|
||||
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
|
||||
|
||||
secret := ""
|
||||
if peer.SecretEncrypted != nil {
|
||||
@@ -158,29 +162,98 @@ func Hysteria2Url(accountId int64) (string, error) {
|
||||
}
|
||||
secret = decrypted
|
||||
}
|
||||
return buildHysteria2Url(secret, hostname, port, obfsType, obfsPassword, sni, remark), nil
|
||||
return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil
|
||||
}
|
||||
|
||||
func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string {
|
||||
// shareRemark даёт имя профиля, которое клиент показывает в списке серверов.
|
||||
//
|
||||
// Раньше оно бралось из HYSTERIA2_CONFIG_REMARK — пустой строки в таблице
|
||||
// `config`, которую clean install создавал один раз и которую никто никогда не
|
||||
// записывал. То есть fragment у ссылки отсутствовал всегда, и все выданные
|
||||
// ссылки выглядели в клиенте одинаково.
|
||||
//
|
||||
// Теперь значение выводится детерминированно из имени пира: у панели с
|
||||
// несколькими пирами это ровно то различие, которое пользователю и нужно
|
||||
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на
|
||||
// публичный хост нужен для пира без имени — база это допускает (name имеет
|
||||
// DEFAULT ''), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
|
||||
func shareRemark(peerName *string, hostname string) string {
|
||||
if peerName != nil {
|
||||
if name := strings.TrimSpace(*peerName); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
|
||||
// isShareableObfsType перечисляет типы обфускации, которые официальная
|
||||
// URI-схема Hysteria умеет передавать клиенту.
|
||||
func isShareableObfsType(obfsType string) bool {
|
||||
return obfsType == "salamander" || obfsType == "gecko"
|
||||
}
|
||||
|
||||
// ShareURIOptions — вход генератора клиентской ссылки.
|
||||
//
|
||||
// Тип экспортируется, чтобы end-to-end проверка подключалась РОВНО тем же
|
||||
// кодом, который выдаёт ссылки пользователю. Раньше e2e собирал URI
|
||||
// собственной реализацией на bash, и дрейф любой из двух реализаций
|
||||
// оставлял обе группы тестов зелёными.
|
||||
type ShareURIOptions struct {
|
||||
Secret string
|
||||
Host string
|
||||
Port int
|
||||
Obfs bo.ObfsShareConfig
|
||||
SNI string
|
||||
Remark string
|
||||
|
||||
// Insecure отключает проверку сертификата на стороне клиента.
|
||||
//
|
||||
// В production всегда false: Hysteria2Url другого значения не передаёт,
|
||||
// и это закреплено тестом. Поле существует только ради e2e, который
|
||||
// работает на самоподписанном сертификате и иначе не смог бы
|
||||
// использовать production-генератор.
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// BuildHysteria2ShareURI собирает ссылку по официальной URI-схеме Hysteria 2.
|
||||
func BuildHysteria2ShareURI(opts ShareURIOptions) string {
|
||||
query := url.Values{}
|
||||
if obfsType == "salamander" && obfsPassword != "" {
|
||||
query.Set("obfs", "salamander")
|
||||
query.Set("obfs-password", obfsPassword)
|
||||
if isShareableObfsType(opts.Obfs.Type) && opts.Obfs.Password != "" {
|
||||
query.Set("obfs", opts.Obfs.Type)
|
||||
query.Set("obfs-password", opts.Obfs.Password)
|
||||
}
|
||||
if sni != "" {
|
||||
query.Set("sni", sni)
|
||||
if opts.SNI != "" {
|
||||
query.Set("sni", opts.SNI)
|
||||
}
|
||||
if opts.Insecure {
|
||||
query.Set("insecure", "1")
|
||||
} else {
|
||||
query.Set("insecure", "0")
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "hysteria2",
|
||||
User: url.User(conPass),
|
||||
Host: net.JoinHostPort(hostname, strconv.Itoa(port)),
|
||||
User: url.User(opts.Secret),
|
||||
Host: net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)),
|
||||
Path: "/",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
if strings.TrimSpace(remark) != "" {
|
||||
u.Fragment = remark
|
||||
if strings.TrimSpace(opts.Remark) != "" {
|
||||
u.Fragment = opts.Remark
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// buildHysteria2Url — production-путь: проверка сертификата никогда не
|
||||
// отключается.
|
||||
func buildHysteria2Url(conPass string, hostname string, port int, obfs bo.ObfsShareConfig, sni string, remark string) string {
|
||||
return BuildHysteria2ShareURI(ShareURIOptions{
|
||||
Secret: conPass,
|
||||
Host: hostname,
|
||||
Port: port,
|
||||
Obfs: obfs,
|
||||
SNI: sni,
|
||||
Remark: remark,
|
||||
Insecure: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,26 +4,115 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/model/bo"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid url, got error: %v (raw=%s)", err, raw)
|
||||
}
|
||||
if parsed.Scheme != "hysteria2" {
|
||||
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_GeckoObfs(t *testing.T) {
|
||||
raw := buildHysteria2Url(
|
||||
"con-pass",
|
||||
"vpn.example.com",
|
||||
443,
|
||||
bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"},
|
||||
"vpn.example.com",
|
||||
"",
|
||||
)
|
||||
|
||||
q := mustParse(t, raw).Query()
|
||||
if q.Get("obfs") != "gecko" {
|
||||
t.Fatalf("expected obfs=gecko, got %q", q.Get("obfs"))
|
||||
}
|
||||
if q.Get("obfs-password") != "gecko-secret" {
|
||||
t.Fatalf("expected gecko obfs password, got %q", q.Get("obfs-password"))
|
||||
}
|
||||
if q.Get("sni") != "vpn.example.com" {
|
||||
t.Fatalf("expected sni, got %q", q.Get("sni"))
|
||||
}
|
||||
if q.Get("insecure") != "0" {
|
||||
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_SalamanderObfs(t *testing.T) {
|
||||
raw := buildHysteria2Url(
|
||||
"con-pass",
|
||||
"vpn.example.com",
|
||||
443,
|
||||
bo.ObfsShareConfig{Type: "salamander", Password: "salamander-secret"},
|
||||
"vpn.example.com",
|
||||
"",
|
||||
)
|
||||
|
||||
q := mustParse(t, raw).Query()
|
||||
if q.Get("obfs") != "salamander" {
|
||||
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
|
||||
}
|
||||
if q.Get("obfs-password") != "salamander-secret" {
|
||||
t.Fatalf("expected salamander obfs password, got %q", q.Get("obfs-password"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_NoObfs(t *testing.T) {
|
||||
raw := buildHysteria2Url("pass", "example.com", 8443, bo.ObfsShareConfig{}, "", "")
|
||||
|
||||
parsed := mustParse(t, raw)
|
||||
if parsed.Host != "example.com:8443" {
|
||||
t.Fatalf("unexpected host: %s", parsed.Host)
|
||||
}
|
||||
q := parsed.Query()
|
||||
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
|
||||
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
|
||||
}
|
||||
if q.Get("insecure") != "0" {
|
||||
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_UnknownObfsTypeIsNotShared(t *testing.T) {
|
||||
// Неизвестный тип не должен попадать в ссылку: клиент получил бы
|
||||
// параметр, который не понимает.
|
||||
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "future-obfs", Password: "x"}, "", "")
|
||||
|
||||
q := mustParse(t, raw).Query()
|
||||
if q.Get("obfs") != "" {
|
||||
t.Fatalf("unknown obfs type must not be shared, got %q", q.Get("obfs"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_ObfsWithoutPasswordIsNotShared(t *testing.T) {
|
||||
raw := buildHysteria2Url("pass", "example.com", 443, bo.ObfsShareConfig{Type: "gecko"}, "", "")
|
||||
|
||||
q := mustParse(t, raw).Query()
|
||||
if q.Get("obfs") != "" || q.Get("obfs-password") != "" {
|
||||
t.Fatalf("obfs without password must not be shared: %s", q.Encode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
|
||||
raw := buildHysteria2Url(
|
||||
"u@ser:#&=+ pass",
|
||||
"example.com",
|
||||
443,
|
||||
"salamander",
|
||||
"obf+s&pass=@x",
|
||||
bo.ObfsShareConfig{Type: "gecko", Password: "obf+s&pass=@x"},
|
||||
"exa mple.com",
|
||||
"my remark #1",
|
||||
)
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid url, got error: %v", err)
|
||||
}
|
||||
if parsed.Scheme != "hysteria2" {
|
||||
t.Fatalf("expected hysteria2 scheme, got %s", parsed.Scheme)
|
||||
}
|
||||
parsed := mustParse(t, raw)
|
||||
if parsed.User == nil {
|
||||
t.Fatal("expected userinfo to be present")
|
||||
}
|
||||
@@ -32,42 +121,266 @@ func TestBuildHysteria2Url_EncodesUserInfoQueryAndFragment(t *testing.T) {
|
||||
}
|
||||
|
||||
q := parsed.Query()
|
||||
if q.Get("obfs") != "salamander" {
|
||||
t.Fatalf("expected obfs=salamander, got %q", q.Get("obfs"))
|
||||
}
|
||||
if q.Get("obfs-password") != "obf+s&pass=@x" {
|
||||
t.Fatalf("expected decoded obfs-password, got %q", q.Get("obfs-password"))
|
||||
}
|
||||
if q.Get("sni") != "exa mple.com" {
|
||||
t.Fatalf("expected decoded sni, got %q", q.Get("sni"))
|
||||
}
|
||||
if q.Get("insecure") != "0" {
|
||||
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
|
||||
}
|
||||
if parsed.Fragment != "my remark #1" {
|
||||
t.Fatalf("expected decoded fragment, got %q", parsed.Fragment)
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "u@ser:#&=+ pass") {
|
||||
t.Fatalf("raw uri must not contain unescaped userinfo: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHysteria2Url_MinimalConfig(t *testing.T) {
|
||||
raw := buildHysteria2Url("pass", "example.com", 8443, "", "", "", "")
|
||||
// Регрессия на upstream-баг 2.9.3: `+` в credentials при разборе share link
|
||||
// превращался в пробел. Проверяем, что кодирование однозначно.
|
||||
func TestBuildHysteria2Url_PlusInCredentialsSurvivesRoundTrip(t *testing.T) {
|
||||
cases := []string{"a+b", "a b", "a#b", "a@b", "a/b", "a?b", "a&b", "a=b", "a%b", "тест"}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid url, got error: %v", err)
|
||||
for _, value := range cases {
|
||||
raw := buildHysteria2Url(value, "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: value}, "", "")
|
||||
parsed := mustParse(t, raw)
|
||||
|
||||
if got := parsed.User.Username(); got != value {
|
||||
t.Fatalf("userinfo round-trip failed for %q: got %q (raw=%s)", value, got, raw)
|
||||
}
|
||||
if parsed.Host != "example.com:8443" {
|
||||
t.Fatalf("unexpected host: %s", parsed.Host)
|
||||
if got := parsed.Query().Get("obfs-password"); got != value {
|
||||
t.Fatalf("obfs-password round-trip failed for %q: got %q (raw=%s)", value, got, raw)
|
||||
}
|
||||
q := parsed.Query()
|
||||
if q.Get("insecure") != "0" {
|
||||
t.Fatalf("expected insecure=0, got %q", q.Get("insecure"))
|
||||
}
|
||||
if q.Get("obfs") != "" || q.Get("obfs-password") != "" || q.Get("sni") != "" {
|
||||
t.Fatalf("unexpected optional query params in minimal config: %s", parsed.RawQuery)
|
||||
|
||||
// Ключевой инвариант: литеральный `+` кодируется как %2B и не может быть
|
||||
// прочитан клиентом как пробел, а пробел кодируется отдельно от него.
|
||||
plus := buildHysteria2Url("a+b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a+b"}, "", "")
|
||||
if !strings.Contains(mustParse(t, plus).RawQuery, "%2B") {
|
||||
t.Fatalf("literal '+' must be percent-encoded as %%2B: %s", plus)
|
||||
}
|
||||
|
||||
space := buildHysteria2Url("a b", "example.com", 443, bo.ObfsShareConfig{Type: "gecko", Password: "a b"}, "", "")
|
||||
if mustParse(t, space).Query().Get("obfs-password") == "a+b" {
|
||||
t.Fatalf("space and '+' must not collapse to the same value: %s", space)
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант production-пути: проверка сертификата не отключается никогда.
|
||||
// Флаг Insecure существует только для e2e с самоподписанным сертификатом.
|
||||
func TestBuildHysteria2Url_ProductionPathNeverDisablesVerification(t *testing.T) {
|
||||
cases := []bo.ObfsShareConfig{
|
||||
{},
|
||||
{Type: "gecko", Password: "p"},
|
||||
{Type: "salamander", Password: "p"},
|
||||
}
|
||||
|
||||
for _, obfs := range cases {
|
||||
raw := buildHysteria2Url("pass", "vpn.example.com", 443, obfs, "vpn.example.com", "remark")
|
||||
if got := mustParse(t, raw).Query().Get("insecure"); got != "0" {
|
||||
t.Fatalf("production share URI must carry insecure=0, got %q (raw=%s)", got, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// e2e использует production-генератор, поэтому единственное расхождение с
|
||||
// пользовательской ссылкой обязано быть ровно одним параметром.
|
||||
func TestBuildHysteria2ShareURI_InsecureDiffersOnlyInThatParam(t *testing.T) {
|
||||
opts := shareOptionsFixture()
|
||||
|
||||
production := BuildHysteria2ShareURI(opts)
|
||||
opts.Insecure = true
|
||||
relaxed := BuildHysteria2ShareURI(opts)
|
||||
|
||||
productionURL := mustParse(t, production)
|
||||
testURL := mustParse(t, relaxed)
|
||||
|
||||
if productionURL.Host != testURL.Host || productionURL.Path != testURL.Path {
|
||||
t.Fatalf("host/path must not depend on the insecure flag: %s vs %s", production, relaxed)
|
||||
}
|
||||
if productionURL.User.String() != testURL.User.String() {
|
||||
t.Fatalf("userinfo must not depend on the insecure flag")
|
||||
}
|
||||
if productionURL.Fragment != testURL.Fragment {
|
||||
t.Fatalf("remark must not depend on the insecure flag")
|
||||
}
|
||||
|
||||
productionQuery := productionURL.Query()
|
||||
testQuery := testURL.Query()
|
||||
|
||||
if productionQuery.Get("insecure") != "0" || testQuery.Get("insecure") != "1" {
|
||||
t.Fatalf("unexpected insecure values: %q / %q",
|
||||
productionQuery.Get("insecure"), testQuery.Get("insecure"))
|
||||
}
|
||||
|
||||
productionQuery.Del("insecure")
|
||||
testQuery.Del("insecure")
|
||||
if productionQuery.Encode() != testQuery.Encode() {
|
||||
t.Fatalf("only the insecure param may differ:\n %s\n %s",
|
||||
productionQuery.Encode(), testQuery.Encode())
|
||||
}
|
||||
}
|
||||
|
||||
func shareOptionsFixture() ShareURIOptions {
|
||||
return ShareURIOptions{
|
||||
Secret: "peer-secret",
|
||||
Host: "vpn.example.com",
|
||||
Port: 443,
|
||||
Obfs: bo.ObfsShareConfig{Type: "gecko", Password: "gecko-secret"},
|
||||
SNI: "vpn.example.com",
|
||||
Remark: "HY2XS",
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveShareSni(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
acmeDomain string
|
||||
publicHost string
|
||||
envDomain string
|
||||
want string
|
||||
}{
|
||||
{name: "acme domain wins", acmeDomain: "acme.example.com", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "acme.example.com"},
|
||||
{name: "file tls falls back to product domain", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "env.example.com", want: "env.example.com"},
|
||||
{name: "public host used when domain is empty", acmeDomain: "", publicHost: "vpn.example.com", envDomain: "", want: "vpn.example.com"},
|
||||
{name: "ipv4 public host is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "", want: ""},
|
||||
{name: "ipv4 env domain is not a valid sni", acmeDomain: "", publicHost: "203.0.113.10", envDomain: "198.51.100.7", want: ""},
|
||||
{name: "whitespace is trimmed", acmeDomain: " acme.example.com ", publicHost: "", envDomain: "", want: "acme.example.com"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("HY2XS_DOMAIN", tc.envDomain)
|
||||
if got := resolveShareSni(tc.acmeDomain, tc.publicHost); got != tc.want {
|
||||
t.Fatalf("resolveShareSni(%q, %q) with HY2XS_DOMAIN=%q = %q, want %q",
|
||||
tc.acmeDomain, tc.publicHost, tc.envDomain, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObfsShare_FromServerConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlConfig string
|
||||
wantType string
|
||||
wantPassword string
|
||||
}{
|
||||
{
|
||||
name: "gecko",
|
||||
yamlConfig: "obfs:\n type: gecko\n gecko:\n password: gecko-pass\n minPacketSize: 512\n maxPacketSize: 1200\n",
|
||||
wantType: "gecko",
|
||||
wantPassword: "gecko-pass",
|
||||
},
|
||||
{
|
||||
name: "salamander",
|
||||
yamlConfig: "obfs:\n type: salamander\n salamander:\n password: salamander-pass\n",
|
||||
wantType: "salamander",
|
||||
wantPassword: "salamander-pass",
|
||||
},
|
||||
{
|
||||
name: "no obfs section",
|
||||
yamlConfig: "listen: 0.0.0.0:443\n",
|
||||
},
|
||||
{
|
||||
name: "type without matching subsection",
|
||||
yamlConfig: "obfs:\n type: gecko\n salamander:\n password: mismatched\n",
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
yamlConfig: "obfs:\n type: future\n gecko:\n password: p\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var config bo.Hysteria2ServerConfig
|
||||
if err := yaml.Unmarshal([]byte(tc.yamlConfig), &config); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
got := config.ObfsShare()
|
||||
if got.Type != tc.wantType || got.Password != tc.wantPassword {
|
||||
t.Fatalf("ObfsShare() = %+v, want type=%q password=%q", got, tc.wantType, tc.wantPassword)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteria2ServerConfig_ParsesModernUpstreamSchema(t *testing.T) {
|
||||
raw := `listen: 0.0.0.0:443
|
||||
tls:
|
||||
cert: /etc/hysteria/server.crt
|
||||
key: /etc/hysteria/server.key
|
||||
clientCA: /etc/hysteria/client-ca.crt
|
||||
ech:
|
||||
keyPath: /etc/hysteria/ech.pem
|
||||
obfs:
|
||||
type: gecko
|
||||
gecko:
|
||||
password: p
|
||||
minPacketSize: 512
|
||||
maxPacketSize: 1200
|
||||
bandwidth:
|
||||
up: 50 mbps
|
||||
down: 50 mbps
|
||||
disableLossCompensation: false
|
||||
congestion:
|
||||
type: bbr
|
||||
bbrProfile: standard
|
||||
quic:
|
||||
disableStatelessReset: false
|
||||
mimic:
|
||||
enabled: false
|
||||
interface: eth0
|
||||
xdpMode: skb
|
||||
realm:
|
||||
stunServers:
|
||||
- stun.example.com:3478
|
||||
ipMode: dual
|
||||
portMapping:
|
||||
enabled: false
|
||||
timeout: 10s
|
||||
masquerade:
|
||||
type: proxy
|
||||
proxy:
|
||||
url: https://example.com
|
||||
rewriteHost: true
|
||||
insecure: false
|
||||
xForwarded: true
|
||||
trafficStats:
|
||||
listen: 127.0.0.1:36712
|
||||
`
|
||||
|
||||
var config bo.Hysteria2ServerConfig
|
||||
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if config.ECH == nil || config.ECH.KeyPath == nil || *config.ECH.KeyPath != "/etc/hysteria/ech.pem" {
|
||||
t.Fatal("ech.keyPath was not parsed")
|
||||
}
|
||||
if config.TLS == nil || config.TLS.ClientCA == nil || *config.TLS.ClientCA != "/etc/hysteria/client-ca.crt" {
|
||||
t.Fatal("tls.clientCA was not parsed")
|
||||
}
|
||||
if config.Congestion == nil || config.Congestion.Type == nil || *config.Congestion.Type != "bbr" {
|
||||
t.Fatal("congestion.type was not parsed")
|
||||
}
|
||||
if config.Congestion.BBRProfile == nil || *config.Congestion.BBRProfile != "standard" {
|
||||
t.Fatal("congestion.bbrProfile was not parsed")
|
||||
}
|
||||
if config.Bandwidth == nil || config.Bandwidth.DisableLossCompensation == nil || *config.Bandwidth.DisableLossCompensation {
|
||||
t.Fatal("bandwidth.disableLossCompensation was not parsed")
|
||||
}
|
||||
if config.QUIC == nil || config.QUIC.DisableStatelessReset == nil || *config.QUIC.DisableStatelessReset {
|
||||
t.Fatal("quic.disableStatelessReset was not parsed")
|
||||
}
|
||||
if config.Mimic == nil || config.Mimic.Enabled == nil || *config.Mimic.Enabled {
|
||||
t.Fatal("mimic section was not parsed")
|
||||
}
|
||||
if config.Realm == nil || len(config.Realm.StunServers) != 1 || config.Realm.PortMapping == nil {
|
||||
t.Fatal("realm section was not parsed")
|
||||
}
|
||||
if config.Masquerade == nil || config.Masquerade.Proxy == nil ||
|
||||
config.Masquerade.Proxy.XForwarded == nil || !*config.Masquerade.Proxy.XForwarded {
|
||||
t.Fatal("masquerade.proxy.xForwarded was not parsed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// RedactedPlaceholder — маркер вырезанного секрета в экспортируемом конфиге.
|
||||
const RedactedPlaceholder = "<redacted>"
|
||||
|
||||
// GetRawHysteria2Config возвращает исходный YAML серверного конфига без
|
||||
// прохода через типизированную модель.
|
||||
//
|
||||
// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
|
||||
// отражает известные HY2XS поля и используется для отображения, а сырой YAML
|
||||
// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
|
||||
//
|
||||
// Источник ровно один — файл. Ветка «сначала SQLite, потом файл» удалена
|
||||
// вместе с ключом HYSTERIA2_CONFIG: см. GetHysteria2Config в service/config.go.
|
||||
// Здесь у неё было дополнительное следствие — выгрузка «фактического
|
||||
// серверного конфига» могла отдать оператору документ, по которому Hysteria
|
||||
// никогда не работала.
|
||||
func GetRawHysteria2Config() (string, error) {
|
||||
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// ExportHysteria2ConfigYaml готовит серверный конфиг к выгрузке оператору.
|
||||
//
|
||||
// Гарантии:
|
||||
// 1. неизвестные upstream-поля сохраняются — будущие версии Hysteria не
|
||||
// обрезаются только потому, что HY2XS ещё не научился их показывать;
|
||||
// 2. известные секреты и неизвестные поля с секретоподобным именем не
|
||||
// покидают сервер в открытом виде.
|
||||
func ExportHysteria2ConfigYaml() ([]byte, error) {
|
||||
raw, err := GetRawHysteria2Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SanitizeHysteria2ConfigYaml(raw)
|
||||
}
|
||||
|
||||
// SanitizeHysteria2ConfigYaml вырезает секреты из YAML, сохраняя структуру и
|
||||
// все прочие поля документа.
|
||||
func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, errors.New("hysteria2 config is empty")
|
||||
}
|
||||
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
redactNode(&document, nil)
|
||||
|
||||
out, err := yaml.Marshal(&document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// secretKeyMarkers покрывает известные секреты HY2XS/Hysteria и типовые имена,
|
||||
// под которыми секрет может появиться в будущем upstream-поле.
|
||||
//
|
||||
// Гарантия формулируется именно так: вырезаются известные секреты и
|
||||
// неизвестные поля с секретоподобным именем. Обещать, что под правило попадёт
|
||||
// ЛЮБОЙ будущий секрет, нельзя — обобщённый sanitizer этого не умеет.
|
||||
//
|
||||
// Список синхронизирован с SECRET_KEY_MARKERS в
|
||||
// orchestrator/src/lib/redaction.ts.
|
||||
var secretKeyMarkers = []string{
|
||||
"password",
|
||||
"passwd",
|
||||
"passphrase",
|
||||
"secret",
|
||||
"token",
|
||||
"credential",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"privatekey",
|
||||
"private_key",
|
||||
"accesskey",
|
||||
"access_key",
|
||||
"secretkey",
|
||||
"secret_key",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"bearer",
|
||||
"signature",
|
||||
}
|
||||
|
||||
func isSecretKey(key string) bool {
|
||||
lowered := strings.ToLower(key)
|
||||
for _, marker := range secretKeyMarkers {
|
||||
if strings.Contains(lowered, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isSecretMapPath — узлы, где секретом являются все значения карты, а не ключ.
|
||||
func isSecretMapPath(path []string) bool {
|
||||
joined := strings.Join(path, ".")
|
||||
switch joined {
|
||||
case "auth.userpass", "acme.dns.config":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactNode(node *yaml.Node, path []string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode:
|
||||
for _, child := range node.Content {
|
||||
redactNode(child, path)
|
||||
}
|
||||
|
||||
case yaml.SequenceNode:
|
||||
for _, child := range node.Content {
|
||||
// Индекс не попадает в путь: правила формулируются по именам полей.
|
||||
redactNode(child, path)
|
||||
}
|
||||
|
||||
case yaml.MappingNode:
|
||||
if isSecretMapPath(path) {
|
||||
redactAllScalarValues(node)
|
||||
return
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
key := node.Content[i].Value
|
||||
value := node.Content[i+1]
|
||||
childPath := append(append([]string{}, path...), key)
|
||||
|
||||
if isSecretKey(key) {
|
||||
redactSubtree(value)
|
||||
continue
|
||||
}
|
||||
redactNode(value, childPath)
|
||||
}
|
||||
|
||||
case yaml.ScalarNode:
|
||||
// Секрет может лежать в URL и без «говорящего» имени ключа.
|
||||
//
|
||||
// Раньше здесь стоял фильтр по имени ключа (url / addr / *url), и
|
||||
// upstream-поле вроде `endpoint: https://user:pass@host/?token=…`
|
||||
// уезжало в выгрузку целиком. Правильная граница — не имя ключа, а
|
||||
// сам факт, что значение разбирается как URL: sanitizeURLValue
|
||||
// возвращает вход без изменений, если это не URL. Ровно так же
|
||||
// устроен TS-санитайзер в orchestrator/src/lib/redaction.ts.
|
||||
sanitizeURLScalar(node)
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeURLScalar применяет URL-санитайзер к скаляру, не трогая ни его тип,
|
||||
// ни значения, которые URL не являются.
|
||||
func sanitizeURLScalar(node *yaml.Node) {
|
||||
if node.Tag != "" && node.Tag != "!!str" {
|
||||
return
|
||||
}
|
||||
sanitized := sanitizeURLValue(node.Value)
|
||||
if sanitized == node.Value {
|
||||
return
|
||||
}
|
||||
node.Value = sanitized
|
||||
node.Tag = "!!str"
|
||||
node.Style = 0
|
||||
}
|
||||
|
||||
func redactSubtree(node *yaml.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
setRedacted(node)
|
||||
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
|
||||
redactAllScalarValues(node)
|
||||
}
|
||||
}
|
||||
|
||||
func redactAllScalarValues(node *yaml.Node) {
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
redactSubtree(node.Content[i+1])
|
||||
}
|
||||
case yaml.SequenceNode, yaml.DocumentNode:
|
||||
for _, child := range node.Content {
|
||||
redactSubtree(child)
|
||||
}
|
||||
case yaml.ScalarNode:
|
||||
setRedacted(node)
|
||||
}
|
||||
}
|
||||
|
||||
func setRedacted(node *yaml.Node) {
|
||||
node.Value = RedactedPlaceholder
|
||||
node.Tag = "!!str"
|
||||
node.Style = 0
|
||||
}
|
||||
|
||||
// sanitizeURLValue убирает из URL встроенные учётные данные и секретные
|
||||
// query-параметры, сохраняя остальную часть адреса читаемой.
|
||||
func sanitizeURLValue(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Scheme == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
parsed.User = url.User(RedactedPlaceholder)
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
changed := false
|
||||
for key := range query {
|
||||
if isSecretKey(key) {
|
||||
query.Set(key, RedactedPlaceholder)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
parsed.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
return parsed.String()
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const exportSampleConfig = `listen: 0.0.0.0:443
|
||||
|
||||
acme:
|
||||
domains:
|
||||
- vpn.example.com
|
||||
email: admin@example.com
|
||||
ca: letsencrypt
|
||||
dir: /var/lib/hysteria/acme
|
||||
listenHost: 0.0.0.0
|
||||
type: http
|
||||
dns:
|
||||
name: cloudflare
|
||||
config:
|
||||
cloudflare_api_token: super-secret-token
|
||||
zone: example.com
|
||||
|
||||
auth:
|
||||
type: http
|
||||
http:
|
||||
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret
|
||||
insecure: false
|
||||
userpass:
|
||||
alice: alice-password
|
||||
bob: bob-password
|
||||
|
||||
obfs:
|
||||
type: gecko
|
||||
gecko:
|
||||
password: gecko-obfs-secret
|
||||
minPacketSize: 512
|
||||
maxPacketSize: 1200
|
||||
|
||||
bandwidth:
|
||||
up: 50 mbps
|
||||
down: 50 mbps
|
||||
disableLossCompensation: false
|
||||
|
||||
congestion:
|
||||
type: bbr
|
||||
bbrProfile: standard
|
||||
|
||||
trafficStats:
|
||||
listen: 127.0.0.1:36712
|
||||
secret: traffic-stats-secret
|
||||
|
||||
outbounds:
|
||||
- name: upstream
|
||||
type: socks5
|
||||
socks5:
|
||||
addr: 10.0.0.1:1080
|
||||
username: proxyuser
|
||||
password: proxy-password
|
||||
|
||||
quic:
|
||||
initStreamReceiveWindow: 8388608
|
||||
disableStatelessReset: false
|
||||
|
||||
someFutureUpstreamFeature:
|
||||
enabled: true
|
||||
nested:
|
||||
tuning: 42
|
||||
list:
|
||||
- a
|
||||
- b
|
||||
# Ни одно из имён ниже не является ни secret-shaped, ни url/addr-подобным:
|
||||
# именно так выглядит поле, которое upstream добавит завтра.
|
||||
endpoint: https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
|
||||
mirrors:
|
||||
- https://mirror-user:mirror-p4ss@mirror.example.com/pull
|
||||
`
|
||||
|
||||
func sanitizeForTest(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
out, err := SanitizeHysteria2ConfigYaml(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("sanitize failed: %v", err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
leaked := []string{
|
||||
"gecko-obfs-secret",
|
||||
"traffic-stats-secret",
|
||||
"machine-secret",
|
||||
"alice-password",
|
||||
"bob-password",
|
||||
"proxy-password",
|
||||
"super-secret-token",
|
||||
"svc-p4ss",
|
||||
"endpoint-token",
|
||||
"mirror-p4ss",
|
||||
}
|
||||
for _, secret := range leaked {
|
||||
if strings.Contains(sanitized, secret) {
|
||||
t.Fatalf("exported config leaks secret %q:\n%s", secret, sanitized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHysteria2ConfigYaml_PreservesUnknownUpstreamFields(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
var parsed map[string]any
|
||||
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
|
||||
t.Fatalf("sanitized output is not valid yaml: %v", err)
|
||||
}
|
||||
|
||||
future, ok := parsed["someFutureUpstreamFeature"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unknown upstream section was dropped:\n%s", sanitized)
|
||||
}
|
||||
if future["enabled"] != true {
|
||||
t.Fatalf("unknown upstream scalar was dropped: %+v", future)
|
||||
}
|
||||
nested, ok := future["nested"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nested unknown section was dropped: %+v", future)
|
||||
}
|
||||
if nested["tuning"] != 42 {
|
||||
t.Fatalf("nested unknown value was dropped: %+v", nested)
|
||||
}
|
||||
if list, ok := nested["list"].([]any); !ok || len(list) != 2 {
|
||||
t.Fatalf("nested unknown list was dropped: %+v", nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
kept := []string{
|
||||
"listen: 0.0.0.0:443",
|
||||
"vpn.example.com",
|
||||
"type: gecko",
|
||||
"minPacketSize: 512",
|
||||
"maxPacketSize: 1200",
|
||||
"bbrProfile: standard",
|
||||
"disableLossCompensation: false",
|
||||
"disableStatelessReset: false",
|
||||
"127.0.0.1:36712",
|
||||
}
|
||||
for _, fragment := range kept {
|
||||
if !strings.Contains(sanitized, fragment) {
|
||||
t.Fatalf("exported config lost operational field %q:\n%s", fragment, sanitized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
if !strings.Contains(sanitized, "127.0.0.1:8080/internal/hysteria/auth") {
|
||||
t.Fatalf("auth url shape was lost:\n%s", sanitized)
|
||||
}
|
||||
if !strings.Contains(sanitized, "access_token="+RedactedPlaceholder) &&
|
||||
!strings.Contains(sanitized, "access_token=%3Credacted%3E") {
|
||||
t.Fatalf("access_token was not redacted:\n%s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: санитайзер применял sanitizeURLValue только к ключам url/addr/*url.
|
||||
// Любое будущее upstream-поле с другим именем уносило встроенные учётные данные
|
||||
// и секретные query-параметры наружу целиком. TS-санитайзер оркестратора такой
|
||||
// границы никогда не имел, и расхождение между двумя реализациями одного
|
||||
// контракта — само по себе дефект.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsURLsUnderArbitraryKeys(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
var parsed map[string]any
|
||||
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
|
||||
t.Fatalf("sanitized output is not valid yaml: %v", err)
|
||||
}
|
||||
future, ok := parsed["someFutureUpstreamFeature"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unknown upstream section was dropped:\n%s", sanitized)
|
||||
}
|
||||
|
||||
endpoint, _ := future["endpoint"].(string)
|
||||
if !strings.Contains(endpoint, "relay.example.com") {
|
||||
t.Fatalf("endpoint потерял адрес, диагностика станет бесполезной: %q", endpoint)
|
||||
}
|
||||
if strings.Contains(endpoint, "svc-p4ss") || strings.Contains(endpoint, "endpoint-token") {
|
||||
t.Fatalf("endpoint унёс секреты наружу: %q", endpoint)
|
||||
}
|
||||
|
||||
// Скаляры внутри последовательностей раньше вообще не доходили до
|
||||
// санитайзера: у redactNode не было ветки ScalarNode.
|
||||
mirrors, ok := future["mirrors"].([]any)
|
||||
if !ok || len(mirrors) != 1 {
|
||||
t.Fatalf("список mirrors потерян: %+v", future["mirrors"])
|
||||
}
|
||||
mirror, _ := mirrors[0].(string)
|
||||
if !strings.Contains(mirror, "mirror.example.com") {
|
||||
t.Fatalf("mirror потерял адрес: %q", mirror)
|
||||
}
|
||||
if strings.Contains(mirror, "mirror-p4ss") {
|
||||
t.Fatalf("mirror унёс встроенные учётные данные: %q", mirror)
|
||||
}
|
||||
}
|
||||
|
||||
// Санитайзер обязан оставаться безвредным для значений, которые URL не
|
||||
// являются: он проходит по каждому скаляру документа.
|
||||
func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
intact := []string{
|
||||
"up: 50 mbps",
|
||||
"down: 50 mbps",
|
||||
"listen: 0.0.0.0:443",
|
||||
"listen: 127.0.0.1:36712",
|
||||
"addr: 10.0.0.1:1080",
|
||||
"email: admin@example.com",
|
||||
"dir: /var/lib/hysteria/acme",
|
||||
"initStreamReceiveWindow: 8388608",
|
||||
"tuning: 42",
|
||||
}
|
||||
for _, fragment := range intact {
|
||||
if !strings.Contains(sanitized, fragment) {
|
||||
t.Fatalf("санитайзер изменил не-URL значение %q:\n%s", fragment, sanitized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeURLValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
mustKeep []string
|
||||
mustRemove []string
|
||||
}{
|
||||
{
|
||||
name: "strips access token",
|
||||
in: "http://127.0.0.1:8080/internal/hysteria/auth?access_token=abc123",
|
||||
mustKeep: []string{"127.0.0.1:8080", "/internal/hysteria/auth"},
|
||||
mustRemove: []string{"abc123"},
|
||||
},
|
||||
{
|
||||
name: "strips embedded credentials",
|
||||
in: "https://user:p4ssw0rd@proxy.example.com:8443/path",
|
||||
mustKeep: []string{"proxy.example.com:8443", "/path"},
|
||||
mustRemove: []string{"p4ssw0rd"},
|
||||
},
|
||||
{
|
||||
name: "leaves clean url untouched",
|
||||
in: "https://example.com/masq",
|
||||
mustKeep: []string{"https://example.com/masq"},
|
||||
},
|
||||
{
|
||||
name: "leaves plain host:port untouched",
|
||||
in: "10.0.0.1:1080",
|
||||
mustKeep: []string{"10.0.0.1:1080"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := sanitizeURLValue(tc.in)
|
||||
for _, fragment := range tc.mustKeep {
|
||||
if !strings.Contains(got, fragment) {
|
||||
t.Fatalf("sanitizeURLValue(%q) = %q, expected to keep %q", tc.in, got, fragment)
|
||||
}
|
||||
}
|
||||
for _, fragment := range tc.mustRemove {
|
||||
if strings.Contains(got, fragment) {
|
||||
t.Fatalf("sanitizeURLValue(%q) = %q, expected to remove %q", tc.in, got, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSecretKey(t *testing.T) {
|
||||
secret := []string{"password", "Password", "obfs_password", "secret", "trafficSecret", "access_token", "apiToken", "credentials"}
|
||||
for _, key := range secret {
|
||||
if !isSecretKey(key) {
|
||||
t.Fatalf("expected %q to be treated as secret", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Пути к файлам не являются секретами и должны остаться читаемыми.
|
||||
notSecret := []string{"key", "keyPath", "cert", "clientCA", "listen", "url", "type", "dir"}
|
||||
for _, key := range notSecret {
|
||||
if isSecretKey(key) {
|
||||
t.Fatalf("expected %q to stay visible in export", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHysteria2ConfigYaml_RejectsEmptyInput(t *testing.T) {
|
||||
if _, err := SanitizeHysteria2ConfigYaml(" \n"); err == nil {
|
||||
t.Fatal("expected an error for empty config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsUnknownFutureSecretField(t *testing.T) {
|
||||
// Обратная сторона сохранения неизвестных полей: новое upstream-поле с
|
||||
// секретом должно вырезаться до того, как HY2XS про него узнает.
|
||||
raw := "listen: 0.0.0.0:443\nfutureFeature:\n apiSecret: leak-me\n nested:\n userPassword: leak-me-too\n"
|
||||
sanitized := sanitizeForTest(t, raw)
|
||||
|
||||
if strings.Contains(sanitized, "leak-me") {
|
||||
t.Fatalf("unknown future secret field leaked:\n%s", sanitized)
|
||||
}
|
||||
if !strings.Contains(sanitized, "futureFeature") {
|
||||
t.Fatalf("unknown future section was dropped:\n%s", sanitized)
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -4,8 +4,10 @@ import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/util"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -58,10 +60,35 @@ func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) {
|
||||
return result, len(result), nil
|
||||
}
|
||||
|
||||
// ExportAdminLog отдаёт журнал самой админки, вырезав из него секреты.
|
||||
//
|
||||
// Раньше этот файл выгружался как есть, через c.File: чужой журнал продукт
|
||||
// санитизировал, а собственный — нет. Обоснования у асимметрии не было, а цена
|
||||
// известна поимённо: пока bootstrap-пароль администратора печатался в лог
|
||||
// warning'ом, обычная кнопка «выгрузить журнал» отдавала его открытым текстом.
|
||||
// Сам warning убран (см. dao/sqlite.go), но защита от следующего такого места
|
||||
// должна стоять и на выходе.
|
||||
func ExportAdminLog() (string, error) {
|
||||
content, err := os.ReadFile(constant.SystemLogPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return SanitizeLogText(string(content)), nil
|
||||
}
|
||||
|
||||
// ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору.
|
||||
//
|
||||
// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные
|
||||
// выгружаемые артефакты: HY2_AUTH_URL несёт access_token, и upstream волен
|
||||
// упомянуть его в сообщении об ошибке обращения к auth-backend.
|
||||
func ExportHysteriaJournalLogs(numLine int) (string, error) {
|
||||
lines := normalizeJournalLineLimit(numLine, 5000, 10000)
|
||||
|
||||
return util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o short-iso", lines))
|
||||
output, err := util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o short-iso", lines))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return SanitizeLogText(output), nil
|
||||
}
|
||||
|
||||
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
||||
@@ -87,12 +114,13 @@ func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
|
||||
if parsed.Msg == "" {
|
||||
parsed.Msg = item.Message
|
||||
}
|
||||
parsed.Msg = SanitizeLogText(parsed.Msg)
|
||||
return parsed
|
||||
}
|
||||
|
||||
return vo.LogHysteria2Vo{
|
||||
Level: fallbackLevel,
|
||||
Msg: item.Message,
|
||||
Msg: SanitizeLogText(item.Message),
|
||||
Time: fallbackTime,
|
||||
}
|
||||
}
|
||||
|
||||
+87
-36
@@ -2,59 +2,110 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Токены подписываются и проверяются библиотекой golang-jwt/jwt/v5.
|
||||
//
|
||||
// Переход с v3 не косметический. У `github.com/golang-jwt/jwt` v3.2.2 есть
|
||||
// GO-2025-3553 (чрезмерное выделение памяти при разборе заголовка), и у неё
|
||||
// НЕТ исправленной версии в ветке v3: `Fixed in: N/A`. Уязвимый код
|
||||
// достигается из ParseToken, то есть с неаутентифицированного запроса — любой,
|
||||
// кто может обратиться к панели, попадает в него до всякой проверки прав.
|
||||
// Обновить здесь было нечего, поэтому лечится только сменой мажорной ветки.
|
||||
//
|
||||
// Заодно закрыт второй, тихий недостаток прежней реализации: keyfunc возвращал
|
||||
// ключ, НЕ проверяя алгоритм подписи. Разбор без ограничения набора алгоритмов
|
||||
// — это тот класс ошибок, из которого растёт подмена алгоритма; ниже стоит
|
||||
// jwt.WithValidMethods, и допустимый алгоритм ровно один.
|
||||
|
||||
const TokenExpireDuration = time.Hour * 24
|
||||
|
||||
// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами,
|
||||
// не должен разбираться как наш даже при совпадении ключа.
|
||||
const tokenIssuer = "hy2xs-admin"
|
||||
|
||||
type MyClaims struct {
|
||||
Admin bo.AccountBo `json:"admin"`
|
||||
jwt.StandardClaims
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenToken(accountBo bo.AccountBo) (string, error) {
|
||||
c := MyClaims{
|
||||
Admin: accountBo,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
|
||||
Issuer: "hy2xs-admin",
|
||||
},
|
||||
}
|
||||
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
|
||||
if err != nil {
|
||||
return "", errors.New(constant.SysError)
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
|
||||
return token.SignedString([]byte(*config.Value))
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (*MyClaims, error) {
|
||||
func jwtSecret() ([]byte, error) {
|
||||
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
|
||||
if err != nil {
|
||||
return nil, errors.New(constant.SysError)
|
||||
}
|
||||
token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) {
|
||||
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
|
||||
// Подпись пустым ключом дала бы токены, которые подделывает кто угодно.
|
||||
// Раньше значение разыменовывалось без проверки: пустой JWT_SECRET
|
||||
// молча превращался в ключ нулевой длины.
|
||||
return nil, errors.New(constant.SysError)
|
||||
}
|
||||
return []byte(*config.Value), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.New(constant.IllegalTokenError)
|
||||
}
|
||||
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, errors.New(constant.TokenExpiredError)
|
||||
}
|
||||
|
||||
func GetToken(c *gin.Context) string {
|
||||
tokenStr := c.Request.Header.Get("Authorization")
|
||||
if tokenStr == "" {
|
||||
return ""
|
||||
func GenToken(accountBo bo.AccountBo) (string, error) {
|
||||
secret, err := jwtSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.SplitN(tokenStr, " ", 2)[1]
|
||||
|
||||
now := time.Now()
|
||||
claims := MyClaims{
|
||||
Admin: accountBo,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(TokenExpireDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
Issuer: tokenIssuer,
|
||||
},
|
||||
}
|
||||
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (*MyClaims, error) {
|
||||
secret, err := jwtSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString,
|
||||
&MyClaims{},
|
||||
func(*jwt.Token) (interface{}, error) { return secret, nil },
|
||||
// Допустимый алгоритм ровно один. Без этого ограничения набор
|
||||
// алгоритмов задаёт сам токен, то есть недоверенный вход.
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||||
jwt.WithIssuer(tokenIssuer),
|
||||
// Токен без срока жизни не должен считаться валидным бессрочным.
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, errors.New(constant.TokenExpiredError)
|
||||
}
|
||||
return nil, errors.New(constant.IllegalTokenError)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*MyClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New(constant.IllegalTokenError)
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Функции GetToken здесь больше нет.
|
||||
//
|
||||
// Она доставала токен из заголовка Authorization вторым, независимым от
|
||||
// middleware.JWTHandler способом — и делала это через
|
||||
// strings.SplitN(tokenStr, " ", 2)[1], то есть паниковала на заголовке без
|
||||
// пробела: `Authorization: abc` давал index out of range. Единственный
|
||||
// потребитель — резервная ветка GetAdminInfo, которая была недостижима и
|
||||
// проверяла меньше, чем middleware. Разбор токена у продукта ровно один.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
func newJwtTestDB(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
newTestDB(t)
|
||||
|
||||
secret := "test-jwt-secret-not-used-anywhere-else-0123456789"
|
||||
if err := dao.UpsertConfigValue(constant.JwtSecret, secret); err != nil {
|
||||
t.Fatalf("не удалось записать %s: %v", constant.JwtSecret, err)
|
||||
}
|
||||
return []byte(secret)
|
||||
}
|
||||
|
||||
func testAccount() bo.AccountBo {
|
||||
return bo.AccountBo{Id: 1, Username: "hy2xsadmin", Roles: []string{"admin"}, TokenVersion: 1}
|
||||
}
|
||||
|
||||
func TestGenAndParseTokenRoundTrip(t *testing.T) {
|
||||
newJwtTestDB(t)
|
||||
|
||||
token, err := GenToken(testAccount())
|
||||
if err != nil {
|
||||
t.Fatalf("GenToken: %v", err)
|
||||
}
|
||||
|
||||
claims, err := ParseToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken: %v", err)
|
||||
}
|
||||
if claims.Admin.Id != 1 || claims.Admin.Username != "hy2xsadmin" {
|
||||
t.Fatalf("claims не совпадают: %+v", claims.Admin)
|
||||
}
|
||||
if claims.Admin.TokenVersion != 1 {
|
||||
t.Fatalf("token_version потерян: %d", claims.Admin.TokenVersion)
|
||||
}
|
||||
if claims.ExpiresAt == nil {
|
||||
t.Fatal("токен выписан без срока жизни")
|
||||
}
|
||||
}
|
||||
|
||||
// Алгоритм подписи задаётся сервером, а не токеном.
|
||||
//
|
||||
// Прежний keyfunc возвращал ключ, не глядя на token.Method: набор допустимых
|
||||
// алгоритмов фактически определял недоверенный вход. Здесь проверяется, что
|
||||
// токен, подписанный другим HMAC-алгоритмом тем же ключом, отклоняется.
|
||||
func TestParseTokenRejectsOtherSigningMethod(t *testing.T) {
|
||||
secret := newJwtTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
claims := MyClaims{
|
||||
Admin: testAccount(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: tokenIssuer,
|
||||
},
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось подписать тестовый токен: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ParseToken(signed); err == nil {
|
||||
t.Fatal("токен, подписанный HS512, принят при разрешённом только HS256")
|
||||
}
|
||||
}
|
||||
|
||||
// Токен без exp не должен считаться бессрочно валидным.
|
||||
func TestParseTokenRejectsTokenWithoutExpiration(t *testing.T) {
|
||||
secret := newJwtTestDB(t)
|
||||
|
||||
claims := MyClaims{
|
||||
Admin: testAccount(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: tokenIssuer,
|
||||
},
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось подписать тестовый токен: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ParseToken(signed); err == nil {
|
||||
t.Fatal("токен без exp принят")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenRejectsExpiredToken(t *testing.T) {
|
||||
secret := newJwtTestDB(t)
|
||||
|
||||
past := time.Now().Add(-2 * time.Hour)
|
||||
claims := MyClaims{
|
||||
Admin: testAccount(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(past.Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(past),
|
||||
Issuer: tokenIssuer,
|
||||
},
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось подписать тестовый токен: %v", err)
|
||||
}
|
||||
|
||||
_, err = ParseToken(signed)
|
||||
if err == nil {
|
||||
t.Fatal("истёкший токен принят")
|
||||
}
|
||||
if err.Error() != constant.TokenExpiredError {
|
||||
t.Errorf("истечение срока должно сообщаться отдельно, получено: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Токен, выписанный не нами, не должен разбираться как наш даже при совпадении
|
||||
// ключа: issuer входит в проверку.
|
||||
func TestParseTokenRejectsForeignIssuer(t *testing.T) {
|
||||
secret := newJwtTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
claims := MyClaims{
|
||||
Admin: testAccount(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "some-other-service",
|
||||
},
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось подписать тестовый токен: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ParseToken(signed); err == nil {
|
||||
t.Fatal("токен с чужим issuer принят")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenRejectsWrongSecret(t *testing.T) {
|
||||
newJwtTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
claims := MyClaims{
|
||||
Admin: testAccount(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: tokenIssuer,
|
||||
},
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("совершенно другой ключ подписи"))
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось подписать тестовый токен: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ParseToken(signed); err == nil {
|
||||
t.Fatal("токен, подписанный чужим ключом, принят")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenRejectsGarbage(t *testing.T) {
|
||||
newJwtTestDB(t)
|
||||
|
||||
for _, value := range []string{
|
||||
"",
|
||||
"not-a-token",
|
||||
"a.b.c",
|
||||
strings.Repeat("A", 500),
|
||||
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhZG1pbiI6eyJpZCI6MX19.",
|
||||
} {
|
||||
if _, err := ParseToken(value); err == nil {
|
||||
t.Errorf("мусорный токен %q принят", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой JWT_SECRET — повреждённое состояние, а не «ключ нулевой длины».
|
||||
// Раньше значение разыменовывалось без проверки, и подпись выполнялась пустым
|
||||
// ключом: такой токен подделывает кто угодно.
|
||||
func TestGenTokenRefusesEmptySecret(t *testing.T) {
|
||||
newTestDB(t)
|
||||
if err := dao.UpsertConfigValue(constant.JwtSecret, ""); err != nil {
|
||||
t.Fatalf("не удалось очистить %s: %v", constant.JwtSecret, err)
|
||||
}
|
||||
|
||||
if _, err := GenToken(testAccount()); err == nil {
|
||||
t.Fatal("токен подписан пустым ключом")
|
||||
}
|
||||
if _, err := ParseToken("whatever"); err == nil {
|
||||
t.Fatal("разбор токена с пустым ключом не отклонён")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Санитайз свободного текста логов.
|
||||
//
|
||||
// Отличается от YAML-санитайзера тем, что у него нет структуры: журнал — это
|
||||
// строки, а не документ. Поэтому правил ровно два и оба консервативные:
|
||||
//
|
||||
// 1. всё, что разбирается как URL, прогоняется через тот же sanitizeURLValue,
|
||||
// что и экспорт конфига: host/port/path остаются читаемыми, встроенные
|
||||
// учётные данные и секретные query-параметры вырезаются;
|
||||
// 2. отдельно ловятся пары `имя=значение` с секретоподобным именем, которые
|
||||
// встречаются в тексте вне URL.
|
||||
//
|
||||
// Зачем это журналу Hysteria: HY2_AUTH_URL содержит access_token, и upstream
|
||||
// волен упомянуть его в сообщении об ошибке обращения к auth-backend. Свой
|
||||
// журнал админки мы контролируем, чужой — нет, а ExportLog отдаёт его
|
||||
// оператору тем же файлом.
|
||||
|
||||
var logURLPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s"'<>` + "`" + `]+`)
|
||||
|
||||
// Имена подобраны по тем же маркерам, что и secretKeyMarkers, но записаны
|
||||
// явным перечислением: в свободном тексте подстрочный поиск по «token» дал бы
|
||||
// слишком много ложных срабатываний на словах вроде «tokenizer».
|
||||
var logSecretAssignmentPattern = regexp.MustCompile(
|
||||
`(?i)([a-z0-9_\-]*(?:access_token|token|secret|password|passwd|passphrase|credential|api_?key|authorization|signature)[a-z0-9_\-]*)\s*[=:]\s*([^\s&,;"'<>` + "`" + `]+)`,
|
||||
)
|
||||
|
||||
// SanitizeLogText вырезает секреты из произвольного текста журнала.
|
||||
func SanitizeLogText(raw string) string {
|
||||
if raw == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
withSafeURLs := logURLPattern.ReplaceAllStringFunc(raw, func(match string) string {
|
||||
body, trailing := splitTrailingPunctuation(match)
|
||||
return sanitizeURLValue(body) + trailing
|
||||
})
|
||||
|
||||
return logSecretAssignmentPattern.ReplaceAllStringFunc(withSafeURLs, func(match string) string {
|
||||
groups := logSecretAssignmentPattern.FindStringSubmatch(match)
|
||||
if len(groups) != 3 {
|
||||
return match
|
||||
}
|
||||
value, trailing := splitTrailingPunctuation(groups[2])
|
||||
|
||||
// Значение уже вырезано URL-санитайзером: percent-кодированный маркер
|
||||
// внутри готового URL трогать нельзя, иначе адрес перестанет быть
|
||||
// валидным URL.
|
||||
if value == RedactedPlaceholder || strings.EqualFold(value, encodedRedactedPlaceholder) {
|
||||
return match
|
||||
}
|
||||
|
||||
separator := "="
|
||||
if strings.Contains(match, ":") && !strings.Contains(match, "=") {
|
||||
separator = ": "
|
||||
}
|
||||
return groups[1] + separator + RedactedPlaceholder + trailing
|
||||
})
|
||||
}
|
||||
|
||||
// encodedRedactedPlaceholder — то, во что превращается маркер внутри query
|
||||
// после url.Values.Encode().
|
||||
const encodedRedactedPlaceholder = "%3Credacted%3E"
|
||||
|
||||
// splitTrailingPunctuation отделяет хвостовую пунктуацию предложения от
|
||||
// значения: в «...access_token=abc.» точка принадлежит тексту, а не токену.
|
||||
func splitTrailingPunctuation(value string) (string, string) {
|
||||
end := len(value)
|
||||
for end > 0 && strings.ContainsRune(".,;:)]}!?", rune(value[end-1])) {
|
||||
end--
|
||||
}
|
||||
return value[:end], value[end:]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const journalSentinel = "SUPER_SECRET_SENTINEL"
|
||||
|
||||
func TestSanitizeLogTextRemovesMachineTokenFromUrl(t *testing.T) {
|
||||
raw := `Aug 27 12:00:01 host hysteria[123]: {"level":"error","msg":"auth backend error: ` +
|
||||
`Post \"http://127.0.0.1:8080/internal/hysteria/auth?access_token=` + journalSentinel + `\": EOF"}`
|
||||
|
||||
sanitized := SanitizeLogText(raw)
|
||||
|
||||
if strings.Contains(sanitized, journalSentinel) {
|
||||
t.Fatalf("machine token остался в журнале: %s", sanitized)
|
||||
}
|
||||
// Адрес обязан остаться читаемым: диагностика теряет смысл, если из строки
|
||||
// пропадает host и path.
|
||||
if !strings.Contains(sanitized, "127.0.0.1:8080/internal/hysteria/auth") {
|
||||
t.Fatalf("санитайз съел адрес целиком: %s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLogTextRemovesBareAssignments(t *testing.T) {
|
||||
cases := []string{
|
||||
"connecting with access_token=" + journalSentinel,
|
||||
"trafficStats secret=" + journalSentinel + " rejected",
|
||||
"obfs password: " + journalSentinel,
|
||||
"Authorization=" + journalSentinel,
|
||||
}
|
||||
|
||||
for _, raw := range cases {
|
||||
sanitized := SanitizeLogText(raw)
|
||||
if strings.Contains(sanitized, journalSentinel) {
|
||||
t.Errorf("секрет остался в строке %q: %s", raw, sanitized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLogTextKeepsOrdinaryLines(t *testing.T) {
|
||||
raw := "server up and running, listening on 0.0.0.0:443"
|
||||
|
||||
if sanitized := SanitizeLogText(raw); sanitized != raw {
|
||||
t.Fatalf("обычная строка изменена: %q -> %q", raw, sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Хвостовая пунктуация не является частью адреса.
|
||||
func TestSanitizeLogTextKeepsTrailingPunctuation(t *testing.T) {
|
||||
raw := "failed to call http://127.0.0.1:8080/internal/hysteria/auth?access_token=" + journalSentinel + "."
|
||||
|
||||
sanitized := SanitizeLogText(raw)
|
||||
|
||||
if strings.Contains(sanitized, journalSentinel) {
|
||||
t.Fatalf("machine token остался: %s", sanitized)
|
||||
}
|
||||
if !strings.HasSuffix(sanitized, ".") {
|
||||
t.Fatalf("потеряна хвостовая пунктуация: %s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Двойной проход не должен ломать уже вырезанное значение.
|
||||
func TestSanitizeLogTextIsIdempotent(t *testing.T) {
|
||||
raw := "http://127.0.0.1:8080/internal/hysteria/auth?access_token=" + journalSentinel
|
||||
|
||||
once := SanitizeLogText(raw)
|
||||
twice := SanitizeLogText(once)
|
||||
|
||||
if once != twice {
|
||||
t.Fatalf("санитайз не идемпотентен: %q -> %q", once, twice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLogTextEmptyInput(t *testing.T) {
|
||||
if SanitizeLogText("") != "" {
|
||||
t.Fatal("пустой вход должен оставаться пустым")
|
||||
}
|
||||
}
|
||||
+390
-82
@@ -46,12 +46,46 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
|
||||
//
|
||||
// Что было. ReservedBootstrapPeerName проверялся исключительно в
|
||||
// applyPeerImportEntry. Обычный CRUD панели такой проверки не имел, то есть
|
||||
// ровно то, ради чего guard существует, делалось через интерфейс: секрет
|
||||
// bootstrap-admin-peer продублирован в /etc/hy2xs/bootstrap-admin.secret,
|
||||
// откуда его читает проверка machine-auth оркестратора, и смена секрета через
|
||||
// форму молча рассинхронизировала бы файл с базой. Дверей в таблицу было две,
|
||||
// а замок висел на одной.
|
||||
//
|
||||
// Границы намеренно разные для разных операций:
|
||||
//
|
||||
// идентичность и секрет — запрещены: они и есть то, что дублируется в файле;
|
||||
// удаление и отключение — разрешены: это осознанные действия оператора,
|
||||
// результат которых виден в списке пиров. Bootstrap-пир после установки
|
||||
// остаётся действующим доступом, и запрет его убрать означал бы вечный
|
||||
// неотзываемый доступ.
|
||||
var errBootstrapPeerIdentity = fmt.Errorf(
|
||||
"пир %q принадлежит установщику: его имя и секрет продублированы в "+
|
||||
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
|
||||
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
|
||||
ReservedBootstrapPeerName,
|
||||
)
|
||||
|
||||
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
||||
if peerDto.Name == nil || *peerDto.Name == "" {
|
||||
return vo.PeerVo{}, errors.New(constant.InvalidError)
|
||||
}
|
||||
if ExistPeerName(*peerDto.Name, 0) {
|
||||
return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name))
|
||||
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
|
||||
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
|
||||
// защитой, предназначенной не ему.
|
||||
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
||||
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||||
}
|
||||
taken, err := ExistPeerName(*peerDto.Name, 0)
|
||||
if err != nil {
|
||||
return vo.PeerVo{}, err
|
||||
}
|
||||
if taken {
|
||||
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name)
|
||||
}
|
||||
secret := ""
|
||||
if peerDto.Secret != nil && *peerDto.Secret != "" {
|
||||
@@ -94,6 +128,10 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
||||
}
|
||||
|
||||
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
||||
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if peerDto.Name != nil && *peerDto.Name != "" {
|
||||
updates["name"] = *peerDto.Name
|
||||
@@ -128,6 +166,59 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
||||
return dao.UpdatePeer([]int64{id}, updates)
|
||||
}
|
||||
|
||||
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
|
||||
// в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя.
|
||||
func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) error {
|
||||
// Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от
|
||||
// проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир
|
||||
// существует.
|
||||
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
||||
existing, err := dao.GetPeer("id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
|
||||
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := dao.GetPeer("id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
|
||||
return nil
|
||||
}
|
||||
|
||||
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) != "" &&
|
||||
strings.TrimSpace(*peerDto.Name) != ReservedBootstrapPeerName {
|
||||
return errBootstrapPeerIdentity
|
||||
}
|
||||
if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" {
|
||||
return errBootstrapPeerIdentity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePeer удаляет пира, включая bootstrap-пира установщика.
|
||||
//
|
||||
// Удаление намеренно НЕ запрещено. Bootstrap-пир после установки — обычный
|
||||
// действующий доступ, секрет которого лежит ещё и в файле на диске; оператор
|
||||
// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление
|
||||
// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в
|
||||
// списке.
|
||||
//
|
||||
// Отзыв при этом ОКОНЧАТЕЛЬНЫЙ, и это свойство обеспечивается не здесь, а
|
||||
// отметкой constant.BootstrapPeerSeeded в dao.ensureSecureBootstrapPeer. Пока
|
||||
// признаком «создавать или нет» служило наличие строки в таблице, удаление
|
||||
// откатывалось первым же перезапуском сервиса: HY2XS_ADMIN_CON_PASS никуда не
|
||||
// девается из /etc/hy2xs/hy2xs.env, и seeder создавал пира с тем же секретом
|
||||
// заново. Разрешать удаление имеет смысл только вместе с этим свойством, иначе
|
||||
// панель предлагает операции, которых не выполняет.
|
||||
//
|
||||
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
|
||||
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
|
||||
// недействующее значение (см. docs/04-admin-panel.md).
|
||||
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
|
||||
|
||||
func GetPeerVo(id int64) (vo.PeerVo, error) {
|
||||
@@ -173,10 +264,42 @@ func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
|
||||
return vo.PeerClientConfigVo{Url: url}, nil
|
||||
}
|
||||
|
||||
// ListExportPeer собирает выгрузку пиров в одном из двух режимов.
|
||||
//
|
||||
// includeSecrets=false — безопасная выгрузка: шифртекст не трогается вовсе,
|
||||
// поле secret остаётся пустым, и это ожидаемое содержимое файла.
|
||||
//
|
||||
// includeSecrets=true — резервная копия с ДЕЙСТВУЮЩИМИ учётными данными,
|
||||
// которую оператор запрашивает отдельной кнопкой с подтверждением риска.
|
||||
// Здесь правило строгое: либо в файл попадают все секреты, либо файла нет.
|
||||
//
|
||||
// Что было:
|
||||
//
|
||||
// if includeSecrets && item.SecretEncrypted != nil {
|
||||
// if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
|
||||
// ex.Secret = dec
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Два молчаливых канала потери. Ошибка расшифровки проглатывалась, и пир
|
||||
// уезжал в файл с пустым secret; пир без шифртекста — тоже. Запрос при этом
|
||||
// завершался успехом, и оператор получал файл, выглядящий полным:
|
||||
//
|
||||
// [{"name":"A","secret":"..."},
|
||||
// {"name":"B","secret":""}, <- потерян молча
|
||||
// {"name":"C","secret":"..."}]
|
||||
//
|
||||
// Обнаруживалось это уже после импорта на новом сервере: B получал новый
|
||||
// сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима в
|
||||
// том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»;
|
||||
// частичный результат под этим именем — худший из возможных ответов.
|
||||
//
|
||||
// Сообщение называет пира по имени: имена не секретны и уже видны в списке, а
|
||||
// без имени оператору некуда идти с этой ошибкой.
|
||||
func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
|
||||
peers, err := dao.ListPeer("1=1")
|
||||
if err != nil {
|
||||
return nil, errors.New(constant.SysError)
|
||||
return nil, err
|
||||
}
|
||||
out := make([]bo.PeerExport, 0, len(peers))
|
||||
for _, item := range peers {
|
||||
@@ -194,129 +317,314 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
|
||||
BannedUntil: int64Val(item.BannedUntil),
|
||||
LastConnectionAt: int64Val(item.LastConnectionAt),
|
||||
}
|
||||
if includeSecrets && item.SecretEncrypted != nil {
|
||||
if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
|
||||
ex.Secret = dec
|
||||
if includeSecrets {
|
||||
secret, secretErr := exportPeerSecret(item)
|
||||
if secretErr != nil {
|
||||
return nil, secretErr
|
||||
}
|
||||
ex.Secret = secret
|
||||
}
|
||||
out = append(out, ex)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func UpsertPeerExport(items []bo.PeerExport) error {
|
||||
// exportPeerSecret возвращает действующий секрет пира или объясняет, почему
|
||||
// резервная копия невозможна.
|
||||
func exportPeerSecret(peer entity.Peer) (string, error) {
|
||||
name := strVal(peer.Name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("id=%d", int64Val(peer.Id))
|
||||
}
|
||||
|
||||
if peer.SecretEncrypted == nil || strings.TrimSpace(*peer.SecretEncrypted) == "" {
|
||||
return "", fmt.Errorf(
|
||||
"резервная копия с учётными данными не создана: у пира %q нет сохранённого секрета. "+
|
||||
"Файл не сформирован целиком, чтобы копия не выглядела полной. "+
|
||||
"Пересоздайте секрет этого пира или выгрузите список без учётных данных",
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"резервная копия с учётными данными не создана: не удалось расшифровать секрет пира %q. "+
|
||||
"Обычная причина — подменённый или потерянный PEER_SECRET_ENCRYPTION_KEY. "+
|
||||
"Файл не сформирован целиком, чтобы копия не выглядела полной",
|
||||
name,
|
||||
)
|
||||
}
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
// preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным
|
||||
// заранее.
|
||||
//
|
||||
// Крипто выносится ИЗ транзакции сознательно. PeerSecretDigest и
|
||||
// EncryptPeerSecret читают ключи из таблицы `config`, то есть ходят в ту же
|
||||
// базу; делать это, удерживая открытую запись, значит без нужды держать
|
||||
// блокировку на время AES по каждой из тысяч записей. Внутри транзакции должна
|
||||
// остаться только работа с таблицей пиров.
|
||||
type preparedPeerImport struct {
|
||||
source bo.PeerExport
|
||||
|
||||
name string
|
||||
authID string
|
||||
remark string
|
||||
quota int64
|
||||
expires int64
|
||||
maxDevices int64
|
||||
disabled int64
|
||||
|
||||
// Задан, только если секрет пришёл в файле: у существующего пира секрет
|
||||
// перезаписывается лишь в этом случае.
|
||||
hasExplicitSecret bool
|
||||
explicitDigest string
|
||||
explicitCipher string
|
||||
|
||||
// Готовятся всегда: понадобятся, если запись окажется новой.
|
||||
createDigest string
|
||||
createCipher string
|
||||
createAuthID string
|
||||
}
|
||||
|
||||
func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
||||
prepared := make([]preparedPeerImport, 0, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
return errors.New(constant.InvalidError)
|
||||
}
|
||||
|
||||
var existing entity.Peer
|
||||
var err error
|
||||
authID := strings.TrimSpace(item.AuthId)
|
||||
if authID != "" {
|
||||
existing, err = dao.GetPeer("auth_id = ?", authID)
|
||||
}
|
||||
if (err != nil || existing.Id == nil) && name != "" {
|
||||
existing, err = dao.GetPeer("name = ?", name)
|
||||
}
|
||||
|
||||
quota := item.QuotaBytes
|
||||
expires := item.ExpiresAt
|
||||
maxDevices := item.MaxDevices
|
||||
if maxDevices <= 0 {
|
||||
maxDevices = 3
|
||||
}
|
||||
disabled := item.Disabled
|
||||
remark := item.Remark
|
||||
|
||||
if err == nil && existing.Id != nil {
|
||||
updates := map[string]interface{}{
|
||||
"name": name,
|
||||
"remark": remark,
|
||||
"quota_bytes": quota,
|
||||
"download_bytes": item.DownloadBytes,
|
||||
"upload_bytes": item.UploadBytes,
|
||||
"expires_at": expires,
|
||||
"max_devices": maxDevices,
|
||||
"disabled": disabled,
|
||||
"banned_until": item.BannedUntil,
|
||||
"last_connection_at": item.LastConnectionAt,
|
||||
}
|
||||
if authID != "" {
|
||||
updates["auth_id"] = authID
|
||||
}
|
||||
if strings.TrimSpace(item.Secret) != "" {
|
||||
digest, derr := PeerSecretDigest(item.Secret)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
enc, eerr := EncryptPeerSecret(item.Secret)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
updates["secret_digest"] = digest
|
||||
updates["secret_ciphertext"] = enc
|
||||
}
|
||||
if uerr := dao.UpdatePeer([]int64{*existing.Id}, updates); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
continue
|
||||
entry := preparedPeerImport{
|
||||
source: item,
|
||||
name: name,
|
||||
authID: authID,
|
||||
remark: item.Remark,
|
||||
quota: item.QuotaBytes,
|
||||
expires: item.ExpiresAt,
|
||||
maxDevices: maxDevices,
|
||||
disabled: item.Disabled,
|
||||
}
|
||||
|
||||
createSecret := strings.TrimSpace(item.Secret)
|
||||
if createSecret == "" {
|
||||
generated, gerr := util.RandomString(24)
|
||||
if gerr != nil {
|
||||
return gerr
|
||||
explicitSecret := strings.TrimSpace(item.Secret)
|
||||
if explicitSecret != "" {
|
||||
digest, err := PeerSecretDigest(explicitSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createSecret = fmt.Sprintf("%s.%s", name, generated)
|
||||
cipher, err := EncryptPeerSecret(explicitSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secretDigest, derr := PeerSecretDigest(createSecret)
|
||||
if derr != nil {
|
||||
return derr
|
||||
entry.hasExplicitSecret = true
|
||||
entry.explicitDigest = digest
|
||||
entry.explicitCipher = cipher
|
||||
entry.createDigest = digest
|
||||
entry.createCipher = cipher
|
||||
} else {
|
||||
generated, err := util.RandomString(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secretEncrypted, eerr := EncryptPeerSecret(createSecret)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
createSecret := fmt.Sprintf("%s.%s", name, generated)
|
||||
digest, err := PeerSecretDigest(createSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authID == "" {
|
||||
authID, err = util.RandomString(18)
|
||||
cipher, err := EncryptPeerSecret(createSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.createDigest = digest
|
||||
entry.createCipher = cipher
|
||||
}
|
||||
|
||||
entry.createAuthID = authID
|
||||
if entry.createAuthID == "" {
|
||||
generated, err := util.RandomString(18)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.createAuthID = generated
|
||||
}
|
||||
|
||||
prepared = append(prepared, entry)
|
||||
}
|
||||
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
// UpsertPeerExport применяет выгрузку пиров целиком или не применяет вовсе.
|
||||
//
|
||||
// Три прохода, и каждый отвечает за своё:
|
||||
//
|
||||
// 1. ValidatePeerImportBatch — содержимое файла, без обращения к базе;
|
||||
// 2. preparePeerImport — весь криптоматериал, без обращения к таблице пиров;
|
||||
// 3. одна транзакция — только записи.
|
||||
//
|
||||
// Раньше третьего прохода не существовало: записи шли по одной, каждая своим
|
||||
// оператором. Комментарий обещал «либо целиком, либо никак», но UNIQUE-конфликт
|
||||
// на 37-й записи оставлял 36 применённых, и откатить это оператор уже не мог.
|
||||
// Конфликт не гипотетический: пусть в базе есть A(auth_id=a, name=alice) и
|
||||
// B(auth_id=b, name=bob), а файл несёт (auth_id=a, name=bob). Поиск найдёт A
|
||||
// по auth_id и переименует его в bob — прямо в UNIQUE(name).
|
||||
func UpsertPeerExport(items []bo.PeerExport) error {
|
||||
if err := ValidatePeerImportBatch(items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prepared, err := preparePeerImport(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.WithPeerTx(func(tx dao.PeerTx) error {
|
||||
for _, entry := range prepared {
|
||||
if err := applyPeerImportEntry(tx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
|
||||
existing, found, err := findPeerForImport(tx, entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Пир установщика не переопределяется импортом ни при каком совпадении:
|
||||
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
|
||||
if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
|
||||
return fmt.Errorf(
|
||||
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
|
||||
ReservedBootstrapPeerName,
|
||||
)
|
||||
}
|
||||
|
||||
if found {
|
||||
updates := map[string]interface{}{
|
||||
"name": entry.name,
|
||||
"remark": entry.remark,
|
||||
"quota_bytes": entry.quota,
|
||||
"download_bytes": entry.source.DownloadBytes,
|
||||
"upload_bytes": entry.source.UploadBytes,
|
||||
"expires_at": entry.expires,
|
||||
"max_devices": entry.maxDevices,
|
||||
"disabled": entry.disabled,
|
||||
"banned_until": entry.source.BannedUntil,
|
||||
"last_connection_at": entry.source.LastConnectionAt,
|
||||
}
|
||||
if entry.authID != "" {
|
||||
updates["auth_id"] = entry.authID
|
||||
}
|
||||
if entry.hasExplicitSecret {
|
||||
updates["secret_digest"] = entry.explicitDigest
|
||||
updates["secret_ciphertext"] = entry.explicitCipher
|
||||
}
|
||||
return tx.UpdatePeer([]int64{*existing.Id}, updates)
|
||||
}
|
||||
|
||||
name := entry.name
|
||||
remark := entry.remark
|
||||
authID := entry.createAuthID
|
||||
digest := entry.createDigest
|
||||
cipher := entry.createCipher
|
||||
quota := entry.quota
|
||||
expires := entry.expires
|
||||
maxDevices := entry.maxDevices
|
||||
disabled := entry.disabled
|
||||
download := entry.source.DownloadBytes
|
||||
upload := entry.source.UploadBytes
|
||||
bannedUntil := entry.source.BannedUntil
|
||||
lastConnection := entry.source.LastConnectionAt
|
||||
|
||||
peer := entity.Peer{
|
||||
Name: &name,
|
||||
Remark: &remark,
|
||||
AuthId: &authID,
|
||||
SecretDigest: &secretDigest,
|
||||
SecretEncrypted: &secretEncrypted,
|
||||
SecretDigest: &digest,
|
||||
SecretEncrypted: &cipher,
|
||||
QuotaBytes: "a,
|
||||
DownloadBytes: &item.DownloadBytes,
|
||||
UploadBytes: &item.UploadBytes,
|
||||
DownloadBytes: &download,
|
||||
UploadBytes: &upload,
|
||||
ExpiresAt: &expires,
|
||||
MaxDevices: &maxDevices,
|
||||
Disabled: &disabled,
|
||||
BannedUntil: &item.BannedUntil,
|
||||
LastConnectionAt: &item.LastConnectionAt,
|
||||
BannedUntil: &bannedUntil,
|
||||
LastConnectionAt: &lastConnection,
|
||||
}
|
||||
if _, serr := dao.SavePeer(peer); serr != nil {
|
||||
return serr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
_, saveErr := tx.SavePeer(peer)
|
||||
return saveErr
|
||||
}
|
||||
|
||||
func ExistPeerName(name string, id int64) bool {
|
||||
// findPeerForImport ищет запись, которую импорт должен обновить.
|
||||
//
|
||||
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
|
||||
// последних двух не существовало — код выглядел так:
|
||||
//
|
||||
// existing, err = tx.GetPeer("auth_id = ?", entry.authID)
|
||||
// if err != nil || existing.Id == nil {
|
||||
// existing, err = tx.GetPeer("name = ?", entry.name)
|
||||
// }
|
||||
//
|
||||
// то есть отказ SQLite на первом запросе трактовался как «по auth_id ничего
|
||||
// нет» и запускал поиск по имени; отказ на втором — как «такого пира нет
|
||||
// вовсе», и запись СОЗДАВАЛАСЬ. При недоступной базе импорт не отказывал, а
|
||||
// пытался наплодить дубликаты; целостность спасала транзакция, но решение
|
||||
// принималось на несуществующих данных.
|
||||
//
|
||||
// Порядок поиска сохранён: authId — устойчивый идентификатор, имя — запасной
|
||||
// ключ для файлов, выгруженных без него.
|
||||
func findPeerForImport(tx dao.PeerTx, entry preparedPeerImport) (entity.Peer, bool, error) {
|
||||
if entry.authID != "" {
|
||||
peer, err := tx.GetPeer("auth_id = ?", entry.authID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return peer, true, nil
|
||||
case !errors.Is(err, dao.ErrPeerNotFound):
|
||||
return entity.Peer{}, false, err
|
||||
}
|
||||
}
|
||||
|
||||
peer, err := tx.GetPeer("name = ?", entry.name)
|
||||
switch {
|
||||
case err == nil:
|
||||
return peer, true, nil
|
||||
case errors.Is(err, dao.ErrPeerNotFound):
|
||||
return entity.Peer{}, false, nil
|
||||
default:
|
||||
return entity.Peer{}, false, err
|
||||
}
|
||||
}
|
||||
|
||||
// ExistPeerName отвечает «имя занято» и отказывает, когда ответить не может.
|
||||
//
|
||||
// Прежняя реализация возвращала `err == nil`, то есть любой отказ хранилища
|
||||
// означал «имя свободно». Проверка уникальности, которая при недоступной базе
|
||||
// отвечает «можно», ничего не проверяет.
|
||||
func ExistPeerName(name string, id int64) (bool, error) {
|
||||
var err error
|
||||
if id != 0 {
|
||||
_, err = dao.GetPeer("name = ? and id != ?", name, id)
|
||||
} else {
|
||||
_, err = dao.GetPeer("name = ?", name)
|
||||
}
|
||||
return err == nil
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, dao.ErrPeerNotFound):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
func UpdatePeerLastConnectionAt(id int64, conAt int64) error {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user