Compare commits
63 Commits
main_v0.2.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 95f81c2858 | |||
| 041f88b6a2 | |||
| 5c1dd24111 | |||
| b7e478f014 | |||
| 079094591b | |||
| bf10810cfc | |||
| 6c286d1ff5 | |||
| a3445f79c5 | |||
| af9f476658 | |||
| ab788725cf | |||
| 65042ee335 | |||
| a8407cf16b | |||
| 82e5ca40cc | |||
| 7d486af712 | |||
| b9d3c03f8d | |||
| b315001288 | |||
| cb20d8d28f | |||
| 8dcb50a07c | |||
| 6d1686b2be | |||
| 162759c599 | |||
| c0a43ae915 | |||
| 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
|
||||
@@ -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">
|
||||
@@ -10,10 +10,13 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#что-это">Что это</a> ·
|
||||
<a href="#статус-приёмки-v100">Приёмка v1.0.0</a> ·
|
||||
<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 +25,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>
|
||||
|
||||
---
|
||||
@@ -35,11 +38,27 @@ HY2XS подходит для сценария, где нужен один produ
|
||||
|
||||
Ключевая идея: на target‑сервере выполняется только установка готового пакета. Вся тяжёлая сборочная часть остаётся на build‑машине.
|
||||
|
||||
## Статус приёмки v1.0.0
|
||||
|
||||
Stable `v1.0.0` выпущен из принятого artifact `1.0.0-rc5` без пересборки.
|
||||
Проверенные source commit, tarball и SHA-256 не изменялись; release сознательно
|
||||
остаётся привязан к тегу `1.0.0-rc5`.
|
||||
|
||||
Финальная production-приёмка на чистом Debian 13 охватила build, установку,
|
||||
reboot persistence, Admin/UI, управление пирами, diagnostics, реальный E2E
|
||||
официальным клиентом Hysteria 2.12.2 и дополнительный gateway/TUN anti-leak
|
||||
прогон. Вердикт: **`PASS / PRODUCTION E2E VALIDATED`**.
|
||||
|
||||
Подробности:
|
||||
|
||||
- [итоговый отчёт о приёмке RC5](docs/acceptance/2026-09-15-v1.0.0-rc5-final-acceptance.md);
|
||||
- [фиксация stable promotion](docs/acceptance/2026-09-15-v1.0.0-stable-promotion.md).
|
||||
|
||||
## Возможности
|
||||
|
||||
HY2XS release‑пакет разворачивает и настраивает:
|
||||
|
||||
- официальный upstream‑бинарник Hysteria2, закреплённый в metadata пакета и проверяемый по SHA256;
|
||||
- официальный upstream‑бинарник Hysteria2: последняя стабильная версия выбирается при сборке пакета, закрепляется в его metadata и проверяется по SHA256;
|
||||
- HY2XS admin — встроенную админ‑панель для управления users/peers, трафиком, конфигурацией, логами и состоянием сервера;
|
||||
- systemd‑юнит `hysteria-server` для Hysteria2;
|
||||
- systemd‑юнит `hy2xs-admin` для админ‑панели;
|
||||
@@ -108,6 +127,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 +155,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 +178,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 +285,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 +493,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/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md).
|
||||
|
||||
### 10. Получите bootstrap‑пароль админки
|
||||
|
||||
@@ -507,9 +638,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` |
|
||||
@@ -518,8 +651,8 @@ hy2xs-orchestrator status \
|
||||
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
|
||||
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
|
||||
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
|
||||
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); валидный UTF‑8 в документированном домене `EnvironmentFile=` (в частности, без U+FEFF), без управляющих символов; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
|
||||
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
|
||||
@@ -531,11 +664,11 @@ hy2xs-orchestrator status \
|
||||
| `HY2XS_HYSTERIA_BIND_HOST` | Bind Hysteria2. В production profile фиксируется на `0.0.0.0` | `0.0.0.0` |
|
||||
| `HY2XS_HYSTERIA_PORT` | UDP‑порт Hysteria2 | `443` |
|
||||
| `HY2XS_HYSTERIA_AUTH_MODE` | Auth mode Hysteria2. Фиксированное значение production‑профиля | `http` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `127.0.0.1` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API. Фиксированное значение production‑профиля: админка обращается к нему только по loopback, поэтому любой другой адрес выключает лимит устройств, учёт трафика и принудительное отключение | `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 +688,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 +760,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 diagnostics collect` | Собрать diagnostic bundle в `/var/log/hy2xs/diagnostics` |
|
||||
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
|
||||
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/lib/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
|
||||
@@ -599,6 +783,22 @@ hy2xs-orchestrator diagnostics collect \
|
||||
--config /etc/hy2xs/hy2xs.env
|
||||
```
|
||||
|
||||
Бандл не содержит сырых промежуточных копий конфигурации или журналов:
|
||||
редакция выполняется в памяти до записи. Файл с повреждённым UTF-8 не
|
||||
декодируется с заменой и не попадает в архив; вместо него записывается
|
||||
безопасная причина пропуска.
|
||||
|
||||
`HY2XS_FORCE_PASSWORD_CHANGE` — диагностический boolean, поэтому его значение
|
||||
`true`/`false` сохраняется. Исключение точное и не распространяется на другие
|
||||
ключи с `PASSWORD`: начальный пароль администратора, `ADMIN_CON_PASS`, пароль
|
||||
obfs и остальные секреты по-прежнему заменяются на `<redacted>`.
|
||||
|
||||
Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог
|
||||
принадлежит `root:root`, имеет режим `0700` и отделён от
|
||||
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
|
||||
Оркестратор отказывает, если каталог подменён symlink, имеет другого владельца
|
||||
или ослабленные права. Незавершённый staging-каталог после упаковки удаляется.
|
||||
|
||||
## Проверка безопасности после установки
|
||||
|
||||
Минимальный набор проверок:
|
||||
@@ -637,6 +837,35 @@ permitopen 127.0.0.1:8080 localhost:8080
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Установка отказывается: обнаружена предыдущая установка
|
||||
|
||||
```text
|
||||
[hy2xs] ERROR: На сервере обнаружена предыдущая или посторонняя установка.
|
||||
HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.
|
||||
Ни один файл на сервере не изменён.
|
||||
```
|
||||
|
||||
Это ожидаемое поведение, а не сбой. Отказ происходит в PHASE 0, до любой
|
||||
мутации: сервер остался в том состоянии, в котором был.
|
||||
|
||||
Что делать:
|
||||
|
||||
1. сохраните нужные данные (база пиров, конфиг) — см.
|
||||
[docs/operations/14-legacy-cleanup.md](docs/operations/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 +875,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 +949,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.8
|
||||
found: 1.25.6
|
||||
FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.8
|
||||
|
||||
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 +999,112 @@ 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. прогоняет dependency-free контракты панели (спрайт иконок, совпадение словарей, коды ошибок, атрибуция и frontend/Go-контракты);
|
||||
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
|
||||
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
||||
6. собирает standalone-бинарник orchestrator;
|
||||
7. устанавливает frontend lock-граф, runtime-компилирует каждое сообщение RU/EN реальным `vue-i18n`, затем проверяет типы и собирает frontend и backend, проставляя версию админки из контракта;
|
||||
8. прогоняет `go vet` и `go test` для HY2XS admin;
|
||||
9. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
|
||||
10. формирует архив и прогоняет acceptance‑проверки.
|
||||
|
||||
Любой сбой на шагах 1–9 останавливает сборку до создания пакета.
|
||||
|
||||
Тесты и типы (шаги 2, 3, 7 и 8) — такой же обязательный гейт, как проверка
|
||||
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
|
||||
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
|
||||
утверждение опирается на фактический прогон, а не на намерение.
|
||||
|
||||
Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`,
|
||||
`go vet ./...`, `go test ./...` и
|
||||
`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts`
|
||||
запускаются напрямую и tarball не создают.
|
||||
|
||||
Runtime-проверка словарей требует установленного frontend lock-графа:
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
pnpm install --frozen-lockfile
|
||||
bun test test/i18n-runtime.test.ts
|
||||
```
|
||||
|
||||
Переменные, управляющие выбором версии 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 +1117,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,12 +1131,27 @@ 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/ # e2e с реальным клиентом Hysteria и контракты панели
|
||||
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения
|
||||
├── docs/ # документация, разложенная по слоям
|
||||
│ ├── architecture/ # baseline-модель и рамки
|
||||
│ ├── build/ # builder layer и состав пакета
|
||||
│ ├── runtime/ # оркестратор, systemd, post-install
|
||||
│ ├── admin/ # HY2XS admin и контракты панели
|
||||
│ ├── operations/ # runbook, разбор отказов, очистка 0.x
|
||||
│ ├── testing/ # набор проверок по слоям
|
||||
│ └── acceptance/ # отчёты о фактических прогонах приёмки
|
||||
├── versions.env # контракт продукта, платформы и toolchain
|
||||
├── CHANGELOG.md
|
||||
├── README.md
|
||||
└── LICENSE
|
||||
```
|
||||
|
||||
Каталог `dist/` создаётся builder’ом и не должен храниться в git.
|
||||
|
||||
Точка входа в документацию — [docs/README.md](docs/README.md).
|
||||
|
||||
## Для кого этот проект
|
||||
|
||||
HY2XS рассчитан на операторов, которым нужен воспроизводимый способ поставить Hysteria2‑сервер с локальной панелью управления, не собирая проект на production‑сервере и не открывая admin UI наружу.
|
||||
@@ -783,4 +1167,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>
|
||||
|
||||
@@ -35,11 +35,18 @@ func run(cmd *cobra.Command, args []string) {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
for {
|
||||
if err := runServer(port); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
// Цикла `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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
} else {
|
||||
tokenVersion := int64(1)
|
||||
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
||||
tokenVersion = *admin.TokenVersion + 1
|
||||
}
|
||||
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)
|
||||
return fmt.Errorf("не удалось создать учётную запись администратора: %w", saveErr)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"не удалось прочитать учётную запись администратора: база данных не ответила. "+
|
||||
"Сброс не выполнен: создавать вторую учётную запись при недоступной базе нельзя: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
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("прежний пароль перестал действовать после неудачного сброса")
|
||||
}
|
||||
}
|
||||
@@ -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,92 @@ 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 {
|
||||
logrus.Errorf("start server err: %v", err)
|
||||
return errors.New("start server err")
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// releaseResource закрывает то, чем владеет сам процесс админки.
|
||||
//
|
||||
// Вызова service.ReleaseHysteria2() здесь больше нет. Функция была пустой
|
||||
// заглушкой `return nil` — остатком модели, в которой панель считала Hysteria
|
||||
// своим подпроцессом и обязана была его отпустить. Жизненным циклом Hysteria
|
||||
// владеет systemd, отпускать админке нечего, и шаг завершения, который ничего
|
||||
// не делает, но выглядит освобождением ресурса, хуже отсутствующего.
|
||||
func releaseResource() {
|
||||
if err := dao.CloseSqliteDB(); err != nil {
|
||||
logrus.Errorf(err.Error())
|
||||
}
|
||||
if err := service.ReleaseHysteria2(); err != nil {
|
||||
logrus.Errorf(err.Error())
|
||||
logrus.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +160,6 @@ func initFile() error {
|
||||
constant.LogDir,
|
||||
constant.SqliteDBDir,
|
||||
constant.BinDir,
|
||||
constant.ExportPathDir,
|
||||
filepath.Dir(constant.SqliteDBPath),
|
||||
}
|
||||
for _, item := range dirs {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Вход в панель живёт в собственном файле, а не среди обработчиков пиров.
|
||||
//
|
||||
// Это не косметика. Пока Login лежал в controller/peer.go, паника валидатора на
|
||||
// форме входа приходила в journal стеком `controller/peer.go:50`, и первая
|
||||
// гипотеза при разборе RC2 указывала на управление пирами — то есть на
|
||||
// подсистему, не имеющую к отказу никакого отношения. Самая ценная дверь
|
||||
// продукта обязана называться своим именем в стеке.
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
// Неверные учётные данные получают код, чтобы панель показала
|
||||
// оператору внятную фразу на его языке. Отказ базы остаётся системной
|
||||
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
|
||||
// значит отправить оператора искать несуществующую опечатку.
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/middleware"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// Контракт входа в панель.
|
||||
//
|
||||
// Что здесь произошло и почему тестов было мало. В RC2 каждый
|
||||
// `POST /api/auth/login` заканчивался HTTP 500: на LoginDto.Username стоял тег
|
||||
// `validateStr`, правило с таким именем не регистрировалось, и
|
||||
// go-playground/validator ПАНИКОВАЛ до всякой проверки логина и пароля.
|
||||
// Существующие тесты этого не видели, потому что проверяли регексп напрямую —
|
||||
// в обход production-валидатора, — а ни одного запроса к обработчику входа не
|
||||
// делал никто.
|
||||
//
|
||||
// Поэтому проверок здесь три уровня, и каждый ловит свой класс:
|
||||
//
|
||||
// структурный — ни один тег ни в одном DTO не ссылается на незарегистрированное
|
||||
// правило (закрывает КЛАСС, а не найденный экземпляр);
|
||||
// правила — LoginDto проходит через тот самый validate, что и в бою;
|
||||
// HTTP — обработчик отвечает конвертом, а не 500, на любом входе.
|
||||
|
||||
// --------------------------------------------------------------- структура ---
|
||||
|
||||
var validationTagPattern = regexp.MustCompile(`validate:"([^"]*)"`)
|
||||
|
||||
// Ни один тег валидации не имеет права ссылаться на правило, которого нет.
|
||||
//
|
||||
// Это главный барьер файла. Наивная версия — «прогнать нулевое DTO через
|
||||
// validate.Struct» — дефекта RC2 НЕ ловит: цепочка правил поля обрывается на
|
||||
// первом несработавшем, поэтому на пустом Username проверка отказывает по
|
||||
// `required` и до испорченного тега не доходит. Поэтому теги извлекаются из
|
||||
// исходников и каждый предъявляется валидатору отдельно: незарегистрированное
|
||||
// правило паникует ровно так же, как паниковало в бою, но здесь — на сборке.
|
||||
func TestEveryValidationTagIsRegistered(t *testing.T) {
|
||||
root := filepath.Join("..", "model")
|
||||
|
||||
users := map[string]map[string]struct{}{}
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||
return nil
|
||||
}
|
||||
source, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for _, match := range validationTagPattern.FindAllSubmatch(source, -1) {
|
||||
for _, rule := range splitValidationRules(string(match[1])) {
|
||||
if users[rule] == nil {
|
||||
users[rule] = map[string]struct{}{}
|
||||
}
|
||||
users[rule][filepath.ToSlash(path)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать исходники модели: %v", err)
|
||||
}
|
||||
|
||||
// Пустой обход означал бы, что проверка молчит из-за неверного пути, а не
|
||||
// из-за отсутствия дефектов. Число намеренно грубое: тест закрепляет факт
|
||||
// «теги найдены», а не их количество.
|
||||
if len(users) < 5 {
|
||||
t.Fatalf("в %s найдено %d правил валидации — обход не дошёл до DTO", root, len(users))
|
||||
}
|
||||
|
||||
for rule, files := range users {
|
||||
assertRuleIsRegistered(t, rule, files)
|
||||
}
|
||||
}
|
||||
|
||||
// splitValidationRules разбирает содержимое тега на отдельные правила.
|
||||
//
|
||||
// Разделителями считаются и `,`, и `|`: go-playground/validator понимает оба —
|
||||
// первый как «и», второй как «или», — и правило, записанное через `|`, обязано
|
||||
// существовать ровно так же.
|
||||
func splitValidationRules(tag string) []string {
|
||||
var rules []string
|
||||
for _, rule := range strings.FieldsFunc(tag, func(r rune) bool { return r == ',' || r == '|' }) {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" || rule == "-" {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func assertRuleIsRegistered(t *testing.T, rule string, files map[string]struct{}) {
|
||||
t.Helper()
|
||||
|
||||
where := make([]string, 0, len(files))
|
||||
for file := range files {
|
||||
where = append(where, file)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Errorf(
|
||||
"правило %q не зарегистрировано в controller/validator.go, но используется в %s: %v\n"+
|
||||
"незарегистрированный тег означает панику на КАЖДОМ запросе к этой форме",
|
||||
rule, strings.Join(where, ", "), recovered,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
// Значение подобрано так, чтобы вопрос был именно о существовании правила:
|
||||
// результат проверки здесь не важен, важно её отсутствие паники.
|
||||
_ = validate.Var("hy2xsadmin", rule)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ правила ---
|
||||
|
||||
func loginDto(username string, password string) dto.LoginDto {
|
||||
return dto.LoginDto{Username: &username, Pass: &password}
|
||||
}
|
||||
|
||||
// normalizedAndValidated повторяет конвейер validateField: нормализация, затем
|
||||
// правила. Проверять правила в обход нормализации бессмысленно — в бою они
|
||||
// видят только приведённый вход.
|
||||
func normalizedAndValidated(login dto.LoginDto) error {
|
||||
login.Normalize()
|
||||
return validate.Struct(&login)
|
||||
}
|
||||
|
||||
// Регрессия RC2: корректный вход не имеет права ни паниковать, ни отказывать.
|
||||
func TestLoginDtoPassesProductionValidator(t *testing.T) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("проверка LoginDto паникует: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := normalizedAndValidated(loginDto("hy2xsadmin", "bootstrap-password")); err != nil {
|
||||
t.Fatalf("корректный LoginDto отклонён: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ обязан быть ОТКАЗОМ — значением с причиной, а не паникой.
|
||||
func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
code string
|
||||
field string
|
||||
}{
|
||||
{"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин длиннее максимума", strings.Repeat("a", credential.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
|
||||
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
|
||||
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeAdminPassword, "pass"},
|
||||
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", credential.AdminPasswordMaxLength+1), constant.ErrCodeAdminPassword, "pass"},
|
||||
// Укладывается в 64 символа и НЕ укладывается в 72 байта. Прежний
|
||||
// контракт принимал это значение, и отказ приходил из bcrypt уже после
|
||||
// всех проверок — системной ошибкой, а не причиной на поле формы.
|
||||
{"пароль длиннее предела bcrypt", "hy2xsadmin", strings.Repeat("я", credential.AdminPasswordMaxLength), constant.ErrCodeAdminPassword, "pass"},
|
||||
{"управляющий символ в пароле", "hy2xsadmin", "bootstrap\npassword", constant.ErrCodeAdminPassword, "pass"},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("проверка обязана вернуть отказ, а не паниковать: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
err := normalizedAndValidated(loginDto(testCase.username, testCase.password))
|
||||
if err == nil {
|
||||
t.Fatal("некорректный вход принят")
|
||||
}
|
||||
|
||||
described := describeValidationErrors(err)
|
||||
if !hasFieldReason(described, testCase.field, testCase.code) {
|
||||
t.Fatalf("ожидалась причина %q на поле %q, получено %+v", testCase.code, testCase.field, described)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hasFieldReason(described []vo.FieldError, field string, code string) bool {
|
||||
for _, reason := range described {
|
||||
if reason.Field == field && reason.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Причина отказа по набору символов обязана НАЗЫВАТЬ набор.
|
||||
//
|
||||
// Оператору негде посмотреть, какие символы допустимы в логине: значение
|
||||
// приходит из hy2xs.env, а панель показывает то, что ответил сервер. Прежнее
|
||||
// «содержит недопустимые символы» вдобавок описывало и отказ по длине.
|
||||
func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
|
||||
err := normalizedAndValidated(loginDto("админ12", "bootstrap-password"))
|
||||
if err == nil {
|
||||
t.Fatal("некорректный логин принят")
|
||||
}
|
||||
|
||||
for _, reason := range describeValidationErrors(err) {
|
||||
if reason.Code != constant.ErrCodeCredentialStr {
|
||||
continue
|
||||
}
|
||||
if reason.Params["charset"] != credential.AdminUsernameCharset {
|
||||
t.Errorf("причина не называет набор символов: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) {
|
||||
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) {
|
||||
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("отказ не содержит причины %q", constant.ErrCodeCredentialStr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- контракты ---
|
||||
|
||||
// passwordFields — все поля продукта, несущие пароль администратора.
|
||||
var passwordFields = []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
field string
|
||||
}{
|
||||
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
|
||||
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
|
||||
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
|
||||
}
|
||||
|
||||
// Правило пароля ОДНО, и границы живут внутри него.
|
||||
//
|
||||
// Здесь стояли `min=6,max=64` прямо в тегах, и проверка следила за тем, чтобы
|
||||
// числа совпадали с контрактом. Совпадали они честно — и всё равно закрепляли
|
||||
// неполное правило: тег считает символы Unicode, а bcrypt считает байты, и
|
||||
// границы в байтах тегом не выразить вовсе. Поэтому теперь проверяется не
|
||||
// равенство чисел, а ОТСУТСТВИЕ чисел: длина принадлежит правилу, а не соседям
|
||||
// по тегу. Тот же барьер уже стоит на логине (TestUsernameLengthLivesInsideItsRule).
|
||||
func TestPasswordLengthLivesInsideItsRule(t *testing.T) {
|
||||
for _, subject := range passwordFields {
|
||||
field, ok := subject.typ.FieldByName(subject.field)
|
||||
if !ok {
|
||||
t.Fatalf("%s: поле не найдено", subject.name)
|
||||
}
|
||||
|
||||
rules := splitValidationRules(field.Tag.Get("validate"))
|
||||
|
||||
for _, rule := range rules {
|
||||
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
|
||||
t.Errorf(
|
||||
"%s: отдельная граница %q рядом с правилом. Длина принадлежит adminPassword: "+
|
||||
"тег умеет считать только символы, а у пароля есть ещё граница в байтах",
|
||||
subject.name, rule,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var hasPasswordRule bool
|
||||
for _, rule := range rules {
|
||||
if rule == "adminPassword" {
|
||||
hasPasswordRule = true
|
||||
}
|
||||
}
|
||||
if !hasPasswordRule {
|
||||
t.Errorf("%s: проверяется правилами %v — среди них нет adminPassword", subject.name, rules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Все поля пароля проверяются ТЕМ ЖЕ правилом, что и bootstrap-установка.
|
||||
//
|
||||
// Проверка идёт через production-валидатор на граничных значениях: совпадения
|
||||
// имён тегов мало, потому что именно расхождение реализаций за одинаковыми
|
||||
// именами и было исходным дефектом.
|
||||
func TestEveryPasswordFieldFollowsTheContract(t *testing.T) {
|
||||
samples := []string{
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength-1),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("я", 37),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat("😀", 19),
|
||||
strings.Repeat("я", credential.AdminPasswordMaxLength),
|
||||
"abcde ",
|
||||
"abcde\n",
|
||||
}
|
||||
|
||||
for _, subject := range passwordFields {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
rules := strings.Join(splitValidationRules(field.Tag.Get("validate")), ",")
|
||||
rules = strings.ReplaceAll(rules, "required,", "")
|
||||
|
||||
for _, sample := range samples {
|
||||
expected := credential.IsValidAdminPassword(sample)
|
||||
if got := validate.Var(sample, rules) == nil; got != expected {
|
||||
t.Errorf(
|
||||
"%s: значение из %d символов / %d байт принято=%v, контракт=%v",
|
||||
subject.name, len([]rune(sample)), len(sample), got, expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Набор символов пароля сервером НЕ ограничивается — ни на входе, ни при смене.
|
||||
//
|
||||
// Это решение, а не упущение: значение всё равно сравнивается с хешем, поэтому
|
||||
// ограничение набора не защищает ничего и умеет только отвергнуть пароль,
|
||||
// который сервер принял бы. Панель обязана держаться этого же контракта, и
|
||||
// проверка существует, чтобы правило не «навели» обратно.
|
||||
func TestPasswordHasNoCharsetRule(t *testing.T) {
|
||||
for _, subject := range passwordFields {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
|
||||
if rule == "credentialStr" || rule == "peerName" {
|
||||
t.Errorf("%s: на пароль повешено правило набора символов %q", subject.name, rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина логина живёт ВНУТРИ правила, а не рядом с ним.
|
||||
//
|
||||
// Два правила длины на одном поле уже приводили к отказу, который невозможно
|
||||
// объяснить оператору: `min=1,max=32` вместе с правилом «6-32» принимали и
|
||||
// отвергали одно и то же значение, а панель показывала «invalid».
|
||||
func TestUsernameLengthLivesInsideItsRule(t *testing.T) {
|
||||
field, ok := reflect.TypeOf(dto.LoginDto{}).FieldByName("Username")
|
||||
if !ok {
|
||||
t.Fatal("LoginDto.Username не найдено")
|
||||
}
|
||||
|
||||
rules := splitValidationRules(field.Tag.Get("validate"))
|
||||
for _, rule := range rules {
|
||||
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
|
||||
t.Errorf("на логине отдельная граница %q: длина принадлежит правилу credentialStr", rule)
|
||||
}
|
||||
}
|
||||
|
||||
var hasCredentialRule bool
|
||||
for _, rule := range rules {
|
||||
if rule == "credentialStr" {
|
||||
hasCredentialRule = true
|
||||
}
|
||||
}
|
||||
if !hasCredentialRule {
|
||||
t.Fatalf("логин проверяется правилами %v — среди них нет credentialStr", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- HTTP ---
|
||||
|
||||
func newAuthTestDB(t *testing.T, username string, password string) int64 {
|
||||
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() })
|
||||
|
||||
// Токен подписывается ключом из таблицы config. Без него успешный вход
|
||||
// отказал бы системной ошибкой, и тест перестал бы отличать работающий
|
||||
// вход от неработающего.
|
||||
if err := dao.UpsertConfigValue(constant.JwtSecret, "test-jwt-secret"); err != nil {
|
||||
t.Fatalf("не удалось записать ключ подписи: %v", err)
|
||||
}
|
||||
|
||||
hash, err := util.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось захешировать пароль: %v", err)
|
||||
}
|
||||
|
||||
name := username
|
||||
status := int64(1)
|
||||
tokenVersion := int64(1)
|
||||
force := int64(0)
|
||||
changedAt := time.Now().UnixMilli()
|
||||
id, err := dao.SaveAdminUser(entity.AdminUser{
|
||||
Username: &name,
|
||||
PasswordHash: &hash,
|
||||
Status: &status,
|
||||
TokenVersion: &tokenVersion,
|
||||
ForcePasswordChange: &force,
|
||||
PasswordChangedAt: &changedAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось создать администратора: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// postLoginRaw отправляет тело КАК ЕСТЬ через маршрут боевой конфигурации.
|
||||
//
|
||||
// `recovery` выбирает, стоит ли перед обработчиком gin.Recovery. Обе ветки
|
||||
// нужны: без него паника роняет тест и называет причину, с ним воспроизводится
|
||||
// ровно то, что видел оператор RC2, — HTTP 500 вместо конверта.
|
||||
func postLoginRaw(t *testing.T, body []byte, recovery bool) (int, apiResult) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
engine := gin.New()
|
||||
if recovery {
|
||||
engine.Use(gin.Recovery())
|
||||
}
|
||||
engine.POST("/api/auth/login", Login)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if recorder.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(recorder.Body.Bytes(), &result)
|
||||
}
|
||||
return recorder.Code, result
|
||||
}
|
||||
|
||||
func postLogin(t *testing.T, body any) (int, apiResult) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
return postLoginRaw(t, payload, false)
|
||||
}
|
||||
|
||||
// postLoginThroughFilter воспроизводит реальную внешнюю дверь login API:
|
||||
// scanner filter выполняется раньше DTO и контроллера. Именно этой связки не
|
||||
// было в тестах до RC3, поэтому backend и smoke были зелёными по отдельности,
|
||||
// а настоящий installer получал 403 на стандартный curl User-Agent.
|
||||
func postLoginThroughFilter(t *testing.T, body any, userAgent string) (int, apiResult) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Recovery(), middleware.FilterHandler())
|
||||
engine.POST("/api/auth/login", Login)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(payload))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", userAgent)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if recorder.Body.Len() > 0 {
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("middleware вернул не JSON: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
return recorder.Code, result
|
||||
}
|
||||
|
||||
// Полный wire-path RC3: стандартный UA curl действительно блокируется, а
|
||||
// выделенный UA установщика проходит тот же middleware до authentication logic.
|
||||
func TestLoginWirePathRespectsScannerFilterAndInstallerUserAgent(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
status, blocked := postLoginThroughFilter(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
}, "curl/8.10.1")
|
||||
if status != http.StatusForbidden || blocked.Code != http.StatusForbidden {
|
||||
t.Fatalf("scanner-like curl не заблокирован: HTTP %d, ответ %+v", status, blocked)
|
||||
}
|
||||
|
||||
status, accepted := postLoginThroughFilter(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
}, "HY2XS-Installer/1.0")
|
||||
if status != http.StatusOK || accepted.Code != constant.CodeSuccess {
|
||||
t.Fatalf("UA установщика не дошёл до успешного входа: HTTP %d, ответ %+v", status, accepted)
|
||||
}
|
||||
var issued struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(accepted.Data, &issued); err != nil || issued.AccessToken == "" {
|
||||
t.Fatalf("успешная wire-проба не выдала токен: %s", string(accepted.Data))
|
||||
}
|
||||
|
||||
_, rejected := postLoginThroughFilter(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
}, "HY2XS-Installer/1.0")
|
||||
var invalidCredentials bool
|
||||
for _, reason := range rejected.Errors {
|
||||
invalidCredentials = invalidCredentials || reason.Code == constant.ErrCodeInvalidCredentials
|
||||
}
|
||||
if rejected.Code != constant.CodeSysError || !invalidCredentials {
|
||||
t.Fatalf("negative wire-проба не дошла до auth logic: %+v", rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// `password` не является скрытым alias: иначе orchestrator и frontend могли бы
|
||||
// незаметно разойтись по двум разным HTTP-контрактам.
|
||||
func TestLoginWirePathRejectsPasswordAlias(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLoginThroughFilter(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"password": "bootstrap-password",
|
||||
}, "HY2XS-Installer/1.0")
|
||||
|
||||
var passRequired bool
|
||||
for _, reason := range result.Errors {
|
||||
passRequired = passRequired || (reason.Field == "pass" && reason.Code == constant.ErrCodeRequired)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError || !passRequired {
|
||||
t.Fatalf("alias password не отклонён как отсутствие wire-поля pass: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен.
|
||||
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
status, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
})
|
||||
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("вход ответил HTTP %d, ожидался 200", status)
|
||||
}
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("вход отклонён: %+v", result)
|
||||
}
|
||||
|
||||
var issued struct {
|
||||
TokenType string `json:"tokenType"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Data, &issued); err != nil {
|
||||
t.Fatalf("ответ не содержит токена: %s", string(result.Data))
|
||||
}
|
||||
if strings.TrimSpace(issued.AccessToken) == "" {
|
||||
t.Fatal("выдан пустой токен")
|
||||
}
|
||||
if issued.TokenType != constant.TokenType {
|
||||
t.Fatalf("тип токена %q, ожидался %q", issued.TokenType, constant.TokenType)
|
||||
}
|
||||
}
|
||||
|
||||
// Повреждённые байты и непарные surrogate escape не имеют права превратиться
|
||||
// в U+FFFD до проверки пароля. Сам U+FFFD остаётся обычным допустимым символом.
|
||||
func TestLoginEndpointRejectsLossyJSONBeforeAuthentication(t *testing.T) {
|
||||
password := "abcde" + string(rune(0xFFFD))
|
||||
id := newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
for _, body := range [][]byte{
|
||||
[]byte{'{', '"', 'u', 's', 'e', 'r', 'n', 'a', 'm', 'e', '"', ':', '"', 'h', 'y', '2', 'x', 's', 'a', 'd', 'm', 'i', 'n', '"', ',', '"', 'p', 'a', 's', 's', '"', ':', '"', 0xFF, '"', '}'},
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uD800"}`),
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uDC00"}`),
|
||||
} {
|
||||
status, result := postLoginRaw(t, body, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("повреждённое тело дало HTTP %d", status)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("повреждённое тело не отклонено как invalid: %+v", result)
|
||||
}
|
||||
var found bool
|
||||
for _, reason := range result.Errors {
|
||||
found = found || reason.Code == constant.ErrCodeBodyInvalid
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("нет причины %q: %+v", constant.ErrCodeBodyInvalid, result)
|
||||
}
|
||||
}
|
||||
admin, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось проверить состояние администратора: %v", err)
|
||||
}
|
||||
if admin.LastLoginAt != nil && *admin.LastLoginAt != 0 {
|
||||
t.Fatalf("отклонённое JSON-тело изменило last_login_at: %d", *admin.LastLoginAt)
|
||||
}
|
||||
|
||||
_, valid := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if valid.Code != constant.CodeSuccess {
|
||||
t.Fatalf("настоящий U+FFFD ошибочно отклонён: %+v", valid)
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
|
||||
//
|
||||
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
|
||||
// ошибку транспорта, и панель не могла сказать о ней ничего осмысленного.
|
||||
func TestLoginEndpointNeverAnswersServerErrorBehindRecovery(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
bodies := [][]byte{
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"bootstrap-password"}`),
|
||||
[]byte(`{"username":"","pass":""}`),
|
||||
[]byte(`{"username":"админ12","pass":"bootstrap-password"}`),
|
||||
[]byte(`{"username":"hy2xsadmin"}`),
|
||||
[]byte(`{}`),
|
||||
[]byte(`{"username":123,"pass":false}`),
|
||||
[]byte(`не json вовсе`),
|
||||
[]byte(``),
|
||||
}
|
||||
|
||||
for _, body := range bodies {
|
||||
status, result := postLoginRaw(t, body, true)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("тело %q дало HTTP %d: обработчик обязан отвечать конвертом, а не отказом транспорта", string(body), status)
|
||||
continue
|
||||
}
|
||||
if result.Code != constant.CodeSuccess && len(result.Errors) == 0 && strings.TrimSpace(result.Message) == "" {
|
||||
t.Errorf("тело %q дало отказ без причины: %+v", string(body), result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Неверный пароль — доменный отказ с кодом, а не системная ошибка.
|
||||
func TestLoginEndpointReportsInvalidCredentials(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
})
|
||||
|
||||
if result.Code == constant.CodeSuccess {
|
||||
t.Fatal("вход с неверным паролем выполнен")
|
||||
}
|
||||
if _, found := errorFor(t, result, ""); !found {
|
||||
t.Fatalf("отказ без причины: %+v", result)
|
||||
}
|
||||
var hasCode bool
|
||||
for _, reason := range result.Errors {
|
||||
if reason.Code == constant.ErrCodeInvalidCredentials {
|
||||
hasCode = true
|
||||
}
|
||||
}
|
||||
if !hasCode {
|
||||
t.Fatalf("отказ не помечен кодом %q: %+v", constant.ErrCodeInvalidCredentials, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Несуществующий администратор неотличим от неверного пароля.
|
||||
//
|
||||
// Иначе форма входа превращается в способ проверять существование имён.
|
||||
func TestLoginEndpointDoesNotRevealWhetherAccountExists(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, missing := postLogin(t, map[string]any{"username": "otheradmin", "pass": "bootstrap-password"})
|
||||
_, wrong := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "wrong-password"})
|
||||
|
||||
if missing.Code != wrong.Code || missing.Message != wrong.Message {
|
||||
t.Fatalf("ответы различимы: несуществующий %+v против неверного пароля %+v", missing, wrong)
|
||||
}
|
||||
}
|
||||
|
||||
// Логин из bootstrap-admin.secret регулярно приезжает с пробелом или переводом
|
||||
// строки на конце. Раньше это давало «неверный логин или пароль».
|
||||
func TestLoginEndpointTrimsSurroundingWhitespaceInUsername(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLogin(t, map[string]any{
|
||||
"username": " hy2xsadmin\n",
|
||||
"pass": "bootstrap-password",
|
||||
})
|
||||
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("логин с окружающими пробелами отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится: его набор символов не ограничен, и пробел по краям —
|
||||
// часть значения, а не мусор ввода.
|
||||
func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password ")
|
||||
|
||||
_, exact := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password "})
|
||||
if exact.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль с пробелом на конце отклонён: %+v", exact)
|
||||
}
|
||||
|
||||
_, trimmed := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password"})
|
||||
if trimmed.Code == constant.CodeSuccess {
|
||||
t.Fatal("пароль обрезан по краям: вход выполнен не тем значением, которое ввёл оператор")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль длиной в 64 символа, назначенный формой смены, обязан пускать в панель.
|
||||
//
|
||||
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
|
||||
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
|
||||
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
|
||||
password := strings.Repeat("a", credential.AdminPasswordMaxLength)
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль предельной длины отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль, упирающийся в предел bcrypt ровно, обязан работать целиком: он
|
||||
// назначается формой смены пароля и вводится формой входа.
|
||||
//
|
||||
// 36 кириллических букв — это ровно 72 байта, то есть последнее значение,
|
||||
// которое GenerateFromPassword принимает. Проверяется весь путь: хеширование
|
||||
// при создании учётной записи и вход этим же значением.
|
||||
func TestLoginEndpointAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
|
||||
password := strings.Repeat("я", 36)
|
||||
if len(password) != credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль в 72 байта отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// А на символ длиннее — отказ ПРАВИЛА, с причиной на поле, а не системная
|
||||
// ошибка из bcrypt.
|
||||
//
|
||||
// Ровно этого не было: 37 кириллических букв (74 байта) укладывались в 64
|
||||
// символа, проходили все проверки и упирались в ErrPasswordTooLong уже внутри
|
||||
// хеширования. На форме смены пароля оператор видел «system error».
|
||||
func TestLoginEndpointRejectsPasswordOverBcryptByteLimitWithFieldReason(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
password := strings.Repeat("я", 37)
|
||||
if len(password) <= credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт, ожидалось больше %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
status, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("HTTP %d: обработчик обязан отвечать конвертом", status)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("код ответа %d, ожидался отказ проверки данных %d: %+v", result.Code, constant.CodeInvalidError, result)
|
||||
}
|
||||
var hasReason bool
|
||||
for _, reason := range result.Errors {
|
||||
if reason.Field == "pass" && reason.Code == constant.ErrCodeAdminPassword {
|
||||
hasReason = true
|
||||
}
|
||||
}
|
||||
if !hasReason {
|
||||
t.Fatalf("отказ не назван причиной %q на поле pass: %+v", constant.ErrCodeAdminPassword, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Отметка о входе обязана писаться: колонка есть в схеме, а писать её было
|
||||
// некому — UpdateAdminLastLoginAt не вызывался ниоткуда.
|
||||
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
|
||||
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
before, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать администратора: %v", err)
|
||||
}
|
||||
if before.LastLoginAt != nil && *before.LastLoginAt != 0 {
|
||||
t.Fatalf("подготовка: отметка о входе уже проставлена (%d)", *before.LastLoginAt)
|
||||
}
|
||||
|
||||
if _, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
}); result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("вход отклонён: %+v", result)
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось перечитать администратора: %v", err)
|
||||
}
|
||||
if after.LastLoginAt == nil || *after.LastLoginAt == 0 {
|
||||
t.Fatal("время входа не записано")
|
||||
}
|
||||
}
|
||||
|
||||
// Неудачная попытка отметку о входе НЕ ставит.
|
||||
func TestFailedLoginDoesNotRecordLastLoginAt(t *testing.T) {
|
||||
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
if _, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
}); result.Code == constant.CodeSuccess {
|
||||
t.Fatal("вход с неверным паролем выполнен")
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать администратора: %v", err)
|
||||
}
|
||||
if after.LastLoginAt != nil && *after.LastLoginAt != 0 {
|
||||
t.Fatalf("неудачная попытка проставила время входа: %d", *after.LastLoginAt)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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 !constant.IsPublicWritableConfigKey(key) {
|
||||
denyUnknownConfigKey(key, constant.PublicWritableConfigKeys(), "write", c)
|
||||
return
|
||||
}
|
||||
|
||||
if key == constant.HUIWebContext {
|
||||
huiWebContext, err := service.GetConfig(constant.HUIWebContext)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
if *huiWebContext.Value != value {
|
||||
needRestart = true
|
||||
}
|
||||
// Один ключ дважды в одной партии — неоднозначный запрос: какое из
|
||||
// двух значений считать намерением оператора, определить нельзя.
|
||||
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 key == constant.ResetTrafficCron {
|
||||
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
if *resetTrafficCron.Value != value {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
|
||||
if err = service.UpdateConfig(key, value); err != nil {
|
||||
if err = service.ValidateConfigValue(key, value); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
updates = append(updates, service.ConfigUpdate{Key: key, Value: value})
|
||||
}
|
||||
|
||||
if needRestart {
|
||||
go func() {
|
||||
_ = service.StopServer()
|
||||
}()
|
||||
if err = service.UpdateConfigs(updates); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
if err = applyRuntimeConfigUpdates(updates); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
vo.Success(nil, c)
|
||||
}
|
||||
|
||||
func GetConfig(c *gin.Context) {
|
||||
configDto, err := validateField(c, dto.ConfigDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
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"
|
||||
// applyRuntimeConfigUpdates доносит уже сохранённые настройки до рантайма.
|
||||
//
|
||||
// Отказ здесь недостижим по построению: значения проверены тем же парсером,
|
||||
// которым планировщик их разбирает, а сам планировщик поднимается в runServer
|
||||
// до старта HTTP-сервера — то есть к моменту обработки запроса он всегда
|
||||
// запущен. Проверка оставлена именно поэтому: если этот инвариант когда-нибудь
|
||||
// сломают, отказ должен быть громким, а не молча пропавшей джобой.
|
||||
func applyRuntimeConfigUpdates(updates []service.ConfigUpdate) error {
|
||||
for _, item := range updates {
|
||||
if item.Key != constant.ResetTrafficCron {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
@@ -170,168 +187,65 @@ func ListConfig(c *gin.Context) {
|
||||
vo.Success(configVos, c)
|
||||
}
|
||||
|
||||
// GetHysteria2Config отдаёт панели конфигурацию в терминах production-профиля.
|
||||
//
|
||||
// Что было: `vo.Success(service.GetHysteria2Config(), c)` — внутренняя модель
|
||||
// серверного конфига сериализовалась в браузер целиком. У этого было два
|
||||
// следствия.
|
||||
//
|
||||
// Первое — секреты. `auth` и `trafficStats.secret` закрыты `json:"-"`, но
|
||||
// пароль обфускации, токены ACME DNS (`acme.dns.config`), учётные данные
|
||||
// outbound-прокси и masquerade уезжали в открытом виде. Скачиваемая выгрузка
|
||||
// того же конфига их вырезает, и читающий экран не имеет права быть щедрее.
|
||||
// Привилегий это не повышало — маршрут под admin JWT, — но и нужды в этих
|
||||
// значениях у read-only экрана нет.
|
||||
//
|
||||
// Второе — смысл ответа. Модель отдавала «все известные HY2XS поля», а панель
|
||||
// накладывала их на полный объект дефолтов, поэтому экран показывал не файл, а
|
||||
// файл, дополненный выдумкой: отсутствующий `trafficStats` превращался в
|
||||
// `:9999`. Ровно тот дрейф, который экран обязан показывать, он и скрывал.
|
||||
//
|
||||
// Теперь ответ описывает профиль явно, отличает «не задано» от значения и
|
||||
// отдельно перечисляет секции вне профиля. Полный документ доступен
|
||||
// санитизированной выгрузкой ниже.
|
||||
func GetHysteria2Config(c *gin.Context) {
|
||||
config, err := service.GetHysteria2Config()
|
||||
profile, err := service.BuildHysteria2Profile()
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(config, c)
|
||||
}
|
||||
|
||||
func UpdateHysteria2Config(c *gin.Context) {
|
||||
vo.Fail("managed by orchestrator: use hy2xs-orchestrator reconfigure", c)
|
||||
vo.Success(profile, 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,304 @@
|
||||
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/model/vo"
|
||||
"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"`
|
||||
Errors []vo.FieldError `json:"errors"`
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// failService переводит отказ сервисного слоя в ответ панели.
|
||||
//
|
||||
// Доменный отказ несёт код и, если он относится к полю формы, имя этого поля
|
||||
// (см. service.PeerError). Всё остальное остаётся отказом уровня операции с
|
||||
// человеческим сообщением — панель покажет его как есть, но разбирать текст ей
|
||||
// при этом не придётся ни в одном известном случае.
|
||||
func failService(err error, c *gin.Context) {
|
||||
var peerErr *service.PeerError
|
||||
if errors.As(err, &peerErr) {
|
||||
vo.FailField(peerErr.Code, peerErr.Field, peerErr.Message, c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func Hysteria2Auth(c *gin.Context) {
|
||||
var req dto.Hysteria2AuthDto
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := strictBindJSON(c, &req); err != nil {
|
||||
vo.Hysteria2AuthBadRequest(c)
|
||||
return
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const jsonBodyMaxBytes = 1024 * 1024
|
||||
|
||||
var errJSONBodyTooLarge = errors.New("тело JSON превышает 1 МиБ")
|
||||
|
||||
// strictBindJSON не позволяет стандартному encoding/json молча заменить
|
||||
// повреждённый UTF-8 или непарный UTF-16 surrogate escape на U+FFFD.
|
||||
//
|
||||
// Это критично для секретов: U+FFFD сам по себе разрешён, поэтому без этой
|
||||
// границы другое байтовое значение могло аутентифицироваться как пароль,
|
||||
// который фактически хранится в базе. После проверки тело восстанавливается и
|
||||
// передаётся штатному Gin binder, чтобы сохранить действующий API-контракт.
|
||||
func strictBindJSON(c *gin.Context, dst any) error {
|
||||
body := c.Request.Body
|
||||
if body == nil {
|
||||
return errors.New("пустое тело JSON")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(body, jsonBodyMaxBytes+1))
|
||||
_ = body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось прочитать тело JSON: %w", err)
|
||||
}
|
||||
if len(raw) > jsonBodyMaxBytes {
|
||||
return errJSONBodyTooLarge
|
||||
}
|
||||
if err := validateStrictJSONEncoding(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.ShouldBindJSON(dst)
|
||||
}
|
||||
|
||||
// validateStrictJSONEncoding проверяет только границу кодировки. Синтаксис,
|
||||
// типы и структуру затем проверяет encoding/json; здесь отдельно закрывается
|
||||
// его документированное replacement-поведение.
|
||||
func validateStrictJSONEncoding(raw []byte) error {
|
||||
if !utf8.Valid(raw) {
|
||||
return errors.New("тело JSON содержит некорректный UTF-8")
|
||||
}
|
||||
|
||||
inString := false
|
||||
for i := 0; i < len(raw); i++ {
|
||||
switch raw[i] {
|
||||
case '"':
|
||||
inString = !inString
|
||||
case '\\':
|
||||
if !inString {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return errors.New("незавершённая escape-последовательность JSON")
|
||||
}
|
||||
if raw[i] != 'u' {
|
||||
continue
|
||||
}
|
||||
|
||||
code, ok := parseJSONHex4(raw, i+1)
|
||||
if !ok {
|
||||
return errors.New("некорректная Unicode escape-последовательность JSON")
|
||||
}
|
||||
i += 4
|
||||
switch {
|
||||
case code >= 0xD800 && code <= 0xDBFF:
|
||||
// Старший суррогат допустим только как первая половина пары и
|
||||
// только в соседней escape-последовательности \uXXXX.
|
||||
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
|
||||
return errors.New("непарный старший UTF-16 суррогат в JSON")
|
||||
}
|
||||
low, lowOK := parseJSONHex4(raw, i+3)
|
||||
if !lowOK || low < 0xDC00 || low > 0xDFFF {
|
||||
return errors.New("непарный старший UTF-16 суррогат в JSON")
|
||||
}
|
||||
i += 6
|
||||
case code >= 0xDC00 && code <= 0xDFFF:
|
||||
return errors.New("непарный младший UTF-16 суррогат в JSON")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJSONHex4(raw []byte, start int) (uint16, bool) {
|
||||
if start < 0 || start+4 > len(raw) {
|
||||
return 0, false
|
||||
}
|
||||
var value uint16
|
||||
for _, character := range raw[start : start+4] {
|
||||
value <<= 4
|
||||
switch {
|
||||
case character >= '0' && character <= '9':
|
||||
value += uint16(character - '0')
|
||||
case character >= 'a' && character <= 'f':
|
||||
value += uint16(character-'a') + 10
|
||||
case character >= 'A' && character <= 'F':
|
||||
value += uint16(character-'A') + 10
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package controller
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateStrictJSONEncoding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{"обычный UTF-8", []byte(`{"password":"пароль"}`), false},
|
||||
{"настоящий U+FFFD", []byte(`{"password":"abcde�"}`), false},
|
||||
{"валидная surrogate pair", []byte(`{"value":"\uD83D\uDE00"}`), false},
|
||||
{"экранированный обратный слеш", []byte(`{"value":"\\uD800"}`), false},
|
||||
{"байт FF", []byte{'{', '"', 'x', '"', ':', '"', 0xFF, '"', '}'}, true},
|
||||
{"оборванный UTF-8", []byte{'{', '"', 'x', '"', ':', '"', 0xC3, '"', '}'}, true},
|
||||
{"UTF-8 суррогата", []byte{'{', '"', 'x', '"', ':', '"', 0xED, 0xA0, 0x80, '"', '}'}, true},
|
||||
{"непарный старший", []byte(`{"value":"\uD800"}`), true},
|
||||
{"старший перед обычным", []byte(`{"value":"\uD800\u0041"}`), true},
|
||||
{"непарный младший", []byte(`{"value":"\uDC00"}`), true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotErr := validateStrictJSONEncoding(test.body) != nil
|
||||
if gotErr != test.wantErr {
|
||||
t.Fatalf("ошибка=%v, ожидалась=%v", gotErr, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,20 @@ func LogSystem(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
exists := util.Exists(constant.SystemLogPath)
|
||||
logSystemVos := make([]vo.LogSystemVo, 0)
|
||||
if !exists {
|
||||
vo.Success(logSystemVos, c)
|
||||
|
||||
// Форма ответа одна для ВСЕХ веток: {records, total}.
|
||||
//
|
||||
// Раньше ветка «файла ещё нет» отвечала голым массивом:
|
||||
//
|
||||
// vo.Success(logSystemVos, c)
|
||||
//
|
||||
// Панель читает `data.records`, поэтому получала undefined и передавала
|
||||
// его в `:data` таблицы. То есть на свежепоставленном хосте — до первой
|
||||
// записи в журнал — страница системных логов была сломана целиком, и
|
||||
// именно этот сценарий воспроизводится на каждой приёмке чистой установки.
|
||||
if !util.Exists(constant.SystemLogPath) {
|
||||
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{LogSystemVos: logSystemVos, Total: 0}, c)
|
||||
return
|
||||
}
|
||||
numLine := 0
|
||||
@@ -38,11 +48,36 @@ func LogSystem(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
logSystemVo := vo.LogSystemVo{}
|
||||
err := json.Unmarshal([]byte(line), &logSystemVo)
|
||||
if err != nil {
|
||||
vo.Fail("Unable to unmarshal log data", c)
|
||||
// Неразбираемая строка ПРОПУСКАЕТСЯ, а не отказывает запросу.
|
||||
//
|
||||
// Было так:
|
||||
//
|
||||
// if err != nil {
|
||||
// vo.Fail("Unable to unmarshal log data", c)
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// — ответ записывался в поток и цикл шёл дальше, а в конце
|
||||
// безусловно выполнялся vo.Success. Клиент получал два JSON-документа
|
||||
// подряд, то есть невалидный ответ, и gin ругался на повторную запись
|
||||
// заголовков. Достаточно было ОДНОЙ битой строки, чтобы страница
|
||||
// перестала открываться целиком.
|
||||
//
|
||||
// Строка при этом бьётся штатно: lumberjack ротирует файл, и обрыв
|
||||
// последней записи на границе ротации — обычное событие, а не признак
|
||||
// повреждения журнала. Отказывать из-за него всей выдаче нечем
|
||||
// оправдать: остальные записи прочитаны и полезны.
|
||||
if err := json.Unmarshal([]byte(line), &logSystemVo); err != nil {
|
||||
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 +115,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 +149,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Форма ответа страницы системных логов.
|
||||
//
|
||||
// Панель читает `data.records` и передаёт результат прямо в `:data` таблицы.
|
||||
// Поэтому ответ обязан нести records/total на КАЖДОЙ ветке — иначе таблица
|
||||
// получает undefined и страница ломается целиком.
|
||||
|
||||
type logPageEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Data *struct {
|
||||
Records []map[string]any `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// requestSystemLog выполняет запрос к странице системных логов, подставив
|
||||
// вместо журнала переданное содержимое. Пустой content означает «файла нет».
|
||||
func requestSystemLog(t *testing.T, content *string) (logPageEnvelope, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "hy2xs-admin.log")
|
||||
if content != nil {
|
||||
if err := os.WriteFile(path, []byte(*content), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать тестовый журнал: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
previous := constant.SystemLogPath
|
||||
constant.SystemLogPath = path
|
||||
t.Cleanup(func() { constant.SystemLogPath = previous })
|
||||
|
||||
engine := gin.New()
|
||||
engine.GET("/log/logSystem", LogSystem)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/log/logSystem?numLine=100", nil))
|
||||
|
||||
raw := recorder.Body.String()
|
||||
var envelope logPageEnvelope
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
|
||||
t.Fatalf("ответ не разбирается как один JSON-документ: %v\nтело: %s", err, raw)
|
||||
}
|
||||
return envelope, raw
|
||||
}
|
||||
|
||||
// Регрессия LOG-04. Ветка «файла ещё нет» отвечала ГОЛЫМ МАССИВОМ:
|
||||
//
|
||||
// vo.Success(logSystemVos, c)
|
||||
//
|
||||
// Панель получала `data.records === undefined` и передавала undefined в
|
||||
// таблицу. То есть на свежепоставленном хосте — до первой записи в журнал —
|
||||
// страница системных логов не работала вовсе, а это ровно тот сценарий,
|
||||
// который проверяется на приёмке каждой чистой установки.
|
||||
func TestLogSystemKeepsPageShapeWhenFileIsMissing(t *testing.T) {
|
||||
envelope, raw := requestSystemLog(t, nil)
|
||||
|
||||
if envelope.Type != "ok" {
|
||||
t.Fatalf("отсутствие журнала признано отказом: %s", raw)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
|
||||
}
|
||||
if envelope.Data.Records == nil {
|
||||
t.Fatalf("ответ не несёт поля records: %s", raw)
|
||||
}
|
||||
if len(envelope.Data.Records) != 0 || envelope.Data.Total != 0 {
|
||||
t.Fatalf("ожидалась пустая страница, получено %+v", *envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия LOG-05. При неразбираемой строке вызывался vo.Fail(...) и следом
|
||||
// `continue`: ответ уже записан в поток, цикл идёт дальше, в конце безусловно
|
||||
// выполняется vo.Success. Клиент получал два JSON-документа подряд — то есть
|
||||
// невалидный ответ, — и одной битой строки хватало, чтобы страница перестала
|
||||
// открываться целиком.
|
||||
//
|
||||
// Строка бьётся штатно: lumberjack ротирует файл, и обрыв последней записи на
|
||||
// границе ротации — обычное событие.
|
||||
func TestLogSystemSkipsBrokenLineWithoutBreakingResponse(t *testing.T) {
|
||||
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","time":"2026-09-01 10:00:00"}
|
||||
{"level":"info","msg":"обрыв записи на границе ротации
|
||||
{"level":"warning","msg":"PATCH /api/peers/12 → 400 (4 ms)","time":"2026-09-01 10:00:01"}
|
||||
`
|
||||
envelope, raw := requestSystemLog(t, &content)
|
||||
|
||||
if envelope.Type != "ok" {
|
||||
t.Fatalf("битая строка отказала всей выдаче: %s", raw)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
|
||||
}
|
||||
if len(envelope.Data.Records) != 2 {
|
||||
t.Fatalf("ожидались две читаемые записи, получено %d: %+v", len(envelope.Data.Records), envelope.Data.Records)
|
||||
}
|
||||
for _, record := range envelope.Data.Records {
|
||||
if message, _ := record["msg"].(string); message == "" {
|
||||
t.Errorf("запись пришла с пустым msg: %+v", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Читаемый журнал доезжает до панели в той же форме.
|
||||
func TestLogSystemReturnsRecordsForReadableFile(t *testing.T) {
|
||||
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","reqMethod":"GET","reqPath":"/api/peers","statusCode":200,"latencyTime":7,"time":"2026-09-01 10:00:00"}
|
||||
`
|
||||
envelope, raw := requestSystemLog(t, &content)
|
||||
|
||||
if envelope.Data == nil || len(envelope.Data.Records) != 1 {
|
||||
t.Fatalf("читаемая запись не доехала до панели: %s", raw)
|
||||
}
|
||||
record := envelope.Data.Records[0]
|
||||
if message, _ := record["msg"].(string); message == "" {
|
||||
t.Fatalf("msg потерян при отдаче: %+v", record)
|
||||
}
|
||||
if path, _ := record["reqPath"].(string); path != "/api/peers" {
|
||||
t.Fatalf("структурные поля не доехали: %+v", record)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,49 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда.
|
||||
//
|
||||
// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все
|
||||
// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть
|
||||
// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она
|
||||
// вызывала validateField, который читает тело запроса, а обработчик следом
|
||||
// читает то же тело второй раз — gin его не буферизует, и второй разбор
|
||||
// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда,
|
||||
// когда понадобился бы.
|
||||
func resolveID(c *gin.Context) (int64, error) {
|
||||
if raw := strings.TrimSpace(c.Param("id")); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err == nil && parsed > 0 {
|
||||
return parsed, nil
|
||||
}
|
||||
raw := strings.TrimSpace(c.Param("id"))
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
vo.FailValidation(
|
||||
"идентификатор пира в адресе некорректен",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeBodyInvalid,
|
||||
Field: "id",
|
||||
Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw),
|
||||
}},
|
||||
c,
|
||||
)
|
||||
return 0, errors.New(constant.ErrCodeBodyInvalid)
|
||||
}
|
||||
idDto, err := validateField(c, dto.IdDto{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return *idDto.Id, nil
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func PagePeer(c *gin.Context) {
|
||||
@@ -50,12 +51,12 @@ func PagePeer(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
records, total, err := service.PagePeer(peerPageDto)
|
||||
records, total, onlineState, err := service.PagePeer(peerPageDto)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.PeerPageVo{Records: records, Total: total}, c)
|
||||
vo.Success(vo.PeerPageVo{Records: records, Total: total, OnlineState: onlineState}, c)
|
||||
}
|
||||
|
||||
func SavePeer(c *gin.Context) {
|
||||
@@ -65,7 +66,7 @@ func SavePeer(c *gin.Context) {
|
||||
}
|
||||
peerVo, err := service.CreatePeer(peerSaveDto)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(peerVo, c)
|
||||
@@ -76,8 +77,14 @@ func DeletePeer(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// failService, а не vo.Fail: удаление умеет завершиться ЧАСТИЧНО — пир
|
||||
// отключён в базе, но завершить его активную сессию не удалось, поэтому
|
||||
// строка намеренно оставлена на месте. Через vo.Fail этот результат уехал
|
||||
// бы панели неотличимо от полного отказа, и оператор сделал бы неверный
|
||||
// вывод: «удаление не сработало, пир как был», — тогда как доступ уже
|
||||
// закрыт, а строка ждёт повторной попытки.
|
||||
if err = service.DeletePeer(id); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(nil, c)
|
||||
@@ -93,12 +100,19 @@ func UpdatePeer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
peerUpdateDto.Id = &id
|
||||
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) {
|
||||
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
|
||||
return
|
||||
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" {
|
||||
taken, nameErr := service.ExistPeerName(*peerUpdateDto.Name, id)
|
||||
if nameErr != nil {
|
||||
vo.Fail(nameErr.Error(), c)
|
||||
return
|
||||
}
|
||||
if taken {
|
||||
failService(service.PeerNameTakenError(*peerUpdateDto.Name), c)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(nil, c)
|
||||
@@ -129,37 +143,98 @@ 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") {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
|
||||
vo.FailValidation(
|
||||
"импорт принимает только файлы .json",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeImportFileExtension,
|
||||
Field: "file",
|
||||
Message: "импорт принимает только файлы .json",
|
||||
}},
|
||||
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
|
||||
}
|
||||
if err = validateStrictJSONEncoding(content); err != nil {
|
||||
vo.Fail("content contains invalid UTF-8 or UTF-16", 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
|
||||
}
|
||||
|
||||
// failService, а не vo.Fail: импорт умеет завершиться ЧАСТИЧНО — партия
|
||||
// зафиксирована в базе целиком, но завершить старые сессии обновлённых
|
||||
// пиров не удалось. Полный отказ здесь означал бы для оператора «файл не
|
||||
// применился», хотя он применился весь.
|
||||
if err = service.UpsertPeerExport(peerExports); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
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 +242,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) {
|
||||
@@ -204,8 +278,12 @@ func KickPeer(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// failService, а не vo.Fail: отзыв доступа умеет завершиться ЧАСТИЧНО —
|
||||
// блокировка записана, активную сессию завершить не удалось. Через
|
||||
// vo.Fail этот результат уезжал бы панели неотличимо от полного отказа, и
|
||||
// оператор сделал бы неверный вывод о состоянии пира.
|
||||
if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(nil, c)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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 postImportBytes(t *testing.T, fileName string, content []byte) 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(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
|
||||
}
|
||||
|
||||
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
t.Helper()
|
||||
return postImportBytes(t, fileName, []byte(content))
|
||||
}
|
||||
|
||||
func TestImportPeerRejectsInvalidUTF8(t *testing.T) {
|
||||
for _, content := range [][]byte{
|
||||
{'[', '"', 0xFF, '"', ']'},
|
||||
{'[', '"', 0xC3, '"', ']'},
|
||||
{'[', '"', 0xED, 0xA0, 0x80, '"', ']'},
|
||||
} {
|
||||
result := postImportBytes(t, "peers.json", content)
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("повреждённый UTF-8 принят: %+v", 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Контракт формы пира: необязательный секрет и внятный отказ.
|
||||
//
|
||||
// Проверяется весь путь запроса — разбор тела, нормализация DTO, правила
|
||||
// валидатора, сервис, база, — потому что дефект жил ровно на стыке этих
|
||||
// слоёв и ни один из них по отдельности его не показывал: панель обещала
|
||||
// автогенерацию, сервис умел её выполнить, а правило `omitempty,min=6` на
|
||||
// поле-указателе отказывало раньше, чем управление доходило до сервиса.
|
||||
|
||||
func newPeerControllerDB(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() })
|
||||
}
|
||||
|
||||
// answerTrafficStats поднимает минимальный Traffic Stats API Hysteria.
|
||||
//
|
||||
// Нужен там, где тест доходит до service.Hysteria2Auth: проверка лимита
|
||||
// устройств теперь fail-closed, и подключение без ответа Traffic Stats API
|
||||
// отклоняется (см. AUTH-01/AUTH-02). Наблюдений этот ответчик не ведёт —
|
||||
// контракт `/kick` и `/online` доказывается в пакете service; здесь он лишь
|
||||
// делает вопрос «сколько устройств онлайн» отвечаемым.
|
||||
func answerTrafficStats(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/online" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", server.URL, err)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := "listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:" + port + "\"\n secret: \"test-secret\"\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
|
||||
}
|
||||
|
||||
previous := constant.Hysteria2ConfigPath
|
||||
constant.Hysteria2ConfigPath = path
|
||||
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||
|
||||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, "test-secret"); err != nil {
|
||||
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// peerPayload — тело создания пира со всеми обязательными полями.
|
||||
// Тесты меняют в нём ровно то, что проверяют.
|
||||
func peerPayload(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"quotaBytes": -1,
|
||||
"expiresAt": 0,
|
||||
"maxDevices": 3,
|
||||
"disabled": 0,
|
||||
"remark": "",
|
||||
}
|
||||
}
|
||||
|
||||
func createPeer(t *testing.T, body map[string]any) apiResult {
|
||||
t.Helper()
|
||||
return postJSON(t, SavePeer, "/peers", body)
|
||||
}
|
||||
|
||||
// errorFor возвращает причину отказа по имени поля.
|
||||
func errorFor(t *testing.T, result apiResult, field string) (string, bool) {
|
||||
t.Helper()
|
||||
for _, item := range result.Errors {
|
||||
if item.Field == field {
|
||||
return item.Code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func storedPeer(t *testing.T, name string) entity.Peer {
|
||||
t.Helper()
|
||||
peer, err := dao.GetPeer("name = ?", name)
|
||||
if err != nil {
|
||||
t.Fatalf("пир %q не найден в базе: %v", name, err)
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
// Регрессия UX-02. Панель писала под полем «оставьте пустым — сгенерируем
|
||||
// автоматически» и отправляла `secret: ""`. Правило `omitempty,min=6` на
|
||||
// поле-указателе НЕ пропускалось (см. hasValue в baked_in.go валидатора),
|
||||
// применялось к пустой строке и отказывало. Оператор видел «Invalid», а
|
||||
// генерация в CreatePeer была недостижима.
|
||||
func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
|
||||
cases := map[string]func(map[string]any){
|
||||
"поле отсутствует": func(body map[string]any) {},
|
||||
"пустая строка": func(body map[string]any) { body["secret"] = "" },
|
||||
"только пробелы": func(body map[string]any) { body["secret"] = " " },
|
||||
"перевод строки": func(body map[string]any) { body["secret"] = "\n" },
|
||||
"табуляция и пробел": func(body map[string]any) { body["secret"] = "\t " },
|
||||
}
|
||||
|
||||
for label, mutate := range cases {
|
||||
t.Run(label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
answerTrafficStats(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
mutate(body)
|
||||
|
||||
result := createPeer(t, body)
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: code=%d message=%q errors=%+v",
|
||||
result.Code, result.Message, result.Errors)
|
||||
}
|
||||
|
||||
peer := storedPeer(t, "client-01")
|
||||
if peer.SecretEncrypted == nil || *peer.SecretEncrypted == "" {
|
||||
t.Fatal("секрет не сохранён")
|
||||
}
|
||||
|
||||
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("сохранённый секрет не расшифровывается: %v", err)
|
||||
}
|
||||
if len(secret) < 6 {
|
||||
t.Fatalf("сгенерирован слишком короткий секрет: %q", secret)
|
||||
}
|
||||
|
||||
// Сгенерированный секрет обязан РАБОТАТЬ немедленно: то, что он
|
||||
// записан, ничего не значит, пока по нему не проходит проверка
|
||||
// доступа. Это же связывает digest и шифртекст между собой.
|
||||
id, authID, authErr := service.Hysteria2Auth(secret)
|
||||
if authErr != nil {
|
||||
t.Fatalf("пир не аутентифицируется своим секретом: %v", authErr)
|
||||
}
|
||||
if id != *peer.Id || authID != *peer.AuthId {
|
||||
t.Fatalf("аутентифицировался другой пир: id=%d authId=%q", id, authID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Два одинаковых запроса не должны давать одинаковый секрет: генератор
|
||||
// обязан быть случайным, а не производной от имени.
|
||||
func TestGeneratedPeerSecretsDiffer(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
secrets := make(map[string]struct{}, 5)
|
||||
for _, name := range []string{"client-01", "client-02", "client-03", "client-04", "client-05"} {
|
||||
if result := createPeer(t, peerPayload(name)); result.Type != "ok" {
|
||||
t.Fatalf("создание %q отклонено: %+v", name, result)
|
||||
}
|
||||
peer := storedPeer(t, name)
|
||||
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("секрет %q не расшифровывается: %v", name, err)
|
||||
}
|
||||
if _, seen := secrets[secret]; seen {
|
||||
t.Fatalf("сгенерированный секрет повторился: %q", secret)
|
||||
}
|
||||
secrets[secret] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы ручного секрета — ровно те, что обещает подсказка под полем.
|
||||
func TestCreatePeerSecretLengthBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
secret string
|
||||
accepted bool
|
||||
expectCode string
|
||||
}{
|
||||
{"5 символов", strings.Repeat("a", 5), false, constant.ErrCodeMinLength},
|
||||
{"6 символов", strings.Repeat("a", 6), true, ""},
|
||||
{"128 символов", strings.Repeat("a", 128), true, ""},
|
||||
{"129 символов", strings.Repeat("a", 129), false, constant.ErrCodeMaxLength},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
body["secret"] = tc.secret
|
||||
result := createPeer(t, body)
|
||||
|
||||
if tc.accepted {
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("секрет длиной %d отклонён: %+v", len(tc.secret), result)
|
||||
}
|
||||
peer := storedPeer(t, "client-01")
|
||||
stored, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("секрет не расшифровывается: %v", err)
|
||||
}
|
||||
if stored != tc.secret {
|
||||
t.Fatalf("сохранён не тот секрет, который передали")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("секрет длиной %d принят", len(tc.secret))
|
||||
}
|
||||
code, ok := errorFor(t, result, "secret")
|
||||
if !ok {
|
||||
t.Fatalf("отказ не назвал поле secret: %+v", result.Errors)
|
||||
}
|
||||
if code != tc.expectCode {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, tc.expectCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия UX-03. Любая ошибка любого поля превращалась в одно слово
|
||||
// `invalid`: панель не могла ни подсветить поле, ни объяснить причину, и
|
||||
// вынуждена была бы разбирать текст, чтобы попытаться.
|
||||
func TestCreatePeerNamesTheFieldAndTheRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
body func() map[string]any
|
||||
field string
|
||||
code string
|
||||
}{
|
||||
{
|
||||
label: "имя не передано",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
delete(body, "name")
|
||||
return body
|
||||
},
|
||||
field: "name",
|
||||
code: constant.ErrCodeRequired,
|
||||
},
|
||||
{
|
||||
label: "имя короче допустимого",
|
||||
body: func() map[string]any { return peerPayload("pc1") },
|
||||
field: "name",
|
||||
code: constant.ErrCodePeerName,
|
||||
},
|
||||
{
|
||||
label: "имя длиннее допустимого",
|
||||
body: func() map[string]any { return peerPayload(strings.Repeat("a", 33)) },
|
||||
field: "name",
|
||||
code: constant.ErrCodePeerName,
|
||||
},
|
||||
{
|
||||
label: "лимит устройств меньше единицы",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["maxDevices"] = 0
|
||||
return body
|
||||
},
|
||||
field: "maxDevices",
|
||||
code: constant.ErrCodeMin,
|
||||
},
|
||||
{
|
||||
label: "disabled вне множества значений",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["disabled"] = 7
|
||||
return body
|
||||
},
|
||||
field: "disabled",
|
||||
code: constant.ErrCodeOneOf,
|
||||
},
|
||||
{
|
||||
label: "квота меньше минимума",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["quotaBytes"] = -2
|
||||
return body
|
||||
},
|
||||
field: "quotaBytes",
|
||||
code: constant.ErrCodeMin,
|
||||
},
|
||||
{
|
||||
label: "комментарий длиннее допустимого",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["remark"] = strings.Repeat("я", 65)
|
||||
return body
|
||||
},
|
||||
field: "remark",
|
||||
code: constant.ErrCodeMaxLength,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, tc.body())
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("некорректный ввод принят: %+v", result)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("код ответа %d, ожидался %d", result.Code, constant.CodeInvalidError)
|
||||
}
|
||||
code, ok := errorFor(t, result, tc.field)
|
||||
if !ok {
|
||||
t.Fatalf("отказ не назвал поле %q: %+v", tc.field, result.Errors)
|
||||
}
|
||||
if code != tc.code {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, tc.code)
|
||||
}
|
||||
// Сообщение остаётся человекочитаемым для клиента без панели, но
|
||||
// панель им не пользуется: у неё есть код.
|
||||
if strings.TrimSpace(result.Message) == "" {
|
||||
t.Fatal("отказ без человекочитаемого сообщения")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: слой контроллеров нёс собственную копию правила имени, в которой
|
||||
// неэкранированный дефис превращал `+-=` в диапазон и впускал `, - . / : ; <`.
|
||||
// Имя `peer/name` создавалось через панель и отклонялось импортом того же
|
||||
// пира, хотя имя уезжает во fragment клиентской ссылки и в секрет.
|
||||
func TestCreatePeerRejectsNamesOutsideTheCharset(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"peer/name",
|
||||
"peer:name",
|
||||
"peer;name",
|
||||
"peer,name",
|
||||
"peer.name",
|
||||
"peer<name",
|
||||
"peer name",
|
||||
"пир-01",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, peerPayload(name))
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("имя %q принято", name)
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerName {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerName)
|
||||
}
|
||||
// Обе двери в таблицу пиров обязаны требовать одного и того же.
|
||||
if service.IsValidPeerName(name) {
|
||||
t.Fatalf("импорт принимает имя %q, которое отклоняет панель", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerReportsTakenName(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
|
||||
t.Fatalf("первое создание отклонено: %+v", result)
|
||||
}
|
||||
|
||||
result := createPeer(t, peerPayload("client-01"))
|
||||
if result.Type != "no" {
|
||||
t.Fatal("повторное имя принято")
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameTaken {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameTaken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerReportsReservedName(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, peerPayload(service.ReservedBootstrapPeerName))
|
||||
if result.Type != "no" {
|
||||
t.Fatal("зарезервированное имя принято")
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameReserved {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameReserved)
|
||||
}
|
||||
}
|
||||
|
||||
// Тело, которое вообще не разобралось, — это не нарушение правила поля.
|
||||
// Панели важно различать: в первом случае подсвечивать нечего.
|
||||
func TestCreatePeerReportsUnparsableBody(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.POST("/peers", SavePeer)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/peers", strings.NewReader("{не json"))
|
||||
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())
|
||||
}
|
||||
if result.Type != "no" {
|
||||
t.Fatal("неразбираемое тело принято")
|
||||
}
|
||||
if len(result.Errors) != 1 || result.Errors[0].Code != constant.ErrCodeBodyInvalid {
|
||||
t.Fatalf("неожиданное описание отказа: %+v", result.Errors)
|
||||
}
|
||||
if result.Errors[0].Field != "" {
|
||||
t.Fatalf("отказ разбора привязан к полю %q", result.Errors[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
// patchPeer выполняет PATCH /peers/:id так же, как это делает панель.
|
||||
func patchPeer(t *testing.T, id int64, body map[string]any) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.PATCH("/peers/:id", UpdatePeer)
|
||||
|
||||
target := "/peers/" + strconv.FormatInt(id, 10)
|
||||
request := httptest.NewRequest(http.MethodPatch, target, strings.NewReader(string(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
|
||||
}
|
||||
|
||||
// При изменении пустой секрет означает «не менять», и это то же самое
|
||||
// состояние, что и отсутствие поля. Панель отправляет `secret: ""` всякий раз,
|
||||
// когда оператор открыл форму и не трогал поле секрета.
|
||||
func TestUpdatePeerKeepsSecretWhenFieldIsBlank(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: %+v", result)
|
||||
}
|
||||
before := storedPeer(t, "client-01")
|
||||
|
||||
for _, blank := range []string{"", " "} {
|
||||
result := patchPeer(t, *before.Id, map[string]any{
|
||||
"name": "client-01",
|
||||
"secret": blank,
|
||||
"remark": "рабочее устройство",
|
||||
})
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("изменение с пустым секретом %q отклонено: %+v", blank, result)
|
||||
}
|
||||
|
||||
after := storedPeer(t, "client-01")
|
||||
if *after.SecretDigest != *before.SecretDigest {
|
||||
t.Fatal("секрет пира изменился, хотя поле оставили пустым")
|
||||
}
|
||||
if after.Remark == nil || *after.Remark != "рабочее устройство" {
|
||||
t.Fatal("остальные поля формы не применились")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой комментарий обязан ОЧИЩАТЬ комментарий, а не означать «не менять»:
|
||||
// иначе оператор не может убрать однажды сделанную пометку. Это граница, по
|
||||
// которой нормализация проходит для каждого поля отдельно.
|
||||
func TestUpdatePeerClearsRemarkWhenFieldIsBlank(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
body["remark"] = "временная пометка"
|
||||
if result := createPeer(t, body); result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: %+v", result)
|
||||
}
|
||||
peer := storedPeer(t, "client-01")
|
||||
|
||||
if result := patchPeer(t, *peer.Id, map[string]any{"remark": ""}); result.Type != "ok" {
|
||||
t.Fatalf("очистка комментария отклонена: %+v", result)
|
||||
}
|
||||
|
||||
after := storedPeer(t, "client-01")
|
||||
if after.Remark != nil && *after.Remark != "" {
|
||||
t.Fatalf("комментарий не очищен: %q", *after.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия, найденная вместе с UX-02 и в отчёте не значившаяся: `el-input`
|
||||
// с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`,
|
||||
// и та же ловушка `omitempty` на указателе отказывала поиску пиров с
|
||||
// «invalid» — то есть список пиров ломался в один клик по крестику.
|
||||
func TestPagePeerAcceptsClearedFilters(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/peers", PagePeer)
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("pageNum", "1")
|
||||
query.Set("pageSize", "10")
|
||||
query.Set("name", "")
|
||||
query.Set("remark", "")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/peers?"+query.Encode(), nil)
|
||||
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())
|
||||
}
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("очищенный фильтр отклонён: code=%d message=%q errors=%+v",
|
||||
result.Code, result.Message, result.Errors)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,261 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
var validate *validator.Validate
|
||||
|
||||
func init() {
|
||||
validate = validator.New()
|
||||
_ = validate.RegisterValidation("validateStr", validateStr)
|
||||
|
||||
// Имя поля в отказе — это имя из JSON, а не из структуры Go. Панель знает
|
||||
// поля формы под теми именами, под которыми их отправляет; `Secret` вместо
|
||||
// `secret` заставил бы её переводить одно в другое ещё одним словарём.
|
||||
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
|
||||
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "" || name == "-" {
|
||||
return field.Name
|
||||
}
|
||||
return name
|
||||
})
|
||||
|
||||
mustRegister("peerName", validatePeerName)
|
||||
mustRegister("credentialStr", validateCredentialStr)
|
||||
mustRegister("adminPassword", validateAdminPassword)
|
||||
}
|
||||
|
||||
func validateStr(f validator.FieldLevel) bool {
|
||||
field := f.Field().String()
|
||||
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов
|
||||
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
|
||||
compile := regexp.MustCompile(reg)
|
||||
return field == "" || compile.MatchString(field)
|
||||
func mustRegister(tag string, fn validator.Func) {
|
||||
if err := validate.RegisterValidation(tag, fn); err != nil {
|
||||
panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
|
||||
}
|
||||
}
|
||||
|
||||
// validatePeerName — единственное правило имени пира.
|
||||
//
|
||||
// Набор символов и длина берутся из service: имя пира проверяется на двух
|
||||
// дверях в одну и ту же таблицу — обычное создание и импорт выгрузки, — и две
|
||||
// независимые копии правила уже расходились. Копия в слое контроллеров
|
||||
// выглядела так:
|
||||
//
|
||||
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
|
||||
//
|
||||
// и её комментарий утверждал, что набор тот же, что у импорта. Он был другим:
|
||||
// дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и
|
||||
// впускает `, - . / 0-9 : ; < =`. То есть через панель проходило имя
|
||||
// `peer/name`, которое импорт того же самого пира отклонял, — а имя пира
|
||||
// уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
|
||||
func validatePeerName(f validator.FieldLevel) bool {
|
||||
return service.IsValidPeerName(f.Field().String())
|
||||
}
|
||||
|
||||
// validateCredentialStr — единственное правило имени администратора.
|
||||
//
|
||||
// Набор символов и длина берутся из service по той же причине, что и у имени
|
||||
// пира: собственная копия правила в слое контроллеров уже расходилась с
|
||||
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
|
||||
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
|
||||
// третье правило.
|
||||
//
|
||||
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
|
||||
// была ловушка: у поля-указателя `required` считает непустым сам факт
|
||||
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
|
||||
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
|
||||
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||
// у него на самом деле.
|
||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||
return credential.IsValidAdminUsername(f.Field().String())
|
||||
}
|
||||
|
||||
// validateAdminPassword — единственное правило пароля администратора.
|
||||
//
|
||||
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
|
||||
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
|
||||
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
|
||||
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
|
||||
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
|
||||
// системная ошибка на штатной смене пароля.
|
||||
//
|
||||
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
|
||||
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
|
||||
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
|
||||
func validateAdminPassword(f validator.FieldLevel) bool {
|
||||
return credential.IsValidAdminPassword(f.Field().String())
|
||||
}
|
||||
|
||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||
// правила.
|
||||
//
|
||||
// Отказ описывается ПОЛЯМИ, а не одним словом. Раньше и ошибка разбора тела, и
|
||||
// нарушение любого правила любого поля превращались в одну строку `invalid`:
|
||||
// оператор, оставивший секрет пустым, видел «Invalid» и не имел ни одного
|
||||
// способа узнать, что именно не так, — а не так было ровно то, что панель ему
|
||||
// же и предлагала сделать.
|
||||
func validateField[T interface{}](c *gin.Context, field T) (T, error) {
|
||||
var bindErr error
|
||||
if c.Request.Method == http.MethodGet {
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet:
|
||||
bindErr = c.ShouldBindQuery(&field)
|
||||
} else if c.Request.Method == http.MethodPost ||
|
||||
c.Request.Method == http.MethodPut ||
|
||||
c.Request.Method == http.MethodPatch ||
|
||||
c.Request.Method == http.MethodDelete {
|
||||
bindErr = c.ShouldBindJSON(&field)
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
bindErr = strictBindJSON(c, &field)
|
||||
}
|
||||
if bindErr != nil {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
return field, fmt.Errorf(constant.InvalidError)
|
||||
vo.FailValidation(
|
||||
"запрос не разобран: проверьте формат и типы полей",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeBodyInvalid,
|
||||
Message: bindErr.Error(),
|
||||
}},
|
||||
c,
|
||||
)
|
||||
return field, errors.New(constant.ErrCodeBodyInvalid)
|
||||
}
|
||||
|
||||
// Нормализация идёт между разбором и проверкой: правила обязаны видеть уже
|
||||
// каноничный вход, иначе «не задано» и «задано пустым» остаются разными
|
||||
// состояниями для валидатора и одинаковыми для человека.
|
||||
if normalizable, ok := any(&field).(dto.Normalizable); ok {
|
||||
normalizable.Normalize()
|
||||
}
|
||||
|
||||
if err := validate.Struct(&field); err != nil {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
return field, fmt.Errorf(constant.InvalidError)
|
||||
vo.FailValidation(
|
||||
"проверка данных не пройдена",
|
||||
describeValidationErrors(err),
|
||||
c,
|
||||
)
|
||||
return field, errors.New(constant.ErrCodeValidationFailed)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// describeValidationErrors переводит отказ валидатора в список причин.
|
||||
func describeValidationErrors(err error) []vo.FieldError {
|
||||
var validationErrors validator.ValidationErrors
|
||||
if !errors.As(err, &validationErrors) {
|
||||
// InvalidValidationError означает ошибку программиста (в проверку
|
||||
// передали не структуру), а не плохой вход оператора. Скрывать её за
|
||||
// сообщением о поле нельзя: она никогда не чинится правкой формы.
|
||||
return []vo.FieldError{{
|
||||
Code: constant.ErrCodeValidationFailed,
|
||||
Message: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
out := make([]vo.FieldError, 0, len(validationErrors))
|
||||
for _, fieldErr := range validationErrors {
|
||||
out = append(out, describeFieldError(fieldErr))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isTextField сообщает, что `min`/`max` на этом поле ограничивают ДЛИНУ, а не
|
||||
// величину. Указатели валидатор к этому моменту уже разыменовал.
|
||||
func isTextField(fieldErr validator.FieldError) bool {
|
||||
return fieldErr.Kind() == reflect.String
|
||||
}
|
||||
|
||||
func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
field := fieldErr.Field()
|
||||
param := fieldErr.Param()
|
||||
|
||||
described := vo.FieldError{Field: field}
|
||||
switch fieldErr.Tag() {
|
||||
case "required":
|
||||
described.Code = constant.ErrCodeRequired
|
||||
described.Message = fmt.Sprintf("поле %q обязательно", field)
|
||||
case "min":
|
||||
if isTextField(fieldErr) {
|
||||
described.Code = constant.ErrCodeMinLength
|
||||
described.Params = map[string]string{"min": param}
|
||||
described.Message = fmt.Sprintf("поле %q короче %s символов", field, param)
|
||||
break
|
||||
}
|
||||
described.Code = constant.ErrCodeMin
|
||||
described.Params = map[string]string{"min": param}
|
||||
described.Message = fmt.Sprintf("поле %q меньше допустимого минимума %s", field, param)
|
||||
case "max":
|
||||
if isTextField(fieldErr) {
|
||||
described.Code = constant.ErrCodeMaxLength
|
||||
described.Params = map[string]string{"max": param}
|
||||
described.Message = fmt.Sprintf("поле %q длиннее %s символов", field, param)
|
||||
break
|
||||
}
|
||||
described.Code = constant.ErrCodeMax
|
||||
described.Params = map[string]string{"max": param}
|
||||
described.Message = fmt.Sprintf("поле %q больше допустимого максимума %s", field, param)
|
||||
case "len":
|
||||
described.Code = constant.ErrCodeLen
|
||||
described.Params = map[string]string{"len": param}
|
||||
described.Message = fmt.Sprintf("поле %q должно иметь длину %s", field, param)
|
||||
case "oneof":
|
||||
described.Code = constant.ErrCodeOneOf
|
||||
described.Params = map[string]string{"values": param}
|
||||
described.Message = fmt.Sprintf("поле %q принимает одно из значений: %s", field, param)
|
||||
case "gt":
|
||||
described.Code = constant.ErrCodeGreaterThan
|
||||
described.Params = map[string]string{"gt": param}
|
||||
described.Message = fmt.Sprintf("поле %q должно быть больше %s", field, param)
|
||||
case "peerName":
|
||||
described.Code = constant.ErrCodePeerName
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", service.PeerNameMinLength),
|
||||
"max": fmt.Sprintf("%d", service.PeerNameMaxLength),
|
||||
"charset": service.PeerNameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"имя пира: от %d до %d символов из набора %s",
|
||||
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
||||
)
|
||||
case "credentialStr":
|
||||
// Причина называет набор и границы ровно так же, как это делает
|
||||
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
|
||||
// вопрос оператора: набор символов логина ему негде посмотреть, а
|
||||
// отказ по длине этой фразой описывался как отказ по символам.
|
||||
described.Code = constant.ErrCodeCredentialStr
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
|
||||
"charset": credential.AdminUsernameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов из набора %s",
|
||||
field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset,
|
||||
)
|
||||
case "adminPassword":
|
||||
// Причина называет ОБЕ границы, потому что их две и они в разных
|
||||
// единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был
|
||||
// бы неправдой: символов там 40, а байтов 160, и оператор, послушав
|
||||
// такую причину, сокращал бы пароль, который отвергнут не за это.
|
||||
described.Code = constant.ErrCodeAdminPassword
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", credential.AdminPasswordMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength),
|
||||
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
field,
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
default:
|
||||
described.Code = constant.ErrCodeRuleUnknown
|
||||
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
||||
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
|
||||
}
|
||||
return described
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
|
||||
//
|
||||
// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
|
||||
// потому не увидели бы расхождения между копией и правилом — а именно так этот
|
||||
// класс дефектов и появлялся: набор символов логина существовал в четырёх
|
||||
// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
|
||||
// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
|
||||
// одинаково на одних и тех же значениях.
|
||||
func TestCredentialStrTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"hy2xsadmin",
|
||||
"admin.ops",
|
||||
"admin+1",
|
||||
"admi",
|
||||
"админ12",
|
||||
"admin пробел",
|
||||
strings.Repeat("a", credential.AdminUsernameMaxLength+1),
|
||||
} {
|
||||
expected := credential.IsValidAdminUsername(candidate)
|
||||
if got := validate.Var(candidate, "credentialStr") == nil; got != expected {
|
||||
t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же вопрос про пароль, и он важнее.
|
||||
//
|
||||
// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
|
||||
// предел bcrypt в байтах — правила длины в байтах у go-playground/validator
|
||||
// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
|
||||
// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
|
||||
// закрепляет, что правило теперь одно и что оно то же самое, которым
|
||||
// пользуются bootstrap-установка и `reset-admin`.
|
||||
func TestAdminPasswordTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"bootstrap-password",
|
||||
"abcde ",
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength-1),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength+1),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("я", 37),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat("😀", 19),
|
||||
strings.Repeat("я", credential.AdminPasswordMaxLength),
|
||||
"abcde\n",
|
||||
} {
|
||||
expected := credential.IsValidAdminPassword(candidate)
|
||||
if got := validate.Var(candidate, "adminPassword") == nil; got != expected {
|
||||
t.Errorf(
|
||||
"тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v",
|
||||
len([]rune(candidate)), len(candidate), got, expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
|
||||
// одну таблицу и не имеют права требовать разного.
|
||||
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
|
||||
accepted := []string{
|
||||
"client-01",
|
||||
"alpha1",
|
||||
"bootstrap-admin-peer",
|
||||
strings.Repeat("a", 6),
|
||||
strings.Repeat("a", 32),
|
||||
}
|
||||
for _, name := range accepted {
|
||||
if !service.IsValidPeerName(name) {
|
||||
t.Errorf("имя %q отклонено, ожидался приём", name)
|
||||
}
|
||||
}
|
||||
|
||||
rejected := []string{
|
||||
"",
|
||||
" ",
|
||||
"pc1",
|
||||
strings.Repeat("a", 33),
|
||||
"peer name",
|
||||
"peer\nname",
|
||||
"peer/name",
|
||||
"peer:name",
|
||||
"peer.name",
|
||||
"пир-01",
|
||||
}
|
||||
for _, name := range rejected {
|
||||
if service.IsValidPeerName(name) {
|
||||
t.Errorf("имя %q принято, ожидался отказ", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package credential объявляет контракт учётных данных администратора — ОДИН
|
||||
// на весь продукт.
|
||||
//
|
||||
// Почему это отдельный пакет, а не файл в service. Контракт нужен четырём
|
||||
// слоям сразу, и два из них лежат НИЖЕ service по графу импортов:
|
||||
//
|
||||
// controller -> service -> util (хеширование пароля)
|
||||
// dao -> util (создание первой учётной записи)
|
||||
// cmd -> dao, util (reset-admin)
|
||||
//
|
||||
// Пока контракт жил в service, `util.HashPassword` не мог его позвать — импорт
|
||||
// был бы циклическим, — и завёл собственную проверку `len(TrimSpace(p)) < 6`.
|
||||
// Эта третья копия правила разошлась с остальными ровно тем способом, ради
|
||||
// предотвращения которого контракт и объявлен: LoginDto считал `"abcde "`
|
||||
// корректным паролем, а HashPassword отказывался его хешировать. Пакет-лист
|
||||
// ниже всех потребителей — единственная запись, при которой у правила
|
||||
// физически не может появиться второго экземпляра.
|
||||
package credential
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Контракт учётных данных администратора.
|
||||
//
|
||||
// Про логин и пароль администратора одновременно знали четыре места: регексп в
|
||||
// слое контроллеров, границы длины в тегах LoginDto, правило формы входа в
|
||||
// панели и правило формы смены пароля в ней же. Ни одно из них не ссылалось на
|
||||
// остальные, и все четыре успели разойтись:
|
||||
//
|
||||
// - тег `validateStr` на Username пережил переименование правила и остался
|
||||
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
|
||||
// а gin.Recovery превращал панику в HTTP 500;
|
||||
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
|
||||
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
|
||||
// форма входа отказывалась отправлять: панель запирала оператора снаружи
|
||||
// после операции, которую сама же и предложила;
|
||||
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
|
||||
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
|
||||
// завершалась успешно, а войти в панель было нельзя.
|
||||
//
|
||||
// Общее у всех трёх — контракт существовал только как совпадение литералов,
|
||||
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
|
||||
// IsValidAdminUsername и IsValidAdminPassword, а расхождение с панелью и
|
||||
// оркестратором ловится тестами, читающими этот же файл.
|
||||
const (
|
||||
AdminUsernameMinLength = 6
|
||||
AdminUsernameMaxLength = 32
|
||||
|
||||
// Границы пароля в СИМВОЛАХ Unicode (code points).
|
||||
AdminPasswordMinLength = 6
|
||||
AdminPasswordMaxLength = 64
|
||||
|
||||
// AdminPasswordMaxBytes — предел, установленный bcrypt.
|
||||
//
|
||||
// Он объявлен ОТДЕЛЬНО от AdminPasswordMaxLength, потому что это ДРУГАЯ
|
||||
// единица измерения, и подменять одну другой нельзя.
|
||||
//
|
||||
// Здесь стоял ровно этот дефект. Верхняя граница в 64 символа была выбрана
|
||||
// «заведомо ниже 72 байт», и обоснование выглядело убедительно, пока пароль
|
||||
// оставался ASCII. Но `golang.org/x/crypto/bcrypt` считает БАЙТЫ:
|
||||
//
|
||||
// 64 x "a" = 64 байта -> GenerateFromPassword принимает
|
||||
// 64 x "я" = 128 байт -> ErrPasswordTooLong
|
||||
// 19 x "😀" = 76 байт -> ErrPasswordTooLong
|
||||
//
|
||||
// причём отказ приходил НЕ на форме, а из хеширования: смена пароля на 64
|
||||
// кириллические буквы проходила все проверки продукта и заканчивалась
|
||||
// системной ошибкой на штатной операции. Комментарий в прежней редакции
|
||||
// вдобавок утверждал, что bcrypt «молча отбрасывает остаток» — так вело
|
||||
// себя издание пакета до v0.28; действующее (v0.55.0, bcrypt.go:96)
|
||||
// отвечает ошибкой:
|
||||
//
|
||||
// if len(password) > 72 { return nil, ErrPasswordTooLong }
|
||||
AdminPasswordMaxBytes = 72
|
||||
)
|
||||
|
||||
// adminUsernameCharacterClass — набор символов логина в записи регекспа.
|
||||
//
|
||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||||
// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=`
|
||||
// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь
|
||||
// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и
|
||||
// установка с логином вроде `admin.ops` перестала бы пускать оператора в
|
||||
// панель. Набор и проверка на стороне оркестратора меняются только вместе.
|
||||
const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=`
|
||||
|
||||
// AdminUsernameCharset — тот же набор в том виде, в каком его показывают
|
||||
// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило
|
||||
// собственную копию того же множества.
|
||||
const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`
|
||||
|
||||
// adminUsernamePattern собирается из констант, а не пишется литералом: границы
|
||||
// длины обязаны быть одними и теми же для правила и для сообщения о нём.
|
||||
var adminUsernamePattern = regexp.MustCompile(
|
||||
"^[" + adminUsernameCharacterClass + "]{" +
|
||||
strconv.Itoa(AdminUsernameMinLength) + "," +
|
||||
strconv.Itoa(AdminUsernameMaxLength) + "}$",
|
||||
)
|
||||
|
||||
// IsValidAdminUsername сообщает, пригодно ли имя администратора.
|
||||
//
|
||||
// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты
|
||||
// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда
|
||||
// значение приходит из файла окружения, а не из DTO.
|
||||
func IsValidAdminUsername(username string) bool {
|
||||
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
// Noncharacters и суррогаты — то, что НЕ ПРИМЕТ systemd.
|
||||
//
|
||||
// `EnvironmentFile=` разбирается systemd, и перед тем как принять пару, он
|
||||
// прогоняет ключ и значение через `utf8_is_valid` (src/basic/env-file.c,
|
||||
// `check_utf8ness_and_warn`). Отказ там — это `-EINVAL`, то есть НЕ загруженный
|
||||
// environment file, то есть юнит, который не стартует; предупреждением это не
|
||||
// является.
|
||||
//
|
||||
// `utf8_is_valid` отвергает встроенный NUL и всё, что не является Unicode
|
||||
// scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх того отвергает:
|
||||
//
|
||||
// U+D800..U+DFFF суррогаты
|
||||
// U+FDD0..U+FDEF noncharacters
|
||||
// (cp & 0xFFFE) == 0xFFFE — U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
|
||||
//
|
||||
// Пока контракт этого не знал, пароль вроде `abcde` — шесть символов,
|
||||
// восемь байт, ни одного управляющего — проходил ВСЕ проверки продукта,
|
||||
// записывался в /etc/hy2xs/hy2xs.env, и админка после этого не стартовала. Тот
|
||||
// же класс дефекта, ради уничтожения которого контракт и существует, только на
|
||||
// один слой ниже.
|
||||
//
|
||||
// Публичная документация systemd также запрещает U+FEFF. Реализация v257.13
|
||||
// случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует
|
||||
// документированному контракту, а не этому расхождению реализации.
|
||||
func isUnicodeNoncharacter(r rune) bool {
|
||||
if r >= 0xFDD0 && r <= 0xFDEF {
|
||||
return true
|
||||
}
|
||||
return r&0xFFFE == 0xFFFE
|
||||
}
|
||||
|
||||
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
|
||||
//
|
||||
// Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения
|
||||
// (границы длины, запрет управляющих символов) живут отдельно — иначе
|
||||
// невозможно объяснить, какое из двух правил нарушено и чем это грозит.
|
||||
func IsEnvTransportableText(value string) bool {
|
||||
// Невалидный UTF-8 сюда попадает не только из файла: Go-строка — это
|
||||
// произвольные байты, и `[]byte` из внешнего источника может оказаться
|
||||
// чем угодно. Проверка идёт ПЕРВОЙ, потому что `range` по невалидной строке
|
||||
// молча отдаёт U+FFFD и скрыл бы причину.
|
||||
if !utf8.ValidString(value) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
|
||||
// отдельной веткой (`embedded NUL`).
|
||||
if r == 0 || r == 0xFEFF {
|
||||
return false
|
||||
}
|
||||
if isUnicodeNoncharacter(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsValidAdminPassword — единственное правило пароля администратора.
|
||||
//
|
||||
// Правило состоит из четырёх частей и НИ ОДНА из них не является набором
|
||||
// символов:
|
||||
//
|
||||
// домен systemd — значение обязано быть загружаемым из EnvironmentFile
|
||||
// (IsEnvTransportableText);
|
||||
// 6-64 code points — граница, которую видит оператор;
|
||||
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
|
||||
// Cc — продуктовая политика, см. hasForbiddenRune.
|
||||
//
|
||||
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
|
||||
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
|
||||
// видит оператор: «пароль из 64 символов» обязано означать одно и то же для
|
||||
// латиницы и для кириллицы.
|
||||
//
|
||||
// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение
|
||||
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
|
||||
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
|
||||
//
|
||||
// Набор символов НЕ ограничивается ничем сверх перечисленного. Пароль назначает
|
||||
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
|
||||
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
|
||||
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
|
||||
// пароль, который сервер принял бы.
|
||||
//
|
||||
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
|
||||
// нормализации DTO, ни при чтении bootstrap-окружения.
|
||||
func IsValidAdminPassword(password string) bool {
|
||||
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до
|
||||
// админки вовсе — systemd откажется загружать файл окружения, и юнит не
|
||||
// стартует. Это отказ более грубого рода, чем нарушение границ длины.
|
||||
if !IsEnvTransportableText(password) {
|
||||
return false
|
||||
}
|
||||
runes := utf8.RuneCountInString(password)
|
||||
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
|
||||
return false
|
||||
}
|
||||
if len(password) > AdminPasswordMaxBytes {
|
||||
return false
|
||||
}
|
||||
return !hasForbiddenRune(password)
|
||||
}
|
||||
|
||||
// hasForbiddenRune — продуктовая политика HY2XS поверх домена systemd.
|
||||
//
|
||||
// Запрещены УПРАВЛЯЮЩИЕ символы Unicode целиком, то есть категория Cc:
|
||||
//
|
||||
// U+0000..U+001F C0
|
||||
// U+007F DEL
|
||||
// U+0080..U+009F C1
|
||||
//
|
||||
// Раньше здесь стояли только C0 и DEL, а комментарий обещал «без управляющих
|
||||
// символов» — то есть документация была шире кода ровно на C1. Проверяется
|
||||
// теперь то, что обещано.
|
||||
//
|
||||
// U+FEFF здесь уже не проверяется: он принадлежит документированному домену
|
||||
// EnvironmentFile и отвергается IsEnvTransportableText.
|
||||
func hasForbiddenRune(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||||
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||
// оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !IsValidAdminUsername(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"", // не задано
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
} {
|
||||
if IsValidAdminUsername(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
||||
// обязан описывать тот же класс, что и регексп.
|
||||
//
|
||||
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
||||
// оператор читает набор, которого правило не принимает.
|
||||
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
|
||||
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) {
|
||||
t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength)
|
||||
}
|
||||
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength)
|
||||
}
|
||||
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength)
|
||||
}
|
||||
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) {
|
||||
t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength)
|
||||
}
|
||||
|
||||
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
|
||||
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
|
||||
punctuation := strings.Fields(AdminUsernameCharset)
|
||||
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
|
||||
if !IsValidAdminUsername("admin" + symbol) {
|
||||
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные
|
||||
// границы.
|
||||
//
|
||||
// Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
|
||||
// байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок
|
||||
// требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым
|
||||
// фиксировал как ожидаемое ровно то значение, на котором продукт ломался:
|
||||
// оркестратор его пропускал, DTO пропускало, а bcrypt отвечал
|
||||
// ErrPasswordTooLong уже при создании учётной записи.
|
||||
func TestAdminPasswordBoundaries(t *testing.T) {
|
||||
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» —
|
||||
// четырёхбайтовым, случаи ниже проверяли бы не то, что написано.
|
||||
if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
|
||||
}
|
||||
if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
password string
|
||||
accepted bool
|
||||
}{
|
||||
{"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true},
|
||||
{"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false},
|
||||
{"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true},
|
||||
{"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false},
|
||||
|
||||
// Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов.
|
||||
{"36 x «я» = 72 байта", strings.Repeat("я", 36), true},
|
||||
{"37 x «я» = 74 байта", strings.Repeat("я", 37), false},
|
||||
{"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true},
|
||||
{"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false},
|
||||
|
||||
// Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА.
|
||||
{"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false},
|
||||
|
||||
// Пробелы по краям — часть пароля, а не мусор ввода.
|
||||
{"пробел на конце", "abcde ", true},
|
||||
{"пробел в начале", " abcde", true},
|
||||
{"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true},
|
||||
|
||||
// Набор символов не ограничен ничем, кроме управляющих.
|
||||
{"кириллица", "пароль-администратора", true},
|
||||
{"знаки препинания", `p@$$w0rd"\'#;`, true},
|
||||
{"перевод строки", "abcde\n", false},
|
||||
{"возврат каретки", "abcde\r", false},
|
||||
{"табуляция", "abcde\t", false},
|
||||
{"нулевой байт", "abcde\x00", false},
|
||||
{"DEL", "abcde\x7f", false},
|
||||
|
||||
// C1: раньше документация обещала «без управляющих символов», а код
|
||||
// проверял только C0 и DEL — то есть обещание было шире проверки.
|
||||
{"C1 NEL", "abcde" + string(rune(0x85)), false},
|
||||
{"C1 верхняя граница", "abcde" + string(rune(0x9F)), false},
|
||||
|
||||
// Домен systemd. Каждое из этих значений — шесть символов, ни одного
|
||||
// управляющего, укладывается в 72 байта, — и при этом делает
|
||||
// /etc/hy2xs/hy2xs.env НЕзагружаемым: `check_utf8ness_and_warn`
|
||||
// отвечает -EINVAL, и юнит hy2xs-admin не стартует.
|
||||
{"noncharacter U+FDD0", "abcde" + string(rune(0xFDD0)), false},
|
||||
{"noncharacter U+FDEF", "abcde" + string(rune(0xFDEF)), false},
|
||||
{"noncharacter U+FFFE", "abcde" + string(rune(0xFFFE)), false},
|
||||
{"noncharacter U+FFFF", "abcde" + string(rune(0xFFFF)), false},
|
||||
{"noncharacter U+1FFFF", "abcde" + string(rune(0x1FFFF)), false},
|
||||
{"noncharacter U+10FFFF", "abcde" + string(rune(0x10FFFF)), false},
|
||||
{"невалидный UTF-8", "abcde\xff", false},
|
||||
|
||||
// Соседи noncharacters приниматься обязаны: правило описывает ровно
|
||||
// множество systemd, а не «всё подозрительное рядом».
|
||||
{"U+FDCF рядом с диапазоном", "abcde" + string(rune(0xFDCF)), true},
|
||||
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
|
||||
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
|
||||
|
||||
// U+FEFF отвергается документированным транспортным доменом systemd.
|
||||
{"U+FEFF", "abcde" + string(rune(0xFEFF)), false},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := IsValidAdminPassword(testCase.password); got != testCase.accepted {
|
||||
t.Errorf(
|
||||
"IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)",
|
||||
got, testCase.accepted,
|
||||
utf8.RuneCountInString(testCase.password), len(testCase.password),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Предел в байтах не выводится из предела в символах, и обратное неверно тоже.
|
||||
//
|
||||
// Проверка защищает от «упрощения», при котором одну из двух констант объявят
|
||||
// производной от другой: у 64 символов длина от 64 до 256 байт.
|
||||
func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
|
||||
if AdminPasswordMaxBytes != 72 {
|
||||
t.Fatalf(
|
||||
"предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72",
|
||||
AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// Пароль, укладывающийся в символы и не укладывающийся в байты, обязан
|
||||
// существовать — иначе байтовая граница недостижима и ничего не проверяет.
|
||||
long := strings.Repeat("я", AdminPasswordMaxLength)
|
||||
if utf8.RuneCountInString(long) > AdminPasswordMaxLength {
|
||||
t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница")
|
||||
}
|
||||
if IsValidAdminPassword(long) {
|
||||
t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется")
|
||||
}
|
||||
}
|
||||
|
||||
// Домен транспорта повторяет публично документированное множество systemd.
|
||||
//
|
||||
// Шире означало бы собственный запрет сверх публичного контракта; уже — что
|
||||
// значение проходит все двери продукта вопреки документации EnvironmentFile.
|
||||
// Второе и было дефектом: контракт знал про управляющие символы и не знал про
|
||||
// noncharacters.
|
||||
//
|
||||
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
|
||||
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
|
||||
func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
|
||||
rejected := []rune{
|
||||
0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
|
||||
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
|
||||
0xFFFE, 0xFFFF,
|
||||
0x1FFFE, 0x1FFFF,
|
||||
0x10FFFE, 0x10FFFF,
|
||||
}
|
||||
for _, r := range rejected {
|
||||
if IsEnvTransportableText(string(r)) {
|
||||
t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
|
||||
}
|
||||
}
|
||||
|
||||
accepted := []rune{
|
||||
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
|
||||
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
|
||||
0xFFFD, // replacement character — обычный символ
|
||||
0x10FFFD, // последний scalar value, не noncharacter
|
||||
0x1F600, // 😀
|
||||
}
|
||||
for _, r := range accepted {
|
||||
if !IsEnvTransportableText(string(r)) {
|
||||
t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Невалидный UTF-8 — не «строка со странными символами», а байты, которые
|
||||
// systemd отвергает первой же проверкой.
|
||||
for _, invalid := range []string{"\xff", "\xc3", "abc\xed\xa0\x80"} {
|
||||
if IsEnvTransportableText(invalid) {
|
||||
t.Errorf("невалидный UTF-8 %q принят", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Продуктовая политика и домен systemd — РАЗНЫЕ множества, и это видно.
|
||||
//
|
||||
// Пока они были склеены, невозможно было ни объяснить отказ, ни заметить, что
|
||||
// одно из двух правил отсутствует.
|
||||
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
|
||||
// Управляющие символы формат несёт — их запрещает HY2XS.
|
||||
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} {
|
||||
password := "abcde" + string(r)
|
||||
if !IsEnvTransportableText(password) && r != 0x00 {
|
||||
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
|
||||
}
|
||||
if IsValidAdminPassword(password) {
|
||||
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
|
||||
}
|
||||
}
|
||||
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
|
||||
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится нигде, включая сам контракт.
|
||||
//
|
||||
// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace,
|
||||
// значение `"abcde "` считалось корректным всеми дверями продукта и не могло
|
||||
// быть захешировано.
|
||||
func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) {
|
||||
// Шесть символов, из которых последний — пробел. По длине без пробела это
|
||||
// пять символов, то есть прежняя проверка отказала бы.
|
||||
const password = "abcde "
|
||||
if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength {
|
||||
t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то")
|
||||
}
|
||||
if !IsValidAdminPassword(password) {
|
||||
t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
|
||||
}
|
||||
}
|
||||
@@ -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,104 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// Bootstrap-путь администратора обязан подчиняться ОБЩЕМУ контракту пароля.
|
||||
//
|
||||
// Здесь было два расхождения, и оба ломали установку молча.
|
||||
//
|
||||
// Первое: `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Продукт
|
||||
// объявляет пробел по краям частью пароля и не снимает его ни на форме входа,
|
||||
// ни при смене, ни в оркестраторе — а здесь снимал. Учётная запись создавалась
|
||||
// с паролем, отличным от того, который оператор записал в hy2xs.env и которым
|
||||
// он потом пытался войти.
|
||||
//
|
||||
// Второе: контракт не проверялся вовсе, поэтому непригодный для bcrypt пароль
|
||||
// (64 кириллические буквы = 128 байт) доходил до GenerateFromPassword, и старт
|
||||
// службы падал с «bcrypt: password length exceeds 72 bytes» — сообщением, по
|
||||
// которому нельзя понять ни что чинить, ни где.
|
||||
|
||||
func startAdminBootstrap(t *testing.T, password string) error {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("HY2XS_ADMIN_USER", "hy2xsadmin")
|
||||
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", password)
|
||||
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||
|
||||
err := InitSqlAt(filepath.Join(t.TempDir(), "hy2xs-admin-test.db"))
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
return err
|
||||
}
|
||||
|
||||
// Пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом.
|
||||
func TestBootstrapAdminKeepsSurroundingWhitespaceInPassword(t *testing.T) {
|
||||
const password = "bootstrap-admin-password "
|
||||
|
||||
if err := startAdminBootstrap(t, password); err != nil {
|
||||
t.Fatalf("запуск сервиса не удался: %v", err)
|
||||
}
|
||||
|
||||
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись администратора не создана: %v", err)
|
||||
}
|
||||
if admin.PasswordHash == nil {
|
||||
t.Fatal("у созданной учётной записи нет хеша пароля")
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(password, *admin.PasswordHash) {
|
||||
t.Error("вход исходным значением невозможен: пароль обрезан при создании учётной записи")
|
||||
}
|
||||
if util.VerifyPassword(strings.TrimSpace(password), *admin.PasswordHash) {
|
||||
t.Error("вход обрезанным значением возможен: пробел по краям снят вопреки контракту")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль, не проходящий контракт, роняет старт с ПРИЧИНОЙ, а не с ошибкой
|
||||
// bcrypt, и учётной записи после этого не остаётся.
|
||||
func TestBootstrapAdminRefusesPasswordOutsideTheContract(t *testing.T) {
|
||||
// 64 символа — в границе; 128 байт — за пределом bcrypt.
|
||||
password := strings.Repeat("я", credential.AdminPasswordMaxLength)
|
||||
if credential.IsValidAdminPassword(password) {
|
||||
t.Fatal("подготовка: контракт принимает значение, проверяется не то")
|
||||
}
|
||||
|
||||
err := startAdminBootstrap(t, password)
|
||||
if err == nil {
|
||||
t.Fatal("старт с непригодным паролем завершился успешно")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HY2XS_ADMIN_INITIAL_PASSWORD") {
|
||||
t.Errorf("отказ не называет переменную, которую надо чинить: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hy2xs.env") {
|
||||
t.Errorf("отказ не называет файл, в котором чинится причина: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль ровно в предел bcrypt (72 байта) — законное значение, и установка с
|
||||
// ним обязана проходить целиком.
|
||||
func TestBootstrapAdminAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
|
||||
password := strings.Repeat("я", 36)
|
||||
if len(password) != credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
if err := startAdminBootstrap(t, password); err != nil {
|
||||
t.Fatalf("установка с паролем в 72 байта не удалась: %v", err)
|
||||
}
|
||||
|
||||
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись администратора не создана: %v", err)
|
||||
}
|
||||
if !util.VerifyPassword(password, *admin.PasswordHash) {
|
||||
t.Error("пароль в 72 байта не проходит проверку собственным хешем")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/util"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -24,8 +22,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 +55,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 +95,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 +109,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 == "" {
|
||||
@@ -109,16 +134,47 @@ func ensureSecureBootstrapAdmin() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
|
||||
passwordGenerated := false
|
||||
// Пароль читается КАК ЕСТЬ.
|
||||
//
|
||||
// Здесь стоял strings.TrimSpace, и он противоречил контракту продукта:
|
||||
// пробел по краям объявлен частью пароля и не снимается ни на форме входа,
|
||||
// ни при смене пароля, ни в оркестраторе. То есть bootstrap-путь
|
||||
// гарантированно создавал учётную запись НЕ с тем паролем, который оператор
|
||||
// записал в hy2xs.env, а войти в неё он пытался бы исходным значением.
|
||||
adminPassword := os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")
|
||||
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`.",
|
||||
)
|
||||
}
|
||||
|
||||
// Контракт проверяется ЗДЕСЬ, а не только при хешировании.
|
||||
//
|
||||
// Отказ bcrypt на непригодном пароле выглядел бы как «bcrypt: password
|
||||
// length exceeds 72 bytes» в journal — сообщение, по которому оператор не
|
||||
// поймёт ни что чинить, ни где. Причина же чинится одной строкой в
|
||||
// hy2xs.env, и назвать её обязан тот, кто знает про этот файл.
|
||||
if !credential.IsValidAdminPassword(adminPassword) {
|
||||
return fmt.Errorf(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
|
||||
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF.\n"+
|
||||
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
|
||||
"Учётная запись администратора с таким значением создана НЕ будет: "+
|
||||
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
|
||||
"Исправьте значение в /etc/hy2xs/hy2xs.env и запустите\n"+
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
|
||||
status := int64(1)
|
||||
tokenVersion := int64(1)
|
||||
@@ -140,28 +196,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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapSecret = generated
|
||||
seeded, err := bootstrapPeerSeeded()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 +285,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 +304,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 +331,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 +396,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 +459,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 +498,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 +550,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 +633,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 +643,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 {
|
||||
@@ -663,62 +741,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 nil
|
||||
}
|
||||
key := constant.Hysteria2TrafficStatsSecret
|
||||
remark := "Hysteria2 trafficStats API secret"
|
||||
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &envSecret, Remark: &remark}); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,5 +19,11 @@ Frontend собирается production builder'ом из [`tools/build`](../..
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build:prod
|
||||
bun test test/i18n-runtime.test.ts
|
||||
pnpm run verify
|
||||
```
|
||||
|
||||
Runtime-тест обходит все строковые сообщения русского и английского словарей
|
||||
через установленный `vue-i18n`. Он проверяет не только исключения, но и
|
||||
compiler diagnostics в `console.error`: некоторые версии компилятора сообщают
|
||||
ошибку формата без исключения из `t()`.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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 и используются как глобальные
|
||||
// типы. Списку положено совпадать с содержимым того файла: `no-undef`
|
||||
// не знает о declare global, и отсутствующее здесь имя падает ошибкой
|
||||
// линтера при совершенно корректном коде.
|
||||
//
|
||||
// DialogType и OptionType отсюда убраны: в src/types/global.d.ts их
|
||||
// нет и в панели они не встречаются — это остаток чужого шаблона,
|
||||
// разрешавший необъявленные имена.
|
||||
IdDto: "readonly",
|
||||
BaseDto: "readonly",
|
||||
PageVo: "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" />
|
||||
|
||||
@@ -2,67 +2,70 @@
|
||||
"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": "^11.4.10",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Настройки resolution принадлежат pnpm, а не npm package manifest.
|
||||
# Единственный workspace-проект — сам frontend.
|
||||
packages:
|
||||
- "."
|
||||
|
||||
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"
|
||||
browserslist: "4.28.7"
|
||||
@@ -1,39 +1,34 @@
|
||||
import { AxiosPromise } from "axios";
|
||||
import request from "@/utils/request";
|
||||
import {
|
||||
ConfigDto,
|
||||
ConfigsDto,
|
||||
ConfigUpdateDto,
|
||||
ConfigVo,
|
||||
Hysteria2AcmePathVo,
|
||||
Hysteria2ServerConfig,
|
||||
Hysteria2ProfileVo,
|
||||
} from "@/api/config/types";
|
||||
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
||||
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
|
||||
// им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и
|
||||
// загрузки сертификатов здесь нет — соответствующих маршрутов не существует.
|
||||
//
|
||||
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
|
||||
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
|
||||
//
|
||||
// getConfigApi удалён вместе с маршрутом: он принимал произвольный ключ той же
|
||||
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
|
||||
// listConfigApi, на стороне сервера — по allowlist.
|
||||
|
||||
// Ответ описывает production-профиль, а не всю модель серверного конфига: он
|
||||
// отличает «не задано» от значения и отдельно перечисляет секции вне профиля.
|
||||
// Раньше сюда приезжала внутренняя модель целиком — вместе с паролем
|
||||
// обфускации, токенами ACME DNS и учётными данными outbound-прокси.
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ProfileVo> {
|
||||
return request({
|
||||
url: "/config/getHysteria2Config",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
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 +45,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 +52,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,283 +19,104 @@ export interface ConfigUpdateDto {
|
||||
configUpdateDtos: Array<ConfigsUpdateDto>;
|
||||
}
|
||||
|
||||
export interface Hysteria2ServerConfig {
|
||||
listen: string;
|
||||
tls?: {
|
||||
cert: string;
|
||||
key: string;
|
||||
sniGuard?: string;
|
||||
};
|
||||
acme?: {
|
||||
domains: string[];
|
||||
email: string;
|
||||
ca: string;
|
||||
listenHost: string;
|
||||
dir: string;
|
||||
type?: string;
|
||||
http?: {
|
||||
altPort: number;
|
||||
};
|
||||
tls?: {
|
||||
altPort: number;
|
||||
};
|
||||
dns?: {
|
||||
name: string;
|
||||
config: { [key: string]: string };
|
||||
};
|
||||
disableHTTP: boolean;
|
||||
disableTLSALPN: boolean;
|
||||
altHTTPPort: number;
|
||||
altTLSALPNPort: number;
|
||||
};
|
||||
obfs?: {
|
||||
type: string;
|
||||
salamander: {
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
quic?: {
|
||||
initStreamReceiveWindow?: number;
|
||||
maxStreamReceiveWindow?: number;
|
||||
initConnReceiveWindow?: number;
|
||||
maxConnReceiveWindow?: number;
|
||||
maxIdleTimeout?: string;
|
||||
maxIncomingStreams?: number;
|
||||
disablePathMTUDiscovery?: boolean;
|
||||
};
|
||||
bandwidth?: {
|
||||
up: string;
|
||||
down: string;
|
||||
};
|
||||
ignoreClientBandwidth?: boolean;
|
||||
speedTest?: boolean;
|
||||
disableUDP?: boolean;
|
||||
udpIdleTimeout?: string;
|
||||
resolver?: {
|
||||
type: string;
|
||||
tcp?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
};
|
||||
udp?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
};
|
||||
tls?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
sni: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
https?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
sni: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
};
|
||||
sniff?: {
|
||||
enable: boolean;
|
||||
timeout: string;
|
||||
rewriteDomain: boolean;
|
||||
tcpPorts?: string;
|
||||
udpPorts?: string;
|
||||
};
|
||||
acl?: {
|
||||
file?: string;
|
||||
inline?: string[];
|
||||
geoip?: string;
|
||||
geosite?: string;
|
||||
geoUpdateInterval?: string;
|
||||
};
|
||||
outbounds?: Hysteria2ServerConfigOutbound[];
|
||||
trafficStats: {
|
||||
listen: string;
|
||||
};
|
||||
masquerade?: {
|
||||
type: string;
|
||||
file?: {
|
||||
dir: string;
|
||||
};
|
||||
proxy?: {
|
||||
url: string;
|
||||
rewriteHost: boolean;
|
||||
insecure: boolean;
|
||||
};
|
||||
string?: {
|
||||
content: string;
|
||||
headers?: { [key: string]: string };
|
||||
statusCode?: number;
|
||||
};
|
||||
listenHTTP?: string;
|
||||
listenHTTPS?: string;
|
||||
forceHTTPS?: boolean;
|
||||
};
|
||||
/**
|
||||
* Конфигурация Hysteria в терминах production-профиля HY2XS.
|
||||
*
|
||||
* Что было. Здесь жил `Hysteria2ServerConfig` — полная модель серверного
|
||||
* конфига со всеми секциями upstream (resolver, sniff, acl, outbounds,
|
||||
* masquerade, mimic, realm, ech), а рядом, в `hysteriaViewModel.ts`, — её
|
||||
* копия с обязательными полями и полный объект значений по умолчанию, поверх
|
||||
* которого накладывался ответ сервера.
|
||||
*
|
||||
* Из-за этой конструкции экран отвечал не на тот вопрос. Отсутствующая секция
|
||||
* `trafficStats` показывалась как `:9999`, отсутствующий блок ACME — как набор
|
||||
* дефолтов UI, `speedTest: false` и `disableUDP: false` считались
|
||||
* ненастроенными и прятали свои вкладки. Диагностический экран скрывал ровно
|
||||
* тот дрейф, ради которого его открывают.
|
||||
*
|
||||
* Продуктом является ОДИН профиль: конфиг генерирует оркестратор и сам же
|
||||
* проверяет соответствие файла профилю. Поэтому панель показывает значения
|
||||
* профиля так, как они записаны, и отдельно перечисляет секции вне профиля.
|
||||
* Полный документ по-прежнему доступен санитизированной выгрузкой.
|
||||
*
|
||||
* `null` означает «в файле этого нет». Это единственный способ отличить
|
||||
* отсутствие от значения: `false`, `0` и пустая строка — законные значения.
|
||||
*/
|
||||
export interface Hysteria2ProfileVo {
|
||||
listen: string | null;
|
||||
auth: Hysteria2ProfileAuth | null;
|
||||
tls: Hysteria2ProfileTls | null;
|
||||
acme: Hysteria2ProfileAcme | null;
|
||||
obfs: Hysteria2ProfileObfs | null;
|
||||
bandwidth: Hysteria2ProfileBandwidth | null;
|
||||
ignoreClientBandwidth: boolean | null;
|
||||
congestion: Hysteria2ProfileCongestion | null;
|
||||
quic: Hysteria2ProfileQuic | null;
|
||||
trafficStats: Hysteria2ProfileTrafficStats | null;
|
||||
/** Секции файла, которых production-профиль не описывает. */
|
||||
drift: 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;
|
||||
socks5?: {
|
||||
addr: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
http?: {
|
||||
url: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
direct?: {
|
||||
mode: string;
|
||||
bindIPv4?: string;
|
||||
bindIPv6?: string;
|
||||
bindDevice?: string;
|
||||
fastOpen?: boolean;
|
||||
};
|
||||
export interface Hysteria2ProfileAuth {
|
||||
type: string | null;
|
||||
/** Адрес backend-auth без machine token: он вырезан на сервере. */
|
||||
url: string | null;
|
||||
insecure: boolean | null;
|
||||
}
|
||||
|
||||
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 Hysteria2ProfileTls {
|
||||
cert: string | null;
|
||||
key: string | null;
|
||||
sniGuard: string | null;
|
||||
clientCA: string | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2AcmePathVo {
|
||||
crtPath: string;
|
||||
keyPath: string;
|
||||
export interface Hysteria2ProfileAcme {
|
||||
domains: string[];
|
||||
email: string | null;
|
||||
ca: string | null;
|
||||
dir: string | null;
|
||||
listenHost: string | null;
|
||||
type: string | null;
|
||||
dnsProvider: string | null;
|
||||
/** Имена параметров DNS-провайдера; значения на сервер не отдаются. */
|
||||
dnsConfigKeys: string[];
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileObfs {
|
||||
type: string | null;
|
||||
/**
|
||||
* Пароль обфускации в панель не приходит: он входит в клиентскую ссылку и
|
||||
* выдаётся там, где нужен. Диагностичен только факт, что он задан.
|
||||
*/
|
||||
passwordSet: boolean;
|
||||
minPacketSize: number | null;
|
||||
maxPacketSize: number | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileBandwidth {
|
||||
up: string | null;
|
||||
down: string | null;
|
||||
disableLossCompensation: boolean | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileCongestion {
|
||||
type: string | null;
|
||||
bbrProfile: string | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileQuic {
|
||||
initStreamReceiveWindow: number | null;
|
||||
maxStreamReceiveWindow: number | null;
|
||||
initConnReceiveWindow: number | null;
|
||||
maxConnReceiveWindow: number | null;
|
||||
maxIdleTimeout: string | null;
|
||||
maxIncomingStreams: number | null;
|
||||
disablePathMTUDiscovery: boolean | null;
|
||||
disableStatelessReset: boolean | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileTrafficStats {
|
||||
listen: string | null;
|
||||
secretSet: boolean;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,23 @@ export interface DashboardSummaryVo {
|
||||
};
|
||||
hysteria: {
|
||||
version: string;
|
||||
/**
|
||||
* Состояние службы по systemd.
|
||||
*
|
||||
* `unknown` — это НЕ «остановлена»: `systemctl is-active` может не
|
||||
* ответить вовсе, и тогда о службе не известно ничего. Пока состояние было
|
||||
* булевым, второе выдавалось за первое, и оператор шёл перезапускать
|
||||
* работающий туннель.
|
||||
*/
|
||||
serviceState: "active" | "inactive" | "unknown";
|
||||
/** Ровно `serviceState === "active"`. */
|
||||
running: boolean;
|
||||
/**
|
||||
* Ответил ли Traffic Stats API на фактическое обращение.
|
||||
*
|
||||
* Независим от `serviceState`: раньше выводился из него и мог утверждать
|
||||
* «API доступен» при «служба остановлена», ни разу не сходив в API.
|
||||
*/
|
||||
apiReachable: boolean;
|
||||
lastApiError?: string;
|
||||
};
|
||||
|
||||
@@ -8,12 +8,20 @@ export interface LogExportDto {
|
||||
|
||||
export interface LogSystemVo {
|
||||
clientIp: string;
|
||||
latencyTime: string;
|
||||
// Числа объявлены числами: сервер шлёт их из vo.LogSystemVo как int64
|
||||
// (latencyTime, statusCode), а не строками. Пока обе колонки не
|
||||
// отображались, расхождение было безвредным; после того как строка журнала
|
||||
// стала читаемой, оно стало бы обычной ошибкой сравнения или форматирования.
|
||||
latencyTime: number;
|
||||
level: string;
|
||||
msg: string;
|
||||
reqMethod: string;
|
||||
reqUri: string;
|
||||
statusCode: string;
|
||||
// Путь без query string: в query по машинному endpoint'у приезжает
|
||||
// действующий machine token, и в журнале его быть не должно.
|
||||
reqPath: string;
|
||||
// Только имена переданных query-параметров, без значений.
|
||||
reqQueryKeys: string;
|
||||
statusCode: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
KickPeerDto,
|
||||
PeerClientConfigVo,
|
||||
PeerPageDto,
|
||||
PeerPageVo,
|
||||
PeerSaveDto,
|
||||
PeerUpdateDto,
|
||||
PeerVo,
|
||||
@@ -16,15 +17,20 @@ export function getPeerApi(data: IdDto): AxiosPromise<PeerVo> {
|
||||
});
|
||||
}
|
||||
|
||||
// Форма пира показывает причины отказа под своими полями, поэтому общий тост
|
||||
// ей не нужен: он повторял бы то же самое вторым сигналом.
|
||||
export function savePeerApi(data: PeerSaveDto): AxiosPromise {
|
||||
return request({
|
||||
url: "/peers",
|
||||
method: "post",
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
|
||||
// Ответ страницы описан собственным типом, а не общим PageVo<PeerVo>: кроме
|
||||
// записей он несёт признак того, известна ли вообще картина подключений.
|
||||
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PeerPageVo> {
|
||||
return request({
|
||||
url: "/peers",
|
||||
method: "get",
|
||||
@@ -32,10 +38,20 @@ export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Действия над строкой пира сообщают свой исход сами (см. reportPeerActionError
|
||||
// в списке пиров), поэтому общий тост им не нужен: он был бы вторым сигналом о
|
||||
// том же событии.
|
||||
//
|
||||
// Для отзыва доступа это не косметика. Операция умеет завершиться ЧАСТИЧНО —
|
||||
// запрет новых подключений записан, активную сессию завершить не удалось, — и
|
||||
// страница показывает такой исход предупреждением. Общий перехватчик показал бы
|
||||
// рядом ошибку, то есть два противоречащих друг другу сообщения об одном
|
||||
// результате.
|
||||
export function deletePeerApi(data: IdDto): AxiosPromise {
|
||||
return request({
|
||||
url: `/peers/${data.id}`,
|
||||
method: "delete",
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,6 +60,7 @@ export function updatePeerApi(data: PeerUpdateDto): AxiosPromise {
|
||||
url: `/peers/${data.id}`,
|
||||
method: "patch",
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,6 +68,7 @@ export function resetPeerTrafficApi(data: IdDto): AxiosPromise {
|
||||
return request({
|
||||
url: `/peers/${data.id}/reset-traffic`,
|
||||
method: "patch",
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +76,7 @@ export function releaseKickPeerApi(data: IdDto): AxiosPromise {
|
||||
return request({
|
||||
url: `/peers/${data.id}/release-kick`,
|
||||
method: "patch",
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +85,7 @@ export function kickPeerApi(id: number, data: KickPeerDto): AxiosPromise {
|
||||
url: `/peers/${id}/kick`,
|
||||
method: "patch",
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +98,15 @@ export function getPeerClientConfigApi(
|
||||
});
|
||||
}
|
||||
|
||||
// Импорт сообщает свой исход сам — по той же причине, что и действия строки
|
||||
// пира.
|
||||
//
|
||||
// Партия применяется одной транзакцией, а после её фиксации завершаются старые
|
||||
// сессии обновлённых пиров. Второй шаг умеет не удаться отдельно от первого, и
|
||||
// тогда ответ несёт peer_disconnect_failed: файл применён целиком, но часть
|
||||
// клиентов остаётся на связи до переподключения. Общий перехватчик показал бы
|
||||
// такой исход красной ошибкой, то есть сообщил бы оператору ровно обратное
|
||||
// тому, что произошло.
|
||||
export function importPeerApi(data: FormData): AxiosPromise {
|
||||
return request({
|
||||
url: "/peer-import",
|
||||
@@ -86,13 +115,30 @@ export function importPeerApi(data: FormData): AxiosPromise {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
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,11 +60,33 @@ export interface PeerVo extends IdDto {
|
||||
createTime: string;
|
||||
online: boolean;
|
||||
onlineDevices: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Известна ли панели картина подключений прямо сейчас.
|
||||
*
|
||||
* Признак относится к ответу целиком, а не к строке: при `unavailable` поле
|
||||
* `online` в строках не означает ничего. Раньше отказ Traffic Stats API молча
|
||||
* превращался в «все офлайн» — ответ, уводящий оператора искать проблему у
|
||||
* пользователей.
|
||||
*/
|
||||
export type PeerOnlineState = "ok" | "unavailable";
|
||||
|
||||
export interface PeerPageVo {
|
||||
records: PeerVo[];
|
||||
total: number;
|
||||
onlineState: PeerOnlineState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентская ссылка пира.
|
||||
*
|
||||
* Поля `qrCode` здесь больше нет: QR рисуется в панели из самой ссылки
|
||||
* (qrcode.vue), и второй его экземпляр в ответе был лишним трафиком и вторым
|
||||
* способом получить то же самое.
|
||||
*/
|
||||
export interface PeerClientConfigVo {
|
||||
url: string;
|
||||
qrCode?: string | Uint8Array;
|
||||
}
|
||||
|
||||
export interface KickPeerDto {
|
||||
|
||||
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="#000000"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="#000000"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="currentColor"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="currentColor"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 768 B After Width: | Height: | Size: 783 B |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 440 B After Width: | Height: | Size: 445 B |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="#000000"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="#000000"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="currentColor"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="currentColor"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="#000000"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="currentColor"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="#000000"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="currentColor"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="#000000"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="currentColor"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,77 +0,0 @@
|
||||
<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 }}
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
import { ElInput } from "element-plus";
|
||||
import { PropType } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
tags: {
|
||||
required: false,
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-toolbar">
|
||||
<el-form :model="queryParams" :inline="true">
|
||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||
<el-select
|
||||
v-model="queryParams.numLine"
|
||||
style="width: 200px"
|
||||
@change="load"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in LINE_OPTIONS"
|
||||
:key="option"
|
||||
:label="String(option)"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="export">
|
||||
<el-button :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item prop="refresh">
|
||||
<el-button @click="load">
|
||||
<template #icon>
|
||||
<i-ep-refresh />
|
||||
</template>
|
||||
{{ $t("common.refresh") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="records" class="log-table">
|
||||
<!--
|
||||
Ширины заданы явно и по назначению колонки.
|
||||
|
||||
Без width/min-width Element Plus делит доступную ширину между
|
||||
колонками практически поровну: уровень и время получали по трети
|
||||
строки, а сообщение — тоже треть, хотя именно оно и является
|
||||
содержимым журнала. У Hysteria в msg приезжает диагностический JSON,
|
||||
поэтому колонка сообщения растягивается за счёт остатка, а служебные
|
||||
колонки зафиксированы.
|
||||
-->
|
||||
<el-table-column
|
||||
prop="level"
|
||||
label="level"
|
||||
width="110"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="msg" label="msg" min-width="640" align="left">
|
||||
<template #default="scope">
|
||||
<!--
|
||||
Сообщение ПЕРЕНОСИТСЯ, а не обрезается многоточием: у Hysteria
|
||||
это диагностический JSON, и обрезанная строка не отвечает ни на
|
||||
один вопрос, ради которого страницу открыли.
|
||||
-->
|
||||
<span class="log-message">{{ scope.row.msg }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="time" label="time" width="200" align="center" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Общая страница журнала: системного и Hysteria.
|
||||
*
|
||||
* Обе страницы были побайтово одинаковы, кроме вызываемого API, и обе несли
|
||||
* одни и те же три дефекта: колонки без ширин, экспорт с сетевым запросом ВНЕ
|
||||
* try и пустой catch, глушивший всё остальное. Починка в двух местах разошлась
|
||||
* бы снова — как разошлись форма ответа и обработка ошибок до этого.
|
||||
*
|
||||
* Различие между страницами вынесено в параметры: чем читать и что выгружать.
|
||||
*/
|
||||
import { AxiosPromise, AxiosResponse } from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { exportLogApi } from "@/api/log";
|
||||
import { LogDto } from "@/api/log/types";
|
||||
import { saveResponseAsFile } from "@/utils/download";
|
||||
|
||||
/** Строка журнала в объёме, который показывает таблица. */
|
||||
interface LogRow {
|
||||
level: string;
|
||||
msg: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Чтение страницы журнала. */
|
||||
fetchLogs: (query: LogDto) => AxiosPromise<PageVo<LogRow>>;
|
||||
/** Значение option для выгрузки: 0 — журнал админки, 1 — журнал Hysteria. */
|
||||
exportOption: number;
|
||||
/** Имя файла, если сервер его не назвал. */
|
||||
exportFileName: string;
|
||||
}>();
|
||||
|
||||
const LINE_OPTIONS = [100, 200, 300];
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const records = ref<LogRow[]>([]);
|
||||
const queryParams = reactive<LogDto>({ numLine: LINE_OPTIONS[0] });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await props.fetchLogs(queryParams);
|
||||
// Ответ обязан нести records на любой ветке — это контракт LogSystemPage.
|
||||
// Запасное значение здесь стоит не вместо контракта, а чтобы отказ формы
|
||||
// ответа не превращался в исключение внутри таблицы.
|
||||
records.value = data?.records ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выгрузка журнала.
|
||||
*
|
||||
* Сетевой запрос находится ВНУТРИ try. Раньше он стоял перед ним, поэтому его
|
||||
* отказ не ловился этим catch вовсе, а сам catch был пуст: оператор не получал
|
||||
* ни файла, ни причины.
|
||||
*
|
||||
* Отказ показывается ОДИН раз. Выгрузка приходит бинарным потоком и не
|
||||
* проходит через общий разбор конверта — у Blob нет полей code и errors, —
|
||||
* поэтому фразу здесь даёт сама страница.
|
||||
*/
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const response: AxiosResponse = await exportLogApi({
|
||||
option: props.exportOption,
|
||||
});
|
||||
saveResponseAsFile(response, props.exportFileName);
|
||||
ElMessage.success(t("common.downloadSuccess"));
|
||||
} catch {
|
||||
ElMessage.error(t("log.exportFailed"));
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-message {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
font-family: var(--el-font-family-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -1,182 +0,0 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "mapObject",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
interface Form {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
mapObject: {
|
||||
required: false,
|
||||
type: Object as PropType<{ [key: string]: string }>,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,42 @@
|
||||
<template>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class="svg-icon"
|
||||
:style="'width:' + size + ';height:' + size"
|
||||
>
|
||||
<use :xlink:href="symbolId" :fill="color" />
|
||||
<use :xlink:href="symbolId" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SYMBOL_PREFIX } from "./symbol";
|
||||
|
||||
/**
|
||||
* Цвет иконке не передаётся — и это контракт, а не упущение.
|
||||
*
|
||||
* Раньше здесь были проп `color` и `:fill="color"` на `<use>`. Ими никто не
|
||||
* пользовался ни разу, а существование такого пропа приглашает чинить
|
||||
* сломанный цвет точечно: «вот этой иконке передадим белый». Монохромная
|
||||
* иконка обязана получать цвет ровно одним способом — наследованием
|
||||
* `currentColor` от компонента и темы; ассет, который так не умеет, чинится в
|
||||
* самом ассете и не доезжает до релиза (см. `symbol.ts`).
|
||||
*
|
||||
* Префикс id тоже больше не проп: он принадлежит спрайту, а не месту вызова, и
|
||||
* объявлен рядом с кодом, который этот id создаёт.
|
||||
*/
|
||||
const props = defineProps({
|
||||
prefix: {
|
||||
type: String,
|
||||
default: "icon",
|
||||
},
|
||||
iconClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "1em",
|
||||
},
|
||||
});
|
||||
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
|
||||
const symbolId = computed(() => `#${SYMBOL_PREFIX}-${props.iconClass}`);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Спрайт локальных 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 при этом потеряна. Для семнадцати вручную отобранных
|
||||
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в
|
||||
* сборке.
|
||||
*
|
||||
* Преобразование файла в `<symbol>` и контракт ассета живут в `./symbol.ts`:
|
||||
* там нет ни Vite, ни DOM, поэтому те же правила проверяются тестом и
|
||||
* релизным гейтом, а не только глазами на живой странице.
|
||||
*/
|
||||
|
||||
import { iconName, toSymbol } from "./symbol";
|
||||
|
||||
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
|
||||
|
||||
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
|
||||
// запросов за иконками не возникает.
|
||||
const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
|
||||
* содержимое, а не добавляет второй элемент с теми же 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;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Превращение исходного SVG-файла в `<symbol>` и контракт, которому исходник
|
||||
* обязан соответствовать.
|
||||
*
|
||||
* Модуль намеренно ЧИСТЫЙ: ни `import.meta.glob`, ни `document`, ни любого
|
||||
* другого Vite/DOM API здесь нет. Сборка спрайта из файлов живёт в `sprite.ts`,
|
||||
* а сюда вынесено ровно то, что можно выполнить вне браузера и вне Vite —
|
||||
* то есть проверить тестом (`tools/test/frontend-sprite.test.ts`) и релизным
|
||||
* гейтом.
|
||||
*
|
||||
* Разделение появилось не ради красоты. Цвет иконок был сломан молча: контракт
|
||||
* `fill: currentcolor` существовал в двух местах (`SvgIcon/index.vue` и
|
||||
* `styles/sidebar.scss`), но восемь из семнадцати ассетов несли литеральный
|
||||
* атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления
|
||||
* перебивает унаследованное CSS-свойство. Все семь иконок бокового меню
|
||||
* рисовались чёрным по `--menuBg: #181818`. Ни одна существующая проверка
|
||||
* этого не видела, потому что проверять было нечего: сам файл иконки под
|
||||
* гейтом не был.
|
||||
*/
|
||||
|
||||
/** Префикс id у `<symbol>`; `SvgIcon` строит по нему `<use href="#icon-…">`. */
|
||||
export const SYMBOL_PREFIX = "icon";
|
||||
|
||||
/**
|
||||
* Иконки, которые многоцветны НАМЕРЕННО.
|
||||
*
|
||||
* Для них собственная палитра — часть ассета, а не дефект, поэтому проверка
|
||||
* цвета к ним не применяется. Список закрытый и явный: «многоцветность»
|
||||
* обязана быть решением, а не следствием того, что иконку скачали с готовыми
|
||||
* значениями fill.
|
||||
*
|
||||
* Всё остальное — монохромный UI: цвет наследуется от компонента и темы через
|
||||
* `currentColor`, и это единственный способ, которым иконка может получить
|
||||
* цвет. Ни CSS-фильтров, ни правил на конкретное имя иконки.
|
||||
*/
|
||||
export const MULTICOLOR_ICONS: ReadonlySet<string> = new Set([
|
||||
"download",
|
||||
"upload",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Значения `fill`/`stroke`, которые цветом не являются и потому разрешены
|
||||
* монохромной иконке.
|
||||
*
|
||||
* `none` — это «не закрашивать», а не цвет: у `refresh` контур рисуется
|
||||
* штрихом, и `fill="none"` там обязателен.
|
||||
*/
|
||||
const NON_COLOR_PAINT = new Set(["currentcolor", "none", "inherit", "transparent"]);
|
||||
|
||||
/** Атрибуты, любое литеральное значение которых задаёт цвет. */
|
||||
const PAINT_ATTRIBUTES = [
|
||||
"fill",
|
||||
"stroke",
|
||||
"stop-color",
|
||||
"flood-color",
|
||||
"lighting-color",
|
||||
];
|
||||
|
||||
/** `icons/log-system.svg` → `log-system`. */
|
||||
export function iconName(filePath: string): string {
|
||||
return filePath.replace(/^.*[\\/]/, "").replace(/\.svg$/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает то, что внутри уже существующего документа не только бесполезно, но
|
||||
* и делает разметку невалидной: XML-пролог, DOCTYPE и комментарии.
|
||||
*/
|
||||
function stripProlog(raw: string): string {
|
||||
return raw
|
||||
.replace(/<\?xml[\s\S]*?\?>/gi, "")
|
||||
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Превращает содержимое файла в `<symbol>`.
|
||||
*
|
||||
* `width` и `height` отбрасываются вместе с корневым тегом — размер задаёт
|
||||
* компонент. Цвета НЕ переписываются: источник истины — сам файл, а
|
||||
* молчаливая нормализация в рантайме скрывала бы ровно тот дефект, который
|
||||
* этот модуль обязан делать видимым. За соответствие отвечает
|
||||
* `findIconContractViolations`, вызываемая тестом и релизным гейтом.
|
||||
*
|
||||
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
|
||||
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
|
||||
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
|
||||
* задают только width/height, поэтому viewBox для них синтезируется — ровно
|
||||
* так же, как это делал заменённый `vite-plugin-svg-icons`.
|
||||
*/
|
||||
export function toSymbol(raw: string, name: string): string {
|
||||
const withoutProlog = stripProlog(raw);
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет ассет на соответствие контракту спрайта.
|
||||
*
|
||||
* Возвращает список нарушений; пустой список означает, что иконка пригодна.
|
||||
* Проверка одна на всех потребителей — тест и релизный гейт зовут её, а не
|
||||
* повторяют правила у себя. Второй экземпляр этих правил неизбежно разошёлся
|
||||
* бы с первым, и разошёлся бы молча.
|
||||
*/
|
||||
export function findIconContractViolations(raw: string, name: string): string[] {
|
||||
const violations: string[] = [];
|
||||
const source = stripProlog(raw);
|
||||
|
||||
const openTag = source.match(/<svg\b[^>]*>/i);
|
||||
if (!openTag) {
|
||||
return [`${name}: нет корневого <svg>`];
|
||||
}
|
||||
|
||||
// Система координат: либо объявленный viewBox, либо пара width/height, из
|
||||
// которой он синтезируется. Иконка без обоих способов сломала бы отрисовку
|
||||
// молча.
|
||||
if (!resolveViewBox(openTag[0])) {
|
||||
violations.push(`${name}: нет ни viewBox, ни пары width/height`);
|
||||
}
|
||||
|
||||
if (MULTICOLOR_ICONS.has(name)) {
|
||||
return violations;
|
||||
}
|
||||
|
||||
for (const attribute of PAINT_ATTRIBUTES) {
|
||||
const pattern = new RegExp(`\\b${attribute}\\s*=\\s*"([^"]*)"`, "gi");
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const value = match[1].trim();
|
||||
if (value === "") {
|
||||
continue;
|
||||
}
|
||||
if (!NON_COLOR_PAINT.has(value.toLowerCase())) {
|
||||
violations.push(
|
||||
`${name}: атрибут ${attribute}="${value}" задаёт цвет мимо currentColor`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Инлайновый style бьёт и атрибут, и наследование, поэтому цвет в нём —
|
||||
// такое же нарушение контракта, как литеральный атрибут.
|
||||
for (const match of source.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)) {
|
||||
const declarations = match[1].toLowerCase();
|
||||
for (const attribute of PAINT_ATTRIBUTES) {
|
||||
const property = declarations.match(
|
||||
new RegExp(`(?:^|;)\\s*${attribute}\\s*:\\s*([^;]+)`)
|
||||
);
|
||||
if (property && !NON_COLOR_PAINT.has(property[1].trim())) {
|
||||
violations.push(
|
||||
`${name}: инлайновый style задаёт ${attribute}: ${property[1].trim()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Непустой <style> внутри ассета уезжает в документ вместе со спрайтом и
|
||||
// способен покрасить что угодно, включая чужие иконки: селекторы там
|
||||
// глобальные. Пустой блок остаётся от редакторов и безвреден.
|
||||
for (const match of source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
||||
if (match[1].trim() !== "") {
|
||||
violations.push(`${name}: непустой <style> внутри ассета`);
|
||||
}
|
||||
}
|
||||
|
||||
// Растр внутри иконки не наследует цвет ничем и никогда.
|
||||
if (/<image\b/i.test(source)) {
|
||||
violations.push(`${name}: растровое <image> не подчиняется currentColor`);
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div style="display: flex; align-items: center">
|
||||
<el-input-number
|
||||
v-model="capacity"
|
||||
placeholder="Please enter a value"
|
||||
:min="-1"
|
||||
:controls="false"
|
||||
:precision="0"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
/>
|
||||
<el-select
|
||||
v-model="unit"
|
||||
:placeholder="$t('peer.unit')"
|
||||
style="width: 100px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in units"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
import {
|
||||
calculateBytes,
|
||||
formatStorageCapacity,
|
||||
formatStorageUnit,
|
||||
} from "@/utils/byte";
|
||||
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
const props = defineProps({
|
||||
valueTmp: {
|
||||
type: Number as PropType<number>,
|
||||
required: true,
|
||||
},
|
||||
setValue: {
|
||||
type: Function as PropType<(newValue: number) => void>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
capacity: 0,
|
||||
unit: "GB",
|
||||
});
|
||||
|
||||
const { capacity, unit } = toRefs(state);
|
||||
|
||||
watch(
|
||||
[capacity, unit],
|
||||
([newC, newU]) => {
|
||||
const newValue = calculateBytes(newC, newU);
|
||||
props.setValue(newValue);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.valueTmp,
|
||||
(newValue) => {
|
||||
state.capacity = formatStorageCapacity(newValue);
|
||||
state.unit = formatStorageUnit(newValue);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Внутренние константы бренда.
|
||||
*
|
||||
* Единственное место, где живёт адрес атрибуции. Это не настройка: оператор
|
||||
* HY2XS не должен иметь возможности переназначить, куда ведёт подпись
|
||||
* разработчика, — ни через панель, ни через hy2xs.env, ни через таблицу
|
||||
* `config`. Поэтому значение принадлежит приложению и попадает в бандл при
|
||||
* сборке.
|
||||
*
|
||||
* По той же причине оно объявлено один раз, а не написано в шаблоне
|
||||
* компонента: литерал, размазанный по нескольким Vue-файлам, невозможно ни
|
||||
* проверить одним гейтом, ни изменить одной правкой.
|
||||
*
|
||||
* Отсутствие адреса в операторской конфигурации проверяется приёмкой сборки.
|
||||
*/
|
||||
export const FLAMY_NAME = "Flamy" as const;
|
||||
export const FLAMY_URL = "https://flamy.studio" as const;
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Контракт учётных данных администратора на стороне панели.
|
||||
*
|
||||
* Зачем этот модуль существует. Правила логина и пароля жили прямо в двух
|
||||
* формах и разошлись и с сервером, и друг с другом:
|
||||
*
|
||||
* форма входа логин 6-32 + набор символов, пароль 6-32 + набор
|
||||
* форма смены пароля пароль 6-64 + набор
|
||||
* сервер логин 6-32 + набор символов, пароль 6-64 без набора
|
||||
*
|
||||
* Следствий было два, и оба закрывали панель. Пароль, назначенный штатной
|
||||
* формой смены, мог оказаться длиннее 32 символов — и форма входа отказывалась
|
||||
* его отправлять: оператор терял доступ после операции, которую панель ему же и
|
||||
* предложила. А набор символов на пароле отвергал значение, которое сервер
|
||||
* принял бы, — панель была строже сервера там, где она не имеет на это права.
|
||||
*
|
||||
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
|
||||
* (apps/credential/admin.go) тестом tools/test/frontend-contract.test.ts.
|
||||
*/
|
||||
|
||||
export const ADMIN_USERNAME_MIN_LENGTH = 6;
|
||||
export const ADMIN_USERNAME_MAX_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Набор символов логина в записи регекспа.
|
||||
*
|
||||
* Дефис ЭКРАНИРОВАН намеренно. В прежней записи `[a-zA-Z0-9!@#$%^&*()_+-=]` он
|
||||
* экранирован не был, поэтому `+-=` образовывал ДИАПАЗОН и молча впускал
|
||||
* `, - . / 0-9 : ; < =`. Действующий набор совпадает с этим фактическим
|
||||
* множеством — сужать его нельзя, оно уже работает на установленных
|
||||
* серверах, — но записан явно: пока он выглядел опечаткой, любая попытка
|
||||
* «навести порядок» развела бы панель и сервер обратно.
|
||||
*/
|
||||
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
|
||||
|
||||
export const ADMIN_USERNAME_PATTERN = new RegExp(
|
||||
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
|
||||
);
|
||||
|
||||
/** Тот же набор в том виде, в каком его показывают оператору. */
|
||||
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
|
||||
|
||||
/**
|
||||
* Границы пароля в СИМВОЛАХ Unicode. Набора символов у пароля НЕТ: сервер его
|
||||
* не ограничивает ни при установке, ни при смене, и панель не имеет права
|
||||
* отвергать значение, которое сервер принял бы.
|
||||
*/
|
||||
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
|
||||
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Предел bcrypt — в БАЙТАХ, и он существует одновременно с границей в символах.
|
||||
*
|
||||
* `golang.org/x/crypto/bcrypt` отвечает `ErrPasswordTooLong` на пароль длиннее
|
||||
* 72 байт. Через границу в символах это не выражается: у 64 символов длина от
|
||||
* 64 до 256 байт в зависимости от алфавита.
|
||||
*/
|
||||
export const ADMIN_PASSWORD_MAX_BYTES = 72;
|
||||
|
||||
/**
|
||||
* Единственное правило пароля на стороне панели.
|
||||
*
|
||||
* Почему не встроенные `min`/`max` Element Plus. Правила формы Element Plus
|
||||
* делегирует библиотеке `async-validator`, а она сравнивает `min`/`max` строки
|
||||
* с `String.prototype.length`, то есть с числом единиц UTF-16. Go и оркестратор
|
||||
* считают code points. Расхождение не теоретическое:
|
||||
*
|
||||
* "😀😀😀" -> Go: 3 руны -> сервер отказывает (минимум 6)
|
||||
* JS: value.length = 6 -> форма считает минимум достигнутым
|
||||
*
|
||||
* то есть панель отправляла бы заведомо отвергаемый пароль и не могла бы
|
||||
* объяснить отказ. Обратная сторона того же — пароль из 40 эмодзи: 40 code
|
||||
* points укладываются в границу символов и не укладываются в 72 байта.
|
||||
*
|
||||
* Поэтому правило одно и считает ровно то же, что сервер: code points через
|
||||
* итератор строки (он идёт по code points, а не по единицам UTF-16) и байты
|
||||
* через `TextEncoder`, который кодирует именно в UTF-8.
|
||||
*
|
||||
* Ограничений НАБОРА в контракте два, и они разного происхождения.
|
||||
*
|
||||
* Первое — домен systemd. Первый пароль администратора уезжает в
|
||||
* `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и
|
||||
* перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там —
|
||||
* это не предупреждение, а незагруженный файл окружения и, следовательно,
|
||||
* юнит, который не стартует. Публичный контракт отвергает NUL, U+FEFF,
|
||||
* суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`. Реализация
|
||||
* v257.13 случайно пропускает U+FEFF; панель следует документации. Иначе
|
||||
* она предложила бы оператору назначить пароль, после которого сервер
|
||||
* перестанет подниматься.
|
||||
*
|
||||
* Одиночные суррогаты проверяются отдельно и по своей причине: строка
|
||||
* JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
|
||||
* суррогат на U+FFFD — то есть без проверки на сервер уехал бы ДРУГОЙ пароль, а
|
||||
* не отказ.
|
||||
*
|
||||
* Второе — продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
|
||||
* DEL, C1). U+FEFF запрещён документированным транспортным доменом. Ни один из них
|
||||
* невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть
|
||||
* они умеют ровно одно — запереть оператора снаружи.
|
||||
*
|
||||
* Проверяются они перебором кодов, а не классом регекспа: литеральные
|
||||
* управляющие символы в исходнике невидимы, и такое правило нельзя проверить
|
||||
* глазами при ревью.
|
||||
*/
|
||||
export function isValidAdminPassword(value: string): boolean {
|
||||
let characters = 0;
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
|
||||
// Домен systemd: значение вне него не доедет до сервера вовсе.
|
||||
if (
|
||||
code === 0 ||
|
||||
code === 0xfeff ||
|
||||
(code >= 0xd800 && code <= 0xdfff) ||
|
||||
(code >= 0xfdd0 && code <= 0xfdef) ||
|
||||
(code & 0xfffe) === 0xfffe
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Политика HY2XS: Cc. U+FEFF уже отвергнут транспортной веткой.
|
||||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
characters += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
characters < ADMIN_PASSWORD_MIN_LENGTH ||
|
||||
characters > ADMIN_PASSWORD_MAX_LENGTH
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(value).length <= ADMIN_PASSWORD_MAX_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Правило пароля в том виде, в каком его принимает `el-form`.
|
||||
*
|
||||
* Собрано здесь, а не в каждой форме, по той же причине, по которой здесь живёт
|
||||
* сам контракт: у формы входа и формы смены пароля правило обязано быть одним и
|
||||
* тем же объектом кода, а не двумя совпадающими записями. Пока они были
|
||||
* двумя, они разошлись — 32 против 64 — и панель заперла оператора снаружи
|
||||
* после штатной смены пароля.
|
||||
*
|
||||
* Пустое значение пропускается: о нём отчитывается правило `required`, и без
|
||||
* этого оператор, отправивший пустую форму, читал бы про границы длины вместо
|
||||
* «поле обязательно». Сервер ведёт себя ровно так же — DTO приводит пустой
|
||||
* пароль к «не задано» до проверки правил.
|
||||
*
|
||||
* `message` передаётся аргументом: фраза принадлежит словарю локализации, а
|
||||
* `t()` доступен только внутри компонента.
|
||||
*/
|
||||
export function adminPasswordFormRule(message: string) {
|
||||
return {
|
||||
validator: (
|
||||
_rule: unknown,
|
||||
value: string,
|
||||
callback: (error?: Error) => void
|
||||
) => {
|
||||
if ((value ?? "") === "" || isValidAdminPassword(value)) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
callback(new Error(message));
|
||||
},
|
||||
trigger: ["change", "blur"] as string[],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Контракт имени пира на стороне панели.
|
||||
*
|
||||
* Сервер остаётся конечным авторитетом: эти значения зеркалят константы из
|
||||
* apps/service/peer_import.go и сверяются с ними release-тестом. Общий модуль
|
||||
* не даёт форме, сообщению об ошибке и подсказке завести разные копии правила.
|
||||
*/
|
||||
|
||||
export const PEER_NAME_MIN_LENGTH = 6;
|
||||
export const PEER_NAME_MAX_LENGTH = 32;
|
||||
|
||||
/** Набор знаков без букв и цифр — передаётся в i18n как значение. */
|
||||
export const PEER_NAME_PUNCTUATION = "!@#$%^&*()_+-=";
|
||||
|
||||
/** Полный набор в том виде, в каком его показывают оператору. */
|
||||
export const PEER_NAME_CHARSET = `a-z A-Z 0-9 ${PEER_NAME_PUNCTUATION}`;
|
||||
|
||||
/**
|
||||
* Набор символов в записи регулярного выражения.
|
||||
*
|
||||
* Дефис экранирован намеренно: неэкранированное `+-=` становится диапазоном и
|
||||
* принимает лишние знаки. Числовые границы берутся из тех же констант, что и
|
||||
* тексты формы.
|
||||
*/
|
||||
const PEER_NAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+\\-=";
|
||||
|
||||
export const PEER_NAME_PATTERN = new RegExp(
|
||||
`^[${PEER_NAME_CHARACTER_CLASS}]{${PEER_NAME_MIN_LENGTH},${PEER_NAME_MAX_LENGTH}}$`
|
||||
);
|
||||
@@ -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']\"");
|
||||
|
||||
@@ -21,8 +21,13 @@ export default {
|
||||
password: "Password",
|
||||
login: "Login",
|
||||
capsLockOn: "Caps lock is On",
|
||||
usernameFormatIncorrect: "Username format is incorrect",
|
||||
passwordFormatIncorrect: "Password format is incorrect",
|
||||
},
|
||||
// Требования к учётным данным администратора: общие для формы входа и формы
|
||||
// смены пароля. См. комментарий в ru.ts.
|
||||
credentials: {
|
||||
usernameFormat: "Username: {min} to {max} characters from {charset}",
|
||||
passwordFormat:
|
||||
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
|
||||
},
|
||||
dashboard: {
|
||||
stale: "Dashboard data is stale. Retrying automatically...",
|
||||
@@ -44,8 +49,19 @@ export default {
|
||||
download: "Download",
|
||||
upload: "Upload",
|
||||
total: "Total",
|
||||
serviceStateLabel: "Hysteria service",
|
||||
serviceState: {
|
||||
active: "Running",
|
||||
inactive: "Stopped",
|
||||
unknown: "State unknown",
|
||||
},
|
||||
trafficApiLabel: "Traffic Stats API",
|
||||
trafficApiReachable: "Reachable",
|
||||
trafficApiUnreachable: "Unreachable",
|
||||
security: {
|
||||
hysteriaStopped: "Hysteria2 is stopped",
|
||||
hysteriaStateUnknown:
|
||||
"Hysteria2 service state is unknown: systemd did not answer",
|
||||
trafficApiUnavailable: "Traffic API is unavailable",
|
||||
},
|
||||
error: {
|
||||
@@ -80,6 +96,7 @@ export default {
|
||||
add: "Add",
|
||||
confirm: "Confirm",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
copySuccess: "Copy successful",
|
||||
nodeUrl: "Node URL",
|
||||
nodeQrCode: "Node QR Code",
|
||||
@@ -89,16 +106,14 @@ export default {
|
||||
save: "Save",
|
||||
update: "Update",
|
||||
downloadSuccess: "Download successful",
|
||||
wait: "The version is being changed, please wait a moment",
|
||||
enableSuccess: "Hysteria2 start successful",
|
||||
disableSuccess: "Hysteria2 stop successful",
|
||||
// `wait`, `enableSuccess` and `disableSuccess` are gone together with the
|
||||
// operations the panel does not have: starting, stopping and changing the
|
||||
// version of Hysteria. That lifecycle belongs to systemd and the orchestrator.
|
||||
success: "Success",
|
||||
refresh: "Refresh",
|
||||
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",
|
||||
@@ -113,7 +128,77 @@ export default {
|
||||
invalid: "Invalid value",
|
||||
switchLanguageSuccess: "Language switched successfully",
|
||||
logoutConfirm: "Are you sure you want to log out?",
|
||||
sessionExpired: "Current session has expired, please log in again",
|
||||
sessionExpired: "Your session has expired. Sign in again to continue.",
|
||||
signInRequired: "Signing in is required.",
|
||||
signIn: "Sign in",
|
||||
systemError: "System error",
|
||||
networkError: "The server is not responding. Check the connection.",
|
||||
},
|
||||
error: {
|
||||
field: {
|
||||
name: "Peer name",
|
||||
secret: "Secret",
|
||||
remark: "Remark",
|
||||
quotaBytes: "Quota",
|
||||
expiresAt: "Expiry",
|
||||
maxDevices: "Max devices",
|
||||
disabled: "State",
|
||||
bannedUntil: "Banned until",
|
||||
file: "File",
|
||||
id: "Identifier",
|
||||
username: "Username",
|
||||
pass: "Password",
|
||||
oldPassword: "Old password",
|
||||
newPassword: "New password",
|
||||
key: "Setting key",
|
||||
value: "Setting value",
|
||||
numLine: "Line count",
|
||||
pageNum: "Page number",
|
||||
pageSize: "Page size",
|
||||
},
|
||||
code: {
|
||||
required: "“{field}”: required",
|
||||
min: "“{field}”: must not be less than {min}",
|
||||
max: "“{field}”: must not be greater than {max}",
|
||||
min_length: "“{field}”: at least {min} characters",
|
||||
max_length: "“{field}”: at most {max} characters",
|
||||
len: "“{field}”: length must be exactly {len}",
|
||||
oneof: "“{field}”: allowed values are {values}",
|
||||
gt: "“{field}”: must be greater than {gt}",
|
||||
peer_name:
|
||||
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
||||
credential_format: "“{field}”: {min} to {max} characters from {charset}",
|
||||
admin_password_format:
|
||||
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
|
||||
rule_violated: "“{field}”: value is not acceptable",
|
||||
validation_failed: "Validation failed",
|
||||
body_invalid:
|
||||
"Request could not be parsed: check field formats and types",
|
||||
peer_name_taken: "A peer with this name already exists",
|
||||
peer_name_reserved: "This name is reserved for the installer peer",
|
||||
peer_bootstrap_identity_locked:
|
||||
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
|
||||
invalid_credentials: "Wrong username or password",
|
||||
// The phrase must open with what has ALREADY been applied, otherwise it
|
||||
// reads as "the operation failed" and the operator repeats an action
|
||||
// that in fact went through.
|
||||
//
|
||||
// It also names no specific operation. This code is reported by
|
||||
// disabling a peer, a temporary ban, secret rotation, quota and expiry
|
||||
// reductions, a lower device limit, a batch import and peer deletion;
|
||||
// the previous "new connections for this peer are now refused" held only
|
||||
// for the first case and is meaningless for a deleted peer.
|
||||
peer_disconnect_failed:
|
||||
"The changes were saved, but the related active sessions could not be terminated: the Hysteria Traffic Stats API is unreachable. Established connections may keep working until the client reconnects. Check the hysteria-server service and retry.",
|
||||
import_file_extension: "Import accepts .json files only",
|
||||
unauthorized: "Signing in is required",
|
||||
session_expired: "Session expired",
|
||||
token_invalid: "Session is not valid",
|
||||
account_disabled: "Account is disabled",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
developedBy: "Made at {brand}",
|
||||
},
|
||||
info: {
|
||||
expireTime: "y-M-d H:m:s",
|
||||
@@ -128,6 +213,19 @@ export default {
|
||||
name: "Peer",
|
||||
remark: "Remark",
|
||||
secret: "Secret",
|
||||
form: {
|
||||
namePlaceholder: "client-01",
|
||||
// The character set is stated as service.IsValidPeerName accepts it. The
|
||||
// previous wording described the rule that was in force before EX-03.
|
||||
nameHint:
|
||||
"Peer identifier: {min} to {max} characters — latin letters, digits and {punctuation}. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
|
||||
remarkPlaceholder: "laptop",
|
||||
remarkHint: "Optional operator note. It is never shown to the client.",
|
||||
secretPlaceholder: "leave empty to generate one",
|
||||
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 +239,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",
|
||||
@@ -151,16 +256,31 @@ export default {
|
||||
onlineStatus: "Online Status",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
onlineUnknown: "Online unknown",
|
||||
onlineUnavailable: "Live connection state is currently unavailable",
|
||||
onlineUnavailableHint:
|
||||
"The Hysteria Traffic Stats API did not answer, so online status and device counts are unknown. Stored peer state in the table is accurate.",
|
||||
device: "Online Devices",
|
||||
unit: "Unit",
|
||||
loginAt: "Last login time",
|
||||
conAt: "Last connection time",
|
||||
createTime: "Create Time",
|
||||
releaseSuccess: "Release successful",
|
||||
kick: "Kick",
|
||||
kickTip: "Force user to log off",
|
||||
releaseKick: "Release",
|
||||
releaseKickTip: "Remove offline status",
|
||||
kick: "Ban temporarily",
|
||||
kickTip:
|
||||
"Drop the session and refuse new connections until the ban expires",
|
||||
kickConfirm:
|
||||
"Ban peer “{name}” until {until}? The current session will be dropped and new connections refused until then.",
|
||||
kickSuccess: "Peer banned temporarily",
|
||||
releaseKick: "Lift ban",
|
||||
releaseKickTip: "Lift the temporary ban early",
|
||||
// Disabling and the temporary ban are separate mechanisms: the ban expires
|
||||
// on its own, disabling is only undone by hand.
|
||||
enablePeer: "Enable peer",
|
||||
disablePeer: "Disable peer",
|
||||
disableConfirm:
|
||||
"Disable peer “{name}”? The current session will be dropped and new connections refused until the peer is enabled again.",
|
||||
enableSuccess: "Peer enabled",
|
||||
disableSuccess: "Peer disabled",
|
||||
createdAt: "Created at",
|
||||
bannedUntil: "Banned until",
|
||||
totalTraffic: "Total traffic",
|
||||
@@ -169,44 +289,21 @@ 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",
|
||||
hysteria2UserTotal: "Number of online users",
|
||||
hysteria2DeviceTotal: "Number of online devices",
|
||||
hysteria2Version: "Hysteria2 Version",
|
||||
hysteria2Running: "Hysteria2 Status",
|
||||
hysteria2RunningTrue: "Running",
|
||||
hysteria2RunningFalse: "Stop",
|
||||
},
|
||||
// The `monitor` section is gone together with its only consumer — the
|
||||
// "Hysteria2 Status" tag on the config page. The service state has three
|
||||
// values, not two, and its phrases live in `dashboard.serviceState`.
|
||||
log: {
|
||||
numLine: "Number of lines",
|
||||
level: "Level",
|
||||
message: "Message",
|
||||
time: "Time",
|
||||
exportFailed: "Could not export the log",
|
||||
},
|
||||
errorPage: {
|
||||
back: "Back",
|
||||
@@ -221,189 +318,34 @@ export default {
|
||||
notFoundBackHome: "Back to home",
|
||||
},
|
||||
hysteria: {
|
||||
enable: "Enable",
|
||||
disable: "Disable",
|
||||
addConfigItem: "Add Config Item",
|
||||
hysteria2Version: "Hysteria2 Version",
|
||||
hysteria2Running: "Hysteria2 Status",
|
||||
hysteria2ChangeVersion: "Change",
|
||||
addOutbound: "Add Outbound",
|
||||
extension: "Extension",
|
||||
listen: "Listen",
|
||||
ownedByOrchestrator: "The Hysteria configuration is owned by the installer",
|
||||
ownedByOrchestratorHint:
|
||||
"The panel shows /etc/hysteria/config.yaml read-only. Changes are applied by `hy2xs-orchestrator reconfigure`.",
|
||||
listen: "Listen address",
|
||||
auth: "Peer authentication",
|
||||
tls: "TLS",
|
||||
obfs: "Obfuscation",
|
||||
quic: "QUIC parameters",
|
||||
bandwidth: "Bandwidth",
|
||||
speedTest: "Speed Test",
|
||||
udp: "UDP",
|
||||
resolver: "Resolver",
|
||||
sniff: "Protocol Sniffing",
|
||||
acl: "ACL",
|
||||
outbounds: "Outbounds",
|
||||
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",
|
||||
tls: {
|
||||
cert: "The path to the Cert file.",
|
||||
key: "The path to the Key file.",
|
||||
sniGuard:
|
||||
'Verify the SNI provided by the client. Accept the connection only when it matches what\'s in the certificate. Terminate the TLS handshake otherwise. Set to strict to enforce this behavior. Set to disable to disable this entirely. The default is dns-san, which enables this feature only when the certificate contains the "Subject Alternative Name" extension with a domain name in it.',
|
||||
},
|
||||
acme: {
|
||||
domains: "Domains",
|
||||
email: "Email",
|
||||
ca: "The CA to use. Can be letsencrypt or zerossl.",
|
||||
listenHost:
|
||||
"The host address (not including the port) to listen on for the ACME challenge. If omitted, the server will listen on all interfaces.",
|
||||
dir: "The directory to store the ACME account key and certificates.",
|
||||
type: "ACME challenge type. Can be http, tls, or dns.",
|
||||
http: {
|
||||
altPort:
|
||||
"Listening port for HTTP challenges. (Note: Changing to a port other than 80 requires port forwarding or HTTP reverse proxy, or the challenge will fail!)",
|
||||
},
|
||||
tls: {
|
||||
altPort:
|
||||
"Listening port for TLS-ALPN challenges. (Note: Changing to a port other than 443 requires port forwarding or TLS reverse proxy, or the challenge will fail!)",
|
||||
},
|
||||
dns: {
|
||||
name: "DNS provider. For details, refer to ACME DNS Configuration.",
|
||||
config: "ACME DNS Configuration",
|
||||
},
|
||||
disableHTTP: "Disable HTTP challenge.",
|
||||
disableTLSALPN: "Disable TLS-ALPN challenge.",
|
||||
altHTTPPort:
|
||||
"Alternate HTTP challenge port. (Note: If you want to use anything other than 80, you must set up port forward/HTTP reverse proxy from 80 to that port, otherwise ACME will not be able to issue the certificate.)",
|
||||
altTLSALPNPort:
|
||||
"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",
|
||||
salamander: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
|
||||
maxStreamReceiveWindow: "The maximum QUIC stream receive window size.",
|
||||
initConnReceiveWindow:
|
||||
"The initial QUIC connection receive window size.",
|
||||
maxConnReceiveWindow:
|
||||
"The maximum QUIC connection receive window size.",
|
||||
maxIdleTimeout:
|
||||
"The maximum idle timeout. How long the server will consider the client still connected without any activity.",
|
||||
maxIncomingStreams:
|
||||
"The maximum number of concurrent incoming streams.",
|
||||
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
},
|
||||
ignoreClientBandwidth:
|
||||
"When enabled, makes the server to disregard any bandwidth hints set by clients",
|
||||
speedTest:
|
||||
"speedTest enables the built-in speed test server. When enabled, clients can test their download and upload speeds with the server. For more information, see the Speed Test documentation.",
|
||||
disableUDP:
|
||||
"disableUDP disables UDP forwarding, only allowing TCP connections.",
|
||||
udpIdleTimeout:
|
||||
"udpIdleTimeout specifies the amount of time the server will keep a local UDP port open for each UDP session that has no activity. This is conceptually similar to the NAT UDP session timeout.",
|
||||
resolver: {
|
||||
type: "Type",
|
||||
tcp: {
|
||||
addr: "The address of the TCP resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
},
|
||||
udp: {
|
||||
addr: "The address of the UDP resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
},
|
||||
tls: {
|
||||
addr: "The address of the TLS resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
sni: "The SNI to use for the TLS resolver.",
|
||||
insecure: "Disable TLS verification for the TLS resolver.",
|
||||
},
|
||||
https: {
|
||||
addr: "The address of the HTTPS resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
sni: "The SNI to use for the TLS resolver.",
|
||||
insecure: "Disable TLS verification for the TLS resolver.",
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: "Whether to enable protocol sniffing.",
|
||||
timeout:
|
||||
"Sniffing timeout. If the protocol/domain cannot be determined within this time, the original address will be used to initiate the connection.",
|
||||
rewriteDomain:
|
||||
"Whether to rewrite requests that are already in domain name form. If enabled, requests with the target address already in domain name form will still be sniffed.",
|
||||
tcpPorts:
|
||||
"List of TCP ports. Only TCP requests on these ports will be sniffed.",
|
||||
udpPorts:
|
||||
"List of UDP ports. Only UDP requests on these ports will be sniffed.",
|
||||
},
|
||||
aclType: "ACL type",
|
||||
acl: {
|
||||
file: "The path to the ACL file.",
|
||||
inline: "The list of inline ACL rules.",
|
||||
geoip:
|
||||
"Optional. Uncomment to enable. The path to the GeoIP database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
|
||||
geosite:
|
||||
"Optional. Uncomment to enable. The path to the GeoSite database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
|
||||
geoUpdateInterval:
|
||||
"Optional. The interval at which to refresh the GeoIP/GeoSite databases. 168 hours (1 week) by default. Only applies if the GeoIP/GeoSite databases are automatically downloaded. (Check the note below for more information.)",
|
||||
},
|
||||
outbounds: {
|
||||
name: "The name of the outbound. This is used in ACL rules.",
|
||||
type: "Type",
|
||||
socks5: {
|
||||
addr: "The address of the SOCKS5 proxy.",
|
||||
username:
|
||||
"Optional. The username for the SOCKS5 proxy, if authentication is required.",
|
||||
password:
|
||||
"Optional. The password for the SOCKS5 proxy, if authentication is required.",
|
||||
},
|
||||
http: {
|
||||
url: "The URL of the HTTP/HTTPS proxy. (Can be http:// or https://)",
|
||||
insecure:
|
||||
"Optional. Whether to disable TLS verification. Applies to HTTPS proxies only.",
|
||||
},
|
||||
direct: {
|
||||
mode: "Type",
|
||||
bindIPv4: "The local IPv4 address to bind to.",
|
||||
bindIPv6: "The local IPv6 address to bind to.",
|
||||
bindDevice: "The local network interface to bind to.",
|
||||
fastOpen: "Enable TCP fast open.",
|
||||
},
|
||||
},
|
||||
trafficStats: {
|
||||
listen: "The address to listen on.",
|
||||
},
|
||||
masquerade: {
|
||||
type: "Type",
|
||||
file: {
|
||||
dir: "The directory to serve files from.",
|
||||
},
|
||||
proxy: {
|
||||
url: "The URL of the website to proxy.",
|
||||
rewriteHost:
|
||||
"Whether to rewrite the Host header to match the proxied website. This is required if the target web server uses Host to determine which site to serve.",
|
||||
insecure: "Disable TLS verification for the proxied website.",
|
||||
},
|
||||
string: {
|
||||
content: "The string to return.",
|
||||
headers: "Optional. The headers to return.",
|
||||
statusCode: "Optional. The status code to return. 200 by default.",
|
||||
},
|
||||
listenHTTP: "HTTP (TCP) listen address.",
|
||||
listenHTTPS: "HTTPS (TCP) listen address.",
|
||||
forceHTTPS:
|
||||
"Whether to force HTTPS. If enabled, all HTTP requests will be redirected to HTTPS.",
|
||||
},
|
||||
},
|
||||
congestion: "Congestion control",
|
||||
trafficStats: "Traffic Stats API",
|
||||
notSet: "not set",
|
||||
sectionMissing: "The section is absent from the configuration",
|
||||
tlsMissing: "Neither tls nor acme is configured",
|
||||
valuesHidden: "values are not shown",
|
||||
secretSet: "set",
|
||||
secretMissing: "not set",
|
||||
obfsPasswordHint: "handed out in the peer share link",
|
||||
driftTitle: "Configuration drift",
|
||||
driftHint:
|
||||
"The file contains sections outside the HY2XS production profile. The orchestrator neither creates nor supports them: the configuration was most likely edited by hand.",
|
||||
trafficStatsMissing: "Traffic Stats API is not configured",
|
||||
trafficStatsMissingHint:
|
||||
"Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
|
||||
trafficStatsUnreachable:
|
||||
"the panel cannot reach this address: the Traffic Stats API is queried through 127.0.0.1 only. While the addresses differ, the device limit, traffic accounting and forced disconnect do not work",
|
||||
trafficStatsWildcard:
|
||||
"the API is reachable but listens on every interface. The HY2XS production profile uses 127.0.0.1: run hy2xs-orchestrator reconfigure",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,8 +19,24 @@ export default {
|
||||
password: "Пароль",
|
||||
login: "Войти",
|
||||
capsLockOn: "Caps Lock включён",
|
||||
usernameFormatIncorrect: "Неверный формат логина",
|
||||
passwordFormatIncorrect: "Неверный формат пароля",
|
||||
},
|
||||
// Требования к учётным данным администратора. Фразы общие для формы входа и
|
||||
// формы смены пароля: требование одно, и второй его формулировки быть не
|
||||
// должно — расхождение здесь означало бы, что оператору обещают разное про
|
||||
// одно и то же поле.
|
||||
//
|
||||
// Обе фразы НАЗЫВАЮТ требование, а не сообщают о его нарушении. Прежние
|
||||
// «Неверный формат логина» и «Некорректное значение» не давали оператору ни
|
||||
// одного способа узнать, что именно от него хотят: набор символов логина
|
||||
// приходит из hy2xs.env, и посмотреть его в панели негде.
|
||||
credentials: {
|
||||
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
|
||||
// Фраза называет ОБЕ границы, потому что их две и они в разных единицах.
|
||||
// Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72
|
||||
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
|
||||
// сокращать пароль, отвергнутый не за это.
|
||||
passwordFormat:
|
||||
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
},
|
||||
dashboard: {
|
||||
stale:
|
||||
@@ -43,8 +59,21 @@ export default {
|
||||
download: "Скачано",
|
||||
upload: "Отдано",
|
||||
total: "Всего",
|
||||
serviceStateLabel: "Служба Hysteria",
|
||||
// Три состояния, а не два: «спросить systemd не удалось» — это не
|
||||
// «служба остановлена», и действия оператора у них разные.
|
||||
serviceState: {
|
||||
active: "Работает",
|
||||
inactive: "Остановлена",
|
||||
unknown: "Состояние неизвестно",
|
||||
},
|
||||
trafficApiLabel: "Traffic Stats API",
|
||||
trafficApiReachable: "Доступен",
|
||||
trafficApiUnreachable: "Недоступен",
|
||||
security: {
|
||||
hysteriaStopped: "Hysteria2 остановлена",
|
||||
hysteriaStateUnknown:
|
||||
"Состояние службы Hysteria2 неизвестно: systemd не ответил",
|
||||
trafficApiUnavailable: "Traffic API недоступен",
|
||||
},
|
||||
error: {
|
||||
@@ -77,6 +106,7 @@ export default {
|
||||
add: "Добавить",
|
||||
confirm: "Подтвердить",
|
||||
cancel: "Отмена",
|
||||
close: "Закрыть",
|
||||
copySuccess: "Скопировано",
|
||||
nodeUrl: "URL узла",
|
||||
nodeQrCode: "QR узла",
|
||||
@@ -86,16 +116,14 @@ export default {
|
||||
save: "Сохранить",
|
||||
update: "Обновить",
|
||||
downloadSuccess: "Загрузка завершена",
|
||||
wait: "Версия меняется, подождите",
|
||||
enableSuccess: "Hysteria2 запущена",
|
||||
disableSuccess: "Hysteria2 остановлена",
|
||||
// Фразы `wait`, `enableSuccess` и `disableSuccess` удалены вместе с
|
||||
// операциями, которых у панели нет: запуском, остановкой и сменой версии
|
||||
// Hysteria. Жизненный цикл принадлежит systemd и оркестратору.
|
||||
success: "Готово",
|
||||
refresh: "Обновить",
|
||||
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: "Формат файла не поддерживается",
|
||||
@@ -110,7 +138,88 @@ export default {
|
||||
invalid: "Некорректное значение",
|
||||
switchLanguageSuccess: "Язык переключён",
|
||||
logoutConfirm: "Выйти из системы?",
|
||||
sessionExpired: "Текущая сессия истекла, войдите снова",
|
||||
sessionExpired: "Сессия истекла. Войдите снова, чтобы продолжить.",
|
||||
signInRequired: "Требуется вход в панель.",
|
||||
signIn: "Войти",
|
||||
systemError: "Системная ошибка",
|
||||
networkError: "Сервер не отвечает. Проверьте соединение с панелью.",
|
||||
},
|
||||
// Причины отказа API.
|
||||
//
|
||||
// Ключи строятся из КОДА ответа, а не из его текста: панель не разбирает
|
||||
// человеческие сообщения сервера. Числа правил приходят в параметрах, поэтому
|
||||
// второй копии границ длины здесь нет — она неизбежно разошлась бы с
|
||||
// серверной.
|
||||
error: {
|
||||
field: {
|
||||
name: "Имя пира",
|
||||
secret: "Секрет",
|
||||
remark: "Комментарий",
|
||||
quotaBytes: "Квота",
|
||||
expiresAt: "Срок действия",
|
||||
maxDevices: "Лимит устройств",
|
||||
disabled: "Состояние",
|
||||
bannedUntil: "Блокировка до",
|
||||
file: "Файл",
|
||||
id: "Идентификатор",
|
||||
username: "Логин",
|
||||
pass: "Пароль",
|
||||
oldPassword: "Старый пароль",
|
||||
newPassword: "Новый пароль",
|
||||
key: "Ключ настройки",
|
||||
value: "Значение настройки",
|
||||
numLine: "Число строк",
|
||||
pageNum: "Номер страницы",
|
||||
pageSize: "Размер страницы",
|
||||
},
|
||||
code: {
|
||||
required: "«{field}»: поле обязательно",
|
||||
min: "«{field}»: значение не может быть меньше {min}",
|
||||
max: "«{field}»: значение не может быть больше {max}",
|
||||
min_length: "«{field}»: не короче {min} символов",
|
||||
max_length: "«{field}»: не длиннее {max} символов",
|
||||
len: "«{field}»: длина должна быть ровно {len}",
|
||||
oneof: "«{field}»: допустимые значения — {values}",
|
||||
gt: "«{field}»: значение должно быть больше {gt}",
|
||||
peer_name:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
|
||||
// Сервер присылает границы и набор в params — фраза называет требование
|
||||
// целиком. Прежнее «недопустимые символы» вдобавок описывало этими же
|
||||
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
|
||||
credential_format:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}",
|
||||
admin_password_format:
|
||||
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
rule_violated: "«{field}»: значение не подходит",
|
||||
validation_failed: "Проверка данных не пройдена",
|
||||
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
||||
peer_name_taken: "Пир с таким именем уже существует",
|
||||
peer_name_reserved: "Это имя зарезервировано за пиром установщика",
|
||||
peer_bootstrap_identity_locked:
|
||||
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
|
||||
invalid_credentials: "Неверный логин или пароль",
|
||||
// Фраза обязана начинаться с того, что УЖЕ СДЕЛАНО: иначе оператор
|
||||
// прочитает её как «операция не выполнена» и повторит действие, которое
|
||||
// на самом деле применилось.
|
||||
//
|
||||
// И она НЕ называет конкретную операцию. Этим кодом отчитываются
|
||||
// отключение пира, временная блокировка, смена секрета, урезание квоты и
|
||||
// срока, снижение лимита устройств, импорт партии и удаление пира;
|
||||
// прежнее «новые подключения пира запрещены» было верно ровно для
|
||||
// первого случая, а для удалённого пира — просто бессмысленно.
|
||||
peer_disconnect_failed:
|
||||
"Изменения сохранены, но завершить связанные активные сессии не удалось: Traffic Stats API Hysteria недоступен. Установленные соединения могут работать до переподключения клиента. Проверьте состояние службы hysteria-server и повторите действие.",
|
||||
import_file_extension: "Импорт принимает только файлы .json",
|
||||
unauthorized: "Требуется вход в панель",
|
||||
session_expired: "Сессия истекла",
|
||||
token_invalid: "Сессия недействительна",
|
||||
account_disabled: "Учётная запись отключена",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
// {brand} подставляется ссылкой, поэтому фраза обязана остаться одной
|
||||
// строкой с одним подстановочным местом.
|
||||
developedBy: "Разработано во {brand}",
|
||||
},
|
||||
info: {
|
||||
expireTime: "г-М-д Ч:м:с",
|
||||
@@ -124,6 +233,21 @@ export default {
|
||||
name: "Пир",
|
||||
remark: "Комментарий",
|
||||
secret: "Секрет",
|
||||
form: {
|
||||
namePlaceholder: "client-01",
|
||||
// Набор символов назван так, как его понимает service.IsValidPeerName.
|
||||
// Прежняя формулировка «латиница, цифры и дефис» осталась от правила,
|
||||
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
|
||||
// сервер принимает, и оператор не имел причин пробовать остальные.
|
||||
nameHint:
|
||||
"Идентификатор пира: от {min} до {max} символов — латиница, цифры и знаки {punctuation}. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
||||
remarkPlaceholder: "ноутбук",
|
||||
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
||||
secretPlaceholder: "оставьте пустым — сгенерируем",
|
||||
secretHint:
|
||||
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
|
||||
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
|
||||
},
|
||||
maxDevices: "Лимит устройств",
|
||||
disabled: "Отключён",
|
||||
status: "Статус",
|
||||
@@ -137,6 +261,13 @@ export default {
|
||||
unlimited: "Безлимит",
|
||||
secretRotateConfirm:
|
||||
"Сменить секрет пира? Текущие клиентские конфиги перестанут работать до обновления.",
|
||||
exportSettings: "Экспорт настроек",
|
||||
exportBackup: "Резервная копия",
|
||||
exportSettingsHint:
|
||||
"Список пиров без секретов. Безопасно хранить и передавать, но после импорта на другой сервер пирам будут выданы новые секреты, и прежние клиентские ссылки работать не будут.",
|
||||
exportBackupConfirm:
|
||||
"Резервная копия содержит ДЕЙСТВУЮЩИЕ секреты подключения всех пиров в открытом виде: любой, кто получит этот файл, получит доступ к VPN. Храните его как пароль и удалите, когда перенос завершён. Только такая копия сохраняет работоспособность существующих клиентских ссылок после переноса на другой сервер.",
|
||||
exportBackupConfirmButton: "Понимаю, скачать копию с секретами",
|
||||
quota: "Квота",
|
||||
download: "Скачано",
|
||||
upload: "Отдано",
|
||||
@@ -147,62 +278,54 @@ export default {
|
||||
onlineStatus: "Онлайн",
|
||||
online: "Онлайн",
|
||||
offline: "Офлайн",
|
||||
onlineUnknown: "Онлайн неизвестен",
|
||||
onlineUnavailable: "Картина подключений сейчас недоступна",
|
||||
onlineUnavailableHint:
|
||||
"Traffic Stats API Hysteria не ответил, поэтому онлайн и число устройств неизвестны. Сохранённое состояние пиров в таблице верно.",
|
||||
device: "Устройства",
|
||||
createdAt: "Создан",
|
||||
bannedUntil: "Блокировка до",
|
||||
totalTraffic: "Суммарный трафик",
|
||||
copyUri: "Копировать URI",
|
||||
more: "Ещё",
|
||||
unit: "Ед. изм.",
|
||||
loginAt: "Последний вход",
|
||||
conAt: "Последнее подключение",
|
||||
createTime: "Создано",
|
||||
releaseSuccess: "Ограничение снято",
|
||||
kick: "Отключить",
|
||||
kickTip: "Принудительно отключить пользователя",
|
||||
releaseKick: "Снять",
|
||||
releaseKickTip: "Снять офлайн-статус",
|
||||
kick: "Временно заблокировать",
|
||||
kickTip: "Прервать соединение и не пускать до истечения срока",
|
||||
kickConfirm:
|
||||
"Заблокировать пир «{name}» до {until}? Текущее соединение будет разорвано, новые подключения до этого времени приниматься не будут.",
|
||||
kickSuccess: "Пир временно заблокирован",
|
||||
releaseKick: "Снять блокировку",
|
||||
releaseKickTip: "Снять временную блокировку досрочно",
|
||||
// Отключение и временная блокировка — разные механизмы: у блокировки есть
|
||||
// срок, который истекает сам, отключение снимается только руками.
|
||||
enablePeer: "Включить пир",
|
||||
disablePeer: "Отключить пир",
|
||||
disableConfirm:
|
||||
"Отключить пир «{name}»? Текущее соединение будет разорвано, а новые подключения запрещены, пока пир не будет включён обратно.",
|
||||
enableSuccess: "Пир включён",
|
||||
disableSuccess: "Пир отключён",
|
||||
},
|
||||
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: "Диск",
|
||||
hysteria2UserTotal: "Пользователей онлайн",
|
||||
hysteria2DeviceTotal: "Устройств онлайн",
|
||||
hysteria2Version: "Версия Hysteria2",
|
||||
hysteria2Running: "Статус Hysteria2",
|
||||
hysteria2RunningTrue: "Работает",
|
||||
hysteria2RunningFalse: "Остановлена",
|
||||
},
|
||||
// Раздел `monitor` удалён вместе со своим единственным потребителем —
|
||||
// плашкой «Статус Hysteria2» на странице конфигурации. Состояние службы
|
||||
// имеет три значения, а не два, и его фразы живут в `dashboard.serviceState`:
|
||||
// «остановлена» и «неизвестно» — разные ответы оператору.
|
||||
log: {
|
||||
numLine: "Количество строк",
|
||||
level: "Уровень",
|
||||
message: "Сообщение",
|
||||
time: "Время",
|
||||
exportFailed: "Не удалось выгрузить журнал",
|
||||
},
|
||||
errorPage: {
|
||||
back: "Назад",
|
||||
@@ -217,140 +340,42 @@ export default {
|
||||
notFoundBackHome: "На главную",
|
||||
},
|
||||
hysteria: {
|
||||
enable: "Включить",
|
||||
disable: "Отключить",
|
||||
addConfigItem: "Добавить параметр",
|
||||
hysteria2Version: "Версия Hysteria2",
|
||||
hysteria2Running: "Статус Hysteria2",
|
||||
hysteria2ChangeVersion: "Сменить",
|
||||
addOutbound: "Добавить outbound",
|
||||
extension: "Расширение",
|
||||
ownedByOrchestrator: "Конфигурацией Hysteria владеет установщик",
|
||||
ownedByOrchestratorHint:
|
||||
"Панель показывает файл /etc/hysteria/config.yaml только на чтение. Изменения вносит `hy2xs-orchestrator reconfigure`.",
|
||||
// Разделы production-профиля. Экран показывает то, что записано в файле,
|
||||
// и отдельно называет секции сверх профиля: универсального редактора всех
|
||||
// возможностей Hysteria здесь нет намеренно — конфиг создаёт оркестратор.
|
||||
listen: "Адрес прослушивания",
|
||||
auth: "Авторизация пиров",
|
||||
tls: "TLS",
|
||||
obfs: "Маскировка",
|
||||
quic: "Параметры QUIC",
|
||||
bandwidth: "Полоса",
|
||||
speedTest: "Тест скорости",
|
||||
udp: "UDP",
|
||||
resolver: "DNS",
|
||||
sniff: "Sniffing протоколов",
|
||||
acl: "ACL",
|
||||
outbounds: "Outbounds",
|
||||
http: "Traffic Stats API (HTTP)",
|
||||
masquerade: "Masquerade",
|
||||
config: {
|
||||
enable: "Включить/отключить",
|
||||
remark: "Комментарий",
|
||||
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
|
||||
tlsType: "Тип TLS",
|
||||
tls: {
|
||||
cert: "Путь к cert-файлу",
|
||||
key: "Путь к key-файлу",
|
||||
sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.",
|
||||
},
|
||||
acme: {
|
||||
domains: "Домены",
|
||||
email: "Email",
|
||||
ca: "CA: letsencrypt или zerossl",
|
||||
listenHost: "Адрес для ACME challenge",
|
||||
dir: "Каталог ACME аккаунта и сертификатов",
|
||||
type: "Тип ACME challenge: http, tls или dns",
|
||||
http: { altPort: "Альтернативный порт HTTP challenge" },
|
||||
tls: { altPort: "Альтернативный порт TLS-ALPN challenge" },
|
||||
dns: { name: "DNS-провайдер", config: "Конфигурация ACME DNS" },
|
||||
disableHTTP: "Отключить HTTP challenge",
|
||||
disableTLSALPN: "Отключить TLS-ALPN challenge",
|
||||
altHTTPPort: "Альтернативный HTTP-порт",
|
||||
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
|
||||
},
|
||||
obfs: {
|
||||
type: "Тип",
|
||||
salamander: { password: "Сильный пароль Salamander" },
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
|
||||
maxStreamReceiveWindow: "Максимальное окно приёма QUIC stream",
|
||||
initConnReceiveWindow: "Начальное окно приёма QUIC connection",
|
||||
maxConnReceiveWindow: "Максимальное окно приёма QUIC connection",
|
||||
maxIdleTimeout: "Максимальный idle timeout",
|
||||
maxIncomingStreams: "Максимум входящих stream",
|
||||
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
|
||||
},
|
||||
bandwidth: { up: "Вверх", down: "Вниз" },
|
||||
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
|
||||
speedTest: "Встроенный сервер теста скорости",
|
||||
disableUDP: "Отключить UDP forwarding",
|
||||
udpIdleTimeout: "Idle timeout для UDP-сессий",
|
||||
resolver: {
|
||||
type: "Тип",
|
||||
tcp: { addr: "TCP DNS resolver", timeout: "Timeout DNS-запросов" },
|
||||
udp: { addr: "UDP DNS resolver", timeout: "Timeout DNS-запросов" },
|
||||
tls: {
|
||||
addr: "DNS over TLS resolver",
|
||||
timeout: "Timeout DNS-запросов",
|
||||
sni: "SNI для TLS resolver",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
https: {
|
||||
addr: "DNS over HTTPS resolver",
|
||||
timeout: "Timeout DNS-запросов",
|
||||
sni: "SNI для HTTPS resolver",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: "Включить sniffing",
|
||||
timeout: "Timeout sniffing",
|
||||
rewriteDomain: "Повторно анализировать доменные запросы",
|
||||
tcpPorts: "TCP-порты для sniffing",
|
||||
udpPorts: "UDP-порты для sniffing",
|
||||
},
|
||||
aclType: "Тип ACL",
|
||||
acl: {
|
||||
file: "Путь к ACL-файлу",
|
||||
inline: "Inline ACL-правила",
|
||||
geoip: "Путь к GeoIP базе",
|
||||
geosite: "Путь к GeoSite базе",
|
||||
geoUpdateInterval: "Интервал обновления GeoIP/GeoSite",
|
||||
},
|
||||
outbounds: {
|
||||
name: "Имя outbound",
|
||||
type: "Тип",
|
||||
socks5: {
|
||||
addr: "Адрес SOCKS5",
|
||||
username: "Логин SOCKS5",
|
||||
password: "Пароль SOCKS5",
|
||||
},
|
||||
http: {
|
||||
url: "URL HTTP/HTTPS proxy",
|
||||
insecure: "Отключить TLS-проверку proxy",
|
||||
},
|
||||
direct: {
|
||||
mode: "Тип",
|
||||
bindIPv4: "Локальный IPv4",
|
||||
bindIPv6: "Локальный IPv6",
|
||||
bindDevice: "Сетевой интерфейс",
|
||||
fastOpen: "TCP fast open",
|
||||
},
|
||||
},
|
||||
trafficStats: { listen: "Адрес прослушивания" },
|
||||
masquerade: {
|
||||
type: "Тип",
|
||||
file: { dir: "Каталог файлов" },
|
||||
proxy: {
|
||||
url: "URL проксируемого сайта",
|
||||
rewriteHost: "Переписывать Host header",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
string: {
|
||||
content: "Ответ строкой",
|
||||
headers: "HTTP headers",
|
||||
statusCode: "HTTP status code",
|
||||
},
|
||||
listenHTTP: "HTTP listen address",
|
||||
listenHTTPS: "HTTPS listen address",
|
||||
forceHTTPS: "Принудительно использовать HTTPS",
|
||||
},
|
||||
},
|
||||
congestion: "Congestion control",
|
||||
trafficStats: "Traffic Stats API",
|
||||
notSet: "не задано",
|
||||
sectionMissing: "Секция в конфигурации отсутствует",
|
||||
tlsMissing: "Ни tls, ни acme в конфигурации не заданы",
|
||||
valuesHidden: "значения не показываются",
|
||||
secretSet: "задан",
|
||||
secretMissing: "не задан",
|
||||
obfsPasswordHint: "выдаётся в клиентской ссылке пира",
|
||||
driftTitle: "Расхождение конфигурации",
|
||||
driftHint:
|
||||
"В файле есть секции вне production-профиля HY2XS. Оркестратор их не создаёт и не поддерживает: скорее всего, конфигурацию правили вручную.",
|
||||
trafficStatsMissing: "Traffic Stats API не настроен",
|
||||
trafficStatsMissingHint:
|
||||
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
|
||||
// Две разные новости об одном поле, и объединять их нельзя: первая
|
||||
// означает, что доступ пиров уже не работает, вторая — что API работает, но
|
||||
// опубликован шире необходимого. Прочие адреса loopback (127.0.0.5 и
|
||||
// подобные) относятся к ПЕРВОЙ: слушатель на конкретном адресе соединения
|
||||
// на 127.0.0.1 не принимает.
|
||||
trafficStatsUnreachable:
|
||||
"панель не может обратиться по этому адресу: Traffic Stats API запрашивается строго через 127.0.0.1. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают",
|
||||
trafficStatsWildcard:
|
||||
"API доступен, но слушает все интерфейсы. Production-профиль HY2XS использует 127.0.0.1: выполните hy2xs-orchestrator reconfigure",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,17 +28,24 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
|
||||
/**
|
||||
* Выход из системы.
|
||||
*
|
||||
* Отмена диалога — это ОТВЕТ оператора, а не ошибка. ElMessageBox отклоняет
|
||||
* промис при нажатии «Отмена», и цепочка без разбора отказа оставляла в
|
||||
* консоли необработанное отклонение промиса на каждый передумавший выход.
|
||||
*/
|
||||
function logout() {
|
||||
ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
|
||||
confirmButtonText: t("navbar.logout"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
adminStore.logout().then(() => {
|
||||
router.push(`/login?redirect=${route.fullPath}`);
|
||||
async function logout() {
|
||||
try {
|
||||
await ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
|
||||
confirmButtonText: t("navbar.logout"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
type: "warning",
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await adminStore.logout();
|
||||
router.push(`/login?redirect=${route.fullPath}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { FLAMY_NAME, FLAMY_URL } from "@/constants/branding";
|
||||
|
||||
defineProps({
|
||||
collapse: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-footer" :class="{ 'is-collapsed': collapse }">
|
||||
<!--
|
||||
Свёрнутое меню шириной 54px не вмещает фразу целиком, поэтому в нём
|
||||
остаётся только имя-ссылка. Прятать подпись совсем нельзя: атрибуция
|
||||
обязана быть видна в обоих состояниях.
|
||||
-->
|
||||
<a
|
||||
v-if="collapse"
|
||||
class="sidebar-footer-brand"
|
||||
:href="FLAMY_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ FLAMY_NAME }}</a
|
||||
>
|
||||
<i18n-t v-else keypath="sidebar.developedBy" tag="span" scope="global">
|
||||
<template #brand>
|
||||
<a
|
||||
class="sidebar-footer-brand"
|
||||
:href="FLAMY_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ FLAMY_NAME }}</a
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: $sidebarFooterHeight;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: rgb(255 255 255 / 45%);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
background-color: var(--menuBg);
|
||||
border-top: 1px solid rgb(255 255 255 / 6%);
|
||||
}
|
||||
|
||||
.sidebar-footer.is-collapsed {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.sidebar-footer-brand {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { useRoute } from "vue-router";
|
||||
|
||||
import SidebarItem from "./SidebarItem.vue";
|
||||
import Logo from "./Logo.vue";
|
||||
import Footer from "./Footer.vue";
|
||||
|
||||
import { usePermissionStore } from "@/store/modules/permission";
|
||||
import { useAppStore } from "@/store/modules/app";
|
||||
@@ -36,5 +37,6 @@ const route = useRoute();
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<Footer :collapse="!appStore.sidebar.opened" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -36,12 +38,22 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Область прокрутки меню ограничена сверху логотипом, снизу — подписью
|
||||
// разработчика. Пункты меню поэтому не могут наехать на подпись даже при
|
||||
// длинном списке: им физически некуда.
|
||||
&.has-logo {
|
||||
.el-scrollbar {
|
||||
height: calc(100% - 50px);
|
||||
height: calc(100% - 50px - #{$sidebarFooterHeight});
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
@@ -178,10 +190,10 @@
|
||||
& > .el-sub-menu__title {
|
||||
& > span {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,8 +218,8 @@
|
||||
&.hideSidebar {
|
||||
.sidebar-container {
|
||||
pointer-events: none;
|
||||
transition-duration: 0.3s;
|
||||
transform: translate3d(-$sideBarWidth, 0, 0);
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
|
||||
|
||||
$sideBarWidth: 210px;
|
||||
$sideBarCollapsedWidth: 54px;
|
||||
|
||||
// Высота подписи разработчика внизу бокового меню.
|
||||
//
|
||||
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
|
||||
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
|
||||
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
|
||||
$sidebarFooterHeight: 34px;
|
||||
|
||||
@@ -1,65 +1,66 @@
|
||||
// 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']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
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']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
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']
|
||||
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']
|
||||
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
|
||||
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
|
||||
LogViewer: typeof import('./../components/LogViewer/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']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import("element-plus/es")["ElLoadingDirective"];
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Разбор структурированного отказа API.
|
||||
*
|
||||
* Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер
|
||||
* отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что
|
||||
* панель могла сделать, — показать это слово тостом. Оператор, оставивший поле
|
||||
* секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и
|
||||
* не имел ни одного способа узнать причину.
|
||||
*
|
||||
* Теперь у отказа есть код, а у отказа по полю — ещё и имя поля. Панель
|
||||
* выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным
|
||||
* вариантом для кода, которого она ещё не знает, и ответом для клиента без UI.
|
||||
*/
|
||||
|
||||
/** Числовые коды ответа; синхронизировано с model/constant/code.go. */
|
||||
export const API_CODE = {
|
||||
success: 20000,
|
||||
systemError: 50000,
|
||||
validationFailed: 50001,
|
||||
unauthorized: 50401,
|
||||
forbidden: 50403,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go.
|
||||
*
|
||||
* Перечислены только те, на которые панель реагирует по-разному. Остальные
|
||||
* доезжают до оператора сообщением сервера.
|
||||
*/
|
||||
export const ERR_CODE = {
|
||||
bodyInvalid: "body_invalid",
|
||||
validationFailed: "validation_failed",
|
||||
required: "required",
|
||||
min: "min",
|
||||
max: "max",
|
||||
// Границы числа и границы длины строки различаются кодом, хотя тег
|
||||
// валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» —
|
||||
// разные фразы для оператора.
|
||||
minLength: "min_length",
|
||||
maxLength: "max_length",
|
||||
len: "len",
|
||||
oneOf: "oneof",
|
||||
greaterThan: "gt",
|
||||
ruleViolated: "rule_violated",
|
||||
peerName: "peer_name",
|
||||
credentialFormat: "credential_format",
|
||||
// Правило пароля администратора целиком: длина в символах, длина в байтах
|
||||
// (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому,
|
||||
// что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у
|
||||
// пароля, укладывающегося в границу символов.
|
||||
adminPasswordFormat: "admin_password_format",
|
||||
peerNameTaken: "peer_name_taken",
|
||||
peerNameReserved: "peer_name_reserved",
|
||||
peerBootstrapLocked: "peer_bootstrap_identity_locked",
|
||||
// ЧАСТИЧНЫЙ результат отзыва доступа, а не отказ операции: запрет новых
|
||||
// подключений записан, активную сессию завершить не удалось. Панель
|
||||
// показывает его предупреждением и обновляет строку — состояние в базе уже
|
||||
// изменилось.
|
||||
peerDisconnectFailed: "peer_disconnect_failed",
|
||||
invalidCredentials: "invalid_credentials",
|
||||
importFileExtension: "import_file_extension",
|
||||
unauthorized: "unauthorized",
|
||||
sessionExpired: "session_expired",
|
||||
tokenInvalid: "token_invalid",
|
||||
accountDisabled: "account_disabled",
|
||||
} as const;
|
||||
|
||||
export interface ApiFieldError {
|
||||
code: string;
|
||||
field?: string;
|
||||
message: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
code: number;
|
||||
message?: string;
|
||||
errors?: ApiFieldError[];
|
||||
}
|
||||
|
||||
/** Отказ API как исключение, сохраняющее машиночитаемую причину. */
|
||||
export class ApiError extends Error {
|
||||
readonly code: number;
|
||||
readonly errors: ApiFieldError[];
|
||||
|
||||
constructor(payload: ApiErrorPayload) {
|
||||
super(payload.message || "Error");
|
||||
this.name = "ApiError";
|
||||
this.code = payload.code;
|
||||
this.errors = payload.errors ?? [];
|
||||
}
|
||||
|
||||
/** Причины, привязанные к полям формы. */
|
||||
fieldErrors(): ApiFieldError[] {
|
||||
return this.errors.filter((item) => !!item.field);
|
||||
}
|
||||
|
||||
/** Первая причина без привязки к полю — отказ уровня операции. */
|
||||
operationError(): ApiFieldError | undefined {
|
||||
return this.errors.find((item) => !item.field);
|
||||
}
|
||||
|
||||
hasCode(code: string): boolean {
|
||||
return this.errors.some((item) => item.code === code);
|
||||
}
|
||||
|
||||
get requiresSignIn(): boolean {
|
||||
return this.code === API_CODE.unauthorized;
|
||||
}
|
||||
|
||||
get sessionExpired(): boolean {
|
||||
return (
|
||||
this.hasCode(ERR_CODE.sessionExpired) ||
|
||||
this.hasCode(ERR_CODE.accountDisabled)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(value: unknown): value is ApiError {
|
||||
return value instanceof ApiError;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import i18n from "@/lang/index";
|
||||
import { ApiError, ApiFieldError } from "@/utils/api-error";
|
||||
|
||||
/**
|
||||
* Локализация причины отказа.
|
||||
*
|
||||
* Ключ строится ИЗ КОДА, а не из текста ответа. Сервер присылает и своё
|
||||
* человекочитаемое сообщение — оно остаётся ответом для клиента без панели и
|
||||
* запасным вариантом здесь: код, которого панель ещё не знает, обязан доехать
|
||||
* до оператора хоть в каком-то виде, а не превратиться в пустую строку.
|
||||
*
|
||||
* Числа правил (границы длины, допустимые значения) приходят в `params`.
|
||||
* Второй копии этих чисел в панели нет намеренно: копия неизбежно разошлась бы
|
||||
* с серверной, и оператор читал бы «от 6 до 128», получая отказ по другим
|
||||
* границам.
|
||||
*/
|
||||
|
||||
const t = i18n.global.t;
|
||||
const te = i18n.global.te;
|
||||
|
||||
/** Локализованное название поля формы; при отсутствии — имя из ответа. */
|
||||
function fieldLabel(field: string): string {
|
||||
const key = `error.field.${field}`;
|
||||
return te(key) ? t(key) : field;
|
||||
}
|
||||
|
||||
/** Сообщение по одной причине отказа. */
|
||||
export function describeFieldError(error: ApiFieldError): string {
|
||||
const key = `error.code.${error.code}`;
|
||||
if (te(key)) {
|
||||
return t(key, {
|
||||
field: error.field ? fieldLabel(error.field) : "",
|
||||
...(error.params ?? {}),
|
||||
});
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
/** Причины по именам полей формы — для подстановки в el-form. */
|
||||
export function fieldErrorMap(error: ApiError): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const item of error.fieldErrors()) {
|
||||
// Первая причина по полю выигрывает: показывать в одном поле две строки
|
||||
// некуда, а порядок ответа отражает порядок правил.
|
||||
if (item.field && !(item.field in result)) {
|
||||
result[item.field] = describeFieldError(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна строка, пригодная для тоста.
|
||||
*
|
||||
* Отказ уровня операции показывается как есть. Отказ по полям сворачивается в
|
||||
* перечисление «поле: причина» — тост при этом остаётся вторым сигналом, а
|
||||
* первым служит подсветка самих полей.
|
||||
*/
|
||||
export function describeApiError(error: ApiError): string {
|
||||
const operation = error.operationError();
|
||||
if (operation) {
|
||||
return describeFieldError(operation);
|
||||
}
|
||||
|
||||
const fields = error.fieldErrors();
|
||||
if (fields.length > 0) {
|
||||
return fields
|
||||
.map((item) => `${fieldLabel(item.field!)}: ${describeFieldError(item)}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
return error.message || t("common.systemError");
|
||||
}
|
||||
@@ -23,73 +23,9 @@ export const formatBytes = (bytes: number, decimals = 2): string => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export const calculateBytes = (value = 0, unit = "Bytes"): number => {
|
||||
// Приведение единицы к верхнему регистру и удаление пробелов
|
||||
const formattedUnit = unit.toUpperCase().trim();
|
||||
|
||||
// Маппинг единиц хранения на количество байт
|
||||
const unitToBytes: Record<string, number> = {
|
||||
BYTES: 1,
|
||||
KB: 1024 ** 1,
|
||||
MB: 1024 ** 2,
|
||||
GB: 1024 ** 3,
|
||||
TB: 1024 ** 4,
|
||||
PB: 1024 ** 5,
|
||||
EB: 1024 ** 6,
|
||||
ZB: 1024 ** 7,
|
||||
YB: 1024 ** 8,
|
||||
};
|
||||
|
||||
// Проверка наличия единицы в маппинге
|
||||
if (!Object.prototype.hasOwnProperty.call(unitToBytes, formattedUnit)) {
|
||||
throw new Error("Invalid unit");
|
||||
}
|
||||
|
||||
if (value == -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Расчёт и возврат количества байт
|
||||
return value * unitToBytes[formattedUnit];
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование единицы хранения
|
||||
* @param bytes Объём хранения в байтах
|
||||
* @param decimals Количество знаков после запятой, по умолчанию 2
|
||||
* @returns Отформатированное значение объёма хранения
|
||||
*/
|
||||
export const formatStorageCapacity = (bytes: number, decimals = 2): number => {
|
||||
// Проверка корректности входных данных
|
||||
if (!bytes || bytes <= 0) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Расчёт единицы хранения
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
// Форматирование и возврат объёма хранения
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование единицы хранения
|
||||
* @param bytes Объём хранения в байтах
|
||||
* @returns Отформатированная единица хранения
|
||||
*/
|
||||
export const formatStorageUnit = (bytes: number): string => {
|
||||
// Проверка корректности входных данных
|
||||
if (!bytes || bytes <= 0) {
|
||||
return "Bytes";
|
||||
}
|
||||
|
||||
// Расчёт единицы хранения
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
// Возврат отформатированной единицы хранения
|
||||
return sizes[i];
|
||||
};
|
||||
// calculateBytes, formatStorageCapacity и formatStorageUnit удалены вместе с
|
||||
// компонентом UnitSelect, который был их единственным потребителем.
|
||||
//
|
||||
// Сам UnitSelect не использовался ни одной страницей панели: квота
|
||||
// вводится обычным полем в байтах, а показывается через formatBytes. Компонент
|
||||
// остался от поколения, в котором форма пира предлагала выбор единиц.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
/**
|
||||
* Скачивание бинарного ответа API одним способом на всю панель.
|
||||
*
|
||||
* Раньше эта последовательность — Blob, createObjectURL, временная ссылка,
|
||||
* клик, revokeObjectURL — была скопирована в трёх местах, и все три копии
|
||||
* успели разойтись: страница пиров разбирала Content-Disposition с запасным
|
||||
* именем файла, а обе страницы логов падали на `undefined.split(...)`, если
|
||||
* заголовка не оказалось.
|
||||
*
|
||||
* Хуже расхождения был порядок на страницах логов:
|
||||
*
|
||||
* const response = await exportLogApi(...); // <- запрос ВНЕ try
|
||||
* try { ... } catch (e) { } // <- и пустой catch
|
||||
*
|
||||
* Сетевой запрос стоял ПЕРЕД try, поэтому его отказ этим catch не ловился
|
||||
* вовсе, а всё, что происходило внутри, глушилось молча. Оператор нажимал
|
||||
* «Экспорт» и не получал ни файла, ни объяснения.
|
||||
*/
|
||||
|
||||
/** Имя файла по умолчанию, если сервер его не назвал. */
|
||||
const CONTENT_DISPOSITION_PREFIX = "attachment; filename=";
|
||||
|
||||
/**
|
||||
* Имя файла из заголовка ответа.
|
||||
*
|
||||
* Отсутствующий или незнакомый заголовок — не отказ: у скачивания есть
|
||||
* разумное запасное имя, и терять из-за формата заголовка уже полученный файл
|
||||
* незачем.
|
||||
*/
|
||||
export function fileNameFromResponse(
|
||||
response: AxiosResponse,
|
||||
fallback: string
|
||||
): string {
|
||||
const header = response.headers?.["content-disposition"];
|
||||
if (typeof header !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
const index = header.indexOf(CONTENT_DISPOSITION_PREFIX);
|
||||
if (index < 0) {
|
||||
return fallback;
|
||||
}
|
||||
const name = header.slice(index + CONTENT_DISPOSITION_PREFIX.length).trim();
|
||||
return name === "" ? fallback : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отдаёт бинарный ответ пользователю как файл.
|
||||
*
|
||||
* Временная ссылка УДАЛЯЕТСЯ из документа, а objectURL освобождается в
|
||||
* `finally`. Прежние копии оставляли `<a>` в body навсегда — по одному
|
||||
* элементу на каждое нажатие «Экспорт» за всю жизнь вкладки.
|
||||
*/
|
||||
export function saveResponseAsFile(
|
||||
response: AxiosResponse,
|
||||
fallbackFileName: string
|
||||
): void {
|
||||
const blob = new Blob([response.data], { type: "application/octet-stream" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
|
||||
try {
|
||||
anchor.href = url;
|
||||
anchor.download = fileNameFromResponse(response, fallbackFileName);
|
||||
anchor.style.display = "none";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
} finally {
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,40 @@
|
||||
import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import { useAdminStoreHook } from "@/store/modules/admin";
|
||||
import i18n from "@/lang/index";
|
||||
import { API_CODE, ApiError, ApiErrorPayload } from "@/utils/api-error";
|
||||
import { describeApiError } from "@/utils/api-message";
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Запрос может отказаться от общего тоста, если показывает причину сам.
|
||||
*
|
||||
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
|
||||
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
|
||||
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
|
||||
* предупреждением, а не ошибкой.
|
||||
*
|
||||
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
|
||||
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
|
||||
* и молчание о нём означало бы операцию, не происходящую без объяснений.
|
||||
*/
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
skipErrorToast?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Создание axios instance
|
||||
const service = axios.create({
|
||||
baseURL: `${dynamicBase}${API_BASE}`,
|
||||
@@ -26,38 +56,110 @@ service.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Сессия кончилась под руками у оператора.
|
||||
*
|
||||
* Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер
|
||||
* отвечал HTTP 200 на любой отказ, поэтому обработчик ошибок axios (второй
|
||||
* аргумент interceptors.response.use) для отказов API не вызывался вовсе — а
|
||||
* жила ветка сессии именно там. Условие в ней проверяло `code === "A0230"` и
|
||||
* поле `msg`, которых в этом API никогда не было: остатки чужого шаблона.
|
||||
* Ключ common.sessionExpired существовал и был мёртвым.
|
||||
*
|
||||
* Диалог показывается ОДИН раз: истёкший токен обычно роняет сразу несколько
|
||||
* параллельных запросов страницы, и без этого оператор получил бы стопку
|
||||
* одинаковых окон.
|
||||
*/
|
||||
let sessionPromptOpen = false;
|
||||
|
||||
function promptSignIn(expired: boolean): void {
|
||||
if (sessionPromptOpen) {
|
||||
return;
|
||||
}
|
||||
sessionPromptOpen = true;
|
||||
|
||||
const finish = () => {
|
||||
sessionPromptOpen = false;
|
||||
// Сбрасывается ТОЛЬКО сессия. Прежний код звал localStorage.clear(), то
|
||||
// есть заодно стирал выбранный оператором язык панели: при следующем входе
|
||||
// интерфейс возвращался к значению по умолчанию без всякой причины.
|
||||
useAdminStoreHook().resetToken();
|
||||
const redirect = encodeURIComponent(
|
||||
window.location.pathname + window.location.search
|
||||
);
|
||||
window.location.href = `/login?redirect=${redirect}`;
|
||||
};
|
||||
|
||||
ElMessageBox.confirm(
|
||||
expired ? t("common.sessionExpired") : t("common.signInRequired"),
|
||||
t("common.warning"),
|
||||
{
|
||||
confirmButtonText: t("common.signIn"),
|
||||
showCancelButton: false,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false,
|
||||
showClose: false,
|
||||
type: "warning",
|
||||
}
|
||||
)
|
||||
.then(finish)
|
||||
.catch(finish);
|
||||
}
|
||||
|
||||
// Response interceptor
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const { code, message } = response.data;
|
||||
if (code === 20000) {
|
||||
return response.data;
|
||||
}
|
||||
// Обработка бинарного ответа при экспорте файлов
|
||||
if (response.data instanceof ArrayBuffer || response.data instanceof Blob) {
|
||||
// Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
|
||||
// проверяться ДО обращения к его полям: у Blob их нет.
|
||||
if (
|
||||
response.data instanceof ArrayBuffer ||
|
||||
response.data instanceof Blob
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
ElMessage.error(message || "Системная ошибка");
|
||||
return Promise.reject(new Error(message || "Error"));
|
||||
},
|
||||
(error: any) => {
|
||||
if (error.response.data) {
|
||||
const { code, msg } = error.response.data;
|
||||
// Token истёк, нужен повторный вход
|
||||
if (code === "A0230") {
|
||||
ElMessageBox.confirm(t("common.sessionExpired"), t("common.warning"), {
|
||||
confirmButtonText: t("common.confirm"),
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
localStorage.clear();
|
||||
window.location.href = "/";
|
||||
});
|
||||
} else {
|
||||
ElMessage.error(msg || "Системная ошибка");
|
||||
}
|
||||
const payload = response.data as ApiErrorPayload;
|
||||
if (payload?.code === API_CODE.success) {
|
||||
return response.data;
|
||||
}
|
||||
return Promise.reject(error.message);
|
||||
|
||||
const apiError = new ApiError(payload ?? { code: API_CODE.systemError });
|
||||
|
||||
if (apiError.requiresSignIn) {
|
||||
promptSignIn(apiError.sessionExpired);
|
||||
return Promise.reject(apiError);
|
||||
}
|
||||
|
||||
if (!response.config?.skipErrorToast) {
|
||||
ElMessage.error(describeApiError(apiError));
|
||||
}
|
||||
return Promise.reject(apiError);
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос,
|
||||
// HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки
|
||||
// самого error.response — то есть при обрыве соединения падал с
|
||||
// TypeError и подменял настоящую причину отказом внутри обработчика.
|
||||
const message = error.response
|
||||
? t("common.systemError")
|
||||
: t("common.networkError");
|
||||
|
||||
// skipErrorToast здесь НЕ действует, и это существенно.
|
||||
//
|
||||
// Флаг существует ради одного случая: страница разбирает причину отказа
|
||||
// сама — раскладывает её по полям формы или отличает частичный результат
|
||||
// от полного отказа по коду. Всё это возможно только для отказа API, у
|
||||
// которого есть конверт с `code` и `errors`.
|
||||
//
|
||||
// У транспортного отказа конверта нет вовсе. Страница о нём не может
|
||||
// сказать ничего, кроме того же «сервер не отвечает», и все они пишут в
|
||||
// своих обработчиках `// транспортный отказ уже показан общим
|
||||
// перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было
|
||||
// ложным: обрыв соединения при сохранении пира, его удалении или отзыве
|
||||
// доступа не показывал оператору НИЧЕГО — операция просто не происходила
|
||||
// молча.
|
||||
ElMessage.error(message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { adminChangePasswordApi } from "@/api/admin";
|
||||
import { useAdminStore } from "@/store/modules/admin";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_BYTES,
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
adminPasswordFormRule,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -46,7 +52,33 @@ const form = reactive({
|
||||
newPassword: "",
|
||||
});
|
||||
|
||||
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
|
||||
// Проверяется ТОЛЬКО длина, и она берётся из общего контракта.
|
||||
//
|
||||
// Здесь стояло правило набора символов `[a-zA-Z0-9!@#$%^&*()_+-=]`, которого
|
||||
// сервер не предъявляет ни при смене пароля, ни при установке. То есть панель
|
||||
// отказывала оператору в пароле, который сервер принял бы, и сообщала об этом
|
||||
// фразой «Некорректное значение», не называя ни одного требования.
|
||||
//
|
||||
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
|
||||
// пароль, назначенный здесь, невозможно было ввести там.
|
||||
//
|
||||
// Правило — собственное, а не встроенные `min`/`max` Element Plus: те
|
||||
// делегируются `async-validator`, который считает длину строки в единицах
|
||||
// UTF-16, тогда как сервер считает code points и вдобавок ограничивает длину в
|
||||
// БАЙТАХ пределом bcrypt. Пароль из 64 кириллических букв проходил здесь и
|
||||
// возвращался с сервера системной ошибкой хеширования.
|
||||
//
|
||||
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
|
||||
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
|
||||
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
|
||||
const passwordRule = adminPasswordFormRule(
|
||||
t("credentials.passwordFormat", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
|
||||
})
|
||||
);
|
||||
|
||||
const rules: FormRules = {
|
||||
oldPassword: [
|
||||
{
|
||||
@@ -54,11 +86,7 @@ const rules: FormRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: passwordPattern,
|
||||
message: t("common.invalid"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{ ...passwordRule },
|
||||
],
|
||||
newPassword: [
|
||||
{
|
||||
@@ -66,11 +94,7 @@ const rules: FormRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: passwordPattern,
|
||||
message: t("common.invalid"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{ ...passwordRule },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -29,13 +29,44 @@
|
||||
risk.severity === 'critical'
|
||||
? 'error'
|
||||
: risk.severity === 'warning'
|
||||
? 'warning'
|
||||
: 'info'
|
||||
? 'warning'
|
||||
: 'info'
|
||||
"
|
||||
:closable="risk.dismissible"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<!--
|
||||
Два независимых факта показываются отдельно и называются своими именами.
|
||||
Раньше их было полтора: доступность Traffic Stats API выводилась из
|
||||
ответа systemctl, поэтому дашборд умел утверждать «служба остановлена» и
|
||||
«API доступен» одновременно.
|
||||
-->
|
||||
<el-card shadow="never" class="mt-2">
|
||||
<el-descriptions :column="3" size="small" border>
|
||||
<el-descriptions-item :label="$t('hysteria.hysteria2Version')">
|
||||
{{ summary.hysteria.version || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('dashboard.serviceStateLabel')">
|
||||
<el-tag size="small" :type="serviceStateTagType">
|
||||
{{ $t(`dashboard.serviceState.${summary.hysteria.serviceState}`) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('dashboard.trafficApiLabel')">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="summary.hysteria.apiReachable ? 'success' : 'danger'"
|
||||
>
|
||||
{{
|
||||
summary.hysteria.apiReachable
|
||||
? $t("dashboard.trafficApiReachable")
|
||||
: $t("dashboard.trafficApiUnreachable")
|
||||
}}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="10" class="mt-2">
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
@@ -58,16 +89,26 @@
|
||||
>{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card
|
||||
></el-col
|
||||
>
|
||||
<!--
|
||||
Пока Traffic Stats API недоступен, «0 онлайн» — не данные, а
|
||||
утверждение, которого никто не проверял. Раньше именно так и было:
|
||||
сборщик метрик получал пустую картину подключений из ответа systemctl и
|
||||
показывал ноль рядом с плашкой «служба остановлена».
|
||||
-->
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
>{{ $t("dashboard.onlinePeers") }}:
|
||||
{{ summary.peers.onlinePeers }}</el-card
|
||||
{{
|
||||
summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?"
|
||||
}}</el-card
|
||||
></el-col
|
||||
>
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
>{{ $t("dashboard.onlineDevices") }}:
|
||||
{{ summary.peers.onlineDevices }}</el-card
|
||||
{{
|
||||
summary.hysteria.apiReachable ? summary.peers.onlineDevices : "?"
|
||||
}}</el-card
|
||||
></el-col
|
||||
>
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
@@ -89,9 +130,9 @@
|
||||
<div class="chart-header">
|
||||
<span>{{ $t("dashboard.trafficChart") }}</span>
|
||||
<el-radio-group v-model="range" size="small" @change="loadDashboard">
|
||||
<el-radio-button label="1h">1h</el-radio-button>
|
||||
<el-radio-button label="24h">24h</el-radio-button>
|
||||
<el-radio-button label="7d">7d</el-radio-button>
|
||||
<el-radio-button value="1h">1h</el-radio-button>
|
||||
<el-radio-button value="24h">24h</el-radio-button>
|
||||
<el-radio-button value="7d">7d</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
@@ -183,7 +224,14 @@ const summary = ref<DashboardSummaryVo>({
|
||||
diskTotalBytes: 0,
|
||||
diskPercent: 0,
|
||||
},
|
||||
hysteria: { version: "-", running: false, apiReachable: false },
|
||||
// До первого ответа сервера о службе не известно ничего: `inactive` здесь
|
||||
// было бы утверждением, которого никто не проверял.
|
||||
hysteria: {
|
||||
version: "-",
|
||||
serviceState: "unknown",
|
||||
running: false,
|
||||
apiReachable: false,
|
||||
},
|
||||
peers: {
|
||||
total: 0,
|
||||
enabled: 0,
|
||||
@@ -231,6 +279,23 @@ const isStale = computed(() => {
|
||||
return Date.now() - lastSuccessAt.value > staleThresholdMs;
|
||||
});
|
||||
|
||||
/**
|
||||
* Цвет плашки состояния службы.
|
||||
*
|
||||
* «Неизвестно» — предупреждение, а не ошибка: это отказ ДИАГНОСТИКИ, и он не
|
||||
* означает, что туннель не работает.
|
||||
*/
|
||||
const serviceStateTagType = computed(() => {
|
||||
switch (summary.value.hysteria.serviceState) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "inactive":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
});
|
||||
|
||||
const loadDashboard = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
@@ -247,7 +312,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,382 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag
|
||||
:key="item"
|
||||
v-for="item in outbounds"
|
||||
@close="handleClose(item)"
|
||||
@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-dialog
|
||||
:title="outboundInfoDialog.title"
|
||||
v-model="outboundInfoDialog.visible"
|
||||
width="600px"
|
||||
append-to-body
|
||||
@close="outboundInfoDialog.visible = false"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.name')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="name" prop="name">
|
||||
<el-tag>{{ outboundInfo.name }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.type')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="type" prop="type">
|
||||
<el-tag>{{ outboundInfo.type }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<template v-if="outboundInfo.type === 'socks5'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.addr')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.addr" prop="socks5.addr">
|
||||
<el-tag>{{ outboundInfo.socks5.addr }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.username')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.username" prop="outboundInfo.username">
|
||||
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
|
||||
</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-tag>{{ outboundInfo.socks5.password }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="outboundInfo.type === 'http'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.http.url')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="http.url" prop="http.url">
|
||||
<el-tag>{{ outboundInfo.http.url }}</el-tag>
|
||||
</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-tag>{{ outboundInfo.http.insecure }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="outboundInfo.type === 'direct'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.mode')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.mode" prop="direct.mode">
|
||||
<el-tag>{{ outboundInfo.direct.mode }}</el-tag>
|
||||
</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-tag>{{ outboundInfo.direct.bindIPv4 }}</el-tag>
|
||||
</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-tag>{{ outboundInfo.direct.bindIPv6 }}</el-tag>
|
||||
</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-tag>{{ outboundInfo.direct.bindDevice }}</el-tag>
|
||||
</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-tag>{{ outboundInfo.direct.fastOpen }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="outboundInfoDialog.visible = false"
|
||||
>{{ $t("common.cancel") }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "outbounds",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defaultHysteria2ServerConfigOutbound,
|
||||
Hysteria2ServerConfigOutbound,
|
||||
} from "@/api/config/types";
|
||||
import { PropType } from "vue";
|
||||
import { deepCopy } from "@/utils/copy";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
outbounds: {
|
||||
required: true,
|
||||
type: Array as PropType<Array<Hysteria2ServerConfigOutbound>>,
|
||||
default: (): Array<Hysteria2ServerConfigOutbound> => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: "update:outbounds",
|
||||
value: Array<Hysteria2ServerConfigOutbound>
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const outbounds = useVModel(props, "outbounds", emit);
|
||||
|
||||
const dataFormRef = ref(ElForm);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
|
||||
state.outboundInfo = 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>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,109 +1,24 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-toolbar">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||
<el-select
|
||||
v-model="queryParams.numLine"
|
||||
style="width: 200px"
|
||||
@change="setRecords"
|
||||
>
|
||||
<el-option label="100" value="100" />
|
||||
<el-option label="200" value="200" />
|
||||
<el-option label="300" value="300" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="export">
|
||||
<el-button @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item prop="refresh">
|
||||
<el-button @click="setRecords">
|
||||
<template #icon>
|
||||
<i-ep-refresh />
|
||||
</template>
|
||||
{{ $t("common.refresh") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="records">
|
||||
<el-table-column
|
||||
key="level"
|
||||
label="level"
|
||||
align="center"
|
||||
prop="level"
|
||||
/>
|
||||
<el-table-column key="msg" label="msg" align="center" prop="msg" />
|
||||
<el-table-column key="time" label="time" align="center" prop="time" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
<log-viewer
|
||||
:fetch-logs="logHysteria2Api"
|
||||
:export-option="1"
|
||||
export-file-name="hysteria2.log"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "index",
|
||||
name: "LogHysteria",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LogDto, LogHysteria2Vo } from "@/api/log/types";
|
||||
import { exportLogApi, logHysteria2Api } from "@/api/log";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
total: 0,
|
||||
records: [] as LogHysteria2Vo[],
|
||||
queryParams: {
|
||||
numLine: 100,
|
||||
} as LogDto,
|
||||
});
|
||||
|
||||
const { loading, records, queryParams } = toRefs(state);
|
||||
|
||||
const setRecords = async () => {
|
||||
try {
|
||||
state.loading = true;
|
||||
const { data } = await logHysteria2Api(state.queryParams);
|
||||
state.records = data.records;
|
||||
state.total = data.total;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
let response = await exportLogApi({ option: 1 });
|
||||
try {
|
||||
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 */
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setRecords();
|
||||
});
|
||||
/**
|
||||
* Журнал Hysteria.
|
||||
*
|
||||
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer — см.
|
||||
* страницу системного журнала.
|
||||
*/
|
||||
import LogViewer from "@/components/LogViewer/index.vue";
|
||||
import { logHysteria2Api } from "@/api/log";
|
||||
</script>
|
||||
|
||||