mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0039b39609 | |||
| 92ff7f568b | |||
| 896f2bc5f8 | |||
| 87605ce8f8 | |||
| a5b191328d | |||
| 14063e66c3 | |||
| 161be2c167 | |||
| d6b83a0c1b | |||
| 7aa5ca3e96 | |||
| 7f69a130d0 | |||
| 5ddb46c3c2 | |||
| 69f62188cf | |||
| f786d60219 | |||
| bdc3513a68 | |||
| acb20a13b7 | |||
| c890ec783d | |||
| 8ccc5e054a | |||
| aef329149d | |||
| 8473b9040f | |||
| 89ab12dcdf | |||
| 919339cdb1 | |||
| 6a8996b17a | |||
| 7557e02e54 | |||
| 90debf144b | |||
| cec5fe9adb | |||
| 6790336885 | |||
| 5aea281e16 | |||
| 3b697f4d13 | |||
| b4a8b582fa | |||
| 34becb6604 | |||
| a6e8722e27 | |||
| 83256a5e26 | |||
| 27e7e065ef | |||
| fdfb6512bb | |||
| cb76b2161e | |||
| 9b19e45711 | |||
| 0cf81a783c | |||
| f0893afe42 | |||
| ae2da67efd | |||
| 63e9d80023 | |||
| b205b47414 | |||
| d517695544 | |||
| 29e58ec66a | |||
| de9146334e | |||
| 00079ac7af | |||
| 2affb31b3e | |||
| 49884d54ac | |||
| dae6286e58 | |||
| da52b66cd2 | |||
| a8da752f5c | |||
| 32763358ea | |||
| 3d9432e3bc | |||
| d515d610b9 | |||
| 08f5b3aa33 | |||
| 67f935c46f | |||
| 79f8aab600 | |||
| 5f42c50689 | |||
| 2b2f914bec | |||
| c18703eba2 | |||
| 3f2150828d | |||
| c76bb77b93 | |||
| 01d5c25df1 | |||
| a19e8d2bcb | |||
| 76f443f900 | |||
| 47b1663938 | |||
| 406f5bb380 | |||
| 85ea6c17a4 | |||
| 3420dc5460 | |||
| 3d7a0b697d | |||
| 065b23d9c3 | |||
| 7919dfde59 | |||
| 565d732967 | |||
| bf88b0dd7e | |||
| 20dc151aec | |||
| 630ed969d3 | |||
| e94aa01213 | |||
| 67d8f7a381 | |||
| 0c7b7deaf5 | |||
| a4def9c5f3 | |||
| 6582348da2 | |||
| 3a14b22ebc | |||
| d7b44916bf | |||
| f8a0bda3fd | |||
| 634562e56d | |||
| d06e02998b | |||
| 169df953d8 | |||
| 7bc33f63ba | |||
| 5925a84a6d | |||
| bbffe5872c | |||
| 98db0e6a5e | |||
| 30591a008a | |||
| 52885d1821 | |||
| 124cf1ced2 | |||
| fef4c28777 | |||
| 9ab5140258 | |||
| 11dc21e46f |
@@ -268,6 +268,10 @@ jobs:
|
||||
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/install.sh
|
||||
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/panel_install.sh
|
||||
|
||||
# 注入固定版本号,使从 Release 页下载的脚本只安装该版本
|
||||
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/install.sh
|
||||
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/panel_install.sh
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -303,14 +307,22 @@ jobs:
|
||||
|
||||
## 🚀 Quick Install
|
||||
|
||||
**Panel:**
|
||||
**Panel (安装此版本 ${VERSION}):**
|
||||
\`\`\`bash
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
\`\`\`
|
||||
|
||||
**Node:**
|
||||
**Node (安装此版本 ${VERSION}):**
|
||||
\`\`\`bash
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
\`\`\`
|
||||
|
||||
**安装最新版:**
|
||||
\`\`\`bash
|
||||
# 面板端
|
||||
curl -L https://raw.githubusercontent.com/${{ github.repository }}/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
# 节点端
|
||||
curl -L https://raw.githubusercontent.com/${{ github.repository }}/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
\`\`\`" \
|
||||
--repo ${{ github.repository }}
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ build/
|
||||
*.so
|
||||
*.dylib
|
||||
your_app.exe
|
||||
go-backend/paneld
|
||||
|
||||
# Go 测试二进制文件
|
||||
*.test
|
||||
|
||||
@@ -43,7 +43,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
|
||||
|
||||
## CONVENTIONS
|
||||
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
|
||||
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
@@ -60,7 +60,7 @@ docker compose -f docker-compose-v6.yml up -d
|
||||
./install.sh
|
||||
|
||||
# Local dev (per subproject)
|
||||
(cd springboot-backend && mvn clean package)
|
||||
(cd go-backend && make build)
|
||||
(cd vite-frontend && npm run dev)
|
||||
(cd go-gost && go run .)
|
||||
```
|
||||
|
||||
@@ -1,201 +1,674 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
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.
|
||||
|
||||
1. Definitions.
|
||||
Preamble
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
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.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
0. Definitions.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
"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.
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
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.
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
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.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
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.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
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.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
1. Source Code.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
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.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
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.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
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.
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
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.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
2. Basic Permissions.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
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.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
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 to 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,19 @@
|
||||
FLVX
|
||||
Copyright 2026 Sagit-chu
|
||||
|
||||
This product includes software developed at
|
||||
flux-panel (https://github.com/bqlpfy/flux-panel)
|
||||
Copyright 2024-2026 bqlpfy
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
This project is a derivative work based on flux-panel.
|
||||
Modifications and new components (backend, agents, frontend updates)
|
||||
are licensed under the GNU General Public License v3.0 (GPLv3).
|
||||
See the LICENSE file for the full GPLv3 text.
|
||||
-------------------------------------------------------------------------
|
||||
@@ -1,9 +1,8 @@
|
||||
# FLVX
|
||||
|
||||
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
||||
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
||||
|
||||
|
||||
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
|
||||
---
|
||||
## 特性
|
||||
|
||||
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
|
||||
@@ -12,12 +11,16 @@
|
||||
- 可针对 **指定用户的指定隧道进行限速** 设置
|
||||
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
|
||||
- 提供灵活的转发策略配置,适用于多种网络场景
|
||||
- 面板分享,支持将节点分享给其他人,面板对接面板
|
||||
- 支持分组权限管理,隧道分组、用户分组
|
||||
- 支持批量功能,可以批量下发配置,启停等
|
||||
- 支持隧道修改配置、转发修改隧道
|
||||
|
||||
|
||||
## 部署流程
|
||||
---
|
||||
### Docker Compose部署
|
||||
#### 快速部署
|
||||
#### 快速部署(安装最新版)
|
||||
面板端:
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
@@ -27,6 +30,18 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
#### 安装特定版本
|
||||
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
|
||||
|
||||
面板端(以 2.1.0 为例):
|
||||
```bash
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
节点端(以 2.1.0 为例):
|
||||
```bash
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
#### 默认管理员账号
|
||||
|
||||
- **账号**: admin_user
|
||||
@@ -34,6 +49,37 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
|
||||
|
||||
> ⚠️ 首次登录后请立即修改默认密码!
|
||||
|
||||
---
|
||||
## Original Project
|
||||
- **Name**: flux-panel
|
||||
- **Source**: https://github.com/bqlpfy/flux-panel
|
||||
- **License**: Apache License 2.0
|
||||
|
||||
## Modifications
|
||||
The following major changes and additions have been made in this fork (FLVX):
|
||||
|
||||
### 1. Backend Architecture (Replaced)
|
||||
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
|
||||
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
|
||||
|
||||
### 2. Forwarding Agent (Modified)
|
||||
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
|
||||
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
|
||||
|
||||
### 3. Frontend (Modified)
|
||||
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
|
||||
|
||||
### 4. Mobile Applications (Removed)
|
||||
- **Removed**: `android-app/` - Source code for the Android client.
|
||||
- **Removed**: `ios-app/` - Source code for the iOS client.
|
||||
|
||||
### 5. Infrastructure & Scripts
|
||||
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
|
||||
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
||||
- **Added**: `AGENTS.md` (Project documentation).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 免责声明
|
||||
|
||||
|
||||
@@ -77,3 +77,57 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
|
||||
安装完成后,服务会自动启动。
|
||||
- 查看状态: `systemctl status flux_agent`
|
||||
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
|
||||
|
||||
---
|
||||
|
||||
## 三、Caddy 反向代理(可选)
|
||||
|
||||
如果需要通过域名访问面板并自动获取 HTTPS 证书,可以使用 Caddy 作为反向代理。
|
||||
|
||||
### 1. 安装 Caddy
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu
|
||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo apt update
|
||||
sudo apt install caddy
|
||||
```
|
||||
|
||||
其他系统请参考 [Caddy 官方安装文档](https://caddyserver.com/docs/install)。
|
||||
|
||||
### 2. 配置 Caddyfile
|
||||
|
||||
编辑 Caddy 配置文件:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
#### 面板域名配置
|
||||
|
||||
将 `panel.example.com` 替换为你自己的域名:
|
||||
|
||||
```caddyfile
|
||||
panel.example.com {
|
||||
reverse_proxy localhost:6366
|
||||
}
|
||||
```
|
||||
|
||||
Caddy 会自动为域名申请和续期 HTTPS 证书,无需额外配置。
|
||||
|
||||
### 3. 重启 Caddy
|
||||
|
||||
```bash
|
||||
sudo systemctl restart caddy
|
||||
```
|
||||
|
||||
### 4. 注意事项
|
||||
|
||||
- 确保域名已正确解析到服务器 IP。
|
||||
- 确保服务器防火墙放行了 **80** 和 **443** 端口(Caddy 自动申请证书需要)。
|
||||
- 使用 Caddy 反向代理后,可以在 `.env` 中将前端端口改为仅监听本地,避免直接暴露:
|
||||
```
|
||||
FRONTEND_PORT=127.0.0.1:6366
|
||||
```
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,335 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FederationClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type RemoteNodeInfo struct {
|
||||
ShareID int64 `json:"shareId"`
|
||||
ShareName string `json:"shareName"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
NodeName string `json:"nodeName"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
Status int `json:"status"`
|
||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||
CurrentFlow int64 `json:"currentFlow"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
PortRangeStart int `json:"portRangeStart"`
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
}
|
||||
|
||||
type RemoteTunnelResponse struct {
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
}
|
||||
|
||||
type RuntimeReservePortRequest struct {
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
Protocol string `json:"protocol"`
|
||||
RequestedPort int `json:"requestedPort"`
|
||||
}
|
||||
|
||||
type RuntimeReservePortResponse struct {
|
||||
ReservationID string `json:"reservationId"`
|
||||
BindingID string `json:"bindingId"`
|
||||
AllocatedPort int `json:"allocatedPort"`
|
||||
}
|
||||
|
||||
type RuntimeTarget struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
type RuntimeApplyRoleRequest struct {
|
||||
ReservationID string `json:"reservationId"`
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
Role string `json:"role"`
|
||||
Protocol string `json:"protocol"`
|
||||
Strategy string `json:"strategy"`
|
||||
Targets []RuntimeTarget `json:"targets"`
|
||||
}
|
||||
|
||||
type RuntimeApplyRoleResponse struct {
|
||||
BindingID string `json:"bindingId"`
|
||||
ReservationID string `json:"reservationId"`
|
||||
AllocatedPort int `json:"allocatedPort"`
|
||||
}
|
||||
|
||||
type RuntimeReleaseRoleRequest struct {
|
||||
BindingID string `json:"bindingId"`
|
||||
ReservationID string `json:"reservationId"`
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
}
|
||||
|
||||
type RuntimeDiagnoseRequest struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
func NewFederationClient() *FederationClient {
|
||||
return &FederationClient{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewFederationClientWithTimeout(timeout time.Duration) *FederationClient {
|
||||
return &FederationClient{
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data RemoteNodeInfo `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
return &res.Data, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) CreateTunnel(url, token, localDomain, protocol string, remotePort int, target string) (*RemoteTunnelResponse, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
payload := map[string]interface{}{
|
||||
"protocol": protocol,
|
||||
"remotePort": remotePort,
|
||||
"target": target,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/tunnel/create", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data RemoteTunnelResponse `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
return &res.Data, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) ReservePort(url, token, localDomain string, reqData RuntimeReservePortRequest) (*RuntimeReservePortResponse, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
bodyBytes, _ := json.Marshal(reqData)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/reserve-port", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data RuntimeReservePortResponse `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
return &res.Data, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData RuntimeApplyRoleRequest) (*RuntimeApplyRoleResponse, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
bodyBytes, _ := json.Marshal(reqData)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/apply-role", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data RuntimeApplyRoleResponse `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
return &res.Data, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) ReleaseRole(url, token, localDomain string, reqData RuntimeReleaseRoleRequest) error {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
bodyBytes, _ := json.Marshal(reqData)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/release-role", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) Diagnose(url, token, localDomain string, reqData RuntimeDiagnoseRequest) (map[string]interface{}, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
bodyBytes, _ := json.Marshal(reqData)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/diagnose", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
if res.Data == nil {
|
||||
return nil, fmt.Errorf("remote api error: empty diagnosis payload")
|
||||
}
|
||||
|
||||
return res.Data, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,10 @@ type nodeRecord struct {
|
||||
TCPListenAddr string
|
||||
UDPListenAddr string
|
||||
InterfaceName string
|
||||
IsRemote int
|
||||
RemoteURL string
|
||||
RemoteToken string
|
||||
RemoteConfig string
|
||||
}
|
||||
|
||||
type chainNodeRecord struct {
|
||||
@@ -59,6 +64,8 @@ type chainNodeRecord struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
NodeName string
|
||||
Protocol string
|
||||
Strategy string
|
||||
}
|
||||
|
||||
type diagnosisTarget struct {
|
||||
@@ -195,7 +202,7 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
|
||||
|
||||
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT id, name, server_ip, server_ip_v4, server_ip_v6, status, port, tcp_listen_addr, udp_listen_addr, interface_name
|
||||
SELECT id, name, server_ip, server_ip_v4, server_ip_v6, status, port, tcp_listen_addr, udp_listen_addr, interface_name, is_remote, remote_url, remote_token, remote_config
|
||||
FROM node
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
@@ -207,7 +214,10 @@ func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||
var tcpListen sql.NullString
|
||||
var udpListen sql.NullString
|
||||
var iface sql.NullString
|
||||
err := row.Scan(&n.ID, &n.Name, &n.ServerIP, &serverIPv4, &serverIPv6, &n.Status, &portRange, &tcpListen, &udpListen, &iface)
|
||||
var remoteURL sql.NullString
|
||||
var remoteToken sql.NullString
|
||||
var remoteConfig sql.NullString
|
||||
err := row.Scan(&n.ID, &n.Name, &n.ServerIP, &serverIPv4, &serverIPv6, &n.Status, &portRange, &tcpListen, &udpListen, &iface, &n.IsRemote, &remoteURL, &remoteToken, &remoteConfig)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("节点不存在")
|
||||
@@ -220,6 +230,9 @@ func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||
n.TCPListenAddr = strings.TrimSpace(tcpListen.String)
|
||||
n.UDPListenAddr = strings.TrimSpace(udpListen.String)
|
||||
n.InterfaceName = strings.TrimSpace(iface.String)
|
||||
n.RemoteURL = strings.TrimSpace(remoteURL.String)
|
||||
n.RemoteToken = strings.TrimSpace(remoteToken.String)
|
||||
n.RemoteConfig = strings.TrimSpace(remoteConfig.String)
|
||||
if n.TCPListenAddr == "" {
|
||||
n.TCPListenAddr = "[::]"
|
||||
}
|
||||
@@ -232,9 +245,9 @@ func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int, error) {
|
||||
func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT ut.id, sl.speed
|
||||
SELECT ut.id, sl.id, sl.speed
|
||||
FROM user_tunnel ut
|
||||
LEFT JOIN speed_limit sl ON sl.id = ut.speed_id
|
||||
WHERE ut.user_id = ? AND ut.tunnel_id = ?
|
||||
@@ -242,19 +255,21 @@ func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *i
|
||||
LIMIT 1
|
||||
`, userID, tunnelID)
|
||||
var userTunnelID int64
|
||||
var limiterID sql.NullInt64
|
||||
var speed sql.NullInt64
|
||||
err := row.Scan(&userTunnelID, &speed)
|
||||
err := row.Scan(&userTunnelID, &limiterID, &speed)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil, nil
|
||||
return 0, nil, nil, nil
|
||||
}
|
||||
return 0, nil, err
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
if !speed.Valid || speed.Int64 <= 0 {
|
||||
return userTunnelID, nil, nil
|
||||
if !limiterID.Valid || limiterID.Int64 <= 0 {
|
||||
return userTunnelID, nil, nil, nil
|
||||
}
|
||||
v := int(speed.Int64)
|
||||
return userTunnelID, &v, nil
|
||||
v := limiterID.Int64
|
||||
s := int(speed.Int64)
|
||||
return userTunnelID, &v, &s, nil
|
||||
}
|
||||
|
||||
func (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
|
||||
@@ -326,18 +341,22 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
||||
return errors.New("转发入口端口不存在")
|
||||
}
|
||||
|
||||
userTunnelID, limiter, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
userTunnelID, limiterID, speed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
|
||||
|
||||
for _, fp := range ports {
|
||||
if limiterID != nil && speed != nil {
|
||||
h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed)
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(fp.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiter)
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID)
|
||||
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
||||
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
||||
@@ -360,7 +379,7 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
if len(ports) == 0 {
|
||||
return nil
|
||||
}
|
||||
userTunnelID, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -773,7 +792,15 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
||||
}
|
||||
item["nodeName"] = fromNode.Name
|
||||
|
||||
pingData, pingErr := h.tcpPingViaNode(fromNodeID, targetIP, targetPort)
|
||||
var (
|
||||
pingData map[string]interface{}
|
||||
pingErr error
|
||||
)
|
||||
if fromNode.IsRemote == 1 {
|
||||
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort)
|
||||
} else {
|
||||
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort)
|
||||
}
|
||||
if pingErr != nil {
|
||||
item["success"] = false
|
||||
item["message"] = pingErr.Error()
|
||||
@@ -862,7 +889,7 @@ func firstPortFromRange(portRange string) int {
|
||||
|
||||
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name
|
||||
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
|
||||
FROM chain_tunnel ct
|
||||
LEFT JOIN node n ON n.id = ct.node_id
|
||||
WHERE ct.tunnel_id = ?
|
||||
@@ -877,7 +904,9 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
|
||||
for rows.Next() {
|
||||
var item chainNodeRecord
|
||||
var name sql.NullString
|
||||
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name); err != nil {
|
||||
var protocol sql.NullString
|
||||
var strategy sql.NullString
|
||||
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name, &protocol, &strategy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(name.String) == "" {
|
||||
@@ -885,6 +914,8 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
|
||||
} else {
|
||||
item.NodeName = name.String
|
||||
}
|
||||
item.Protocol = defaultString(protocol.String, "tls")
|
||||
item.Strategy = defaultString(strategy.String, "round")
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -909,6 +940,25 @@ func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (map[string]interface{}, error) {
|
||||
if node == nil {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
return nil, errors.New("远程节点缺少共享配置")
|
||||
}
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
|
||||
IP: strings.TrimSpace(ip),
|
||||
Port: port,
|
||||
Count: 4,
|
||||
Timeout: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
func splitRemoteTargets(remoteAddr string) []string {
|
||||
parts := strings.Split(remoteAddr, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
@@ -997,7 +1047,7 @@ func isNotFoundError(err error) bool {
|
||||
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
|
||||
}
|
||||
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiter *int) []map[string]interface{} {
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64) []map[string]interface{} {
|
||||
protocols := []string{"tcp", "udp"}
|
||||
services := make([]map[string]interface{}, 0, 2)
|
||||
targets := splitRemoteTargets(forward.RemoteAddr)
|
||||
@@ -1038,8 +1088,8 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
|
||||
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
|
||||
}
|
||||
if limiter != nil && *limiter > 0 {
|
||||
service["limiter"] = strconv.Itoa(*limiter)
|
||||
if limiterID != nil && *limiterID > 0 {
|
||||
service["limiter"] = strconv.FormatInt(*limiterID, 10)
|
||||
}
|
||||
services = append(services, service)
|
||||
}
|
||||
@@ -1102,3 +1152,49 @@ func asBool(v interface{}, def bool) bool {
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) sendLimiterConfig(limiterID int64, speedMbps int, tunnelID int64) error {
|
||||
rate := float64(speedMbps) / 8.0
|
||||
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": strconv.FormatInt(limiterID, 10),
|
||||
"limits": []string{limitStr},
|
||||
}
|
||||
|
||||
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, nodeID := range nodes {
|
||||
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) sendDeleteLimiterConfig(limiterID int64, tunnelID int64) error {
|
||||
payload := map[string]interface{}{
|
||||
"limiter": strconv.FormatInt(limiterID, 10),
|
||||
}
|
||||
|
||||
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, nodeID := range nodes {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", payload, false, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) {
|
||||
rate := float64(speed) / 8.0
|
||||
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
|
||||
payload := map[string]interface{}{
|
||||
"name": strconv.FormatInt(limiterID, 10),
|
||||
"limits": []string{limitStr},
|
||||
}
|
||||
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls"); err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001); err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now); err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
share := &sqlite.PeerShare{
|
||||
ID: 77,
|
||||
NodeID: 1,
|
||||
PortRangeStart: 3000,
|
||||
PortRangeEnd: 3004,
|
||||
}
|
||||
|
||||
port, err := h.pickPeerSharePort(share, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("pick auto port: %v", err)
|
||||
}
|
||||
if port != 3003 {
|
||||
t.Fatalf("expected port 3003, got %d", port)
|
||||
}
|
||||
|
||||
if _, err := h.pickPeerSharePort(share, 3001); err == nil {
|
||||
t.Fatalf("expected requested busy port to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
|
||||
h := &Handler{}
|
||||
state := &tunnelCreateState{
|
||||
TunnelID: 1,
|
||||
Type: 2,
|
||||
InNodes: []tunnelRuntimeNode{
|
||||
{NodeID: 11, ChainType: 1, Protocol: "tls"},
|
||||
},
|
||||
ChainHops: [][]tunnelRuntimeNode{
|
||||
{
|
||||
{NodeID: 12, ChainType: 2, Inx: 1, Port: 41000, Protocol: "tls", Strategy: "round"},
|
||||
},
|
||||
},
|
||||
OutNodes: []tunnelRuntimeNode{
|
||||
{NodeID: 13, ChainType: 3, Port: 42000, Protocol: "tls", Strategy: "round"},
|
||||
},
|
||||
Nodes: map[int64]*nodeRecord{
|
||||
11: {ID: 11, Name: "remote-in", IsRemote: 1},
|
||||
12: {ID: 12, Name: "remote-chain", IsRemote: 1},
|
||||
13: {ID: 13, Name: "remote-out", IsRemote: 1},
|
||||
},
|
||||
}
|
||||
|
||||
chains, services, err := h.applyTunnelRuntime(state)
|
||||
if err != nil {
|
||||
t.Fatalf("apply runtime: %v", err)
|
||||
}
|
||||
if len(chains) != 0 {
|
||||
t.Fatalf("expected no local chains created, got %d", len(chains))
|
||||
}
|
||||
if len(services) != 0 {
|
||||
t.Fatalf("expected no local services created, got %d", len(services))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||
res, execErr := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`)
|
||||
if execErr != nil {
|
||||
t.Fatalf("insert node %s: %v", name, execErr)
|
||||
}
|
||||
id, idErr := res.LastInsertId()
|
||||
if idErr != nil {
|
||||
t.Fatalf("node id %s: %v", name, idErr)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
entryID := insertNode("entry", 1, "31000-31010", 0)
|
||||
remoteOutID := insertNode("remote-out", 1, "30000", 1)
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000); err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
tx, err := repo.DB().Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
req := map[string]interface{}{
|
||||
"name": "test-tunnel",
|
||||
"inNodeId": []interface{}{
|
||||
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"outNodeId": []interface{}{
|
||||
map[string]interface{}{"nodeId": float64(remoteOutID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||
},
|
||||
"chainNodes": []interface{}{},
|
||||
}
|
||||
|
||||
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare state should not fail for remote auto-port: %v", err)
|
||||
}
|
||||
if len(state.OutNodes) != 1 {
|
||||
t.Fatalf("expected 1 out node, got %d", len(state.OutNodes))
|
||||
}
|
||||
if state.OutNodes[0].Port != 0 {
|
||||
t.Fatalf("expected remote out port to remain 0 before federation reserve, got %d", state.OutNodes[0].Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "limited-share",
|
||||
NodeID: 1,
|
||||
Token: "limited-token",
|
||||
MaxBandwidth: 2048,
|
||||
CurrentFlow: 2048,
|
||||
PortRangeStart: 30000,
|
||||
PortRangeEnd: 30010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"resourceKey": "tunnel:1:node:1:type:3:hop:0",
|
||||
"protocol": "tls",
|
||||
"requestedPort": 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/reserve-port", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer limited-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationRuntimeReservePort(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 403 {
|
||||
t.Fatalf("expected response code 403, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
if payload.Msg != "Share traffic limit exceeded" {
|
||||
t.Fatalf("unexpected response message: %q", payload.Msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestFederationShareCreateRejectsRemoteNode(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertRes, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-share-node", "remote-share-secret", "10.10.10.1", "10.10.10.1", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":1}`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
remoteNodeID, err := insertRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get remote node id: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(createPeerShareRequest{
|
||||
Name: "remote-node-share",
|
||||
NodeID: remoteNodeID,
|
||||
MaxBandwidth: 0,
|
||||
ExpiryTime: 0,
|
||||
PortRangeStart: 20000,
|
||||
PortRangeEnd: 20010,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/create", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationShareCreate(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != -1 {
|
||||
t.Fatalf("expected response code -1, got %d", payload.Code)
|
||||
}
|
||||
if payload.Msg != "Only local nodes can be shared" {
|
||||
t.Fatalf("expected rejection message %q, got %q", "Only local nodes can be shared", payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, remoteNodeID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("query peer_share count: %v", err)
|
||||
}
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected no share rows for remote node, got %d", shareCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertRes, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "local-share-node", "local-share-secret", "10.20.30.40", "10.20.30.40", "", "21000-21010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
localNodeID, err := insertRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get local node id: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(createPeerShareRequest{
|
||||
Name: "local-node-share",
|
||||
NodeID: localNodeID,
|
||||
MaxBandwidth: 0,
|
||||
ExpiryTime: 0,
|
||||
PortRangeStart: 21000,
|
||||
PortRangeEnd: 21010,
|
||||
AllowedIPs: "bad-ip-entry",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/create", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationShareCreate(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != -1 {
|
||||
t.Fatalf("expected response code -1, got %d", payload.Code)
|
||||
}
|
||||
if !strings.Contains(payload.Msg, "Invalid allowed IP or CIDR") {
|
||||
t.Fatalf("expected invalid IP message, got %q", payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, localNodeID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("query peer_share count: %v", err)
|
||||
}
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected no share rows for node, got %d", shareCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "provider-share",
|
||||
NodeID: 9,
|
||||
Token: "share-list-token",
|
||||
MaxBandwidth: 1024,
|
||||
CurrentFlow: 512,
|
||||
PortRangeStart: 22000,
|
||||
PortRangeEnd: 22010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
|
||||
share, err := repo.GetPeerShareByToken("share-list-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
share.ID, share.NodeID, "r-1", "rk-1", "b-1", "middle", "fed_chain_1", "fed_svc_1", "tls", "round", 22001, "", 1, 1, now, now,
|
||||
share.ID, share.NodeID, "r-2", "rk-2", "b-2", "exit", "", "fed_svc_2", "tls", "round", 22002, "", 1, 1, now, now,
|
||||
share.ID, share.NodeID, "r-3", "rk-3", "", "", "", "", "tls", "round", 22003, "", 0, 0, now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.federationShareList(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
rows, ok := payload.Data.([]interface{})
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty share list, got %T", payload.Data)
|
||||
}
|
||||
|
||||
first, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected share row object, got %T", rows[0])
|
||||
}
|
||||
|
||||
if int(first["activeRuntimeNum"].(float64)) != 2 {
|
||||
t.Fatalf("expected activeRuntimeNum=2, got %v", first["activeRuntimeNum"])
|
||||
}
|
||||
|
||||
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||
}
|
||||
if len(usedPortsRaw) != 2 {
|
||||
t.Fatalf("expected 2 used ports, got %d", len(usedPortsRaw))
|
||||
}
|
||||
if int(usedPortsRaw[0].(float64)) != 22001 || int(usedPortsRaw[1].(float64)) != 22002 {
|
||||
t.Fatalf("unexpected used ports payload: %v", usedPortsRaw)
|
||||
}
|
||||
|
||||
detailsRaw, ok := first["usedPortDetails"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPortDetails array, got %T", first["usedPortDetails"])
|
||||
}
|
||||
if len(detailsRaw) != 2 {
|
||||
t.Fatalf("expected 2 usedPortDetails rows, got %d", len(detailsRaw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "delete-cleanup-share",
|
||||
NodeID: 99,
|
||||
Token: "delete-cleanup-token",
|
||||
MaxBandwidth: 4096,
|
||||
PortRangeStart: 40000,
|
||||
PortRangeEnd: 40010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
|
||||
share, err := repo.GetPeerShareByToken("delete-cleanup-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
share.ID, 99, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
|
||||
share.ID, 99, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||
}
|
||||
|
||||
var runtimeCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1`, share.ID).Scan(&runtimeCount); err != nil {
|
||||
t.Fatalf("count active runtimes before: %v", err)
|
||||
}
|
||||
if runtimeCount != 2 {
|
||||
t.Fatalf("expected 2 active runtimes before delete, got %d", runtimeCount)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(deletePeerShareRequest{ID: share.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/delete", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationShareDelete(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE id = ?`, share.ID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("count peer_share after: %v", err)
|
||||
}
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected peer_share deleted, got %d rows", shareCount)
|
||||
}
|
||||
|
||||
var runtimeCountAfter int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, share.ID).Scan(&runtimeCountAfter); err != nil {
|
||||
t.Fatalf("count peer_share_runtime after: %v", err)
|
||||
}
|
||||
if runtimeCountAfter != 0 {
|
||||
t.Fatalf("expected all peer_share_runtime rows deleted, got %d", runtimeCountAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`); err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.federationRemoteUsageList(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
rows, ok := payload.Data.([]interface{})
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
|
||||
}
|
||||
|
||||
first, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected row map, got %T", rows[0])
|
||||
}
|
||||
|
||||
if int64(first["shareId"].(float64)) != 42 {
|
||||
t.Fatalf("expected stale shareId=42 on sync failure, got %v", first["shareId"])
|
||||
}
|
||||
if int64(first["currentFlow"].(float64)) != 999999 {
|
||||
t.Fatalf("expected stale currentFlow=999999 on sync failure, got %v", first["currentFlow"])
|
||||
}
|
||||
|
||||
syncErr, _ := first["syncError"].(string)
|
||||
if syncErr == "" {
|
||||
t.Fatalf("expected non-empty syncError field on unreachable provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareResetFlow(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "reset-flow-share",
|
||||
NodeID: 11,
|
||||
Token: "reset-flow-token",
|
||||
MaxBandwidth: 4096,
|
||||
CurrentFlow: 2048,
|
||||
PortRangeStart: 23000,
|
||||
PortRangeEnd: 23010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := repo.GetPeerShareByToken("reset-flow-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(resetPeerShareFlowRequest{ID: share.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/reset-flow", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationShareResetFlow(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
updated, err := repo.GetPeerShare(share.ID)
|
||||
if err != nil || updated == nil {
|
||||
t.Fatalf("reload peer share: %v", err)
|
||||
}
|
||||
if updated.CurrentFlow != 0 {
|
||||
t.Fatalf("expected current flow reset to 0, got %d", updated.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageList(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
resNode, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
nodeID, err := resNode.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("remote node id: %v", err)
|
||||
}
|
||||
|
||||
resTunnelA, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel a: %v", err)
|
||||
}
|
||||
tunnelAID, _ := resTunnelA.LastInsertId()
|
||||
|
||||
resTunnelB, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel b: %v", err)
|
||||
}
|
||||
tunnelBID, _ := resTunnelB.LastInsertId()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
|
||||
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("insert federation bindings: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.federationRemoteUsageList(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
rows, ok := payload.Data.([]interface{})
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
|
||||
}
|
||||
|
||||
first, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected first usage row map, got %T", rows[0])
|
||||
}
|
||||
if int64(first["shareId"].(float64)) != 88 {
|
||||
t.Fatalf("expected shareId=88, got %v", first["shareId"])
|
||||
}
|
||||
|
||||
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||
}
|
||||
if len(usedPortsRaw) != 2 {
|
||||
t.Fatalf("expected 2 used ports, got %d", len(usedPortsRaw))
|
||||
}
|
||||
if int(usedPortsRaw[0].(float64)) != 31001 || int(usedPortsRaw[1].(float64)) != 31002 {
|
||||
t.Fatalf("unexpected used ports payload: %v", usedPortsRaw)
|
||||
}
|
||||
|
||||
bindingsRaw, ok := first["bindings"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected bindings array, got %T", first["bindings"])
|
||||
}
|
||||
if len(bindingsRaw) != 2 {
|
||||
t.Fatalf("expected 2 binding rows, got %d", len(bindingsRaw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedIPs string
|
||||
remoteAddr string
|
||||
xff string
|
||||
wantAllowed bool
|
||||
}{
|
||||
{
|
||||
name: "exact ip allowed",
|
||||
allowedIPs: "203.0.113.10",
|
||||
remoteAddr: "203.0.113.10:23456",
|
||||
wantAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "cidr allowed",
|
||||
allowedIPs: "203.0.113.0/24",
|
||||
remoteAddr: "203.0.113.11:23456",
|
||||
wantAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "trusted proxy xff allowed",
|
||||
allowedIPs: "198.51.100.20",
|
||||
remoteAddr: "172.20.0.3:34567",
|
||||
xff: "198.51.100.20, 172.20.0.3",
|
||||
wantAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "non whitelisted ip denied",
|
||||
allowedIPs: "203.0.113.10",
|
||||
remoteAddr: "203.0.113.99:23456",
|
||||
wantAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := fmt.Sprintf("share-token-%d", idx)
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "share-" + tt.name,
|
||||
NodeID: 1,
|
||||
Token: token,
|
||||
PortRangeStart: 10000,
|
||||
PortRangeEnd: 10010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
AllowedIPs: tt.allowedIPs,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
|
||||
nextCalled := false
|
||||
wrapped := h.authPeer(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/connect", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if tt.xff != "" {
|
||||
req.Header.Set("X-Forwarded-For", tt.xff)
|
||||
}
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
wrapped(res, req)
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if tt.wantAllowed {
|
||||
if !nextCalled {
|
||||
t.Fatalf("expected next handler to be called")
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if nextCalled {
|
||||
t.Fatalf("expected next handler not to be called")
|
||||
}
|
||||
if payload.Code != 403 {
|
||||
t.Fatalf("expected code 403, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
if payload.Msg != "IP not allowed" {
|
||||
t.Fatalf("expected IP rejection message, got %q", payload.Msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,21 @@ func (h *Handler) processFlowItem(item flowItem) {
|
||||
}
|
||||
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok {
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.processPeerShareFlow(runtimeID, item)
|
||||
}
|
||||
|
||||
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
||||
@@ -66,6 +71,72 @@ func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
||||
return forwardID, userID, userTunnelID, true
|
||||
}
|
||||
|
||||
func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
|
||||
const prefix = "fed_svc_"
|
||||
if !strings.HasPrefix(serviceName, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(serviceName, prefix)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.SplitN(raw, "_", 2)
|
||||
runtimeID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || runtimeID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return runtimeID, true
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
if h == nil || h.repo == nil || runtimeID <= 0 {
|
||||
return
|
||||
}
|
||||
runtime, err := h.repo.GetPeerShareRuntimeByID(runtimeID)
|
||||
if err != nil || runtime == nil || runtime.ShareID <= 0 || runtime.Status != 1 {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||
|
||||
share, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || share == nil {
|
||||
return
|
||||
}
|
||||
if !isPeerShareFlowExceeded(share) {
|
||||
return
|
||||
}
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
}
|
||||
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
|
||||
if err != nil || len(runtimes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, runtime := range runtimes {
|
||||
if h.wsServer != nil && runtime.Applied == 1 {
|
||||
if strings.TrimSpace(runtime.ServiceName) != "" {
|
||||
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
|
||||
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
|
||||
}
|
||||
}
|
||||
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) scaleFlowByTunnel(forwardID int64, inFlow int64, outFlow int64) (int64, int64) {
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "flow-share",
|
||||
NodeID: 1,
|
||||
Token: "flow-share-token",
|
||||
MaxBandwidth: 3000,
|
||||
CurrentFlow: 1000,
|
||||
PortRangeStart: 32000,
|
||||
PortRangeEnd: 32010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := repo.GetPeerShareByToken("flow-share-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now); err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
|
||||
updatedShare, err := repo.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 3100 {
|
||||
t.Fatalf("expected current_flow=3100, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
|
||||
runtime, err := repo.GetPeerShareRuntimeByID(17)
|
||||
if err != nil || runtime == nil {
|
||||
t.Fatalf("reload runtime: %v", err)
|
||||
}
|
||||
if runtime.Status != 0 {
|
||||
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ type Handler struct {
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
|
||||
captchaMu sync.Mutex
|
||||
captchaTokens map[string]int64
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
@@ -39,6 +42,11 @@ type loginRequest struct {
|
||||
CaptchaID string `json:"captchaId"`
|
||||
}
|
||||
|
||||
type captchaVerifyRequest struct {
|
||||
ID string `json:"id"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type nameRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -63,9 +71,10 @@ type flowItem struct {
|
||||
|
||||
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
captchaTokens: make(map[string]int64),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +94,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
|
||||
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
|
||||
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
||||
mux.HandleFunc("/api/v1/user/updatePassword", h.updatePassword)
|
||||
mux.HandleFunc("/api/v1/node/list", h.nodeList)
|
||||
@@ -143,6 +153,19 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/group/permission/assign", h.groupPermissionAssign)
|
||||
mux.HandleFunc("/api/v1/group/permission/remove", h.groupPermissionRemove)
|
||||
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
|
||||
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
||||
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
||||
mux.HandleFunc("/api/v1/federation/share/update", h.federationShareUpdate)
|
||||
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
||||
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
|
||||
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
|
||||
mux.HandleFunc("/api/v1/federation/connect", h.authPeer(h.federationConnect))
|
||||
mux.HandleFunc("/api/v1/federation/tunnel/create", h.authPeer(h.federationTunnelCreate))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/reserve-port", h.authPeer(h.federationRuntimeReservePort))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||
|
||||
mux.HandleFunc("/flow/test", h.flowTest)
|
||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||
@@ -177,20 +200,23 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if captchaEnabled {
|
||||
if strings.TrimSpace(req.CaptchaID) == "" {
|
||||
captchaID := strings.TrimSpace(req.CaptchaID)
|
||||
if captchaID == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
return
|
||||
}
|
||||
|
||||
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||
if err != nil || secretCfg == nil || secretCfg.Value == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码配置错误:未配置Secret Key"))
|
||||
return
|
||||
}
|
||||
if !h.consumeCaptchaToken(captchaID) {
|
||||
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||
if err != nil || secretCfg == nil || strings.TrimSpace(secretCfg.Value) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.verifyCloudflareTurnstile(req.CaptchaID, secretCfg.Value) {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
return
|
||||
if !h.verifyCloudflareTurnstile(captchaID, strings.TrimSpace(secretCfg.Value)) {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +321,9 @@ func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
h.syncRemoteNodeStatuses(items)
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
@@ -579,6 +608,40 @@ func (h *Handler) checkCaptcha(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK(0))
|
||||
}
|
||||
|
||||
func (h *Handler) captchaVerify(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req captchaVerifyRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
h.writeCaptchaVerifyResult(w, false, "")
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(req.ID)
|
||||
data := strings.TrimSpace(req.Data)
|
||||
if id == "" || data == "" {
|
||||
h.writeCaptchaVerifyResult(w, false, "")
|
||||
return
|
||||
}
|
||||
|
||||
verified := false
|
||||
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||
if err == nil && secretCfg != nil && strings.TrimSpace(secretCfg.Value) != "" {
|
||||
verified = h.verifyCloudflareTurnstile(data, strings.TrimSpace(secretCfg.Value))
|
||||
} else {
|
||||
verified = data == "ok"
|
||||
}
|
||||
if !verified {
|
||||
h.writeCaptchaVerifyResult(w, false, "")
|
||||
return
|
||||
}
|
||||
|
||||
h.markCaptchaToken(id)
|
||||
h.writeCaptchaVerifyResult(w, true, id)
|
||||
}
|
||||
|
||||
func (h *Handler) flowTest(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("test"))
|
||||
@@ -893,6 +956,69 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
||||
return strings.EqualFold(cfg.Value, "true"), nil
|
||||
}
|
||||
|
||||
func (h *Handler) markCaptchaToken(token string) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
exp := now + int64(5*time.Minute/time.Millisecond)
|
||||
|
||||
h.captchaMu.Lock()
|
||||
defer h.captchaMu.Unlock()
|
||||
if h.captchaTokens == nil {
|
||||
h.captchaTokens = make(map[string]int64)
|
||||
}
|
||||
for k, v := range h.captchaTokens {
|
||||
if v <= now {
|
||||
delete(h.captchaTokens, k)
|
||||
}
|
||||
}
|
||||
h.captchaTokens[token] = exp
|
||||
}
|
||||
|
||||
func (h *Handler) consumeCaptchaToken(token string) bool {
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
h.captchaMu.Lock()
|
||||
defer h.captchaMu.Unlock()
|
||||
if h.captchaTokens == nil {
|
||||
return false
|
||||
}
|
||||
for k, v := range h.captchaTokens {
|
||||
if v <= now {
|
||||
delete(h.captchaTokens, k)
|
||||
}
|
||||
}
|
||||
exp, ok := h.captchaTokens[token]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(h.captchaTokens, token)
|
||||
return exp > now
|
||||
}
|
||||
|
||||
func (h *Handler) writeCaptchaVerifyResult(w http.ResponseWriter, success bool, token string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]interface{}{
|
||||
"success": success,
|
||||
"data": map[string]interface{}{
|
||||
"validToken": token,
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func decodeJSON(body io.ReadCloser, out interface{}) error {
|
||||
defer body.Close()
|
||||
decoder := json.NewDecoder(body)
|
||||
|
||||
@@ -15,8 +15,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -273,8 +275,8 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().UnixMilli()
|
||||
inx := nextIndex(db, "node")
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
name,
|
||||
randomToken(16),
|
||||
@@ -293,6 +295,10 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
defaultString(asString(req["tcpListenAddr"]), "[::]"),
|
||||
defaultString(asString(req["udpListenAddr"]), "[::]"),
|
||||
inx,
|
||||
asInt(req["isRemote"], 0),
|
||||
nullableText(asString(req["remoteUrl"])),
|
||||
nullableText(asString(req["remoteToken"])),
|
||||
nullableText(asString(req["remoteConfig"])),
|
||||
)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -500,7 +506,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal)
|
||||
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, 0)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -509,6 +515,50 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
inIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes)
|
||||
}
|
||||
|
||||
if len(runtimeState.InNodes) > 0 {
|
||||
firstNodeID := runtimeState.InNodes[0].NodeID
|
||||
var isRemote int
|
||||
var rUrl, rToken sql.NullString
|
||||
if err := h.repo.DB().QueryRow("SELECT is_remote, remote_url, remote_token FROM node WHERE id = ?", firstNodeID).Scan(&isRemote, &rUrl, &rToken); err == nil && isRemote == 1 {
|
||||
fc := client.NewFederationClient()
|
||||
|
||||
targetProto := "tcp"
|
||||
targetPort := 0
|
||||
targetAddr := ""
|
||||
|
||||
if typeVal == 1 {
|
||||
if len(runtimeState.OutNodes) > 0 {
|
||||
outNode := runtimeState.OutNodes[0]
|
||||
outNodeRec := runtimeState.Nodes[outNode.NodeID]
|
||||
targetAddr = processServerAddress(outNodeRec.ServerIP)
|
||||
if outNode.Port > 0 {
|
||||
targetAddr = fmt.Sprintf("%s:%d", targetAddr, outNode.Port)
|
||||
}
|
||||
}
|
||||
if len(runtimeState.InNodes) > 0 {
|
||||
inNodesRaw := asMapSlice(req["inNodeId"])
|
||||
if len(inNodesRaw) > 0 {
|
||||
targetPort = asInt(inNodesRaw[0]["port"], 0)
|
||||
targetProto = defaultString(asString(inNodesRaw[0]["protocol"]), "tcp")
|
||||
}
|
||||
}
|
||||
|
||||
if targetPort > 0 && targetAddr != "" {
|
||||
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
|
||||
localDomain := ""
|
||||
if domainCfg != nil {
|
||||
localDomain = domainCfg.Value
|
||||
}
|
||||
_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Remote tunnel creation failed: "+err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx)
|
||||
if err != nil {
|
||||
@@ -517,12 +567,28 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
tunnelID, _ := res.LastInsertId()
|
||||
runtimeState.TunnelID = tunnelID
|
||||
var federationBindings []sqlite.FederationTunnelBinding
|
||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||
if typeVal == 2 {
|
||||
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
applyTunnelPortsToRequest(req, runtimeState)
|
||||
if err := replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := replaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -530,6 +596,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||
if applyErr != nil {
|
||||
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.deleteTunnelByID(tunnelID)
|
||||
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
||||
return
|
||||
@@ -603,6 +670,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
h.cleanupTunnelRuntime(id)
|
||||
h.cleanupFederationRuntime(id)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
typeVal := asInt(req["type"], 1)
|
||||
@@ -614,12 +682,21 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal)
|
||||
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
runtimeState.TunnelID = id
|
||||
var federationBindings []sqlite.FederationTunnelBinding
|
||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||
if typeVal == 2 {
|
||||
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
applyTunnelPortsToRequest(req, runtimeState)
|
||||
|
||||
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
|
||||
@@ -634,10 +711,17 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := replaceTunnelChainsTx(tx, id, req); err != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := replaceFederationTunnelBindingsTx(tx, id, federationBindings); err != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -646,6 +730,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||
if applyErr != nil {
|
||||
h.rollbackTunnelRuntime(createdChains, createdServices, id)
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)
|
||||
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
||||
return
|
||||
}
|
||||
@@ -664,6 +754,7 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.cleanupTunnelRuntime(id)
|
||||
h.cleanupFederationRuntime(id)
|
||||
if err := h.deleteTunnelByID(id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -722,6 +813,7 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
|
||||
fail := 0
|
||||
for _, id := range ids {
|
||||
h.cleanupTunnelRuntime(id)
|
||||
h.cleanupFederationRuntime(id)
|
||||
if err := h.deleteTunnelByID(id); err != nil {
|
||||
fail++
|
||||
} else {
|
||||
@@ -731,6 +823,82 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
|
||||
}
|
||||
|
||||
func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := &tunnelCreateState{
|
||||
TunnelID: tunnelID,
|
||||
Type: tunnel.Type,
|
||||
InNodes: make([]tunnelRuntimeNode, 0),
|
||||
ChainHops: make([][]tunnelRuntimeNode, 0),
|
||||
OutNodes: make([]tunnelRuntimeNode, 0),
|
||||
Nodes: make(map[int64]*nodeRecord),
|
||||
NodeIDList: make([]int64, 0),
|
||||
}
|
||||
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
|
||||
for _, r := range inNodes {
|
||||
state.InNodes = append(state.InNodes, tunnelRuntimeNode{
|
||||
NodeID: r.NodeID,
|
||||
Protocol: r.Protocol,
|
||||
Strategy: r.Strategy,
|
||||
ChainType: 1,
|
||||
})
|
||||
state.NodeIDList = append(state.NodeIDList, r.NodeID)
|
||||
}
|
||||
|
||||
for _, r := range outNodes {
|
||||
state.OutNodes = append(state.OutNodes, tunnelRuntimeNode{
|
||||
NodeID: r.NodeID,
|
||||
Protocol: r.Protocol,
|
||||
Strategy: r.Strategy,
|
||||
ChainType: 3,
|
||||
Port: r.Port,
|
||||
})
|
||||
state.NodeIDList = append(state.NodeIDList, r.NodeID)
|
||||
}
|
||||
|
||||
for _, hop := range chainHops {
|
||||
stateHop := make([]tunnelRuntimeNode, 0)
|
||||
for _, r := range hop {
|
||||
stateHop = append(stateHop, tunnelRuntimeNode{
|
||||
NodeID: r.NodeID,
|
||||
Protocol: r.Protocol,
|
||||
Strategy: r.Strategy,
|
||||
ChainType: 2,
|
||||
Inx: int(r.Inx),
|
||||
Port: r.Port,
|
||||
})
|
||||
state.NodeIDList = append(state.NodeIDList, r.NodeID)
|
||||
}
|
||||
state.ChainHops = append(state.ChainHops, stateHop)
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range state.NodeIDList {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
node, err := h.getNodeRecord(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Nodes[id] = node
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||
ids := idsFromBody(r, w)
|
||||
if ids == nil {
|
||||
@@ -739,6 +907,51 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||
success := 0
|
||||
fail := 0
|
||||
for _, tunnelID := range ids {
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
|
||||
if tunnel.Type == 2 {
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
h.cleanupFederationRuntime(tunnelID)
|
||||
state, err := h.reconstructTunnelState(tunnelID)
|
||||
if err != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state)
|
||||
if fedErr != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
tx, txErr := h.repo.DB().Begin()
|
||||
if txErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if replaceErr := replaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
||||
_ = tx.Rollback()
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if commitErr := tx.Commit(); commitErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
_, _, applyErr := h.applyTunnelRuntime(state)
|
||||
if applyErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
fail++
|
||||
@@ -1373,12 +1586,15 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := h.repo.DB().Exec(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
name, asInt(req["speed"], 100), tunnelID, tunnelName, now, now, asInt(req["status"], 1))
|
||||
speed := asInt(req["speed"], 100)
|
||||
res, err := h.repo.DB().Exec(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
name, speed, tunnelID, tunnelName, now, now, asInt(req["status"], 1))
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
_ = h.sendLimiterConfig(id, speed, tunnelID)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1400,12 +1616,14 @@ func (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
speed := asInt(req["speed"], 100)
|
||||
_, err := h.repo.DB().Exec(`UPDATE speed_limit SET name=?, speed=?, tunnel_id=?, tunnel_name=?, status=?, updated_time=? WHERE id=?`,
|
||||
asString(req["name"]), asInt(req["speed"], 100), tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli(), id)
|
||||
asString(req["name"]), speed, tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli(), id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
_ = h.sendLimiterConfig(id, speed, tunnelID)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1414,11 +1632,17 @@ func (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
var tunnelID int64
|
||||
_ = h.repo.DB().QueryRow(`SELECT tunnel_id FROM speed_limit WHERE id = ?`, id).Scan(&tunnelID)
|
||||
|
||||
_, err := h.repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if tunnelID > 0 {
|
||||
_ = h.sendDeleteLimiterConfig(id, tunnelID)
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1720,7 +1944,7 @@ type tunnelCreateState struct {
|
||||
NodeIDList []int64
|
||||
}
|
||||
|
||||
func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{}, tunnelType int) (*tunnelCreateState, error) {
|
||||
func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
|
||||
state := &tunnelCreateState{
|
||||
Type: tunnelType,
|
||||
InNodes: make([]tunnelRuntimeNode, 0),
|
||||
@@ -1762,10 +1986,16 @@ func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{
|
||||
nodeIDs = append(nodeIDs, nodeID)
|
||||
port := asInt(item["port"], 0)
|
||||
if port <= 0 {
|
||||
var err error
|
||||
port, err = pickNodePortTx(tx, nodeID, allocated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
isRemote, remoteErr := isRemoteNodeTx(tx, nodeID)
|
||||
if remoteErr != nil {
|
||||
return nil, remoteErr
|
||||
}
|
||||
if !isRemote {
|
||||
var err error
|
||||
port, err = pickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
state.OutNodes = append(state.OutNodes, tunnelRuntimeNode{
|
||||
@@ -1790,10 +2020,16 @@ func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{
|
||||
nodeIDs = append(nodeIDs, nodeID)
|
||||
port := asInt(item["port"], 0)
|
||||
if port <= 0 {
|
||||
var err error
|
||||
port, err = pickNodePortTx(tx, nodeID, allocated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
isRemote, remoteErr := isRemoteNodeTx(tx, nodeID)
|
||||
if remoteErr != nil {
|
||||
return nil, remoteErr
|
||||
}
|
||||
if !isRemote {
|
||||
var err error
|
||||
port, err = pickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
hop = append(hop, tunnelRuntimeNode{
|
||||
@@ -1897,6 +2133,303 @@ func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateSt
|
||||
}
|
||||
}
|
||||
|
||||
type federationRuntimeReleaseRef struct {
|
||||
RemoteURL string
|
||||
RemoteToken string
|
||||
BindingID string
|
||||
ReservationID string
|
||||
ResourceKey string
|
||||
}
|
||||
|
||||
func federationRuntimeResourceKey(tunnelID int64, nodeID int64, chainType int, hopInx int) string {
|
||||
return fmt.Sprintf("tunnel:%d:node:%d:type:%d:hop:%d", tunnelID, nodeID, chainType, hopInx)
|
||||
}
|
||||
|
||||
func remoteShareIDFromConfig(raw string) int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return 0
|
||||
}
|
||||
return asInt64(cfg["shareId"], 0)
|
||||
}
|
||||
|
||||
func (h *Handler) federationLocalDomain() string {
|
||||
cfg, _ := h.repo.GetConfigByName("panel_domain")
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.Value)
|
||||
}
|
||||
|
||||
func (h *Handler) applyFederationRuntime(state *tunnelCreateState) ([]sqlite.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {
|
||||
bindings := make([]sqlite.FederationTunnelBinding, 0)
|
||||
releaseRefs := make([]federationRuntimeReleaseRef, 0)
|
||||
if h == nil || state == nil || state.Type != 2 {
|
||||
return bindings, releaseRefs, nil
|
||||
}
|
||||
fc := client.NewFederationClient()
|
||||
localDomain := h.federationLocalDomain()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
for outIdx := range state.OutNodes {
|
||||
outNode := state.OutNodes[outIdx]
|
||||
node := state.Nodes[outNode.NodeID]
|
||||
if node == nil || node.IsRemote != 1 {
|
||||
continue
|
||||
}
|
||||
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
|
||||
}
|
||||
|
||||
resourceKey := federationRuntimeResourceKey(state.TunnelID, outNode.NodeID, 3, 0)
|
||||
reserveReq := client.RuntimeReservePortRequest{
|
||||
ResourceKey: resourceKey,
|
||||
Protocol: defaultString(outNode.Protocol, "tls"),
|
||||
RequestedPort: outNode.Port,
|
||||
}
|
||||
reserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
|
||||
if err != nil && reserveReq.RequestedPort > 0 {
|
||||
reserveReq.RequestedPort = 0
|
||||
reserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
|
||||
}
|
||||
if err != nil {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, fmt.Errorf("远程节点 %s 端口分配失败: %w", nodeDisplayName(node), err)
|
||||
}
|
||||
|
||||
state.OutNodes[outIdx].Port = reserveRes.AllocatedPort
|
||||
outNode = state.OutNodes[outIdx]
|
||||
|
||||
applyReq := client.RuntimeApplyRoleRequest{
|
||||
ReservationID: reserveRes.ReservationID,
|
||||
ResourceKey: resourceKey,
|
||||
Role: "exit",
|
||||
Protocol: defaultString(outNode.Protocol, "tls"),
|
||||
Strategy: defaultString(outNode.Strategy, "round"),
|
||||
}
|
||||
applyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)
|
||||
if err != nil {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, fmt.Errorf("远程节点 %s 运行时下发失败: %w", nodeDisplayName(node), err)
|
||||
}
|
||||
if applyRes.AllocatedPort > 0 {
|
||||
state.OutNodes[outIdx].Port = applyRes.AllocatedPort
|
||||
outNode = state.OutNodes[outIdx]
|
||||
}
|
||||
|
||||
bindings = append(bindings, sqlite.FederationTunnelBinding{
|
||||
TunnelID: state.TunnelID,
|
||||
NodeID: outNode.NodeID,
|
||||
ChainType: 3,
|
||||
HopInx: 0,
|
||||
RemoteURL: remoteURL,
|
||||
ResourceKey: resourceKey,
|
||||
RemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),
|
||||
AllocatedPort: outNode.Port,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
releaseRefs = append(releaseRefs, federationRuntimeReleaseRef{
|
||||
RemoteURL: remoteURL,
|
||||
RemoteToken: remoteToken,
|
||||
BindingID: applyRes.BindingID,
|
||||
ReservationID: reserveRes.ReservationID,
|
||||
ResourceKey: resourceKey,
|
||||
})
|
||||
}
|
||||
|
||||
for hopIdx := len(state.ChainHops) - 1; hopIdx >= 0; hopIdx-- {
|
||||
for nodeIdx := range state.ChainHops[hopIdx] {
|
||||
chainNode := state.ChainHops[hopIdx][nodeIdx]
|
||||
node := state.Nodes[chainNode.NodeID]
|
||||
if node == nil || node.IsRemote != 1 {
|
||||
continue
|
||||
}
|
||||
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
|
||||
}
|
||||
|
||||
resourceKey := federationRuntimeResourceKey(state.TunnelID, chainNode.NodeID, 2, hopIdx+1)
|
||||
reserveReq := client.RuntimeReservePortRequest{
|
||||
ResourceKey: resourceKey,
|
||||
Protocol: defaultString(chainNode.Protocol, "tls"),
|
||||
RequestedPort: chainNode.Port,
|
||||
}
|
||||
reserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
|
||||
if err != nil && reserveReq.RequestedPort > 0 {
|
||||
reserveReq.RequestedPort = 0
|
||||
reserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
|
||||
}
|
||||
if err != nil {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, fmt.Errorf("远程节点 %s 端口分配失败: %w", nodeDisplayName(node), err)
|
||||
}
|
||||
|
||||
state.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort
|
||||
chainNode = state.ChainHops[hopIdx][nodeIdx]
|
||||
|
||||
nextTargets := state.OutNodes
|
||||
if hopIdx+1 < len(state.ChainHops) {
|
||||
nextTargets = state.ChainHops[hopIdx+1]
|
||||
}
|
||||
applyTargets := make([]client.RuntimeTarget, 0, len(nextTargets))
|
||||
for _, target := range nextTargets {
|
||||
targetNode := state.Nodes[target.NodeID]
|
||||
if targetNode == nil {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, errors.New("节点不存在")
|
||||
}
|
||||
host, hostErr := selectTunnelDialHost(node, targetNode)
|
||||
if hostErr != nil {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, hostErr
|
||||
}
|
||||
if target.Port <= 0 {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, errors.New("节点端口不能为空")
|
||||
}
|
||||
applyTargets = append(applyTargets, client.RuntimeTarget{
|
||||
Host: host,
|
||||
Port: target.Port,
|
||||
Protocol: defaultString(target.Protocol, "tls"),
|
||||
})
|
||||
}
|
||||
|
||||
applyReq := client.RuntimeApplyRoleRequest{
|
||||
ReservationID: reserveRes.ReservationID,
|
||||
ResourceKey: resourceKey,
|
||||
Role: "middle",
|
||||
Protocol: defaultString(chainNode.Protocol, "tls"),
|
||||
Strategy: defaultString(chainNode.Strategy, "round"),
|
||||
Targets: applyTargets,
|
||||
}
|
||||
applyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)
|
||||
if err != nil {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, fmt.Errorf("远程节点 %s 运行时下发失败: %w", nodeDisplayName(node), err)
|
||||
}
|
||||
if applyRes.AllocatedPort > 0 {
|
||||
state.ChainHops[hopIdx][nodeIdx].Port = applyRes.AllocatedPort
|
||||
chainNode = state.ChainHops[hopIdx][nodeIdx]
|
||||
}
|
||||
|
||||
bindings = append(bindings, sqlite.FederationTunnelBinding{
|
||||
TunnelID: state.TunnelID,
|
||||
NodeID: chainNode.NodeID,
|
||||
ChainType: 2,
|
||||
HopInx: hopIdx + 1,
|
||||
RemoteURL: remoteURL,
|
||||
ResourceKey: resourceKey,
|
||||
RemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),
|
||||
AllocatedPort: chainNode.Port,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
releaseRefs = append(releaseRefs, federationRuntimeReleaseRef{
|
||||
RemoteURL: remoteURL,
|
||||
RemoteToken: remoteToken,
|
||||
BindingID: applyRes.BindingID,
|
||||
ReservationID: reserveRes.ReservationID,
|
||||
ResourceKey: resourceKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return bindings, releaseRefs, nil
|
||||
}
|
||||
|
||||
func (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) {
|
||||
if h == nil || len(refs) == 0 {
|
||||
return
|
||||
}
|
||||
fc := client.NewFederationClient()
|
||||
localDomain := h.federationLocalDomain()
|
||||
for i := len(refs) - 1; i >= 0; i-- {
|
||||
ref := refs[i]
|
||||
if strings.TrimSpace(ref.RemoteURL) == "" || strings.TrimSpace(ref.RemoteToken) == "" {
|
||||
continue
|
||||
}
|
||||
req := client.RuntimeReleaseRoleRequest{
|
||||
BindingID: ref.BindingID,
|
||||
ReservationID: ref.ReservationID,
|
||||
ResourceKey: ref.ResourceKey,
|
||||
}
|
||||
_ = fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupFederationRuntime(tunnelID int64) {
|
||||
if h == nil || tunnelID <= 0 {
|
||||
return
|
||||
}
|
||||
bindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)
|
||||
if err != nil || len(bindings) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
localDomain := h.federationLocalDomain()
|
||||
for _, b := range bindings {
|
||||
node, nodeErr := h.repo.GetNodeByID(b.NodeID)
|
||||
if nodeErr != nil || node == nil {
|
||||
continue
|
||||
}
|
||||
remoteURL := strings.TrimSpace(node.RemoteURL.String)
|
||||
if remoteURL == "" {
|
||||
remoteURL = strings.TrimSpace(b.RemoteURL)
|
||||
}
|
||||
remoteToken := strings.TrimSpace(node.RemoteToken.String)
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
continue
|
||||
}
|
||||
req := client.RuntimeReleaseRoleRequest{
|
||||
BindingID: strings.TrimSpace(b.RemoteBindingID),
|
||||
ResourceKey: strings.TrimSpace(b.ResourceKey),
|
||||
}
|
||||
_ = fc.ReleaseRole(remoteURL, remoteToken, localDomain, req)
|
||||
}
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||
}
|
||||
|
||||
func replaceFederationTunnelBindingsTx(tx *sql.Tx, tunnelID int64, bindings []sqlite.FederationTunnelBinding) error {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM federation_tunnel_binding WHERE tunnel_id = ?`, tunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range bindings {
|
||||
created := b.CreatedTime
|
||||
if created <= 0 {
|
||||
created = time.Now().UnixMilli()
|
||||
}
|
||||
updated := b.UpdatedTime
|
||||
if updated <= 0 {
|
||||
updated = created
|
||||
}
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelID, b.NodeID, b.ChainType, b.HopInx, b.RemoteURL, b.ResourceKey, b.RemoteBindingID, b.AllocatedPort, b.Status, created, updated)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {
|
||||
if h == nil || state == nil {
|
||||
return nil, nil, errors.New("invalid tunnel runtime state")
|
||||
@@ -1908,6 +2441,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for _, inNode := range state.InNodes {
|
||||
if node := state.Nodes[inNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
continue
|
||||
}
|
||||
targets := state.OutNodes
|
||||
if len(state.ChainHops) > 0 {
|
||||
targets = state.ChainHops[0]
|
||||
@@ -1928,6 +2464,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
nextTargets = state.ChainHops[i+1]
|
||||
}
|
||||
for _, chainNode := range hop {
|
||||
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
continue
|
||||
}
|
||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes)
|
||||
if err != nil {
|
||||
return createdChains, createdServices, err
|
||||
@@ -1946,6 +2485,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for _, outNode := range state.OutNodes {
|
||||
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
|
||||
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||
@@ -1983,6 +2525,23 @@ func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tu
|
||||
}
|
||||
}
|
||||
|
||||
func shouldDeferTunnelRuntimeApplyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "节点不在线") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "等待节点响应超时") || strings.Contains(msg, "timeout") || strings.Contains(msg, "超时") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord) (map[string]interface{}, error) {
|
||||
fromNode := nodes[fromNodeID]
|
||||
if fromNode == nil {
|
||||
@@ -2154,7 +2713,24 @@ func pickNodeAddressV6(node *nodeRecord) string {
|
||||
return strings.TrimSpace(node.ServerIP)
|
||||
}
|
||||
|
||||
func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int) (int, error) {
|
||||
func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, errors.New("database unavailable")
|
||||
}
|
||||
if nodeID <= 0 {
|
||||
return false, errors.New("节点不存在")
|
||||
}
|
||||
var isRemote int
|
||||
if err := tx.QueryRow(`SELECT is_remote FROM node WHERE id = ? LIMIT 1`, nodeID).Scan(&isRemote); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, errors.New("节点不存在")
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return isRemote == 1, nil
|
||||
}
|
||||
|
||||
func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
|
||||
if tx == nil {
|
||||
return 0, errors.New("database unavailable")
|
||||
}
|
||||
@@ -2178,7 +2754,13 @@ func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int) (int, err
|
||||
}
|
||||
|
||||
used := map[int]struct{}{}
|
||||
chainRows, err := tx.Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL`, nodeID)
|
||||
var chainRows *sql.Rows
|
||||
var err error
|
||||
if excludeTunnelID > 0 {
|
||||
chainRows, err = tx.Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL AND tunnel_id != ?`, nodeID, excludeTunnelID)
|
||||
} else {
|
||||
chainRows, err = tx.Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL`, nodeID)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -2281,7 +2863,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
|
||||
port := asInt(n["port"], 0)
|
||||
if port <= 0 {
|
||||
var pickErr error
|
||||
port, pickErr = pickNodePortTx(tx, nodeID, allocated)
|
||||
port, pickErr = pickNodePortTx(tx, nodeID, allocated, 0)
|
||||
if pickErr != nil {
|
||||
return pickErr
|
||||
}
|
||||
@@ -2302,7 +2884,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
|
||||
port := asInt(n["port"], 0)
|
||||
if port <= 0 {
|
||||
var pickErr error
|
||||
port, pickErr = pickNodePortTx(tx, nodeID, allocated)
|
||||
port, pickErr = pickNodePortTx(tx, nodeID, allocated, 0)
|
||||
if pickErr != nil {
|
||||
return pickErr
|
||||
}
|
||||
@@ -2325,6 +2907,7 @@ func (h *Handler) deleteNodeByID(id int64) error {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
_, _ = tx.Exec(`DELETE FROM forward_port WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM chain_tunnel WHERE node_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM federation_tunnel_binding WHERE node_id = ?`, id)
|
||||
_, err = tx.Exec(`DELETE FROM node WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2343,6 +2926,7 @@ func (h *Handler) deleteTunnelByID(id int64) error {
|
||||
_, _ = tx.Exec(`DELETE FROM user_tunnel WHERE tunnel_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM speed_limit WHERE tunnel_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM chain_tunnel WHERE tunnel_id = ?`, id)
|
||||
_, _ = tx.Exec(`DELETE FROM federation_tunnel_binding WHERE tunnel_id = ?`, id)
|
||||
_, err = tx.Exec(`DELETE FROM tunnel WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -81,6 +81,18 @@ func shouldSkip(path string) bool {
|
||||
return true
|
||||
case path == "/api/v1/user/login":
|
||||
return true
|
||||
case path == "/api/v1/federation/connect":
|
||||
return true
|
||||
case path == "/api/v1/federation/tunnel/create":
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/reserve-port":
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/apply-role":
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/release-role":
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/diagnose":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -91,6 +103,10 @@ func requiresAdmin(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/federation/share/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/node/") {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -95,13 +95,69 @@ type StatisticsFlow struct {
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
ID int64
|
||||
Secret string
|
||||
Version sql.NullString
|
||||
HTTP int
|
||||
TLS int
|
||||
Socks int
|
||||
Status int
|
||||
ID int64
|
||||
Secret string
|
||||
Version sql.NullString
|
||||
HTTP int
|
||||
TLS int
|
||||
Socks int
|
||||
Status int
|
||||
IsRemote int
|
||||
RemoteURL sql.NullString
|
||||
RemoteToken sql.NullString
|
||||
RemoteConfig sql.NullString
|
||||
}
|
||||
|
||||
type PeerShare struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
Token string `json:"token"`
|
||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
PortRangeStart int `json:"portRangeStart"`
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
CurrentFlow int64 `json:"currentFlow"`
|
||||
IsActive int `json:"isActive"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
AllowedDomains string `json:"allowedDomains"`
|
||||
AllowedIPs string `json:"allowedIps"`
|
||||
}
|
||||
|
||||
type PeerShareRuntime struct {
|
||||
ID int64
|
||||
ShareID int64
|
||||
NodeID int64
|
||||
ReservationID string
|
||||
ResourceKey string
|
||||
BindingID string
|
||||
Role string
|
||||
ChainName string
|
||||
ServiceName string
|
||||
Protocol string
|
||||
Strategy string
|
||||
Port int
|
||||
Target string
|
||||
Applied int
|
||||
Status int
|
||||
CreatedTime int64
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
type FederationTunnelBinding struct {
|
||||
ID int64
|
||||
TunnelID int64
|
||||
NodeID int64
|
||||
ChainType int
|
||||
HopInx int
|
||||
RemoteURL string
|
||||
ResourceKey string
|
||||
RemoteBindingID string
|
||||
AllocatedPort int
|
||||
Status int
|
||||
CreatedTime int64
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
func Open(path string) (*Repository, error) {
|
||||
@@ -124,6 +180,11 @@ func Open(path string) (*Repository, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Repository{db: db}, nil
|
||||
}
|
||||
|
||||
@@ -389,9 +450,25 @@ func (r *Repository) GetNodeBySecret(secret string) (*Node, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
|
||||
row := r.db.QueryRow(`SELECT id, secret, version, http, tls, socks, status FROM node WHERE secret = ? LIMIT 1`, secret)
|
||||
row := r.db.QueryRow(`SELECT id, secret, version, http, tls, socks, status, is_remote, remote_url, remote_token, remote_config FROM node WHERE secret = ? LIMIT 1`, secret)
|
||||
var n Node
|
||||
if err := row.Scan(&n.ID, &n.Secret, &n.Version, &n.HTTP, &n.TLS, &n.Socks, &n.Status); err != nil {
|
||||
if err := row.Scan(&n.ID, &n.Secret, &n.Version, &n.HTTP, &n.TLS, &n.Socks, &n.Status, &n.IsRemote, &n.RemoteURL, &n.RemoteToken, &n.RemoteConfig); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetNodeByID(id int64) (*Node, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
|
||||
row := r.db.QueryRow(`SELECT id, secret, version, http, tls, socks, status, is_remote, remote_url, remote_token, remote_config FROM node WHERE id = ? LIMIT 1`, id)
|
||||
var n Node
|
||||
if err := row.Scan(&n.ID, &n.Secret, &n.Version, &n.HTTP, &n.TLS, &n.Socks, &n.Status, &n.IsRemote, &n.RemoteURL, &n.RemoteToken, &n.RemoteConfig); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -454,7 +531,7 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, inx, name, server_ip, server_ip_v4, server_ip_v6, port, tcp_listen_addr, udp_listen_addr, version, http, tls, socks, status
|
||||
SELECT id, inx, name, server_ip, server_ip_v4, server_ip_v6, port, tcp_listen_addr, udp_listen_addr, version, http, tls, socks, status, is_remote, remote_url, remote_token, remote_config
|
||||
FROM node
|
||||
ORDER BY inx ASC, id ASC
|
||||
`)
|
||||
@@ -467,10 +544,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
for rows.Next() {
|
||||
var id, inx int64
|
||||
var name, serverIP, port string
|
||||
var serverIPV4, serverIPV6, tcpListen, udpListen, version sql.NullString
|
||||
var httpVal, tlsVal, socksVal, status int
|
||||
var serverIPV4, serverIPV6, tcpListen, udpListen, version, remoteURL, remoteToken, remoteConfig sql.NullString
|
||||
var httpVal, tlsVal, socksVal, status, isRemote int
|
||||
|
||||
if err := rows.Scan(&id, &inx, &name, &serverIP, &serverIPV4, &serverIPV6, &port, &tcpListen, &udpListen, &version, &httpVal, &tlsVal, &socksVal, &status); err != nil {
|
||||
if err := rows.Scan(&id, &inx, &name, &serverIP, &serverIPV4, &serverIPV6, &port, &tcpListen, &udpListen, &version, &httpVal, &tlsVal, &socksVal, &status, &isRemote, &remoteURL, &remoteToken, &remoteConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -490,6 +567,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
"tls": tlsVal,
|
||||
"socks": socksVal,
|
||||
"status": status,
|
||||
"isRemote": isRemote,
|
||||
"remoteUrl": nullableString(remoteURL),
|
||||
"remoteToken": nullableString(remoteToken),
|
||||
"remoteConfig": nullableString(remoteConfig),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1167,25 +1248,410 @@ func bootstrapSchema(db *sql.DB) error {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
var exists int
|
||||
err := db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='user'`).Scan(&exists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check schema: %w", err)
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("sqlite schema not found, bootstrapping embedded schema")
|
||||
if _, err := db.Exec(embeddedSchema); err != nil {
|
||||
return fmt.Errorf("apply schema.sql: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(embeddedSeedData); err != nil {
|
||||
return fmt.Errorf("apply data.sql: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateSchema(db *sql.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
ensureColumn := func(table, col, typ string) {
|
||||
var dummy interface{}
|
||||
err := db.QueryRow(fmt.Sprintf("SELECT %s FROM %s LIMIT 1", col, table)).Scan(&dummy)
|
||||
if err == nil || errors.Is(err, sql.ErrNoRows) {
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "no such column") {
|
||||
if _, alterErr := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, typ)); alterErr != nil {
|
||||
log.Printf("failed to add column %s to %s: %v", col, table, alterErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
columnsByTable := map[string]map[string]string{
|
||||
"peer_share": {
|
||||
"allowed_domains": "TEXT DEFAULT ''",
|
||||
"allowed_ips": "TEXT DEFAULT ''",
|
||||
},
|
||||
"node": {
|
||||
"server_ip_v4": "VARCHAR(100)",
|
||||
"server_ip_v6": "VARCHAR(100)",
|
||||
"inx": "INTEGER NOT NULL DEFAULT 0",
|
||||
"is_remote": "INTEGER DEFAULT 0",
|
||||
"remote_url": "TEXT",
|
||||
"remote_token": "TEXT",
|
||||
"remote_config": "TEXT",
|
||||
},
|
||||
"tunnel": {
|
||||
"inx": "INTEGER NOT NULL DEFAULT 0",
|
||||
},
|
||||
"forward": {
|
||||
"inx": "INTEGER NOT NULL DEFAULT 0",
|
||||
},
|
||||
"chain_tunnel": {
|
||||
"inx": "INTEGER",
|
||||
},
|
||||
}
|
||||
|
||||
for table, columns := range columnsByTable {
|
||||
for col, typ := range columns {
|
||||
ensureColumn(table, col, typ)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreatePeerShare(share *PeerShare) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO peer_share(name, node_id, token, max_bandwidth, expiry_time, port_range_start, port_range_end, current_flow, is_active, created_time, updated_time, allowed_domains, allowed_ips)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.Name, share.NodeID, share.Token, share.MaxBandwidth, share.ExpiryTime, share.PortRangeStart, share.PortRangeEnd, share.CurrentFlow, share.IsActive, share.CreatedTime, share.UpdatedTime, share.AllowedDomains, share.AllowedIPs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) UpdatePeerShare(share *PeerShare) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
_, err := r.db.Exec(`
|
||||
UPDATE peer_share SET name=?, max_bandwidth=?, expiry_time=?, port_range_start=?, port_range_end=?, is_active=?, updated_time=?, allowed_domains=?, allowed_ips=?
|
||||
WHERE id=?
|
||||
`, share.Name, share.MaxBandwidth, share.ExpiryTime, share.PortRangeStart, share.PortRangeEnd, share.IsActive, share.UpdatedTime, share.AllowedDomains, share.AllowedIPs, share.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) DeletePeerShare(id int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
_, _ = tx.Exec(`DELETE FROM peer_share_runtime WHERE share_id = ?`, id)
|
||||
if _, err := tx.Exec(`DELETE FROM peer_share WHERE id=?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Repository) GetPeerShare(id int64) (*PeerShare, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
row := r.db.QueryRow(`SELECT id, name, node_id, token, max_bandwidth, expiry_time, port_range_start, port_range_end, current_flow, is_active, created_time, updated_time, allowed_domains, allowed_ips FROM peer_share WHERE id = ?`, id)
|
||||
var s PeerShare
|
||||
if err := row.Scan(&s.ID, &s.Name, &s.NodeID, &s.Token, &s.MaxBandwidth, &s.ExpiryTime, &s.PortRangeStart, &s.PortRangeEnd, &s.CurrentFlow, &s.IsActive, &s.CreatedTime, &s.UpdatedTime, &s.AllowedDomains, &s.AllowedIPs); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetPeerShareByToken(token string) (*PeerShare, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
row := r.db.QueryRow(`SELECT id, name, node_id, token, max_bandwidth, expiry_time, port_range_start, port_range_end, current_flow, is_active, created_time, updated_time, allowed_domains, allowed_ips FROM peer_share WHERE token = ?`, token)
|
||||
var s PeerShare
|
||||
if err := row.Scan(&s.ID, &s.Name, &s.NodeID, &s.Token, &s.MaxBandwidth, &s.ExpiryTime, &s.PortRangeStart, &s.PortRangeEnd, &s.CurrentFlow, &s.IsActive, &s.CreatedTime, &s.UpdatedTime, &s.AllowedDomains, &s.AllowedIPs); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListPeerShares() ([]PeerShare, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
rows, err := r.db.Query(`SELECT id, name, node_id, token, max_bandwidth, expiry_time, port_range_start, port_range_end, current_flow, is_active, created_time, updated_time, allowed_domains, allowed_ips FROM peer_share ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var shares []PeerShare
|
||||
for rows.Next() {
|
||||
var s PeerShare
|
||||
if err := rows.Scan(&s.ID, &s.Name, &s.NodeID, &s.Token, &s.MaxBandwidth, &s.ExpiryTime, &s.PortRangeStart, &s.PortRangeEnd, &s.CurrentFlow, &s.IsActive, &s.CreatedTime, &s.UpdatedTime, &s.AllowedDomains, &s.AllowedIPs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shares = append(shares, s)
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||
FROM peer_share_runtime
|
||||
WHERE share_id = ? AND resource_key = ?
|
||||
LIMIT 1
|
||||
`, shareID, resourceKey)
|
||||
var item PeerShareRuntime
|
||||
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||
FROM peer_share_runtime
|
||||
WHERE share_id = ? AND reservation_id = ?
|
||||
LIMIT 1
|
||||
`, shareID, reservationID)
|
||||
var item PeerShareRuntime
|
||||
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||
FROM peer_share_runtime
|
||||
WHERE share_id = ? AND binding_id = ?
|
||||
LIMIT 1
|
||||
`, shareID, bindingID)
|
||||
var item PeerShareRuntime
|
||||
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetPeerShareRuntimeByID(id int64) (*PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||
FROM peer_share_runtime
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`, id)
|
||||
var item PeerShareRuntime
|
||||
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||
FROM peer_share_runtime
|
||||
WHERE share_id = ? AND status = 1
|
||||
ORDER BY port ASC, id ASC
|
||||
`, shareID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]PeerShareRuntime, 0)
|
||||
for rows.Next() {
|
||||
var item PeerShareRuntime
|
||||
if err := rows.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if shareID <= 0 || delta <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(`UPDATE peer_share SET current_flow = current_flow + ?, updated_time = ? WHERE id = ?`, delta, unixMilliNow(), shareID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if shareID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if updatedTime <= 0 {
|
||||
updatedTime = unixMilliNow()
|
||||
}
|
||||
_, err := r.db.Exec(`UPDATE peer_share SET current_flow = 0, updated_time = ? WHERE id = ?`, updatedTime, shareID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CreatePeerShareRuntime(item *PeerShareRuntime) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if item == nil {
|
||||
return errors.New("runtime item is nil")
|
||||
}
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, item.ShareID, item.NodeID, item.ReservationID, item.ResourceKey, item.BindingID, item.Role, item.ChainName, item.ServiceName, item.Protocol, item.Strategy, item.Port, item.Target, item.Applied, item.Status, item.CreatedTime, item.UpdatedTime)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) UpdatePeerShareRuntime(item *PeerShareRuntime) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if item == nil {
|
||||
return errors.New("runtime item is nil")
|
||||
}
|
||||
_, err := r.db.Exec(`
|
||||
UPDATE peer_share_runtime
|
||||
SET binding_id = ?, role = ?, chain_name = ?, service_name = ?, protocol = ?, strategy = ?, port = ?, target = ?, applied = ?, status = ?, updated_time = ?
|
||||
WHERE id = ?
|
||||
`, item.BindingID, item.Role, item.ChainName, item.ServiceName, item.Protocol, item.Strategy, item.Port, item.Target, item.Applied, item.Status, item.UpdatedTime, item.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
_, err := r.db.Exec(`UPDATE peer_share_runtime SET status = 0, updated_time = ? WHERE id = ?`, updatedTime, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
rows, err := r.db.Query(`SELECT port FROM peer_share_runtime WHERE share_id = ? AND node_id = ? AND status = 1 AND port > 0`, shareID, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int, 0)
|
||||
for rows.Next() {
|
||||
var port int
|
||||
if err := rows.Scan(&port); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if port > 0 {
|
||||
out = append(out, port)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertFederationTunnelBinding(item *FederationTunnelBinding) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if item == nil {
|
||||
return errors.New("binding item is nil")
|
||||
}
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(tunnel_id, node_id, chain_type, hop_inx)
|
||||
DO UPDATE SET
|
||||
remote_url = excluded.remote_url,
|
||||
resource_key = excluded.resource_key,
|
||||
remote_binding_id = excluded.remote_binding_id,
|
||||
allocated_port = excluded.allocated_port,
|
||||
status = excluded.status,
|
||||
updated_time = excluded.updated_time
|
||||
`, item.TunnelID, item.NodeID, item.ChainType, item.HopInx, item.RemoteURL, item.ResourceKey, item.RemoteBindingID, item.AllocatedPort, item.Status, item.CreatedTime, item.UpdatedTime)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]FederationTunnelBinding, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time
|
||||
FROM federation_tunnel_binding
|
||||
WHERE tunnel_id = ? AND status = 1
|
||||
ORDER BY chain_type ASC, hop_inx ASC, id ASC
|
||||
`, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]FederationTunnelBinding, 0)
|
||||
for rows.Next() {
|
||||
var item FederationTunnelBinding
|
||||
if err := rows.Scan(&item.ID, &item.TunnelID, &item.NodeID, &item.ChainType, &item.HopInx, &item.RemoteURL, &item.ResourceKey, &item.RemoteBindingID, &item.AllocatedPort, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
_, err := r.db.Exec(`DELETE FROM federation_tunnel_binding WHERE tunnel_id = ?`, tunnelID)
|
||||
return err
|
||||
}
|
||||
|
||||
var osMkdirAll = func(path string) error {
|
||||
return os.MkdirAll(path, 0o755)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ CREATE TABLE IF NOT EXISTS node (
|
||||
status INTEGER NOT NULL,
|
||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
inx INTEGER NOT NULL DEFAULT 0,
|
||||
is_remote INTEGER DEFAULT 0,
|
||||
remote_url TEXT,
|
||||
remote_token TEXT,
|
||||
remote_config TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||
@@ -181,3 +185,61 @@ CREATE TABLE IF NOT EXISTS vite_config (
|
||||
value VARCHAR(200) NOT NULL,
|
||||
time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
max_bandwidth INTEGER DEFAULT 0,
|
||||
expiry_time INTEGER DEFAULT 0,
|
||||
port_range_start INTEGER DEFAULT 0,
|
||||
port_range_end INTEGER DEFAULT 0,
|
||||
current_flow INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
allowed_domains TEXT DEFAULT '',
|
||||
allowed_ips TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share_runtime (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
reservation_id TEXT NOT NULL UNIQUE,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
binding_id TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
chain_name TEXT NOT NULL DEFAULT '',
|
||||
service_name TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tls',
|
||||
strategy TEXT NOT NULL DEFAULT 'round',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
applied INTEGER NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
chain_type INTEGER NOT NULL,
|
||||
hop_inx INTEGER NOT NULL DEFAULT 0,
|
||||
remote_url TEXT NOT NULL,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
remote_binding_id TEXT NOT NULL,
|
||||
allocated_port INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
||||
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -205,6 +206,173 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
remoteToken := "remote-diagnose-token"
|
||||
var remoteDiagnoseCalls int32
|
||||
remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/federation/runtime/diagnose" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "Bearer "+remoteToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": -1, "msg": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": -1, "msg": "bad request"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(valueAsString(req["ip"])) != "10.50.0.30" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": -1, "msg": "unexpected target ip"})
|
||||
return
|
||||
}
|
||||
if valueAsInt(req["port"]) != 30003 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": -1, "msg": "unexpected target port"})
|
||||
return
|
||||
}
|
||||
|
||||
atomic.AddInt32(&remoteDiagnoseCalls, 1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"success": true,
|
||||
"averageTime": 12.5,
|
||||
"packetLoss": 0,
|
||||
"message": "remote tcp ok",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer remoteServer.Close()
|
||||
|
||||
insertLocalNode := func(name, ip string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert local node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get local node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
insertRemoteNode := func(name, ip string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?, ?, 1, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, "", "", "31000-31010", "", "", now, now, "[::]", "[::]", 1, remoteServer.URL, remoteToken, `{"shareId": 123}`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert remote node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get remote node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
entryNodeID := insertLocalNode("entry-local", "10.50.0.10")
|
||||
remoteChainNodeID := insertRemoteNode("middle-remote", "10.50.0.20")
|
||||
exitNodeID := insertLocalNode("exit-local", "10.50.0.30")
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-remote-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID); err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, remoteChainNodeID); err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID); err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose", bytes.NewBufferString(`{"tunnelId":`+strconv.FormatInt(tunnelID, 10)+`}`))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
payload, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload, got %T", out.Data)
|
||||
}
|
||||
results, ok := payload["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Fatalf("expected non-empty results, got %v", payload["results"])
|
||||
}
|
||||
|
||||
remoteStepFound := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if valueAsInt(item["fromChainType"]) == 2 && valueAsInt(item["toChainType"]) == 3 {
|
||||
remoteStepFound = true
|
||||
if !valueAsBool(item["success"]) {
|
||||
t.Fatalf("expected remote chain->exit diagnosis success, got item=%v", item)
|
||||
}
|
||||
if strings.TrimSpace(valueAsString(item["message"])) != "remote tcp ok" {
|
||||
t.Fatalf("expected remote diagnosis message, got %q", valueAsString(item["message"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !remoteStepFound {
|
||||
t.Fatalf("expected chain->exit diagnosis item for remote node")
|
||||
}
|
||||
if atomic.LoadInt32(&remoteDiagnoseCalls) == 0 {
|
||||
t.Fatalf("expected federation runtime diagnose endpoint to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func valueAsInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
@@ -223,6 +391,24 @@ func valueAsString(v interface{}) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func valueAsBool(v interface{}) bool {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case float64:
|
||||
return b != 0
|
||||
case int:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(b))
|
||||
return s == "1" || s == "t" || s == "true" || s == "yes" || s == "y"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setupDiagnosisContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "diagnosis-contract.db")
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
providerSecret := "provider-contract-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
providerServer := httptest.NewServer(providerRouter)
|
||||
defer providerServer.Close()
|
||||
|
||||
consumerSecret := "consumer-contract-jwt"
|
||||
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
|
||||
|
||||
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate consumer admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry", "198.51.100.11", "43000-43010", "provider-entry-secret", 1)
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
|
||||
|
||||
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "entry-share",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-token",
|
||||
PortRangeStart: 43000,
|
||||
PortRangeEnd: 43010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "middle-share",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-token",
|
||||
PortRangeStart: 44000,
|
||||
PortRangeEnd: 44010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "exit-share",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-token",
|
||||
PortRangeStart: 45000,
|
||||
PortRangeEnd: 45010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-token")
|
||||
|
||||
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-token")
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||
|
||||
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
|
||||
defer stopExit()
|
||||
|
||||
createTunnel := func(name string) int64 {
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", consumerAdminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
var tunnelID int64
|
||||
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id (%s): %v", name, err)
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id for %s", name)
|
||||
}
|
||||
return tunnelID
|
||||
}
|
||||
|
||||
firstTunnelID := createTunnel("dual-panel-middle-exit-1")
|
||||
|
||||
assertTunnelPortInRange(t, consumerRepo, firstTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
||||
assertTunnelPortInRange(t, consumerRepo, firstTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
||||
|
||||
assertCount(t, consumerRepo, `SELECT COUNT(1) FROM federation_tunnel_binding WHERE tunnel_id = ? AND status = 1`, firstTunnelID, 2)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||
|
||||
deleteBody, err := json.Marshal(map[string]interface{}{"id": firstTunnelID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal delete payload: %v", err)
|
||||
}
|
||||
deleteReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/delete", bytes.NewReader(deleteBody))
|
||||
deleteReq.Header.Set("Authorization", consumerAdminToken)
|
||||
deleteReq.Header.Set("Content-Type", "application/json")
|
||||
deleteRes := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(deleteRes, deleteReq)
|
||||
assertCode(t, deleteRes, 0)
|
||||
|
||||
assertCount(t, consumerRepo, `SELECT COUNT(1) FROM federation_tunnel_binding WHERE tunnel_id = ?`, firstTunnelID, 0)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 0`, middleShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 0`, exitShareID, 1)
|
||||
|
||||
secondTunnelID := createTunnel("dual-panel-middle-exit-2")
|
||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
||||
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
||||
}
|
||||
|
||||
func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
providerSecret := "provider-contract-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
providerServer := httptest.NewServer(providerRouter)
|
||||
defer providerServer.Close()
|
||||
|
||||
consumerSecret := "consumer-contract-jwt"
|
||||
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
|
||||
|
||||
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate consumer admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry-dx", "203.0.113.11", "53000-53010", "provider-entry-dx-secret", 1)
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-dx", "203.0.113.12", "54000-54010", "provider-middle-dx-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-dx", "203.0.113.13", "55000-55010", "provider-exit-dx-secret", 1)
|
||||
|
||||
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "entry-share-dx",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-dx-token",
|
||||
PortRangeStart: 53000,
|
||||
PortRangeEnd: 53010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "middle-share-dx",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-dx-token",
|
||||
PortRangeStart: 54000,
|
||||
PortRangeEnd: 54010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "exit-share-dx",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-dx-token",
|
||||
PortRangeStart: 55000,
|
||||
PortRangeEnd: 55010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-dx-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-dx-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-dx-token")
|
||||
|
||||
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-dx-token")
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-dx-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-dx-token")
|
||||
|
||||
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-dx-secret")
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-dx-secret")
|
||||
defer stopExit()
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "dual-panel-diagnose-remote",
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||
createReq.Header.Set("Authorization", consumerAdminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
|
||||
var tunnelID int64
|
||||
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "dual-panel-diagnose-remote").Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id: %v", err)
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id")
|
||||
}
|
||||
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
||||
|
||||
diagnoseReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose", bytes.NewBufferString(fmt.Sprintf(`{"tunnelId":%d}`, tunnelID)))
|
||||
diagnoseReq.Header.Set("Authorization", consumerAdminToken)
|
||||
diagnoseRes := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(diagnoseRes, diagnoseReq)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(diagnoseRes.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode diagnose response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected diagnose code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
payload, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map payload, got %T", out.Data)
|
||||
}
|
||||
results, ok := payload["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Fatalf("expected non-empty results, got %v", payload["results"])
|
||||
}
|
||||
|
||||
chainToExitFound := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if valueAsInt(item["fromChainType"]) == 2 && valueAsInt(item["toChainType"]) == 3 {
|
||||
chainToExitFound = true
|
||||
if !valueAsBool(item["success"]) {
|
||||
t.Fatalf("expected chain->exit diagnosis success, got item=%v", item)
|
||||
}
|
||||
if msg := strings.TrimSpace(valueAsString(item["message"])); msg != "mock tcp ok" {
|
||||
t.Fatalf("expected remote diagnosis message 'mock tcp ok', got %q", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !chainToExitFound {
|
||||
t.Fatalf("expected chain->exit diagnosis item in results")
|
||||
}
|
||||
}
|
||||
|
||||
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func insertPeerShare(t *testing.T, repo *sqlite.Repository, share *sqlite.PeerShare) int64 {
|
||||
t.Helper()
|
||||
if share == nil {
|
||||
t.Fatalf("share is nil")
|
||||
}
|
||||
if err := repo.CreatePeerShare(share); err != nil {
|
||||
t.Fatalf("create peer share %s: %v", share.Name, err)
|
||||
}
|
||||
saved, err := repo.GetPeerShareByToken(share.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("query peer share %s: %v", share.Name, err)
|
||||
}
|
||||
if saved == nil {
|
||||
t.Fatalf("peer share %s not found after create", share.Name)
|
||||
}
|
||||
return saved.ID
|
||||
}
|
||||
|
||||
func importRemoteNodeForContract(t *testing.T, router http.Handler, adminToken, remoteURL, token string) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"remoteUrl": remoteURL,
|
||||
"token": token,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal import payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/node/import", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
|
||||
func queryRemoteNodeIDByToken(t *testing.T, repo *sqlite.Repository, token string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM node WHERE is_remote = 1 AND remote_token = ? ORDER BY id DESC LIMIT 1`, token).Scan(&id); err != nil {
|
||||
t.Fatalf("query remote node by token %s: %v", token, err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid remote node id for token %s", token)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func assertTunnelPortInRange(t *testing.T, repo *sqlite.Repository, tunnelID int64, chainType int, nodeID int64, minPort int, maxPort int) {
|
||||
t.Helper()
|
||||
var port int
|
||||
err := repo.DB().QueryRow(`
|
||||
SELECT port
|
||||
FROM chain_tunnel
|
||||
WHERE tunnel_id = ? AND chain_type = ? AND node_id = ?
|
||||
LIMIT 1
|
||||
`, tunnelID, chainType, nodeID).Scan(&port)
|
||||
if err != nil {
|
||||
t.Fatalf("query tunnel=%d chainType=%d node=%d port: %v", tunnelID, chainType, nodeID, err)
|
||||
}
|
||||
if port < minPort || port > maxPort {
|
||||
t.Fatalf("expected port in range [%d,%d], got %d", minPort, maxPort, port)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interface{}, expected int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := repo.DB().QueryRow(query, arg).Scan(&got); err != nil {
|
||||
t.Fatalf("count query failed: %v", err)
|
||||
}
|
||||
if got != expected {
|
||||
t.Fatalf("expected count %d, got %d (query: %s, arg: %v)", expected, got, query, arg)
|
||||
}
|
||||
}
|
||||
|
||||
func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func() {
|
||||
t.Helper()
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse provider url: %v", err)
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "https") {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = "/system-info"
|
||||
q := u.Query()
|
||||
q.Set("type", "1")
|
||||
q.Set("secret", nodeSecret)
|
||||
q.Set("version", "v1")
|
||||
q.Set("http", "1")
|
||||
q.Set("tls", "1")
|
||||
q.Set("socks", "1")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial mock node websocket: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
_, raw, readErr := conn.ReadMessage()
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plain := raw
|
||||
var wrap struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
|
||||
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
|
||||
if cryptoErr == nil {
|
||||
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
|
||||
plain = []byte(dec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &cmd); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||
respPayload := map[string]interface{}{
|
||||
"type": respType,
|
||||
"success": true,
|
||||
"message": "OK",
|
||||
"requestId": cmd.RequestID,
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(cmd.Type), "TcpPing") {
|
||||
respPayload["data"] = map[string]interface{}{
|
||||
"success": true,
|
||||
"averageTime": 8.5,
|
||||
"packetLoss": 0,
|
||||
"message": "mock tcp ok",
|
||||
}
|
||||
}
|
||||
respBytes, err := json.Marshal(respPayload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func valueAsInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func valueAsString(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func valueAsBool(v interface{}) bool {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case float64:
|
||||
return b != 0
|
||||
case int:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(b))
|
||||
return s == "1" || s == "t" || s == "true" || s == "yes" || s == "y"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
@@ -213,3 +216,117 @@ func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.
|
||||
h := handler.New(repo, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), repo
|
||||
}
|
||||
|
||||
func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-2.0.7-beta.db")
|
||||
legacyDB, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy sqlite: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = legacyDB.Close()
|
||||
})
|
||||
|
||||
if _, err := legacyDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL,
|
||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create legacy node table: %v", err)
|
||||
}
|
||||
|
||||
if _, err := legacyDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create legacy tunnel table: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := legacyDB.Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1, "[::]", "[::]"); err != nil {
|
||||
t.Fatalf("seed legacy node row: %v", err)
|
||||
}
|
||||
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
})
|
||||
|
||||
nodes, err := repo.ListNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("list nodes after migration: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node after migration, got %d", len(nodes))
|
||||
}
|
||||
|
||||
columns := readTableColumns(t, repo.DB(), "node")
|
||||
|
||||
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
|
||||
if !columns[required] {
|
||||
t.Fatalf("expected node column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
|
||||
tunnelColumns := readTableColumns(t, repo.DB(), "tunnel")
|
||||
if !tunnelColumns["inx"] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
|
||||
}
|
||||
}
|
||||
|
||||
func readTableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
t.Fatalf("inspect %s columns: %v", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, typ string
|
||||
var defaultValue sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
t.Fatalf("scan %s pragma row: %v", table, err)
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate %s pragma rows: %v", table, err)
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,6 +31,7 @@ import (
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
selector_parser "github.com/go-gost/x/config/parsing/selector"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
@@ -181,6 +183,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
)
|
||||
}
|
||||
|
||||
var trafficLimiter listener.Option
|
||||
if cfg.Limiter != "" {
|
||||
lim := registry.TrafficLimiterRegistry().Get(cfg.Limiter)
|
||||
if lim == nil {
|
||||
// Try to parse as simple number (bandwidth in bytes/sec)
|
||||
if val, err := strconv.Atoi(cfg.Limiter); err == nil && val > 0 {
|
||||
lim = xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption(fmt.Sprintf("%s %dB %dB", xtraffic.ServiceLimitKey, val, val)),
|
||||
)
|
||||
}
|
||||
if lim == nil {
|
||||
lim = xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption(fmt.Sprintf("%s %s %s", xtraffic.ServiceLimitKey, cfg.Limiter, cfg.Limiter)),
|
||||
)
|
||||
}
|
||||
}
|
||||
trafficLimiter = listener.TrafficLimiterOption(
|
||||
cache_limiter.NewCachedTrafficLimiter(
|
||||
lim,
|
||||
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
|
||||
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
|
||||
cache_limiter.ScopeOption(limiterScope),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
listenOpts := []listener.Option{
|
||||
listener.AddrOption(cfg.Addr),
|
||||
listener.RouterOption(xchain.NewRouter(routerOpts...)),
|
||||
@@ -188,14 +216,6 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
|
||||
listener.TLSConfigOption(tlsConfig),
|
||||
listener.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
|
||||
listener.TrafficLimiterOption(
|
||||
cache_limiter.NewCachedTrafficLimiter(
|
||||
registry.TrafficLimiterRegistry().Get(cfg.Limiter),
|
||||
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
|
||||
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
|
||||
cache_limiter.ScopeOption(limiterScope),
|
||||
),
|
||||
),
|
||||
listener.ConnLimiterOption(registry.ConnLimiterRegistry().Get(cfg.CLimiter)),
|
||||
listener.ServiceOption(cfg.Name),
|
||||
listener.ProxyProtocolOption(ppv),
|
||||
@@ -203,6 +223,9 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
listener.NetnsOption(netnsIn),
|
||||
listener.LoggerOption(listenerLogger),
|
||||
}
|
||||
if trafficLimiter != nil {
|
||||
listenOpts = append(listenOpts, trafficLimiter)
|
||||
}
|
||||
|
||||
if netnsIn != "" {
|
||||
runtime.LockOSThread()
|
||||
|
||||
@@ -136,6 +136,9 @@ func (l *trafficLimiter) In(ctx context.Context, key string, opts ...limiter.Opt
|
||||
return nil
|
||||
|
||||
case limiter.ScopeClient:
|
||||
if lim, ok := l.inLimits.Get(key); ok && lim != nil {
|
||||
return lim.(traffic.Limiter)
|
||||
}
|
||||
return nil
|
||||
|
||||
case limiter.ScopeConn:
|
||||
@@ -215,6 +218,9 @@ func (l *trafficLimiter) Out(ctx context.Context, key string, opts ...limiter.Op
|
||||
return nil
|
||||
|
||||
case limiter.ScopeClient:
|
||||
if lim, ok := l.outLimits.Get(key); ok && lim != nil {
|
||||
return lim.(traffic.Limiter)
|
||||
}
|
||||
return nil
|
||||
|
||||
case limiter.ScopeConn:
|
||||
|
||||
+11
-30
@@ -3,6 +3,9 @@
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
# 固定版本号(Release 构建时自动填充,留空则获取最新版)
|
||||
PINNED_VERSION=""
|
||||
|
||||
# 获取系统架构
|
||||
get_architecture() {
|
||||
ARCH=$(uname -m)
|
||||
@@ -22,16 +25,10 @@ get_architecture() {
|
||||
# 安装目录
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
|
||||
# 识别国家(用于镜像加速)
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
|
||||
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
echo "https://ghfast.top/${url}"
|
||||
else
|
||||
echo "$url"
|
||||
fi
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
@@ -40,39 +37,19 @@ resolve_latest_release_tag() {
|
||||
latest_url="https://github.com/${REPO}/releases/latest"
|
||||
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# CN 环境下可尝试通过镜像访问(不影响非 CN)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 方式2:GitHub API(无需 jq)
|
||||
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -85,6 +62,10 @@ resolve_version() {
|
||||
echo "$FLUX_VERSION"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${PINNED_VERSION:-}" ]]; then
|
||||
echo "$PINNED_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if resolve_latest_release_tag; then
|
||||
return 0
|
||||
|
||||
+10
-25
@@ -10,15 +10,13 @@ export LC_ALL=C
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
# 固定版本号(Release 构建时自动填充,留空则获取最新版)
|
||||
PINNED_VERSION=""
|
||||
|
||||
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
echo "https://ghfast.top/${url}"
|
||||
else
|
||||
echo "$url"
|
||||
fi
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
@@ -27,36 +25,19 @@ resolve_latest_release_tag() {
|
||||
latest_url="https://github.com/${REPO}/releases/latest"
|
||||
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -69,6 +50,10 @@ resolve_version() {
|
||||
echo "$FLUX_VERSION"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${PINNED_VERSION:-}" ]]; then
|
||||
echo "$PINNED_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if resolve_latest_release_tag; then
|
||||
return 0
|
||||
|
||||
+23
-9
@@ -10,22 +10,36 @@ Web management console for FLVX (formerly Flux Panel).
|
||||
```
|
||||
vite-frontend/
|
||||
├── src/
|
||||
│ ├── pages/ # Route views (some very large single-file pages)
|
||||
│ ├── components/ # Reusable UI parts
|
||||
│ ├── layouts/ # Admin vs H5 layouts
|
||||
│ ├── api/ # API functions + axios wrapper
|
||||
│ ├── api/ # Axios wrapper + typed endpoint helpers
|
||||
│ ├── components/ # Shared UI components (HeroUI based)
|
||||
│ ├── config/ # Site config (title, repo, version)
|
||||
│ └── utils/ # Auth/JWT + WebView helpers
|
||||
│ ├── layouts/ # Admin vs H5 page chrome
|
||||
│ ├── pages/ # Route views (many large single-file pages)
|
||||
│ ├── utils/ # Auth/JWT + WebView helpers
|
||||
│ ├── App.tsx # Routes + ProtectedRoute + H5 layout selection
|
||||
│ ├── main.tsx # ReactDOM + Providers (HeroUI, Theme, Toast)
|
||||
│ └── provider.tsx # Context provider wrapper
|
||||
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
|
||||
├── eslint.config.mjs # ESLint 9 flat config
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Route definitions** | `src/App.tsx` | React Router v6; H5 detection logic |
|
||||
| **API Client** | `src/api/network.ts` | Sets `Authorization` header (raw token) |
|
||||
| **Endpoint Calls** | `src/api/index.ts` | Thin `Network.post` wrappers |
|
||||
| **Login Flow** | `src/pages/index.tsx` | Calls `login()`, stores `localStorage.token` |
|
||||
| **Auth Logic** | `src/utils/auth.ts` | `isAdmin()` checks `role_id == 0` |
|
||||
| **Token Decoding** | `src/utils/jwt.ts` | Checks `exp` vs current time |
|
||||
| **WebView Logic** | `src/utils/panel.ts` | Handles panel address selection in app mode |
|
||||
|
||||
## CONVENTIONS
|
||||
- **Routing**: React Router v6 routes in `vite-frontend/src/App.tsx`.
|
||||
- **Auth**: JWT stored as `localStorage.token`; sent as `Authorization` header (no prefix) in `vite-frontend/src/api/network.ts`.
|
||||
- **Base URL**: Defaults to `/api/v1/` (or `VITE_API_BASE`); WebView mode selects a panel address via `vite-frontend/src/utils/panel.ts`.
|
||||
- **UI**: HeroUI provider + theme + toast wired in `vite-frontend/src/provider.tsx`.
|
||||
- **Auth**: JWT stored as `localStorage.token`. Sent in `Authorization` header (no "Bearer" prefix).
|
||||
- **API**: Default base URL is `/api/v1/`.
|
||||
- **WebView**: In WebView mode, base URL is derived from selected panel address. If unset, API returns `code: -1`.
|
||||
- **Routing**: URL query param `h5=true` forces mobile layout.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
|
||||
@@ -39,8 +39,10 @@
|
||||
"@heroui/switch": "^2.2.21",
|
||||
"@heroui/system": "2.4.19",
|
||||
"@heroui/table": "^2.2.24",
|
||||
"@heroui/tabs": "^2.2.27",
|
||||
"@heroui/theme": "2.4.19",
|
||||
"@heroui/use-theme": "2.1.10",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@nextui-org/system": "^2.4.6",
|
||||
"@react-aria/visually-hidden": "3.8.25",
|
||||
"@react-types/shared": "3.30.0",
|
||||
@@ -56,7 +58,6 @@
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-is": "^19.2.4",
|
||||
"react-router-dom": "6.30.3",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"recharts": "^3.1.1",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-variants": "1.0.0",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# VITE FRONTEND (src) KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
React app entry + routing + providers. This is where UI architecture decisions live.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
vite-frontend/src/
|
||||
├── main.tsx # ReactDOM + BrowserRouter + Provider
|
||||
├── provider.tsx # HeroUI + theme + toaster + i18n wrapper
|
||||
├── App.tsx # Routes + ProtectedRoute + H5 layout selection
|
||||
├── api/ # Axios wrapper + typed endpoint helpers
|
||||
├── pages/ # Route views (large)
|
||||
├── layouts/ # Admin/H5 page chrome
|
||||
├── components/ # Shared UI components
|
||||
├── utils/ # JWT parsing + auth helpers + WebView utilities
|
||||
└── styles/ # globals.css
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Route definitions | `vite-frontend/src/App.tsx` | React Router v6 |
|
||||
| API client baseURL | `vite-frontend/src/api/network.ts` | `/api/v1/` + token header |
|
||||
| Token decoding | `vite-frontend/src/utils/jwt.ts` | Checks `exp` vs now |
|
||||
| Role checks | `vite-frontend/src/utils/auth.ts` | `isAdmin()` is `role_id == 0` |
|
||||
| WebView integration | `vite-frontend/src/api/network.ts` | Panel address selection in WebView mode |
|
||||
|
||||
## CONVENTIONS
|
||||
- Token is stored in `localStorage.token` and sent as `Authorization` header (raw token string).
|
||||
- H5 mode detection is in `vite-frontend/src/App.tsx` (screen/user-agent/query param `h5=true`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd vite-frontend
|
||||
npm run dev
|
||||
npm run lint
|
||||
```
|
||||
@@ -12,6 +12,7 @@ import GroupPage from "@/pages/group";
|
||||
import ProfilePage from "@/pages/profile";
|
||||
import LimitPage from "@/pages/limit";
|
||||
import ConfigPage from "@/pages/config";
|
||||
import PanelSharingPage from "@/pages/panel-sharing";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import H5Layout from "@/layouts/h5";
|
||||
@@ -235,12 +236,20 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<ProtectedRoute>
|
||||
<ConfigPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/config"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PanelSharingPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/panel-sharing"
|
||||
/>
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# VITE FRONTEND (src/api) KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
API client layer. Wraps axios and normalizes backend responses (`{ code, msg, data }`).
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Axios wrapper | `vite-frontend/src/api/network.ts` | Sets `axios.defaults.baseURL`; adds `Authorization` header |
|
||||
| BaseURL init (WebView vs web) | `vite-frontend/src/api/network.ts` | WebView mode calls `getPanelAddresses()` |
|
||||
| Endpoint functions | `vite-frontend/src/api/index.ts` | Mostly `Network.post("/…")` |
|
||||
|
||||
## CONVENTIONS
|
||||
- Default baseURL is `/api/v1/` (or `${VITE_API_BASE}/api/v1/`).
|
||||
- In WebView mode, baseURL is derived from the selected panel address; if unset, requests return `code: -1` with a “set panel address” message.
|
||||
- 401 responses clear localStorage and redirect to `/`.
|
||||
@@ -187,3 +187,36 @@ export const assignGroupPermission = (data: {
|
||||
}) => Network.post("/group/permission/assign", data);
|
||||
export const removeGroupPermission = (id: number) =>
|
||||
Network.post("/group/permission/remove", { id });
|
||||
|
||||
// 面板共享 (Federation) 接口
|
||||
export const getPeerShareList = () => Network.post("/federation/share/list");
|
||||
export const createPeerShare = (data: {
|
||||
name: string;
|
||||
nodeId: number;
|
||||
maxBandwidth?: number;
|
||||
expiryTime?: number;
|
||||
portRangeStart?: number;
|
||||
portRangeEnd?: number;
|
||||
allowedDomains?: string;
|
||||
allowedIps?: string;
|
||||
}) => Network.post("/federation/share/create", data);
|
||||
export const updatePeerShare = (data: {
|
||||
id: number;
|
||||
name: string;
|
||||
maxBandwidth: number;
|
||||
expiryTime: number;
|
||||
portRangeStart: number;
|
||||
portRangeEnd: number;
|
||||
allowedDomains: string;
|
||||
allowedIps: string;
|
||||
}) => Network.post("/federation/share/update", data);
|
||||
export const deletePeerShare = (id: number) =>
|
||||
Network.post("/federation/share/delete", { id });
|
||||
export const resetPeerShareFlow = (id: number) =>
|
||||
Network.post("/federation/share/reset-flow", { id });
|
||||
export const getPeerRemoteUsageList = () =>
|
||||
Network.post("/federation/share/remote-usage/list");
|
||||
export const importRemoteNode = (data: {
|
||||
remoteUrl: string;
|
||||
token: string;
|
||||
}) => Network.post("/federation/node/import", data);
|
||||
|
||||
@@ -144,6 +144,16 @@ export default function AdminLayout({
|
||||
),
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/panel-sharing",
|
||||
label: "共享",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z" />
|
||||
</svg>
|
||||
),
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config",
|
||||
label: "设置",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# VITE FRONTEND (pages) KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Route views rendered by `vite-frontend/src/App.tsx`. Several pages are large, single-file screens.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
vite-frontend/src/pages/
|
||||
├── index.tsx # Login + captcha flow
|
||||
├── dashboard.tsx
|
||||
├── forward.tsx # Large
|
||||
├── tunnel.tsx # Large
|
||||
├── node.tsx # Large
|
||||
├── user.tsx # Large
|
||||
├── config.tsx
|
||||
├── limit.tsx
|
||||
├── profile.tsx
|
||||
├── settings.tsx
|
||||
└── change-password.tsx
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Login flow | `vite-frontend/src/pages/index.tsx` | Calls `login()` and stores `localStorage.token` |
|
||||
| API calls | `vite-frontend/src/api/index.ts` | Thin wrappers around `Network.post` |
|
||||
| Token expiration behavior | `vite-frontend/src/api/network.ts` | Clears localStorage + redirects on 401 |
|
||||
|
||||
## CONVENTIONS
|
||||
- Pages call API wrappers from `vite-frontend/src/api/index.ts` (most endpoints are POST).
|
||||
@@ -56,6 +56,13 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
||||
"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: "panel_domain",
|
||||
label: "面板域名",
|
||||
placeholder: "请输入面板域名",
|
||||
description: "当前面板的域名,用于与其他面板进行联邦共享时验证身份",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: "app_name",
|
||||
label: "应用名称",
|
||||
@@ -99,6 +106,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
||||
"cloudflare_site_key",
|
||||
"cloudflare_secret_key",
|
||||
"ip",
|
||||
"panel_domain",
|
||||
];
|
||||
const initialConfigs: Record<string, string> = {};
|
||||
|
||||
|
||||
+192
-151
@@ -62,7 +62,10 @@ interface Node {
|
||||
http?: number; // 0 关 1 开
|
||||
tls?: number; // 0 关 1 开
|
||||
socks?: number; // 0 关 1 开
|
||||
status: number; // 1: 在线, 0: 离线
|
||||
status: number;
|
||||
isRemote?: number;
|
||||
remoteUrl?: string;
|
||||
syncError?: string;
|
||||
connectionStatus: "online" | "offline";
|
||||
systemInfo?: {
|
||||
cpuUsage: number;
|
||||
@@ -217,7 +220,8 @@ export default function NodePage() {
|
||||
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||
...node,
|
||||
inx: node.inx ?? 0,
|
||||
connectionStatus: node.status === 1 ? "online" : "offline",
|
||||
connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline",
|
||||
syncError: node.syncError || undefined,
|
||||
systemInfo: null,
|
||||
copyLoading: false,
|
||||
}));
|
||||
@@ -1112,9 +1116,12 @@ export default function NodePage() {
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedNodes.map((node) => (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
{sortedNodes.map((node) => {
|
||||
const isRemoteNode = node.isRemote === 1;
|
||||
|
||||
return (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||
@@ -1147,6 +1154,16 @@ export default function NodePage() {
|
||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||
</svg>
|
||||
</div>
|
||||
{isRemoteNode && (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
远程
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={
|
||||
@@ -1166,6 +1183,17 @@ export default function NodePage() {
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3">
|
||||
{isRemoteNode && node.syncError && (
|
||||
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||
{node.syncError === "provider_share_deleted"
|
||||
? "提供方已删除该分享"
|
||||
: node.syncError === "provider_share_disabled"
|
||||
? "提供方已禁用该分享"
|
||||
: node.syncError === "provider_share_expired"
|
||||
? "提供方分享已过期"
|
||||
: `远程同步失败: ${node.syncError}`}
|
||||
</div>
|
||||
)}
|
||||
{/* 基础信息 */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
@@ -1203,162 +1231,174 @@ export default function NodePage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">版本</span>
|
||||
<span className="text-xs">
|
||||
{node.version || "未知"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">开机时间</span>
|
||||
<span className="text-xs">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatUptime(node.systemInfo.uptime)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统监控 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>CPU</span>
|
||||
<span className="font-mono">
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">版本</span>
|
||||
<span className="text-xs">
|
||||
{node.version || "未知"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">开机时间</span>
|
||||
<span className="text-xs">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||
? formatUptime(node.systemInfo.uptime)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="CPU使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>内存</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="内存使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
上传
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.uploadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
下载
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.downloadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流量统计 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
|
||||
↑ 上行流量
|
||||
</div>
|
||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(node.systemInfo.uploadTraffic)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||
<div className="text-success-600 dark:text-success-400 mb-0.5">
|
||||
↓ 下行流量
|
||||
</div>
|
||||
<div className="font-mono text-success-700 dark:text-success-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
{/* 系统监控 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>CPU</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="CPU使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>内存</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="内存使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
上传
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.uploadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
下载
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.downloadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流量统计 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
|
||||
↑ 上行流量
|
||||
</div>
|
||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(node.systemInfo.uploadTraffic)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||
<div className="text-success-600 dark:text-success-400 mb-0.5">
|
||||
↓ 下行流量
|
||||
</div>
|
||||
<div className="font-mono text-success-700 dark:text-success-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleCopyInstallCommand(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(node)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleCopyInstallCommand(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(node)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
className={`min-h-8 ${isRemoteNode ? "w-full" : "flex-1"}`}
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -1372,7 +1412,8 @@ export default function NodePage() {
|
||||
</Card>
|
||||
)}
|
||||
</SortableItem>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Tabs, Tab } from "@heroui/tabs";
|
||||
import { Input } from "@heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@heroui/modal";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { toast } from "react-hot-toast";
|
||||
import {
|
||||
getNodeList,
|
||||
createPeerShare,
|
||||
getPeerShareList,
|
||||
deletePeerShare,
|
||||
resetPeerShareFlow,
|
||||
getPeerRemoteUsageList,
|
||||
importRemoteNode,
|
||||
updatePeerShare,
|
||||
} from "@/api";
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
isRemote?: number;
|
||||
}
|
||||
|
||||
interface PeerShare {
|
||||
id: number;
|
||||
name: string;
|
||||
token: string;
|
||||
maxBandwidth: number;
|
||||
currentFlow: number;
|
||||
expiryTime: number;
|
||||
portRangeStart: number;
|
||||
portRangeEnd: number;
|
||||
isActive: number;
|
||||
allowedDomains?: string;
|
||||
allowedIps?: string;
|
||||
usedPorts?: number[];
|
||||
usedPortDetails?: Array<{
|
||||
runtimeId: number;
|
||||
port: number;
|
||||
role: string;
|
||||
protocol: string;
|
||||
resourceKey: string;
|
||||
applied: number;
|
||||
updatedTime: number;
|
||||
}>;
|
||||
activeRuntimeNum?: number;
|
||||
}
|
||||
|
||||
interface RemoteUsageBinding {
|
||||
bindingId: number;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
chainType: number;
|
||||
hopInx: number;
|
||||
allocatedPort: number;
|
||||
resourceKey: string;
|
||||
remoteBindingId: string;
|
||||
updatedTime: number;
|
||||
}
|
||||
|
||||
interface RemoteUsageNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
remoteUrl: string;
|
||||
shareId: number;
|
||||
portRangeStart: number;
|
||||
portRangeEnd: number;
|
||||
maxBandwidth: number;
|
||||
currentFlow: number;
|
||||
usedPorts: number[];
|
||||
bindings: RemoteUsageBinding[];
|
||||
activeBindingNum: number;
|
||||
syncError?: string;
|
||||
}
|
||||
|
||||
export default function PanelSharingPage() {
|
||||
const [selectedTab, setSelectedTab] = useState("my-shares");
|
||||
const [shares, setShares] = useState<PeerShare[]>([]);
|
||||
const [remoteUsageNodes, setRemoteUsageNodes] = useState<RemoteUsageNode[]>(
|
||||
[],
|
||||
);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [remoteUsageLoading, setRemoteUsageLoading] = useState(false);
|
||||
|
||||
// Modals
|
||||
const [createShareOpen, setCreateShareOpen] = useState(false);
|
||||
const [editShareOpen, setEditShareOpen] = useState(false);
|
||||
const [importNodeOpen, setImportNodeOpen] = useState(false);
|
||||
|
||||
// Forms
|
||||
const [shareForm, setShareForm] = useState({
|
||||
name: "",
|
||||
nodeId: "",
|
||||
maxBandwidth: 0,
|
||||
expiryDays: 30,
|
||||
portRangeStart: 10000,
|
||||
portRangeEnd: 20000,
|
||||
allowedDomains: "",
|
||||
allowedIps: "",
|
||||
});
|
||||
|
||||
const [importForm, setImportForm] = useState({
|
||||
remoteUrl: "",
|
||||
token: "",
|
||||
});
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
id: 0,
|
||||
name: "",
|
||||
maxBandwidth: 0,
|
||||
expiryTime: 0,
|
||||
portRangeStart: 10000,
|
||||
portRangeEnd: 20000,
|
||||
allowedDomains: "",
|
||||
allowedIps: "",
|
||||
});
|
||||
|
||||
const loadShares = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getPeerShareList();
|
||||
if (res.code === 0) {
|
||||
setShares(res.data || []);
|
||||
} else {
|
||||
toast.error(res.msg || "加载分享列表失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNodes = useCallback(async () => {
|
||||
try {
|
||||
const res = await getNodeList();
|
||||
if (res.code === 0) {
|
||||
const localNodes: Node[] = (res.data || []).filter(
|
||||
(node: Node) => (node?.isRemote ?? 0) !== 1,
|
||||
);
|
||||
setNodes(localNodes);
|
||||
setShareForm((prev) => {
|
||||
if (!prev.nodeId) {
|
||||
return prev;
|
||||
}
|
||||
const hasSelectedNode = localNodes.some(
|
||||
(node: Node) => String(node.id) === prev.nodeId,
|
||||
);
|
||||
return hasSelectedNode ? prev : { ...prev, nodeId: "" };
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRemoteUsage = useCallback(async () => {
|
||||
setRemoteUsageLoading(true);
|
||||
try {
|
||||
const res = await getPeerRemoteUsageList();
|
||||
if (res.code === 0) {
|
||||
setRemoteUsageNodes(res.data || []);
|
||||
} else {
|
||||
toast.error(res.msg || "加载远程占用端口失败");
|
||||
}
|
||||
} finally {
|
||||
setRemoteUsageLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTab === "my-shares") {
|
||||
loadShares();
|
||||
loadNodes();
|
||||
return;
|
||||
}
|
||||
if (selectedTab === "remote-nodes") {
|
||||
loadRemoteUsage();
|
||||
}
|
||||
}, [selectedTab, loadShares, loadNodes, loadRemoteUsage]);
|
||||
|
||||
const handleCreateShare = async () => {
|
||||
if (!shareForm.name || !shareForm.nodeId) {
|
||||
toast.error("请填写必要信息");
|
||||
return;
|
||||
}
|
||||
const nodeId = parseInt(shareForm.nodeId, 10);
|
||||
if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) {
|
||||
toast.error("仅可选择本地节点");
|
||||
return;
|
||||
}
|
||||
if (shareForm.maxBandwidth < 0) {
|
||||
toast.error("流量上限不能为负数");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const expiryTime =
|
||||
Date.now() + shareForm.expiryDays * 24 * 60 * 60 * 1000;
|
||||
const res = await createPeerShare({
|
||||
name: shareForm.name,
|
||||
nodeId,
|
||||
maxBandwidth: Math.max(0, shareForm.maxBandwidth) * 1024 * 1024 * 1024,
|
||||
expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime,
|
||||
portRangeStart: shareForm.portRangeStart,
|
||||
portRangeEnd: shareForm.portRangeEnd,
|
||||
allowedDomains: shareForm.allowedDomains,
|
||||
allowedIps: shareForm.allowedIps,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
toast.success("创建成功");
|
||||
setCreateShareOpen(false);
|
||||
loadShares();
|
||||
} else {
|
||||
toast.error(res.msg || "创建失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteShare = async (id: number) => {
|
||||
try {
|
||||
const res = await deletePeerShare(id);
|
||||
if (res.code === 0) {
|
||||
toast.success("删除成功");
|
||||
loadShares();
|
||||
} else {
|
||||
toast.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetShareFlow = async (id: number) => {
|
||||
try {
|
||||
const res = await resetPeerShareFlow(id);
|
||||
if (res.code === 0) {
|
||||
toast.success("共享流量已重置");
|
||||
loadShares();
|
||||
} else {
|
||||
toast.error(res.msg || "重置流量失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误");
|
||||
}
|
||||
};
|
||||
|
||||
const openEditShare = (share: PeerShare) => {
|
||||
setEditForm({
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0,
|
||||
expiryTime: share.expiryTime,
|
||||
portRangeStart: share.portRangeStart,
|
||||
portRangeEnd: share.portRangeEnd,
|
||||
allowedDomains: share.allowedDomains || "",
|
||||
allowedIps: share.allowedIps || "",
|
||||
});
|
||||
setEditShareOpen(true);
|
||||
};
|
||||
|
||||
const handleEditShare = async () => {
|
||||
if (!editForm.name) {
|
||||
toast.error("名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (editForm.maxBandwidth < 0) {
|
||||
toast.error("流量上限不能为负数");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await updatePeerShare({
|
||||
id: editForm.id,
|
||||
name: editForm.name,
|
||||
maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024,
|
||||
expiryTime: editForm.expiryTime,
|
||||
portRangeStart: editForm.portRangeStart,
|
||||
portRangeEnd: editForm.portRangeEnd,
|
||||
allowedDomains: editForm.allowedDomains,
|
||||
allowedIps: editForm.allowedIps,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
toast.success("编辑成功");
|
||||
setEditShareOpen(false);
|
||||
loadShares();
|
||||
} else {
|
||||
toast.error(res.msg || "编辑失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportNode = async () => {
|
||||
if (!importForm.remoteUrl || !importForm.token) {
|
||||
toast.error("请填写完整信息");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Automatically add http/https if missing
|
||||
let url = importForm.remoteUrl.trim();
|
||||
if (!url.startsWith("http")) {
|
||||
url = "http://" + url;
|
||||
}
|
||||
|
||||
const res = await importRemoteNode({
|
||||
remoteUrl: url,
|
||||
token: importForm.token.trim(),
|
||||
});
|
||||
if (res.code === 0) {
|
||||
toast.success("导入成功,请前往节点列表查看");
|
||||
setImportNodeOpen(false);
|
||||
setImportForm({ remoteUrl: "", token: "" });
|
||||
loadRemoteUsage();
|
||||
} else {
|
||||
toast.error(res.msg || "导入失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToken = (token: string) => {
|
||||
navigator.clipboard.writeText(token);
|
||||
toast.success("Token已复制");
|
||||
};
|
||||
|
||||
const formatFlowGB = (bytes: number) => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
};
|
||||
|
||||
const formatChainType = (chainType: number, hopInx: number) => {
|
||||
if (chainType === 2) {
|
||||
return `中继跳点 #${hopInx}`;
|
||||
}
|
||||
if (chainType === 3) {
|
||||
return "出口节点";
|
||||
}
|
||||
return "未知链路";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">面板共享 (Panel Peering)</h1>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
disableCursorAnimation
|
||||
aria-label="Options"
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(k) => setSelectedTab(k as string)}
|
||||
>
|
||||
<Tab key="my-shares" title="我分享的 (Provider)">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="mb-4">
|
||||
<Button color="primary" onPress={() => setCreateShareOpen(true)}>
|
||||
创建分享
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">暂无分享</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{shares.map((share) => (
|
||||
<Card key={share.id} className="border border-divider shadow-sm">
|
||||
<CardHeader className="flex justify-between">
|
||||
<h3 className="font-bold">{share.name}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => openEditShare(share)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleResetShareFlow(share.id)}
|
||||
>
|
||||
重置流量
|
||||
</Button>
|
||||
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm space-y-2">
|
||||
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
|
||||
<p>流量上限: {share.maxBandwidth > 0 ? formatFlowGB(share.maxBandwidth) : "不限制"}</p>
|
||||
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
|
||||
<p>远程占用端口: {share.usedPorts && share.usedPorts.length > 0 ? share.usedPorts.join(", ") : "暂无"}</p>
|
||||
{share.usedPortDetails && share.usedPortDetails.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{share.usedPortDetails.map((item) => (
|
||||
<span key={item.runtimeId} className="text-xs rounded-full px-2 py-1 bg-default-100">
|
||||
{item.port} / {item.role || "reserved"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{share.allowedDomains && <p>允许域名: {share.allowedDomains}</p>}
|
||||
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>}
|
||||
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly size="sm" value={share.token} />
|
||||
<Button size="sm" onPress={() => copyToken(share.token)}>复制</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Tab>
|
||||
<Tab key="remote-nodes" title="远程节点 (Consumer)">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="mb-4">
|
||||
<Button color="secondary" onPress={() => setImportNodeOpen(true)}>
|
||||
导入远程节点
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{remoteUsageLoading ? (
|
||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||
) : remoteUsageNodes.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<p>暂无远程节点占用记录。</p>
|
||||
<p className="mt-2">导入远程节点并创建隧道后,这里会显示远端端口占用情况。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{remoteUsageNodes.map((node) => (
|
||||
<Card key={node.nodeId} className="border border-divider shadow-sm">
|
||||
<CardHeader className="flex justify-between">
|
||||
<h3 className="font-bold">{node.nodeName}</h3>
|
||||
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm space-y-2">
|
||||
{node.syncError && (
|
||||
<div className="px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||
{node.syncError === "provider_share_deleted"
|
||||
? "提供方已删除该分享"
|
||||
: node.syncError === "provider_share_disabled"
|
||||
? "提供方已禁用该分享"
|
||||
: node.syncError === "provider_share_expired"
|
||||
? "提供方分享已过期"
|
||||
: `远程同步失败: ${node.syncError}`}
|
||||
</div>
|
||||
)}
|
||||
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
||||
<p>共享ID: {node.shareId || "-"}</p>
|
||||
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
||||
<p>共享流量: {node.maxBandwidth > 0 ? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}` : `${formatFlowGB(node.currentFlow || 0)} / 不限制`}</p>
|
||||
<p>远端占用端口: {node.usedPorts && node.usedPorts.length > 0 ? node.usedPorts.join(", ") : "暂无"}</p>
|
||||
{node.bindings && node.bindings.length > 0 && (
|
||||
<div className="space-y-1 pt-1">
|
||||
{node.bindings.map((binding) => (
|
||||
<p key={binding.bindingId} className="text-xs text-default-600">
|
||||
隧道 {binding.tunnelName || `#${binding.tunnelId}`}
|
||||
{" · "}
|
||||
端口 {binding.allocatedPort}
|
||||
{" · "}
|
||||
{formatChainType(binding.chainType, binding.hopInx)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{/* Create Share Modal */}
|
||||
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)}>
|
||||
<ModalContent>
|
||||
<ModalHeader>创建分享</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
label="名称"
|
||||
placeholder="备注名称"
|
||||
value={shareForm.name}
|
||||
onChange={(e) => setShareForm({ ...shareForm, name: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="选择节点"
|
||||
placeholder="选择要分享的本地节点"
|
||||
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
|
||||
onChange={(e) => setShareForm({ ...shareForm, nodeId: e.target.value })}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<SelectItem key={node.id} textValue={node.name}>
|
||||
{node.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
label="起始端口"
|
||||
type="number"
|
||||
value={shareForm.portRangeStart.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, portRangeStart: parseInt(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="结束端口"
|
||||
type="number"
|
||||
value={shareForm.portRangeEnd.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, portRangeEnd: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="有效期 (天)"
|
||||
type="number"
|
||||
description="0 表示永久"
|
||||
value={shareForm.expiryDays.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="流量上限 (GB)"
|
||||
type="number"
|
||||
description="0 表示不限流量"
|
||||
value={shareForm.maxBandwidth.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="允许的域名 (可选)"
|
||||
placeholder="example.com, panel.test.com"
|
||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||
value={shareForm.allowedDomains}
|
||||
onChange={(e) => setShareForm({ ...shareForm, allowedDomains: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="允许的API IP (可选)"
|
||||
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||
value={shareForm.allowedIps}
|
||||
onChange={(e) => setShareForm({ ...shareForm, allowedIps: e.target.value })}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={() => setCreateShareOpen(false)}>取消</Button>
|
||||
<Button color="primary" onPress={handleCreateShare}>创建</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Share Modal */}
|
||||
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
|
||||
<ModalContent>
|
||||
<ModalHeader>编辑分享</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
label="名称"
|
||||
placeholder="备注名称"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
label="起始端口"
|
||||
type="number"
|
||||
value={editForm.portRangeStart.toString()}
|
||||
onChange={(e) => setEditForm({ ...editForm, portRangeStart: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="结束端口"
|
||||
type="number"
|
||||
value={editForm.portRangeEnd.toString()}
|
||||
onChange={(e) => setEditForm({ ...editForm, portRangeEnd: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="流量上限 (GB)"
|
||||
type="number"
|
||||
description="0 表示不限流量"
|
||||
value={editForm.maxBandwidth.toString()}
|
||||
onChange={(e) => setEditForm({ ...editForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="过期时间"
|
||||
type="datetime-local"
|
||||
description="留空或清除表示永久有效"
|
||||
value={editForm.expiryTime > 0 ? new Date(editForm.expiryTime).toISOString().slice(0, 16) : ""}
|
||||
onChange={(e) => setEditForm({ ...editForm, expiryTime: e.target.value ? new Date(e.target.value).getTime() : 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="允许的域名 (可选)"
|
||||
placeholder="example.com, panel.test.com"
|
||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||
value={editForm.allowedDomains}
|
||||
onChange={(e) => setEditForm({ ...editForm, allowedDomains: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="允许的API IP (可选)"
|
||||
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||
value={editForm.allowedIps}
|
||||
onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value })}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
|
||||
<Button color="primary" onPress={handleEditShare}>保存</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Import Node Modal */}
|
||||
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
|
||||
<ModalContent>
|
||||
<ModalHeader>导入远程节点</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
label="远程面板地址"
|
||||
placeholder="http://panel.example.com:8088"
|
||||
value={importForm.remoteUrl}
|
||||
onChange={(e) => setImportForm({ ...importForm, remoteUrl: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Token"
|
||||
placeholder="Bearer Token"
|
||||
value={importForm.token}
|
||||
onChange={(e) => setImportForm({ ...importForm, token: e.target.value })}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={() => setImportNodeOpen(false)}>取消</Button>
|
||||
<Button color="secondary" onPress={handleImportNode}>导入</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,30 @@ export default function ProfilePage() {
|
||||
"bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400",
|
||||
description: "管理用户限速策略",
|
||||
},
|
||||
{
|
||||
path: "/panel-sharing",
|
||||
label: "面板共享",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z" />
|
||||
</svg>
|
||||
),
|
||||
color:
|
||||
"bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400",
|
||||
description: "与其他面板进行联邦共享",
|
||||
},
|
||||
{
|
||||
path: "/group",
|
||||
label: "分组管理",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a3 3 0 100 6 3 3 0 000-6zM4 9a3 3 0 100 6 3 3 0 000-6zm12 0a3 3 0 100 6 3 3 0 000-6M4 16a2 2 0 00-2 2h4a2 2 0 00-2-2zm12 0a2 2 0 00-2 2h4a2 2 0 00-2-2zm-6 0a2 2 0 00-2 2h4a2 2 0 00-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
color:
|
||||
"bg-green-100 dark:bg-green-500/20 text-green-600 dark:text-green-400",
|
||||
description: "管理用户和隧道分组",
|
||||
},
|
||||
{
|
||||
path: "/user",
|
||||
label: "用户管理",
|
||||
|
||||
Reference in New Issue
Block a user