fix: align listener generation with mihomo official spec
- Port now supports mihomo ports format (single/range/list) - Add explicit RoutingMark field support - Auto-propagate UDP flag for vmess/vless/trojan/anytls/trusttunnel - Update validator to accept port ranges - Sync visual config and client converter port types - Rename module from dzx941 to kazeyukiro - Fix missing ExpiresAt in JWTClaims - Fix nodeDTO Port type to string - Fix frontend tsconfig and add missing dependencies
This commit is contained in:
commit
5b4cb63a73
143 files changed
+11975
No files matched your search
@@ -0,0 +1,56 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache: true
|
||||
- name: Go format check
|
||||
run: test -z "$(gofmt -l .)"
|
||||
- name: Go vet
|
||||
run: go vet ./...
|
||||
- name: Go tests
|
||||
run: go test ./...
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm build
|
||||
|
||||
scripts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate POSIX shell scripts
|
||||
run: |
|
||||
set -eu
|
||||
for script in scripts/install.sh scripts/update.sh scripts/uninstall.sh scripts/3m-ui.sh scripts/3m-ui; do
|
||||
sh -n "$script"
|
||||
done
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.x'
|
||||
|
||||
- name: Download Go dependencies
|
||||
working-directory: backend
|
||||
run: go mod tidy
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Embed frontend assets
|
||||
run: |
|
||||
rm -rf backend/cmd/server/web/dist
|
||||
mkdir -p backend/cmd/server/web
|
||||
cp -r frontend/dist backend/cmd/server/web/dist
|
||||
|
||||
- name: Install CGO cross compilers
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get remove -y gcc-multilib || true
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
|
||||
|
||||
- name: Build Linux binaries
|
||||
working-directory: backend
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p ../dist
|
||||
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="manual-${GITHUB_RUN_NUMBER}"
|
||||
fi
|
||||
|
||||
GIT_COMMIT="$(git rev-parse --short HEAD)"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LDFLAGS="-s -w -X main.version=${VERSION} -X main.gitCommit=${GIT_COMMIT} -X main.buildTime=${BUILD_TIME}"
|
||||
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$LDFLAGS" -o ../dist/3m-ui-linux-amd64 ./cmd/server
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc go build -trimpath -ldflags "$LDFLAGS" -o ../dist/3m-ui-linux-arm64 ./cmd/server
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=arm GOARM=7 CC=arm-linux-gnueabihf-gcc go build -trimpath -ldflags "$LDFLAGS" -o ../dist/3m-ui-linux-armv7 ./cmd/server
|
||||
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags sqlite_modernc -trimpath -ldflags "$LDFLAGS" -o ../dist/3m-ui-linux-amd64-static ./cmd/server
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags sqlite_modernc -trimpath -ldflags "$LDFLAGS" -o ../dist/3m-ui-linux-arm64-static ./cmd/server
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -tags sqlite_modernc -trimpath -ldflags "$LDFLAGS" -o ../dist/3m-ui-linux-armv7-static ./cmd/server
|
||||
|
||||
- name: Verify binaries
|
||||
run: |
|
||||
set -eu
|
||||
ls -lh dist
|
||||
file dist/3m-ui-linux-*
|
||||
|
||||
chmod 0755 dist/3m-ui-linux-amd64 dist/3m-ui-linux-amd64-static
|
||||
dist/3m-ui-linux-amd64 --version >/dev/null
|
||||
dist/3m-ui-linux-amd64-static --version >/dev/null
|
||||
|
||||
file dist/3m-ui-linux-arm64 | grep -E 'ARM aarch64|ARM64|AArch64'
|
||||
file dist/3m-ui-linux-arm64-static | grep -E 'ARM aarch64|ARM64|AArch64'
|
||||
file dist/3m-ui-linux-armv7 | grep -E 'ARM|EABI5'
|
||||
file dist/3m-ui-linux-armv7-static | grep -E 'ARM|EABI5'
|
||||
|
||||
- name: Validate deployment scripts
|
||||
run: |
|
||||
set -eu
|
||||
for script in scripts/install.sh scripts/update.sh scripts/uninstall.sh scripts/3m-ui.sh scripts/3m-ui; do
|
||||
sh -n "$script"
|
||||
done
|
||||
|
||||
- name: Add deployment scripts
|
||||
run: |
|
||||
cp scripts/install.sh scripts/update.sh scripts/uninstall.sh scripts/3m-ui.sh scripts/3m-ui dist/
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('manual-{0}', github.run_number) }}
|
||||
name: ${{ github.ref_type == 'tag' && github.ref_name || format('Manual build {0}', github.run_number) }}
|
||||
files: |
|
||||
dist/3m-ui-linux-amd64
|
||||
dist/3m-ui-linux-arm64
|
||||
dist/3m-ui-linux-armv7
|
||||
dist/3m-ui-linux-amd64-static
|
||||
dist/3m-ui-linux-arm64-static
|
||||
dist/3m-ui-linux-armv7-static
|
||||
dist/install.sh
|
||||
dist/update.sh
|
||||
dist/uninstall.sh
|
||||
dist/3m-ui.sh
|
||||
dist/3m-ui
|
||||
@@ -0,0 +1,321 @@
|
||||
# 3m-ui Development Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
3m-ui is a web management panel for Mihomo Core.
|
||||
|
||||
The goal is to provide a simple and powerful VPS management interface similar to 3x-ui, but based on Mihomo Core instead of Xray.
|
||||
|
||||
Main functions:
|
||||
|
||||
- Manage Mihomo Core
|
||||
- Create and manage Mihomo listeners
|
||||
- Generate Mihomo configuration files
|
||||
- Manage users
|
||||
- Generate subscriptions
|
||||
- Monitor service status
|
||||
- View logs
|
||||
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Backend
|
||||
|
||||
Language:
|
||||
- Go 1.24+
|
||||
|
||||
Framework:
|
||||
- Gin
|
||||
|
||||
Database:
|
||||
- SQLite (default)
|
||||
- GORM ORM
|
||||
|
||||
Authentication:
|
||||
- JWT
|
||||
|
||||
|
||||
### Frontend
|
||||
|
||||
Framework:
|
||||
- React 19
|
||||
|
||||
Language:
|
||||
- TypeScript
|
||||
|
||||
Build tool:
|
||||
- Vite
|
||||
|
||||
UI:
|
||||
- Ant Design
|
||||
|
||||
State management:
|
||||
- Zustand or Redux Toolkit
|
||||
|
||||
Routing:
|
||||
- React Router
|
||||
|
||||
|
||||
## Project Architecture
|
||||
|
||||
|
||||
Backend:
|
||||
|
||||
backend/
|
||||
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
|
||||
├── internal/
|
||||
|
||||
│ ├── auth/
|
||||
│ │ Authentication logic
|
||||
|
||||
│ ├── mihomo/
|
||||
│ │ Mihomo Core management
|
||||
|
||||
│ ├── listener/
|
||||
│ │ Listener management
|
||||
|
||||
│ ├── subscription/
|
||||
│ │ Subscription generation
|
||||
|
||||
│ ├── system/
|
||||
│ │ Linux system operations
|
||||
|
||||
│ └── database/
|
||||
│ Database models and migrations
|
||||
|
||||
|
||||
|
||||
Frontend:
|
||||
|
||||
frontend/
|
||||
|
||||
src/
|
||||
|
||||
├── api/
|
||||
│ API requests
|
||||
|
||||
├── pages/
|
||||
│ Main pages
|
||||
|
||||
├── components/
|
||||
│ Reusable components
|
||||
|
||||
├── layouts/
|
||||
│ Page layouts
|
||||
|
||||
└── stores/
|
||||
State management
|
||||
|
||||
|
||||
|
||||
## Mihomo Rules
|
||||
|
||||
All Mihomo related code MUST be placed inside:
|
||||
|
||||
backend/internal/mihomo/
|
||||
|
||||
|
||||
The Mihomo service layer is responsible for:
|
||||
|
||||
- Starting Mihomo
|
||||
- Stopping Mihomo
|
||||
- Restarting Mihomo
|
||||
- Checking status
|
||||
- Reading logs
|
||||
- Calling Mihomo External Controller API
|
||||
|
||||
|
||||
Do not directly execute Mihomo commands inside API handlers.
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
Correct:
|
||||
|
||||
API Handler
|
||||
|
|
||||
↓
|
||||
Mihomo Service
|
||||
|
|
||||
↓
|
||||
Mihomo Core
|
||||
|
||||
|
||||
Incorrect:
|
||||
|
||||
API Handler
|
||||
|
|
||||
↓
|
||||
Execute mihomo command directly
|
||||
|
||||
|
||||
|
||||
## Configuration Rules
|
||||
|
||||
Mihomo configuration must be generated through templates.
|
||||
|
||||
Do not manually concatenate YAML strings.
|
||||
|
||||
Configuration flow:
|
||||
|
||||
Database
|
||||
|
||||
↓
|
||||
|
||||
Config Generator
|
||||
|
||||
↓
|
||||
|
||||
YAML Template
|
||||
|
||||
↓
|
||||
|
||||
config.yaml
|
||||
|
||||
↓
|
||||
|
||||
Mihomo Reload
|
||||
|
||||
|
||||
|
||||
## Database Rules
|
||||
|
||||
Use GORM models.
|
||||
|
||||
Main tables:
|
||||
|
||||
|
||||
users
|
||||
|
||||
- id
|
||||
- username
|
||||
- password_hash
|
||||
- role
|
||||
- created_at
|
||||
|
||||
|
||||
listeners
|
||||
|
||||
- id
|
||||
- name
|
||||
- type
|
||||
- listen
|
||||
- port
|
||||
- enabled
|
||||
- config
|
||||
|
||||
|
||||
listener_users
|
||||
|
||||
- id
|
||||
- listener_id
|
||||
- username
|
||||
- password
|
||||
|
||||
|
||||
subscriptions
|
||||
|
||||
- id
|
||||
- user_id
|
||||
- token
|
||||
- format
|
||||
- expire_time
|
||||
|
||||
|
||||
configs
|
||||
|
||||
- id
|
||||
- name
|
||||
- content
|
||||
|
||||
|
||||
## API Rules
|
||||
|
||||
All APIs use:
|
||||
|
||||
/api/v1/
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
GET:
|
||||
|
||||
/api/v1/mihomo/status
|
||||
|
||||
|
||||
POST:
|
||||
|
||||
/api/v1/listeners
|
||||
|
||||
|
||||
DELETE:
|
||||
|
||||
/api/v1/listeners/:id
|
||||
|
||||
|
||||
|
||||
## Frontend Rules
|
||||
|
||||
Use Ant Design components.
|
||||
|
||||
Keep pages separated.
|
||||
|
||||
Do not put business logic directly inside components.
|
||||
|
||||
Use:
|
||||
|
||||
API Layer
|
||||
|
||||
↓
|
||||
|
||||
State Store
|
||||
|
||||
↓
|
||||
|
||||
Components
|
||||
|
||||
|
||||
|
||||
## Security Rules
|
||||
|
||||
Never store plaintext passwords.
|
||||
|
||||
Use password hashing.
|
||||
|
||||
Validate all user input.
|
||||
|
||||
Do not expose sensitive system information.
|
||||
|
||||
Do not allow arbitrary command execution from Web UI.
|
||||
|
||||
|
||||
|
||||
## Development Rules
|
||||
|
||||
Before implementing a large feature:
|
||||
|
||||
1. Explain the design
|
||||
2. Confirm database changes
|
||||
3. Confirm API changes
|
||||
4. Then write code
|
||||
|
||||
|
||||
Keep the project modular.
|
||||
|
||||
Avoid unnecessary dependencies.
|
||||
|
||||
Prefer simple and maintainable solutions.
|
||||
|
||||
|
||||
## Future Features
|
||||
|
||||
The architecture should allow adding:
|
||||
|
||||
- Traffic statistics
|
||||
- Multiple Mihomo instances
|
||||
- Multi-user management
|
||||
- Docker deployment
|
||||
- Cluster management
|
||||
@@ -0,0 +1,277 @@
|
||||
Eclipse Public License - v 2.0
|
||||
|
||||
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
|
||||
PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
|
||||
OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
|
||||
|
||||
1. DEFINITIONS
|
||||
|
||||
"Contribution" means:
|
||||
|
||||
a) in the case of the initial Contributor, the initial content
|
||||
Distributed under this Agreement, and
|
||||
|
||||
b) in the case of each subsequent Contributor:
|
||||
i) changes to the Program, and
|
||||
ii) additions to the Program;
|
||||
where such changes and/or additions to the Program originate from
|
||||
and are Distributed by that particular Contributor. A Contribution
|
||||
"originates" from a Contributor if it was added to the Program by
|
||||
such Contributor itself or anyone acting on such Contributor's behalf.
|
||||
Contributions do not include changes or additions to the Program that
|
||||
are not Modified Works.
|
||||
|
||||
"Contributor" means any person or entity that Distributes the Program.
|
||||
|
||||
"Licensed Patents" mean patent claims licensable by a Contributor which
|
||||
are necessarily infringed by the use or sale of its Contribution alone
|
||||
or when combined with the Program.
|
||||
|
||||
"Program" means the Contributions Distributed in accordance with this
|
||||
Agreement.
|
||||
|
||||
"Recipient" means anyone who receives the Program under this Agreement
|
||||
or any Secondary License (as applicable), including Contributors.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source Code or other
|
||||
form, that is based on (or derived from) the Program and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship.
|
||||
|
||||
"Modified Works" shall mean any work in Source Code or other form that
|
||||
results from an addition to, deletion from, or modification of the
|
||||
contents of the Program, including, for purposes of clarity any new file
|
||||
in Source Code form that contains any contents of the Program. Modified
|
||||
Works shall not include works that contain only declarations,
|
||||
interfaces, types, classes, structures, or files of the Program solely
|
||||
in each case in order to link to, bind by name, or subclass the Program
|
||||
or Modified Works thereof.
|
||||
|
||||
"Distribute" means the acts of a) distributing or b) making available
|
||||
in any manner that enables the transfer of a copy.
|
||||
|
||||
"Source Code" means the form of a Program preferred for making
|
||||
modifications, including but not limited to software source code,
|
||||
documentation source, and configuration files.
|
||||
|
||||
"Secondary License" means either the GNU General Public License,
|
||||
Version 2.0, or any later versions of that license, including any
|
||||
exceptions or additional permissions as identified by the initial
|
||||
Contributor.
|
||||
|
||||
2. GRANT OF RIGHTS
|
||||
|
||||
a) Subject to the terms of this Agreement, each Contributor hereby
|
||||
grants Recipient a non-exclusive, worldwide, royalty-free copyright
|
||||
license to reproduce, prepare Derivative Works of, publicly display,
|
||||
publicly perform, Distribute and sublicense the Contribution of such
|
||||
Contributor, if any, and such Derivative Works.
|
||||
|
||||
b) Subject to the terms of this Agreement, each Contributor hereby
|
||||
grants Recipient a non-exclusive, worldwide, royalty-free patent
|
||||
license under Licensed Patents to make, use, sell, offer to sell,
|
||||
import and otherwise transfer the Contribution of such Contributor,
|
||||
if any, in Source Code or other form. This patent license shall
|
||||
apply to the combination of the Contribution and the Program if, at
|
||||
the time the Contribution is added by the Contributor, such addition
|
||||
of the Contribution causes such combination to be covered by the
|
||||
Licensed Patents. The patent license shall not apply to any other
|
||||
combinations which include the Contribution. No hardware per se is
|
||||
licensed hereunder.
|
||||
|
||||
c) Recipient understands that although each Contributor grants the
|
||||
licenses to its Contributions set forth herein, no assurances are
|
||||
provided by any Contributor that the Program does not infringe the
|
||||
patent or other intellectual property rights of any other entity.
|
||||
Each Contributor disclaims any liability to Recipient for claims
|
||||
brought by any other entity based on infringement of intellectual
|
||||
property rights or otherwise. As a condition to exercising the
|
||||
rights and licenses granted hereunder, each Recipient hereby
|
||||
assumes sole responsibility to secure any other intellectual
|
||||
property rights needed, if any. For example, if a third party
|
||||
patent license is required to allow Recipient to Distribute the
|
||||
Program, it is Recipient's responsibility to acquire that license
|
||||
before distributing the Program.
|
||||
|
||||
d) Each Contributor represents that to its knowledge it has
|
||||
sufficient copyright rights in its Contribution, if any, to grant
|
||||
the copyright license set forth in this Agreement.
|
||||
|
||||
e) Notwithstanding the terms of any Secondary License, no
|
||||
Contributor makes additional grants to any Recipient (other than
|
||||
those set forth in this Agreement) as a result of such Recipient's
|
||||
receipt of the Program under the terms of a Secondary License
|
||||
(if permitted under the terms of Section 3).
|
||||
|
||||
3. REQUIREMENTS
|
||||
|
||||
3.1 If a Contributor Distributes the Program in any form, then:
|
||||
|
||||
a) the Program must also be made available as Source Code, in
|
||||
accordance with section 3.2, and the Contributor must accompany
|
||||
the Program with a statement that the Source Code for the Program
|
||||
is available under this Agreement, and informs Recipients how to
|
||||
obtain it in a reasonable manner on or through a medium customarily
|
||||
used for software exchange; and
|
||||
|
||||
b) the Contributor may Distribute the Program under a license
|
||||
different than this Agreement, provided that such license:
|
||||
i) effectively disclaims on behalf of all other Contributors all
|
||||
warranties and conditions, express and implied, including
|
||||
warranties or conditions of title and non-infringement, and
|
||||
implied warranties or conditions of merchantability and fitness
|
||||
for a particular purpose;
|
||||
|
||||
ii) effectively excludes on behalf of all other Contributors all
|
||||
liability for damages, including direct, indirect, special,
|
||||
incidental and consequential damages, such as lost profits;
|
||||
|
||||
iii) does not attempt to limit or alter the recipients' rights
|
||||
in the Source Code under section 3.2; and
|
||||
|
||||
iv) requires any subsequent distribution of the Program by any
|
||||
party to be under a license that satisfies the requirements
|
||||
of this section 3.
|
||||
|
||||
3.2 When the Program is Distributed as Source Code:
|
||||
|
||||
a) it must be made available under this Agreement, or if the
|
||||
Program (i) is combined with other material in a separate file or
|
||||
files made available under a Secondary License, and (ii) the initial
|
||||
Contributor attached to the Source Code the notice described in
|
||||
Exhibit A of this Agreement, then the Program may be made available
|
||||
under the terms of such Secondary Licenses, and
|
||||
|
||||
b) a copy of this Agreement must be included with each copy of
|
||||
the Program.
|
||||
|
||||
3.3 Contributors may not remove or alter any copyright, patent,
|
||||
trademark, attribution notices, disclaimers of warranty, or limitations
|
||||
of liability ("notices") contained within the Program from any copy of
|
||||
the Program which they Distribute, provided that Contributors may add
|
||||
their own appropriate notices.
|
||||
|
||||
4. COMMERCIAL DISTRIBUTION
|
||||
|
||||
Commercial distributors of software may accept certain responsibilities
|
||||
with respect to end users, business partners and the like. While this
|
||||
license is intended to facilitate the commercial use of the Program,
|
||||
the Contributor who includes the Program in a commercial product
|
||||
offering should do so in a manner that does not create potential
|
||||
liability for other Contributors. Therefore, if a Contributor includes
|
||||
the Program in a commercial product offering, such Contributor
|
||||
("Commercial Contributor") hereby agrees to defend and indemnify every
|
||||
other Contributor ("Indemnified Contributor") against any losses,
|
||||
damages and costs (collectively "Losses") arising from claims, lawsuits
|
||||
and other legal actions brought by a third party against the Indemnified
|
||||
Contributor to the extent caused by the acts or omissions of such
|
||||
Commercial Contributor in connection with its distribution of the Program
|
||||
in a commercial product offering. The obligations in this section do not
|
||||
apply to any claims or Losses relating to any actual or alleged
|
||||
intellectual property infringement. In order to qualify, an Indemnified
|
||||
Contributor must: a) promptly notify the Commercial Contributor in
|
||||
writing of such claim, and b) allow the Commercial Contributor to control,
|
||||
and cooperate with the Commercial Contributor in, the defense and any
|
||||
related settlement negotiations. The Indemnified Contributor may
|
||||
participate in any such claim at its own expense.
|
||||
|
||||
For example, a Contributor might include the Program in a commercial
|
||||
product offering, Product X. That Contributor is then a Commercial
|
||||
Contributor. If that Commercial Contributor then makes performance
|
||||
claims, or offers warranties related to Product X, those performance
|
||||
claims and warranties are such Commercial Contributor's responsibility
|
||||
alone. Under this section, the Commercial Contributor would have to
|
||||
defend claims against the other Contributors related to those performance
|
||||
claims and warranties, and if a court requires any other Contributor to
|
||||
pay any damages as a result, the Commercial Contributor must pay
|
||||
those damages.
|
||||
|
||||
5. NO WARRANTY
|
||||
|
||||
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
|
||||
PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED 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. Each Recipient is solely responsible for determining the
|
||||
appropriateness of using and distributing the Program and assumes all
|
||||
risks associated with its exercise of rights under this Agreement,
|
||||
including but not limited to the risks and costs of program errors,
|
||||
compliance with applicable laws, damage to or loss of data, programs
|
||||
or equipment, and unavailability or interruption of operations.
|
||||
|
||||
6. DISCLAIMER OF LIABILITY
|
||||
|
||||
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
|
||||
PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
|
||||
SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
|
||||
PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
|
||||
EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
7. GENERAL
|
||||
|
||||
If any provision of this Agreement is invalid or unenforceable under
|
||||
applicable law, it shall not affect the validity or enforceability of
|
||||
the remainder of the terms of this Agreement, and without further
|
||||
action by the parties hereto, such provision shall be reformed to the
|
||||
minimum extent necessary to make such provision valid and enforceable.
|
||||
|
||||
If Recipient institutes patent litigation against any entity
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that the
|
||||
Program itself (excluding combinations of the Program with other software
|
||||
or hardware) infringes such Recipient's patent(s), then such Recipient's
|
||||
rights granted under Section 2(b) shall terminate as of the date such
|
||||
litigation is filed.
|
||||
|
||||
All Recipient's rights under this Agreement shall terminate if it
|
||||
fails to comply with any of the material terms or conditions of this
|
||||
Agreement and does not cure such failure in a reasonable period of
|
||||
time after becoming aware of such noncompliance. If all Recipient's
|
||||
rights under this Agreement terminate, Recipient agrees to cease use
|
||||
and distribution of the Program as soon as reasonably practicable.
|
||||
However, Recipient's obligations under this Agreement and any licenses
|
||||
granted by Recipient relating to the Program shall continue and survive.
|
||||
|
||||
Everyone is permitted to copy and distribute copies of this Agreement,
|
||||
but in order to avoid inconsistency the Agreement is copyrighted and
|
||||
may only be modified in the following manner. The Agreement Steward
|
||||
reserves the right to publish new versions (including revisions) of
|
||||
this Agreement from time to time. No one other than the Agreement
|
||||
Steward has the right to modify this Agreement. The Eclipse Foundation
|
||||
is the initial Agreement Steward. The Eclipse Foundation may assign the
|
||||
responsibility to serve as the Agreement Steward to a suitable separate
|
||||
entity. Each new version of the Agreement will be given a distinguishing
|
||||
version number. The Program (including Contributions) may always be
|
||||
Distributed subject to the version of the Agreement under which it was
|
||||
received. In addition, after a new version of the Agreement is published,
|
||||
Contributor may elect to Distribute the Program (including its
|
||||
Contributions) under the new version.
|
||||
|
||||
Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
|
||||
receives no rights or licenses to the intellectual property of any
|
||||
Contributor under this Agreement, whether expressly, by implication,
|
||||
estoppel or otherwise. All rights in the Program not expressly granted
|
||||
under this Agreement are reserved. Nothing in this Agreement is intended
|
||||
to be enforceable by any entity that is not a Contributor or Recipient.
|
||||
No third-party beneficiary rights are created under this Agreement.
|
||||
|
||||
Exhibit A - Form of Secondary Licenses Notice
|
||||
|
||||
"This Source Code may also be made available under the following
|
||||
Secondary Licenses when the conditions for such availability set forth
|
||||
in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
|
||||
version(s), and exceptions or additional permissions here}."
|
||||
|
||||
Simply including a copy of this Agreement, including this Exhibit A
|
||||
is not sufficient to license the Source Code under Secondary Licenses.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to
|
||||
look for a copy of the Agreement.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
@@ -0,0 +1,35 @@
|
||||
APP_NAME := 3m-ui
|
||||
BACKEND_DIR := backend
|
||||
FRONTEND_DIR := frontend
|
||||
DIST_DIR := dist
|
||||
VERSION ?= v0.1.0-rc.3
|
||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME)
|
||||
|
||||
.PHONY: build build-linux build-linux-static release clean
|
||||
|
||||
build:
|
||||
mkdir -p $(DIST_DIR)
|
||||
cd $(FRONTEND_DIR) && pnpm build
|
||||
rm -rf $(BACKEND_DIR)/cmd/server/web/dist
|
||||
cp -R $(FRONTEND_DIR)/dist $(BACKEND_DIR)/cmd/server/web/dist
|
||||
cd $(BACKEND_DIR) && go build -trimpath -ldflags "$(LDFLAGS)" -o ../$(DIST_DIR)/$(APP_NAME) ./cmd/server
|
||||
|
||||
build-linux:
|
||||
mkdir -p $(DIST_DIR)
|
||||
cd $(BACKEND_DIR) && GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -trimpath -ldflags "$(LDFLAGS)" -o ../$(DIST_DIR)/$(APP_NAME)-linux-amd64 ./cmd/server
|
||||
cd $(BACKEND_DIR) && GOOS=linux GOARCH=arm64 CGO_ENABLED=1 go build -trimpath -ldflags "$(LDFLAGS)" -o ../$(DIST_DIR)/$(APP_NAME)-linux-arm64 ./cmd/server
|
||||
cd $(BACKEND_DIR) && GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=1 go build -trimpath -ldflags "$(LDFLAGS)" -o ../$(DIST_DIR)/$(APP_NAME)-linux-armv7 ./cmd/server
|
||||
|
||||
build-linux-static:
|
||||
mkdir -p $(DIST_DIR)
|
||||
cd $(BACKEND_DIR) && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -tags sqlite_modernc -trimpath -ldflags "$(LDFLAGS)" -o ../$(DIST_DIR)/$(APP_NAME)-linux-amd64-static ./cmd/server
|
||||
cd $(BACKEND_DIR) && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -tags sqlite_modernc -trimpath -ldflags "$(LDFLAGS)" -o ../$(DIST_DIR)/$(APP_NAME)-linux-arm64-static ./cmd/server
|
||||
cd $(BACKEND_DIR) && GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -tags sqlite_modernc -trimpath -ldflags "$(LDFLAGS)" -o ../$(DIST_DIR)/$(APP_NAME)-linux-armv7-static ./cmd/server
|
||||
|
||||
release: clean build build-linux build-linux-static
|
||||
cp scripts/install.sh scripts/update.sh scripts/uninstall.sh $(DIST_DIR)/
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST_DIR)
|
||||
@@ -0,0 +1,252 @@
|
||||
# 3M-UI — Mihomo Core Management Console
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> | <a href="#中文">中文</a>
|
||||
</p>
|
||||
|
||||
A lightweight, modern web management console for [Mihomo Core](https://github.com/MetaCubeX/mihomo), designed for VPS proxy node management, client configuration distribution, and core lifecycle control.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ React 19 │────▶│ Vite Dev │────▶│ Ant Design │
|
||||
│ + Zustand │ │ Server │ │ 6 │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
▼ HTTP/REST
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Gin Router │────▶│ JWT │────▶│ SQLite │
|
||||
│ + GORM │ │ Auth │ │ (GORM) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
▶ exec.Command
|
||||
┌─────────────┐
|
||||
│ Mihomo Core │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.22+
|
||||
- Node.js 20+
|
||||
- Linux VPS (Ubuntu/Debian recommended)
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go mod tidy
|
||||
go build -o ../3m-ui ./cmd/server
|
||||
../3m-ui --config /etc/3m-ui/config.yaml
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Development
|
||||
npm run build # Production build → ../backend/static/
|
||||
```
|
||||
|
||||
### One-line Install (Production)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
`/etc/3m-ui/config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
public_url: "https://your-domain.com" # ← Required for X-Forwarded-Host validation
|
||||
|
||||
database:
|
||||
path: "/etc/3m-ui/3m-ui.db"
|
||||
|
||||
jwt:
|
||||
secret: "change-me-to-a-32-char-random-string"
|
||||
|
||||
mihomo:
|
||||
binary_path: "/usr/local/bin/mihomo"
|
||||
config_path: "/etc/mihomo/config.yaml"
|
||||
|
||||
security:
|
||||
cors_origins: [] # ← Empty = deny all cross-origin; add "https://admin.your-domain.com" if needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security Recommendations
|
||||
|
||||
1. **Change default password immediately** after first login (`admin`/`admin`).
|
||||
2. **Use HTTPS** in production (reverse proxy with Nginx/Caddy).
|
||||
3. **Set strong JWT secret** (≥ 32 random characters).
|
||||
4. **Restrict CORS origins** to your admin domain only.
|
||||
5. **Place mihomo binary** in `/usr/local/bin/` or `/usr/bin/` (whitelist enforced).
|
||||
6. **Set `public_url`** to prevent Host header injection attacks.
|
||||
|
||||
---
|
||||
|
||||
## 📡 API Overview
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| POST | `/api/v1/auth/login` | No | Login, returns JWT |
|
||||
| POST | `/api/v1/auth/password` | Yes | Change password |
|
||||
| GET | `/api/v1/auth/me` | Yes | Current user info |
|
||||
| GET | `/api/v1/dashboard` | Yes | System & mihomo status |
|
||||
| GET | `/api/v1/nodes` | Yes | List listeners |
|
||||
| POST | `/api/v1/nodes` | Yes | Create listener |
|
||||
| GET | `/api/v1/nodes/:id/uri` | Yes | Export node URI |
|
||||
| POST | `/api/v1/mihomo/start` | Yes | Start core |
|
||||
| POST | `/api/v1/mihomo/stop` | Yes | Stop core |
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<p id="中文"></p>
|
||||
|
||||
<h1 align="center">3M-UI — Mihomo 核心管理控制台</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="#3m-ui--mihomo-core-management-console">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
|
||||
[Mihomo Core](https://github.com/MetaCubeX/mihomo) 的轻量级现代化 Web 管理控制台,专为 VPS 代理节点管理、客户端配置分发和核心生命周期控制而设计。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ React 19 │────▶│ Vite 开发 │────▶│ Ant Design │
|
||||
│ + Zustand │ │ 服务器 │ │ 6 │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
▼ HTTP/REST
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Gin 路由 │────▶│ JWT │────▶│ SQLite │
|
||||
│ + GORM │ │ 认证 │ │ (GORM) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
▶ exec.Command
|
||||
┌─────────────┐
|
||||
│ Mihomo 核心 │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Go 1.22+
|
||||
- Node.js 20+
|
||||
- Linux VPS(推荐 Ubuntu/Debian)
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go mod tidy
|
||||
go build -o ../3m-ui ./cmd/server
|
||||
../3m-ui --config /etc/3m-ui/config.yaml
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # 开发模式
|
||||
npm run build # 生产构建 → ../backend/static/
|
||||
```
|
||||
|
||||
### 一键安装(生产环境)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
`/etc/3m-ui/config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
public_url: "https://your-domain.com" # ← X-Forwarded-Host 校验必需
|
||||
|
||||
database:
|
||||
path: "/etc/3m-ui/3m-ui.db"
|
||||
|
||||
jwt:
|
||||
secret: "请更换为32位以上随机字符串"
|
||||
|
||||
mihomo:
|
||||
binary_path: "/usr/local/bin/mihomo"
|
||||
config_path: "/etc/mihomo/config.yaml"
|
||||
|
||||
security:
|
||||
cors_origins: [] # ← 空列表 = 拒绝所有跨域;如需管理后台可添加 "https://admin.your-domain.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 安全建议
|
||||
|
||||
1. **首次登录后立即修改默认密码**(`admin`/`admin`)。
|
||||
2. **生产环境使用 HTTPS**(通过 Nginx/Caddy 反向代理)。
|
||||
3. **设置强 JWT Secret**(≥ 32 位随机字符)。
|
||||
4. **严格限制 CORS 来源**,仅允许管理域名访问。
|
||||
5. **将 mihomo 二进制文件**放置在 `/usr/local/bin/` 或 `/usr/bin/`(已启用白名单校验)。
|
||||
6. **配置 `public_url`**,防止 Host 头注入攻击。
|
||||
|
||||
---
|
||||
|
||||
## 📡 API 概览
|
||||
|
||||
| 方法 | 接口 | 需认证 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| POST | `/api/v1/auth/login` | 否 | 登录,返回 JWT |
|
||||
| POST | `/api/v1/auth/password` | 是 | 修改密码 |
|
||||
| GET | `/api/v1/auth/me` | 是 | 当前用户信息 |
|
||||
| GET | `/api/v1/dashboard` | 是 | 系统与核心状态 |
|
||||
| GET | `/api/v1/nodes` | 是 | 监听器列表 |
|
||||
| POST | `/api/v1/nodes` | 是 | 创建监听器 |
|
||||
| GET | `/api/v1/nodes/:id/uri` | 是 | 导出节点 URI |
|
||||
| POST | `/api/v1/mihomo/start` | 是 | 启动核心 |
|
||||
| POST | `/api/v1/mihomo/stop` | 是 | 停止核心 |
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License — 详见 [LICENSE](LICENSE)。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Third-Party Notices
|
||||
|
||||
3m-ui is licensed under the Eclipse Public License 2.0 (EPL-2.0).
|
||||
|
||||
This repository and its release artifacts may include, download, invoke, or distribute third-party software that is subject to its own license terms. A third-party component's license is not changed by the license of 3m-ui.
|
||||
|
||||
## Mihomo Core
|
||||
|
||||
3m-ui uses the Mihomo Core as a separate core component. The Mihomo Core is distributed under the GNU General Public License, version 3 (GPLv3), according to the license applicable to the Mihomo version included with or downloaded by a particular 3m-ui release.
|
||||
|
||||
- Project: https://github.com/MetaCubeX/mihomo
|
||||
- License: GNU General Public License v3.0
|
||||
- License text: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
When redistributing a 3m-ui release together with a Mihomo binary, the Mihomo binary and its corresponding source-code/license obligations remain subject to the GPLv3 terms applicable to that Mihomo release. This notice does not relicense Mihomo under EPL-2.0.
|
||||
|
||||
## Other dependencies
|
||||
|
||||
Dependencies and bundled components that are not part of 3m-ui itself remain subject to their respective upstream licenses. Where a release includes a third-party component, its applicable license and attribution notices should be retained.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Binaries
|
||||
server
|
||||
server.exe
|
||||
|
||||
# SQLite database and dynamic config data
|
||||
data/
|
||||
backend/data/
|
||||
3m-ui.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
backend_run.log
|
||||
@@ -0,0 +1,18 @@
|
||||
server:
|
||||
port: 8080
|
||||
mode: "debug"
|
||||
|
||||
database:
|
||||
path: "backend/data/3m-ui.db"
|
||||
|
||||
jwt:
|
||||
secret: "3m-ui-default-jwt-secret-key-change-it-in-production"
|
||||
security:
|
||||
# Empty or omit → allow all origins (legacy * behaviour).
|
||||
# Example: ["https://panel.example.com"]
|
||||
cors_origins: []
|
||||
credential_key: "3m-ui-default-credential-key-change-it-in-production"
|
||||
|
||||
mihomo:
|
||||
binary: "/usr/local/bin/mihomo"
|
||||
config: "backend/data/mihomo/config.yaml"
|
||||
@@ -0,0 +1,60 @@
|
||||
module github.com/kazeyukiro/3m-ui/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,152 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/auth"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database"
|
||||
dbconfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/router"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/security"
|
||||
)
|
||||
|
||||
// Run boots the application and serves the embedded frontend.
|
||||
// Runtime dependencies are assembled once by NewContainer; cmd/server remains
|
||||
// a thin entrypoint and handlers can be migrated away from package globals
|
||||
// incrementally.
|
||||
func Run(frontendFS fs.FS) error {
|
||||
configPath := defaultConfigPath()
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
db, err := database.InitDB(cfg.Database.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize database: %w", err)
|
||||
}
|
||||
|
||||
if created, username, password, err := auth.EnsureAdmin(db, cfg.Database.Path); err != nil {
|
||||
return fmt.Errorf("initialize administrator: %w", err)
|
||||
} else if created {
|
||||
log.Printf("initial administrator created: username=%s", username)
|
||||
passwordFile := filepath.Join(filepath.Dir(cfg.Database.Path), ".initial_admin_password")
|
||||
if err := os.WriteFile(passwordFile, []byte(password+"\n"), 0600); err != nil {
|
||||
log.Printf("warning: could not write initial admin password file: %v", err)
|
||||
} else {
|
||||
log.Printf("initial administrator password saved to %s", passwordFile)
|
||||
}
|
||||
}
|
||||
|
||||
security.InitCredentialKey(cfg.Security.CredentialKey)
|
||||
container := NewContainer(db, cfg)
|
||||
|
||||
// The config package receives its credential provider from the composition
|
||||
// root. This avoids importing the user service from the Mihomo compiler and
|
||||
// keeps credential loading behind the application boundary.
|
||||
dbconfig.CredentialProvider = func() (map[uint][]dbconfig.Credential, error) {
|
||||
if container.User == nil {
|
||||
return map[uint][]dbconfig.Credential{}, nil
|
||||
}
|
||||
provided, err := container.User.ActiveCredentialsByListener()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[uint][]dbconfig.Credential, len(provided))
|
||||
for listenerID, credentials := range provided {
|
||||
converted := make([]dbconfig.Credential, 0, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
converted = append(converted, dbconfig.Credential{
|
||||
Username: credential.Username,
|
||||
Password: credential.Password,
|
||||
UUID: credential.UUID,
|
||||
})
|
||||
}
|
||||
result[listenerID] = converted
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Build and validate the actual Mihomo configuration before serving HTTP.
|
||||
// The running core is therefore always backed by the same database-driven
|
||||
// Listener configuration exposed by the panel.
|
||||
generatedConfig, err := container.ConfigEngine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate Mihomo configuration: %w", err)
|
||||
}
|
||||
if container.Mihomo == nil {
|
||||
return fmt.Errorf("initialize Mihomo service: service is nil")
|
||||
}
|
||||
if err := container.Mihomo.SaveConfig(generatedConfig); err != nil {
|
||||
return fmt.Errorf("validate Mihomo configuration: %w", err)
|
||||
}
|
||||
if err := container.Mihomo.StartMihomo(); err != nil {
|
||||
return fmt.Errorf("start Mihomo core: %w", err)
|
||||
}
|
||||
log.Printf("Mihomo core started successfully")
|
||||
|
||||
r := router.SetupRouterWithDeps(container.RouterDeps())
|
||||
mountFrontend(r, frontendFS)
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
log.Printf("3m-ui listening on %s", addr)
|
||||
if err := r.Run(addr); err != nil {
|
||||
return fmt.Errorf("run server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultConfigPath() string {
|
||||
if value := os.Getenv("THREE_M_UI_CONFIG"); value != "" {
|
||||
return value
|
||||
}
|
||||
if _, err := os.Stat("/etc/3m-ui/config.yaml"); err == nil {
|
||||
return "/etc/3m-ui/config.yaml"
|
||||
}
|
||||
return "backend/config/config.yaml"
|
||||
}
|
||||
|
||||
func mountFrontend(r *gin.Engine, frontendFS fs.FS) {
|
||||
staticFS, err := fs.Sub(frontendFS, "web/dist")
|
||||
if err != nil {
|
||||
log.Printf("frontend assets unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(staticFS))
|
||||
r.RedirectTrailingSlash = false
|
||||
r.RedirectFixedPath = false
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if len(path) >= 4 && path[:4] == "/api" {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if path == "/" {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", mustReadFile(staticFS, "index.html"))
|
||||
return
|
||||
}
|
||||
f, err := staticFS.Open(path[1:])
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", mustReadFile(staticFS, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
func mustReadFile(fsys fs.FS, name string) []byte {
|
||||
data, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
log.Printf("read frontend %s failed: %v", name, err)
|
||||
return []byte("3m-ui frontend unavailable")
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/listener"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
|
||||
dbconfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/node"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/router"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/system"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/traffic"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Container is the application composition root.
|
||||
type Container struct {
|
||||
DB *gorm.DB
|
||||
Config *config.Config
|
||||
Mihomo *mihomo.Service
|
||||
Listener *listener.Service
|
||||
Node *node.Service
|
||||
User *user.Service
|
||||
Traffic *traffic.Service
|
||||
TrafficCollector *traffic.Collector
|
||||
TrafficScheduler *traffic.Scheduler
|
||||
System *system.Service
|
||||
ConfigEngine *dbconfig.ConfigEngine
|
||||
}
|
||||
|
||||
// NewContainer constructs services and starts the traffic collection loop.
|
||||
func NewContainer(db *gorm.DB, cfg *config.Config) *Container {
|
||||
mihomoSvc := mihomo.NewService(cfg)
|
||||
nodeSvc := node.NewService(db, cfg.Mihomo.Config, mihomoSvc)
|
||||
listenerSvc := listener.NewService(db, cfg.Mihomo.Config, mihomoSvc)
|
||||
userSvc := user.NewService(db)
|
||||
systemSvc := system.NewService()
|
||||
|
||||
trafficSvc := traffic.NewService()
|
||||
userTraffic := traffic.NewUserService(db)
|
||||
collector := traffic.NewCollectorFromDefaults(db, trafficSvc, userTraffic)
|
||||
enforcer := traffic.NewEnforcer(db, nodeSvc)
|
||||
scheduler := traffic.NewScheduler(collector, enforcer, 0)
|
||||
scheduler.Start()
|
||||
|
||||
return &Container{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
Mihomo: mihomoSvc,
|
||||
Listener: listenerSvc,
|
||||
Node: nodeSvc,
|
||||
User: userSvc,
|
||||
Traffic: trafficSvc,
|
||||
TrafficCollector: collector,
|
||||
TrafficScheduler: scheduler,
|
||||
System: systemSvc,
|
||||
ConfigEngine: dbconfig.NewConfigEngine(db),
|
||||
}
|
||||
}
|
||||
|
||||
// RouterDeps builds the dependency bag consumed by the HTTP layer.
|
||||
func (c *Container) RouterDeps() router.Deps {
|
||||
return router.Deps{
|
||||
DB: c.DB,
|
||||
Config: c.Config,
|
||||
Mihomo: c.Mihomo,
|
||||
Traffic: c.Traffic,
|
||||
TrafficCollector: c.TrafficCollector,
|
||||
User: c.User,
|
||||
Node: c.Node,
|
||||
System: c.System,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type loginAttempt struct {
|
||||
count int
|
||||
blocked time.Time
|
||||
last time.Time
|
||||
}
|
||||
|
||||
var loginLimiter = struct {
|
||||
sync.Mutex
|
||||
items map[string]loginAttempt
|
||||
}{items: make(map[string]loginAttempt)}
|
||||
|
||||
const (
|
||||
loginWindow = 15 * time.Minute
|
||||
loginMaxAttempt = 8
|
||||
)
|
||||
|
||||
// clientIdentifier returns a stable identifier for rate-limiting.
|
||||
func clientIdentifier(c *gin.Context) string {
|
||||
remote := c.ClientIP()
|
||||
xff := c.GetHeader("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
first := strings.TrimSpace(parts[0])
|
||||
if first != "" && net.ParseIP(first) != nil {
|
||||
remote = first
|
||||
}
|
||||
}
|
||||
return remote
|
||||
}
|
||||
|
||||
func allowLogin(ip string) bool {
|
||||
now := time.Now()
|
||||
loginLimiter.Lock()
|
||||
defer loginLimiter.Unlock()
|
||||
|
||||
for key, attempt := range loginLimiter.items {
|
||||
if now.Sub(attempt.last) > loginWindow {
|
||||
delete(loginLimiter.items, key)
|
||||
}
|
||||
}
|
||||
|
||||
attempt := loginLimiter.items[ip]
|
||||
if !attempt.blocked.IsZero() && now.Before(attempt.blocked) {
|
||||
return false
|
||||
}
|
||||
if attempt.last.IsZero() || now.Sub(attempt.last) > loginWindow {
|
||||
attempt.count = 0
|
||||
}
|
||||
attempt.last = now
|
||||
if attempt.count >= loginMaxAttempt {
|
||||
attempt.blocked = now.Add(loginWindow)
|
||||
loginLimiter.items[ip] = attempt
|
||||
return false
|
||||
}
|
||||
attempt.count++
|
||||
loginLimiter.items[ip] = attempt
|
||||
return true
|
||||
}
|
||||
|
||||
func resetLoginLimit(ip string) {
|
||||
loginLimiter.Lock()
|
||||
delete(loginLimiter.items, ip)
|
||||
loginLimiter.Unlock()
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
secret string
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
|
||||
secret := ""
|
||||
if cfg != nil {
|
||||
secret = cfg.JWT.Secret
|
||||
}
|
||||
return &Handler{db: db, secret: secret, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.POST("/login", h.Login)
|
||||
rg.POST("/password", RequireAuth(h.db, h.secret), h.ChangePassword)
|
||||
rg.GET("/me", RequireAuth(h.db, h.secret), h.Me)
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
clientID := clientIdentifier(c)
|
||||
if !allowLogin(clientID) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts; try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
var input LoginInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
|
||||
return
|
||||
}
|
||||
result, err := Login(h.db, h.secret, input)
|
||||
if err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
if err.Error() != "invalid username or password" {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
c.JSON(status, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
resetLoginLimit(clientID)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) ChangePassword(c *gin.Context) {
|
||||
claims, ok := ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "current_password and new_password are required"})
|
||||
return
|
||||
}
|
||||
if len([]rune(req.NewPassword)) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "new password must be at least 8 characters"})
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(req.NewPassword), []byte(req.CurrentPassword)) == 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "new password must differ from current password"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := h.db.First(&user, claims.UserID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if !CheckPasswordHash(req.CurrentPassword, user.PasswordHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "current password is incorrect"})
|
||||
return
|
||||
}
|
||||
hash, err := HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
nextSessionVersion := user.SessionVersion + 1
|
||||
if nextSessionVersion == 0 {
|
||||
nextSessionVersion = 1
|
||||
}
|
||||
if err := h.db.Model(&user).Updates(map[string]any{
|
||||
"password_hash": hash,
|
||||
"must_change_password": false,
|
||||
"session_version": nextSessionVersion,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save password"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.cfg != nil {
|
||||
passwordFile := filepath.Join(filepath.Dir(h.cfg.Database.Path), ".initial_admin_password")
|
||||
if err := os.Remove(passwordFile); err != nil && !os.IsNotExist(err) {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "password changed successfully"})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
claims, ok := ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := h.db.First(&user, claims.UserID).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"role": claims.Role,
|
||||
"expires_at": claims.ExpiresAt,
|
||||
"must_change_password": user.MustChangePassword,
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAuth validates the Bearer JWT against the provided secret and loads
|
||||
// the administrator from the provided database.
|
||||
func RequireAuth(db *gorm.DB, secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := TokenFromRequest(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
claims, err := ParseToken(secret, token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, claims.UserID).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(user.Role, "admin") {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "administrator access required"})
|
||||
return
|
||||
}
|
||||
if user.SessionVersion == 0 || claims.SessionVersion == 0 || user.SessionVersion != claims.SessionVersion {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has been invalidated; please log in again"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("auth.claims", claims)
|
||||
c.Set("auth.user", &user)
|
||||
|
||||
path := c.Request.URL.Path
|
||||
if path != "/api/v1/auth/password" && path != "/api/v1/auth/me" && user.MustChangePassword {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "password change required",
|
||||
"code": "PASSWORD_CHANGE_REQUIRED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ClaimsFromContext(c *gin.Context) (*JWTClaims, bool) {
|
||||
value, ok := c.Get("auth.claims")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
claims, ok := value.(*JWTClaims)
|
||||
return claims, ok
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword takes a plaintext password and returns its bcrypt hash
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// CheckPasswordHash compares a bcrypt hashed password with its plaintext counterpart
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestHashAndCheckPassword(t *testing.T) {
|
||||
password := "my_secure_password"
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
if hash == "" {
|
||||
t.Fatal("expected hash to be non-empty")
|
||||
}
|
||||
|
||||
if hash == password {
|
||||
t.Fatal("expected hash to be different from plaintext password")
|
||||
}
|
||||
|
||||
if !auth.CheckPasswordHash(password, hash) {
|
||||
t.Fatal("expected password hash check to succeed")
|
||||
}
|
||||
|
||||
if auth.CheckPasswordHash("wrong_password", hash) {
|
||||
t.Fatal("expected password hash check to fail for incorrect password")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTClaims represents the authenticated administrator identity.
|
||||
type JWTClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionVersion uint `json:"session_version"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// GenerateToken issues a signed JWT using the standard library.
|
||||
func GenerateToken(secret string, userID uint, username, role string, sessionVersion uint, ttl time.Duration) (string, time.Time, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return "", time.Time{}, errors.New("JWT secret is not configured")
|
||||
}
|
||||
exp := time.Now().Add(ttl)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"session_version": sessionVersion,
|
||||
"exp": exp.Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
signed, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sign token: %w", err)
|
||||
}
|
||||
return signed, exp, nil
|
||||
}
|
||||
|
||||
// ParseToken verifies the signature and returns parsed claims.
|
||||
func ParseToken(secret, tokenString string) (*JWTClaims, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return nil, errors.New("JWT secret is not configured")
|
||||
}
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid or expired token")
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
|
||||
exp, _ := claims["exp"].(float64)
|
||||
if time.Now().Unix() > int64(exp) {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
|
||||
jc := &JWTClaims{}
|
||||
if v, ok := claims["user_id"].(float64); ok {
|
||||
jc.UserID = uint(v)
|
||||
}
|
||||
if v, ok := claims["username"].(string); ok {
|
||||
jc.Username = v
|
||||
}
|
||||
if v, ok := claims["role"].(string); ok {
|
||||
jc.Role = v
|
||||
}
|
||||
if v, ok := claims["session_version"].(float64); ok {
|
||||
jc.SessionVersion = uint(v)
|
||||
}
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
jc.ExpiresAt = time.Unix(int64(exp), 0)
|
||||
}
|
||||
return jc, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const DefaultTokenTTL = 24 * time.Hour
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
MustChangePassword bool `json:"must_change_password"`
|
||||
}
|
||||
|
||||
func Login(db *gorm.DB, jwtSecret string, input LoginInput) (*LoginResult, error) {
|
||||
var user models.User
|
||||
if err := db.Where("username = ?", strings.TrimSpace(input.Username)).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("invalid username or password")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !CheckPasswordHash(input.Password, user.PasswordHash) {
|
||||
return nil, errors.New("invalid username or password")
|
||||
}
|
||||
if user.SessionVersion == 0 {
|
||||
user.SessionVersion = 1
|
||||
if err := db.Model(&user).Update("session_version", user.SessionVersion).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
token, exp, err := GenerateToken(jwtSecret, user.ID, user.Username, user.Role, user.SessionVersion, DefaultTokenTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResult{Token: token, ExpiresAt: exp, Username: user.Username, Role: user.Role, MustChangePassword: user.MustChangePassword}, nil
|
||||
}
|
||||
|
||||
// EnsureAdmin creates the first administrator only when the database has no
|
||||
// administrator. An explicit THREE_M_UI_ADMIN_PASSWORD is preferred.
|
||||
// Otherwise a random password is written to the database directory so the
|
||||
// installer/operator can retrieve it once.
|
||||
func EnsureAdmin(db *gorm.DB, dbPath string) (created bool, username, password string, err error) {
|
||||
var count int64
|
||||
if err := db.Model(&models.User{}).Where("role = ?", "admin").Count(&count).Error; err != nil {
|
||||
return false, "", "", err
|
||||
}
|
||||
if count > 0 {
|
||||
return false, "", "", nil
|
||||
}
|
||||
|
||||
username = strings.TrimSpace(os.Getenv("THREE_M_UI_ADMIN_USERNAME"))
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
password = os.Getenv("THREE_M_UI_ADMIN_PASSWORD")
|
||||
if password == "" {
|
||||
password = "admin"
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return false, "", "", err
|
||||
}
|
||||
if err := db.Create(&models.User{Username: username, PasswordHash: hash, Role: "admin", MustChangePassword: true, SessionVersion: 1}).Error; err != nil {
|
||||
return false, "", "", fmt.Errorf("create initial admin: %w", err)
|
||||
}
|
||||
|
||||
return true, username, password, nil
|
||||
}
|
||||
|
||||
func randomPassword(n int) (string, error) {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range buf {
|
||||
buf[i] = alphabet[int(buf[i])%len(alphabet)]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func BearerToken(header string) string {
|
||||
const prefix = "Bearer "
|
||||
if strings.HasPrefix(header, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(header, prefix))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TokenFromRequest(authHeader string) string { return BearerToken(authHeader) }
|
||||
|
||||
func EncodePassword(password string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(password))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
Mihomo MihomoConfig `yaml:"mihomo"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
Mode string `yaml:"mode"`
|
||||
PublicURL string `yaml:"public_url"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `yaml:"secret"`
|
||||
}
|
||||
|
||||
type MihomoConfig struct {
|
||||
Binary string `yaml:"binary"`
|
||||
Config string `yaml:"config"`
|
||||
}
|
||||
|
||||
var GlobalConfig *Config
|
||||
|
||||
// IsMihomoListenerProtocol reports whether protocol is part of the listener
|
||||
// protocol surface supported by 3m-ui's listener editor/export pipeline.
|
||||
func IsMihomoListenerProtocol(protocol string) bool {
|
||||
switch protocol {
|
||||
case "shadowsocks",
|
||||
"snell",
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"hysteria2",
|
||||
"hysteria2-realm",
|
||||
"tuic",
|
||||
"shadowquic",
|
||||
"anytls",
|
||||
"mieru",
|
||||
"sudoku",
|
||||
"trusttunnel":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open config file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var cfg Config
|
||||
decoder := yaml.NewDecoder(file)
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode config YAML: %w", err)
|
||||
}
|
||||
|
||||
GlobalConfig = &cfg
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
CredentialKey string `yaml:"credential_key"`
|
||||
CORSOrigins []string `yaml:"cors_origins"`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsMihomoListenerProtocol(t *testing.T) {
|
||||
supported := []string{
|
||||
"shadowsocks",
|
||||
"snell",
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"hysteria2",
|
||||
"hysteria2-realm",
|
||||
"tuic",
|
||||
"shadowquic",
|
||||
"anytls",
|
||||
"mieru",
|
||||
"sudoku",
|
||||
"trusttunnel",
|
||||
}
|
||||
for _, protocol := range supported {
|
||||
if !IsMihomoListenerProtocol(protocol) {
|
||||
t.Errorf("expected %q to be supported", protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMihomoListenerProtocolRejectsInboundOnlyTypes(t *testing.T) {
|
||||
rejected := []string{
|
||||
"socks",
|
||||
"http",
|
||||
"tproxy",
|
||||
"redir",
|
||||
"mixed",
|
||||
"tunnel",
|
||||
"tun",
|
||||
"wireguard",
|
||||
"",
|
||||
}
|
||||
for _, protocol := range rejected {
|
||||
if IsMihomoListenerProtocol(protocol) {
|
||||
t.Errorf("expected %q to be rejected", protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ResolveServerAddress(cfg *config.Config, req *http.Request) string {
|
||||
if envURL := os.Getenv("PUBLIC_URL"); envURL != "" {
|
||||
return cleanURLHost(envURL)
|
||||
}
|
||||
if cfg != nil && cfg.Server.PublicURL != "" {
|
||||
return cleanURLHost(cfg.Server.PublicURL)
|
||||
}
|
||||
if req != nil && req.Host != "" {
|
||||
host := req.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return h
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
func cleanURLHost(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u := raw
|
||||
if parsed, err := url.Parse(raw); err == nil && parsed.Host != "" {
|
||||
u = parsed.Host
|
||||
} else {
|
||||
u = strings.TrimPrefix(u, "https://")
|
||||
u = strings.TrimPrefix(u, "http://")
|
||||
u = strings.Split(u, "/")[0]
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(u); err == nil {
|
||||
u = h
|
||||
}
|
||||
u = strings.Trim(u, "[]")
|
||||
if u == "" || strings.ContainsAny(u, "\r\n/\\") {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func GetSubscriptionURL(cfg *config.Config, req *http.Request, token string, target string) string {
|
||||
var base string
|
||||
if envURL := os.Getenv("PUBLIC_URL"); envURL != "" {
|
||||
base = envURL
|
||||
} else if cfg != nil && cfg.Server.PublicURL != "" {
|
||||
base = cfg.Server.PublicURL
|
||||
} else if req != nil && req.Host != "" {
|
||||
scheme := "http"
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
base = fmt.Sprintf("%s://%s", scheme, req.Host)
|
||||
} else {
|
||||
base = "http://127.0.0.1:8080"
|
||||
}
|
||||
base = strings.TrimSuffix(base, "/")
|
||||
pathToken := url.PathEscape(token)
|
||||
if target == "" {
|
||||
return fmt.Sprintf("%s/api/v1/client/sub/%s", base, pathToken)
|
||||
}
|
||||
return fmt.Sprintf("%s/api/v1/client/sub/%s?target=%s", base, pathToken, url.QueryEscape(strings.ToLower(strings.TrimSpace(target))))
|
||||
}
|
||||
|
||||
func GenerateRawConfig(db *gorm.DB, token models.AccessToken, req *http.Request) ([]byte, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database is not initialized")
|
||||
}
|
||||
if token.ListenerID == 0 {
|
||||
return nil, fmt.Errorf("access token is not bound to a listener")
|
||||
}
|
||||
|
||||
var listener models.Listener
|
||||
if err := db.First(&listener, token.ListenerID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("listener not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch listener: %w", err)
|
||||
}
|
||||
if !listener.Enabled {
|
||||
return nil, fmt.Errorf("listener is disabled")
|
||||
}
|
||||
|
||||
serverHost := ResolveServerAddress(config.GlobalConfig, req)
|
||||
credentials := []user.Credential{}
|
||||
if byListener, err := user.NewService(db).ActiveCredentialsByListener(); err != nil {
|
||||
return nil, fmt.Errorf("failed to load listener credentials: %w", err)
|
||||
} else {
|
||||
credentials = byListener[listener.ID]
|
||||
}
|
||||
|
||||
proxies, err := listenerToProxies(listener, serverHost, credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(proxies) == 0 {
|
||||
return nil, fmt.Errorf("listener %q has no exportable client credentials", listener.Name)
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(proxies))
|
||||
for _, proxy := range proxies {
|
||||
if name, ok := proxy["name"].(string); ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
cfg := map[string]interface{}{
|
||||
"proxies": proxies,
|
||||
"proxy-groups": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "PROXY",
|
||||
"type": "select",
|
||||
"proxies": names,
|
||||
},
|
||||
},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
return yaml.Marshal(cfg)
|
||||
}
|
||||
|
||||
func listenerToProxies(l models.Listener, server string, credentials []user.Credential) ([]map[string]interface{}, error) {
|
||||
protocol := strings.ToLower(strings.TrimSpace(l.Protocol))
|
||||
if protocol == "" {
|
||||
protocol = strings.ToLower(strings.TrimSpace(l.Type))
|
||||
}
|
||||
if !config.IsMihomoListenerProtocol(protocol) {
|
||||
return nil, fmt.Errorf("unsupported listener protocol %q", protocol)
|
||||
}
|
||||
if protocol == "hysteria2-realm" {
|
||||
return nil, fmt.Errorf("hysteria2-realm is an auxiliary realm service and has no client proxy configuration")
|
||||
}
|
||||
|
||||
opts, err := decodeOptions(l.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid listener config for %q: %w", l.Name, err)
|
||||
}
|
||||
|
||||
base := map[string]interface{}{
|
||||
"type": protocol,
|
||||
"server": server,
|
||||
"port": l.Port,
|
||||
}
|
||||
if l.UDP && clientSupportsUDP(protocol) {
|
||||
base["udp"] = true
|
||||
}
|
||||
copyClientTLS(base, opts)
|
||||
copyTransport(base, opts)
|
||||
|
||||
makeProxy := func(suffix string) map[string]interface{} {
|
||||
p := cloneMap(base)
|
||||
name := l.Name
|
||||
if suffix != "" {
|
||||
name += "-" + suffix
|
||||
}
|
||||
p["name"] = name
|
||||
return p
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, 0, max(1, len(credentials)))
|
||||
switch protocol {
|
||||
case "shadowsocks":
|
||||
p := makeProxy(credentialSuffix(credentials, 0))
|
||||
copyOption(p, opts, "cipher")
|
||||
if len(credentials) > 1 {
|
||||
return nil, fmt.Errorf("listener %q: Shadowsocks can export only one password", l.Name)
|
||||
}
|
||||
if len(credentials) == 1 && credentials[0].Password != "" {
|
||||
p["password"] = credentials[0].Password
|
||||
} else if value, ok := opts["password"]; ok {
|
||||
p["password"] = value
|
||||
}
|
||||
if value, ok := opts["simple-obfs"].(map[string]interface{}); ok && boolValue(value["enable"]) {
|
||||
p["plugin"] = "simple-obfs"
|
||||
p["plugin-opts"] = value
|
||||
}
|
||||
if value := shadowTLSClientOptions(opts); value != nil {
|
||||
p["shadow-tls-opts"] = value
|
||||
}
|
||||
if value := resTLSClientOptions(opts); value != nil {
|
||||
p["restls-opts"] = value
|
||||
}
|
||||
if value := jlsClientOptions(opts); value != nil {
|
||||
p["jls-opts"] = value
|
||||
}
|
||||
result = append(result, p)
|
||||
|
||||
case "snell":
|
||||
p := makeProxy("")
|
||||
copyOption(p, opts, "psk")
|
||||
copyOption(p, opts, "version")
|
||||
copyOption(p, opts, "udp")
|
||||
if value, ok := opts["obfs-opts"]; ok {
|
||||
p["obfs-opts"] = value
|
||||
}
|
||||
result = append(result, p)
|
||||
|
||||
case "vmess", "vless":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for %s client export", l.Name, protocol)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
if cred.UUID == "" {
|
||||
continue
|
||||
}
|
||||
p["uuid"] = cred.UUID
|
||||
copyOption(p, opts, "alterId")
|
||||
copyOption(p, opts, "cipher")
|
||||
copyOption(p, opts, "packet-encoding")
|
||||
copyOption(p, opts, "global-padding")
|
||||
copyOption(p, opts, "authenticated-length")
|
||||
copyOption(p, opts, "encryption")
|
||||
copyOption(p, opts, "flow")
|
||||
if value := realityClientOptions(opts); value != nil {
|
||||
p["reality-opts"] = value
|
||||
}
|
||||
if value := shadowTLSClientOptions(opts); value != nil {
|
||||
p["shadow-tls-opts"] = value
|
||||
}
|
||||
if value := resTLSClientOptions(opts); value != nil {
|
||||
p["restls-opts"] = value
|
||||
}
|
||||
if value := jlsClientOptions(opts); value != nil {
|
||||
p["jls-opts"] = value
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
|
||||
case "trojan":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for Trojan client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["password"] = cred.Password
|
||||
copyOption(p, opts, "sni")
|
||||
copyOption(p, opts, "alpn")
|
||||
copyOption(p, opts, "fingerprint")
|
||||
copyOption(p, opts, "client-fingerprint")
|
||||
copyOption(p, opts, "skip-cert-verify")
|
||||
copyOption(p, opts, "name-cert-verify")
|
||||
if value := realityClientOptions(opts); value != nil {
|
||||
p["reality-opts"] = value
|
||||
}
|
||||
if value := shadowTLSClientOptions(opts); value != nil {
|
||||
p["shadow-tls-opts"] = value
|
||||
}
|
||||
if value := resTLSClientOptions(opts); value != nil {
|
||||
p["restls-opts"] = value
|
||||
}
|
||||
if value := jlsClientOptions(opts); value != nil {
|
||||
p["jls-opts"] = value
|
||||
}
|
||||
if value, ok := opts["ss-option"]; ok {
|
||||
p["ss-opts"] = value
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
|
||||
case "hysteria2":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for Hysteria 2 client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"up", "down", "obfs", "obfs-password", "masquerade", "bbr-profile",
|
||||
"realm-opts", "alpn", "sni", "skip-cert-verify", "name-cert-verify", "fingerprint",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
|
||||
case "tuic":
|
||||
if token, ok := opts["token"]; ok {
|
||||
p := makeProxy("")
|
||||
p["token"] = token
|
||||
for _, key := range []string{
|
||||
"congestion-controller", "bbr-profile", "max-idle-time",
|
||||
"authentication-timeout", "alpn", "max-udp-relay-packet-size",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
} else {
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires TUIC V5 users or a V4 token", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["uuid"] = cred.UUID
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"congestion-controller", "bbr-profile", "max-idle-time",
|
||||
"authentication-timeout", "alpn", "max-udp-relay-packet-size",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
|
||||
case "shadowquic":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for ShadowQUIC client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["username"] = cred.Username
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"sni", "alpn", "quic-versions", "zero-rtt", "udp-over-stream",
|
||||
"keep-alive-interval", "congestion-controller", "up", "down", "cwnd",
|
||||
"bbr-profile", "max-datagram-frame-size", "max-open-streams",
|
||||
"recv-window-conn", "recv-window", "disable-mtu-discovery",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
|
||||
case "anytls":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for AnyTLS client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"client-fingerprint", "udp", "idle-session-check-interval",
|
||||
"idle-session-timeout", "min-idle-session", "sni", "alpn",
|
||||
"skip-cert-verify", "name-cert-verify",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
if value := shadowTLSClientOptions(opts); value != nil {
|
||||
p["shadow-tls-opts"] = value
|
||||
}
|
||||
if value := resTLSClientOptions(opts); value != nil {
|
||||
p["restls-opts"] = value
|
||||
}
|
||||
if value := jlsClientOptions(opts); value != nil {
|
||||
p["jls-opts"] = value
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
|
||||
case "mieru":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for Mieru client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["username"] = cred.Username
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{"transport", "multiplexing", "handshake-mode", "traffic-pattern"} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
|
||||
case "sudoku":
|
||||
p := makeProxy("")
|
||||
for _, key := range []string{
|
||||
"key", "aead-method", "padding-min", "padding-max", "table-type",
|
||||
"custom-table", "custom-tables", "handshake-timeout",
|
||||
"enable-pure-downlink", "httpmask",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
|
||||
case "trusttunnel":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for TrustTunnel client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["username"] = cred.Username
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"client-fingerprint", "health-check", "udp", "sni", "alpn",
|
||||
"skip-cert-verify", "name-cert-verify", "quic", "congestion-controller",
|
||||
"bbr-profile", "max-connections", "min-streams", "max-streams",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeOptions(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var options map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options == nil {
|
||||
options = map[string]interface{}{}
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func copyClientTLS(dst, src map[string]interface{}) {
|
||||
if _, ok := src["certificate"]; ok {
|
||||
dst["tls"] = true
|
||||
}
|
||||
for _, key := range []string{
|
||||
"sni", "alpn", "fingerprint", "client-fingerprint",
|
||||
"skip-cert-verify", "name-cert-verify",
|
||||
} {
|
||||
copyOption(dst, src, key)
|
||||
}
|
||||
}
|
||||
|
||||
func copyTransport(dst, src map[string]interface{}) {
|
||||
if path, ok := src["ws-path"].(string); ok && path != "" {
|
||||
dst["network"] = "ws"
|
||||
dst["ws-opts"] = map[string]interface{}{"path": path}
|
||||
}
|
||||
if service, ok := src["grpc-service-name"].(string); ok && service != "" {
|
||||
dst["network"] = "grpc"
|
||||
dst["grpc-opts"] = map[string]interface{}{"grpc-service-name": service}
|
||||
}
|
||||
if value, ok := src["xhttp-config"]; ok {
|
||||
dst["network"] = "xhttp"
|
||||
dst["xhttp-opts"] = value
|
||||
}
|
||||
}
|
||||
|
||||
func realityClientOptions(src map[string]interface{}) map[string]interface{} {
|
||||
cfg, ok := src["reality-config"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
publicKey, hasPublic := cfg["public-key"]
|
||||
if !hasPublic {
|
||||
return nil
|
||||
}
|
||||
result := map[string]interface{}{"public-key": publicKey}
|
||||
if ids, ok := cfg["short-id"].([]interface{}); ok && len(ids) > 0 {
|
||||
result["short-id"] = ids[0]
|
||||
} else if value, ok := cfg["short-id"]; ok {
|
||||
result["short-id"] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func shadowTLSClientOptions(src map[string]interface{}) map[string]interface{} {
|
||||
cfg, ok := src["shadow-tls"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := map[string]interface{}{}
|
||||
for _, key := range []string{"version", "password"} {
|
||||
if value, ok := cfg[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resTLSClientOptions(src map[string]interface{}) map[string]interface{} {
|
||||
cfg, ok := src["res-tls"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := map[string]interface{}{}
|
||||
if value, ok := cfg["password"]; ok {
|
||||
result["password"] = value
|
||||
}
|
||||
if value, ok := cfg["version-hint"]; ok {
|
||||
result["version-hint"] = value
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func jlsClientOptions(src map[string]interface{}) map[string]interface{} {
|
||||
cfg, ok := src["jls-config"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := map[string]interface{}{}
|
||||
if users, ok := cfg["users"].([]interface{}); ok && len(users) > 0 {
|
||||
if first, ok := users[0].(map[string]interface{}); ok {
|
||||
if value, ok := first["username"]; ok {
|
||||
result["username"] = value
|
||||
}
|
||||
if value, ok := first["password"]; ok {
|
||||
result["password"] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneMap(src map[string]interface{}) map[string]interface{} {
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for key, value := range src {
|
||||
dst[key] = value
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func copyOption(dst, src map[string]interface{}, key string) {
|
||||
if value, ok := src[key]; ok {
|
||||
dst[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func boolValue(v interface{}) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func clientSupportsUDP(protocol string) bool {
|
||||
switch protocol {
|
||||
case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func credentialSuffix(credentials []user.Credential, index int) string {
|
||||
if index >= 0 && index < len(credentials) && credentials[index].Username != "" {
|
||||
return credentials[index].Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package converter_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/converter"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory database: %v", err)
|
||||
}
|
||||
if err = db.AutoMigrate(&models.User{}, &models.Listener{}, &models.AccessToken{}); err != nil {
|
||||
t.Fatalf("failed to run migrations: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAccessTokenCRUDAndListenerBinding(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
listener := models.Listener{
|
||||
Name: "test-vless",
|
||||
Protocol: "vless",
|
||||
BindAddress: "0.0.0.0",
|
||||
Port: 10086,
|
||||
Enabled: true,
|
||||
Config: `{"uuid":"11111111-1111-1111-1111-111111111111","flow":"xtls-rprx-vision"}`,
|
||||
}
|
||||
if err := db.Create(&listener).Error; err != nil {
|
||||
t.Fatalf("failed to create listener: %v", err)
|
||||
}
|
||||
|
||||
token := models.AccessToken{
|
||||
Name: "Test Token",
|
||||
Token: "test-listener-token",
|
||||
Enabled: true,
|
||||
ListenerID: listener.ID,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("failed to create access token: %v", err)
|
||||
}
|
||||
|
||||
var fetched models.AccessToken
|
||||
if err := db.Where("token = ?", token.Token).First(&fetched).Error; err != nil {
|
||||
t.Fatalf("failed to find access token: %v", err)
|
||||
}
|
||||
if fetched.ListenerID != listener.ID {
|
||||
t.Fatalf("expected listener id %d, got %d", listener.ID, fetched.ListenerID)
|
||||
}
|
||||
|
||||
fetched.Enabled = false
|
||||
if err := db.Save(&fetched).Error; err != nil {
|
||||
t.Fatalf("failed to disable token: %v", err)
|
||||
}
|
||||
|
||||
var disabled models.AccessToken
|
||||
if err := db.First(&disabled, fetched.ID).Error; err != nil {
|
||||
t.Fatalf("failed to fetch disabled token: %v", err)
|
||||
}
|
||||
if disabled.Enabled {
|
||||
t.Errorf("expected token to be disabled")
|
||||
}
|
||||
|
||||
if err := db.Delete(&disabled).Error; err != nil {
|
||||
t.Fatalf("failed to delete access token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerSubscriptionGeneration(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
listener := models.Listener{
|
||||
Name: "hk-vless-listener",
|
||||
Protocol: "vless",
|
||||
BindAddress: "0.0.0.0",
|
||||
Port: 10086,
|
||||
Enabled: true,
|
||||
UDP: true,
|
||||
Config: `{"uuid":"11111111-1111-1111-1111-111111111111","flow":"xtls-rprx-vision","network":"tcp"}`,
|
||||
}
|
||||
if err := db.Create(&listener).Error; err != nil {
|
||||
t.Fatalf("failed to create listener: %v", err)
|
||||
}
|
||||
|
||||
token := models.AccessToken{
|
||||
Name: "Listener Subscription",
|
||||
Token: "listener-token-xyz",
|
||||
Enabled: true,
|
||||
ListenerID: listener.ID,
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("failed to create access token: %v", err)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", "http://127.0.0.1:8080/api/v1/client/sub/listener-token-xyz", nil)
|
||||
req.Host = "vps-public-domain.com:8080"
|
||||
config.GlobalConfig = &config.Config{}
|
||||
config.GlobalConfig.Server.Port = 8080
|
||||
|
||||
data, err := converter.GenerateRawConfig(db, token, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate raw config: %v", err)
|
||||
}
|
||||
|
||||
var generated map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &generated); err != nil {
|
||||
t.Fatalf("failed to unmarshal generated yaml: %v", err)
|
||||
}
|
||||
|
||||
proxies, ok := generated["proxies"].([]interface{})
|
||||
if !ok || len(proxies) != 1 {
|
||||
t.Fatalf("expected 1 proxy in subscription, got: %v", generated["proxies"])
|
||||
}
|
||||
p := proxies[0].(map[string]interface{})
|
||||
if p["name"] != "hk-vless-listener" {
|
||||
t.Errorf("expected proxy name 'hk-vless-listener', got '%v'", p["name"])
|
||||
}
|
||||
if p["type"] != "vless" {
|
||||
t.Errorf("expected proxy type 'vless', got '%v'", p["type"])
|
||||
}
|
||||
if p["server"] != "vps-public-domain.com" {
|
||||
t.Errorf("expected resolved server address 'vps-public-domain.com', got '%v'", p["server"])
|
||||
}
|
||||
if p["port"] != 10086 {
|
||||
t.Errorf("expected port 10086, got '%v'", p["port"])
|
||||
}
|
||||
if p["uuid"] != "11111111-1111-1111-1111-111111111111" {
|
||||
t.Errorf("expected UUID to be preserved, got '%v'", p["uuid"])
|
||||
}
|
||||
if p["flow"] != "xtls-rprx-vision" {
|
||||
t.Errorf("expected flow to be preserved, got '%v'", p["flow"])
|
||||
}
|
||||
if p["udp"] != true {
|
||||
t.Errorf("expected UDP to be preserved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// EncodeBase64 returns base64 string representation of the given bytes.
|
||||
func EncodeBase64(data []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
)
|
||||
|
||||
func TestListenerToProxiesDoesNotLeakServerTLSSecrets(t *testing.T) {
|
||||
listener := models.Listener{
|
||||
Name: "vless-reality",
|
||||
Protocol: "vless",
|
||||
Port: 443,
|
||||
Enabled: true,
|
||||
Config: `{"certificate":"/etc/server.crt","private-key":"/etc/server.key","flow":"xtls-rprx-vision","ws-path":"/ws","reality-config":{"private-key":"server-secret","short-id":["0123456789abcdef"]}}`,
|
||||
}
|
||||
proxies, err := listenerToProxies(listener, "example.com", []user.Credential{{Username: "alice", UUID: "11111111-1111-4111-8111-111111111111"}})
|
||||
if err != nil {
|
||||
t.Fatalf("listenerToProxies failed: %v", err)
|
||||
}
|
||||
if len(proxies) != 1 {
|
||||
t.Fatalf("expected one proxy, got %d", len(proxies))
|
||||
}
|
||||
proxy := proxies[0]
|
||||
if proxy["tls"] != true {
|
||||
t.Fatal("TLS was not enabled from server certificate configuration")
|
||||
}
|
||||
for _, key := range []string{"certificate", "private-key", "reality-config"} {
|
||||
if _, ok := proxy[key]; ok {
|
||||
t.Fatalf("server-only field %q leaked into client config", key)
|
||||
}
|
||||
}
|
||||
if proxy["uuid"] != "11111111-1111-4111-8111-111111111111" {
|
||||
t.Fatal("listener credential UUID was not exported")
|
||||
}
|
||||
ws, ok := proxy["ws-opts"].(map[string]interface{})
|
||||
if !ok || ws["path"] != "/ws" {
|
||||
t.Fatal("WebSocket listener transport was not converted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerToProxiesRejectsRealm(t *testing.T) {
|
||||
_, err := listenerToProxies(models.Listener{Name: "realm", Protocol: "hysteria2-realm", Port: 10820}, "example.com", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected realm listener to be rejected as a client proxy export")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerToProxiesSupportsExtendedProtocols(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
protocol string
|
||||
config string
|
||||
creds []user.Credential
|
||||
}{
|
||||
{"snell", "snell", `{"psk":"secret","version":4}`, nil},
|
||||
{"anytls", "anytls", `{"certificate":"cert","private-key":"key"}`, []user.Credential{{Username: "alice", Password: "secret"}}},
|
||||
{"mieru", "mieru", `{"transport":"TCP"}`, []user.Credential{{Username: "alice", Password: "secret"}}},
|
||||
{"sudoku", "sudoku", `{"key":"client-key","aead-method":"chacha20-poly1305"}`, nil},
|
||||
{"trusttunnel", "trusttunnel", `{"certificate":"cert","private-key":"key"}`, []user.Credential{{Username: "alice", Password: "secret"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
proxies, err := listenerToProxies(models.Listener{Name: tc.name, Protocol: tc.protocol, Port: 443, Config: tc.config}, "example.com", tc.creds)
|
||||
if err != nil {
|
||||
t.Fatalf("listenerToProxies failed: %v", err)
|
||||
}
|
||||
if len(proxies) == 0 {
|
||||
t.Fatal("expected at least one client proxy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
)
|
||||
|
||||
var SubconverterURL = "http://127.0.0.1:25500"
|
||||
|
||||
// CallSubconverter calls the local subconverter service to convert standard configurations.
|
||||
func CallSubconverter(cfg *config.Config, token string, target string, rawYAML []byte) ([]byte, error) {
|
||||
port := 8080
|
||||
if cfg != nil && cfg.Server.Port != 0 {
|
||||
port = cfg.Server.Port
|
||||
}
|
||||
|
||||
// Build raw source config URL pointing back to our own local endpoint
|
||||
sourceURL := fmt.Sprintf("http://127.0.0.1:%d/api/v1/client/sub/%s?raw=true", port, token)
|
||||
|
||||
u, err := url.Parse(fmt.Sprintf("%s/sub", SubconverterURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid subconverter URL: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("target", target)
|
||||
q.Set("url", sourceURL)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
// Call subconverter with a safe timeout
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(u.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subconverter service unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("subconverter returned error status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
converted, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read subconverter response: %w", err)
|
||||
}
|
||||
|
||||
return converted, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
)
|
||||
|
||||
// EnsureListenerCredentials adds a stable client credential to listeners that
|
||||
// require one but do not have one yet. It deliberately has no dependency on
|
||||
// node, user, or config packages so credential generation can be shared by
|
||||
// validation and export without creating import cycles.
|
||||
func EnsureListenerCredentials(l *models.Listener) error {
|
||||
if l == nil {
|
||||
return fmt.Errorf("listener is nil")
|
||||
}
|
||||
cfg, err := decodeConfig(l.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proto := strings.ToLower(strings.TrimSpace(l.Protocol))
|
||||
if proto == "" {
|
||||
proto = strings.ToLower(strings.TrimSpace(l.Type))
|
||||
}
|
||||
if requiresUserCredentials(proto) && !hasExportCredentials(proto, cfg) {
|
||||
switch proto {
|
||||
case "vless", "vmess":
|
||||
uuid, err := randomUUID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate client uuid: %w", err)
|
||||
}
|
||||
cfg["users"] = []interface{}{map[string]interface{}{"username": "client", "uuid": uuid}}
|
||||
case "trojan", "hysteria2", "anytls", "mieru", "shadowquic", "tuic":
|
||||
password, err := randomSecret(24)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate client credential: %w", err)
|
||||
}
|
||||
username := "client"
|
||||
if proto == "tuic" {
|
||||
username, err = randomUUID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate TUIC client uuid: %w", err)
|
||||
}
|
||||
}
|
||||
cfg["users"] = map[string]interface{}{username: password}
|
||||
}
|
||||
encoded, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode listener credentials: %w", err)
|
||||
}
|
||||
l.Config = string(encoded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiresUserCredentials(proto string) bool {
|
||||
switch strings.ToLower(proto) {
|
||||
case "vless", "vmess", "trojan", "hysteria2", "anytls", "mieru", "shadowquic", "tuic":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
func hasExportCredentials(proto string, cfg map[string]interface{}) bool {
|
||||
users, ok := cfg["users"]
|
||||
if !ok || users == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(proto) {
|
||||
case "hysteria2", "anytls", "mieru", "tuic":
|
||||
m, ok := users.(map[string]interface{})
|
||||
return ok && len(m) > 0
|
||||
default:
|
||||
list, ok := users.([]interface{})
|
||||
return ok && len(list) > 0
|
||||
}
|
||||
}
|
||||
func randomSecret(length int) (string, error) {
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
func randomUUID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
func decodeConfig(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid listener configuration: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var GlobalDB *gorm.DB
|
||||
|
||||
func InitDB(dbPath string) (*gorm.DB, error) {
|
||||
// Create directory of database path if not exists
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.New(sqlite.Config{
|
||||
DriverName: sqliteDriverName,
|
||||
DSN: dbPath,
|
||||
}), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to sqlite database: %w", err)
|
||||
}
|
||||
|
||||
// AutoMigrate all models
|
||||
err = db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Listener{},
|
||||
&models.ListenerUser{},
|
||||
&models.Subscription{},
|
||||
&models.AccessToken{},
|
||||
&models.Config{},
|
||||
&models.ProxyUser{},
|
||||
&models.TrafficRecord{},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run database auto-migration: %w", err)
|
||||
}
|
||||
|
||||
GlobalDB = db
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AccessToken grants a client access to one Listener's generated node
|
||||
// configuration. Listener is the single source of truth for client exports.
|
||||
type AccessToken struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Token string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
ExpireAt *time.Time `json:"expire_at"`
|
||||
ListenerID uint `gorm:"index" json:"listener_id"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Config struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
||||
Type string `gorm:"type:varchar(50);not null;default:'custom'" json:"type"` // e.g., "custom", "dns", "general"
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Listener struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Protocol string `gorm:"type:varchar(50);not null;default:'shadowsocks'" json:"protocol"`
|
||||
Type string `gorm:"type:varchar(50)" json:"type"`
|
||||
Port string `gorm:"type:varchar(50);not null;default:'0'" json:"port"`
|
||||
BindAddress string `gorm:"type:varchar(100);not null;default:'0.0.0.0'" json:"bind_address"`
|
||||
Listen string `gorm:"type:varchar(100)" json:"listen"`
|
||||
TLS bool `gorm:"not null;default:false" json:"tls,omitempty"`
|
||||
UDP bool `gorm:"not null;default:false" json:"udp,omitempty"`
|
||||
Enabled bool `gorm:"not null;default:false" json:"enabled"`
|
||||
Proxy string `gorm:"type:varchar(255)" json:"proxy,omitempty"`
|
||||
Rule string `gorm:"type:text" json:"rule,omitempty"`
|
||||
Config string `gorm:"type:text" json:"config"`
|
||||
Status string `gorm:"type:varchar(50);default:'inactive'" json:"status"`
|
||||
RoutingMark int `gorm:"default:0" json:"routing_mark,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// ListenerUser is the explicit many-to-many join between ProxyUser and Listener.
|
||||
// Username/Password are retained for database compatibility with the Phase 1/2 schema;
|
||||
// new code uses ProxyUserID and never stores proxy credentials in this join table.
|
||||
type ListenerUser struct {
|
||||
gorm.Model
|
||||
|
||||
ListenerID uint `gorm:"not null;index;uniqueIndex:idx_listener_proxy_user,priority:1" json:"listener_id"`
|
||||
ProxyUserID uint `gorm:"not null;index;uniqueIndex:idx_listener_proxy_user,priority:2" json:"proxy_user_id"`
|
||||
|
||||
// Deprecated legacy fields. They are not used for Phase 6 authentication generation.
|
||||
Username string `gorm:"size:100;not null;default:''" json:"-"`
|
||||
Password string `gorm:"type:text;not null;default:''" json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ProxyUser is a user account used to authenticate against Mihomo server nodes.
|
||||
// It is deliberately separate from User, which represents a 3m-ui administrator.
|
||||
type ProxyUser struct {
|
||||
gorm.Model
|
||||
|
||||
Username string `gorm:"size:100;not null;uniqueIndex" json:"username"`
|
||||
// PasswordEncrypted contains an AES-GCM encrypted password. It is never serialized to API responses.
|
||||
PasswordEncrypted string `gorm:"type:text" json:"-"`
|
||||
UUID string `gorm:"size:64;not null;uniqueIndex" json:"-"`
|
||||
TrafficLimit int64 `gorm:"not null;default:0" json:"traffic_limit"`
|
||||
TrafficUsed int64 `gorm:"not null;default:0" json:"traffic_used"`
|
||||
// UploadBytes/DownloadBytes are cumulative counters split by direction.
|
||||
// TrafficUsed (above) remains the single source of truth for quota
|
||||
// enforcement and equals UploadBytes+DownloadBytes over time.
|
||||
UploadBytes int64 `gorm:"not null;default:0" json:"upload_bytes"`
|
||||
DownloadBytes int64 `gorm:"not null;default:0" json:"download_bytes"`
|
||||
// LastSeen is updated whenever the traffic collector observes an active
|
||||
// Mihomo connection attributable to this user. Nil means never seen.
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
// Online reflects whether the user had at least one active connection
|
||||
// during the most recent traffic collection tick.
|
||||
Online bool `gorm:"not null;default:false" json:"online"`
|
||||
ExpireTime time.Time `json:"expire_time"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Subscription struct {
|
||||
gorm.Model
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Token string `gorm:"uniqueIndex;not null" json:"token"`
|
||||
Format string `gorm:"not null" json:"format"` // e.g., "clash", "sing-box"
|
||||
ExpireTime *time.Time `json:"expire_time"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// TrafficRecord stores sampled traffic information for a proxy user.
|
||||
// It keeps history separate from ProxyUser to avoid growing the user table.
|
||||
type TrafficRecord struct {
|
||||
gorm.Model
|
||||
|
||||
ProxyUserID uint `gorm:"index;not null" json:"proxy_user_id"`
|
||||
UploadBytes int64 `gorm:"not null;default:0" json:"upload_bytes"`
|
||||
DownloadBytes int64 `gorm:"not null;default:0" json:"download_bytes"`
|
||||
Online bool `gorm:"default:false" json:"online"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
Role string `gorm:"not null" json:"role"` // e.g., "admin", "user"
|
||||
MustChangePassword bool `gorm:"not null;default:false" json:"must_change_password"`
|
||||
SessionVersion uint `gorm:"not null;default:1" json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !sqlite_modernc
|
||||
|
||||
package database
|
||||
|
||||
const sqliteDriverName = "sqlite3"
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build sqlite_modernc
|
||||
|
||||
package database
|
||||
|
||||
import _ "modernc.org/sqlite"
|
||||
|
||||
const sqliteDriverName = "sqlite"
|
||||
@@ -0,0 +1,110 @@
|
||||
package listener
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
)
|
||||
|
||||
// Handler serves listener HTTP endpoints with an injected Service.
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewHandler constructs a listener HTTP handler.
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers listener CRUD routes under the provided group.
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("", h.ListListeners)
|
||||
rg.POST("", h.CreateListener)
|
||||
rg.GET("/:id", h.GetListener)
|
||||
rg.PUT("/:id", h.UpdateListener)
|
||||
rg.DELETE("/:id", h.DeleteListener)
|
||||
rg.POST("/:id/reload", h.ReloadListener)
|
||||
}
|
||||
|
||||
func (h *Handler) ListListeners(c *gin.Context) {
|
||||
list, err := h.svc.GetAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateListener(c *gin.Context) {
|
||||
var l models.Listener
|
||||
if err := c.ShouldBindJSON(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Create(&l); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, l)
|
||||
}
|
||||
|
||||
func (h *Handler) GetListener(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
l, err := h.svc.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateListener(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
var l models.Listener
|
||||
if err := c.ShouldBindJSON(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l.ID = uint(id)
|
||||
if err := h.svc.Update(&l); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteListener(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "listener deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) ReloadListener(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.TriggerReload(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "listener reloaded"})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package listener
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// GenerateConfigYAML converts persisted listeners to Mihomo's native listener
|
||||
// schema. Runtime configuration and export now share the same protocol-aware
|
||||
// compiler so listener serialization cannot drift between code paths.
|
||||
func GenerateConfigYAML(dbListeners []models.Listener) (string, error) {
|
||||
listenersList, err := mihomoConfig.GenerateListenersForExport(dbListeners)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
yamlBytes, err := yaml.Marshal(&MihomoConfig{Listeners: listenersList})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal mihomo config to yaml: %w", err)
|
||||
}
|
||||
return string(yamlBytes), nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package listener_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/listener"
|
||||
)
|
||||
|
||||
func TestGenerateConfigYAML(t *testing.T) {
|
||||
dbListeners := []models.Listener{
|
||||
{
|
||||
Name: "test-shadowsocks",
|
||||
Type: "shadowsocks",
|
||||
Protocol: "shadowsocks",
|
||||
Listen: "0.0.0.0",
|
||||
BindAddress: "0.0.0.0",
|
||||
Port: 1080,
|
||||
Enabled: true,
|
||||
UDP: true,
|
||||
Config: `{"cipher":"aes-256-gcm","password":"pass"}`,
|
||||
},
|
||||
{
|
||||
Name: "disabled",
|
||||
Type: "vless",
|
||||
Listen: "127.0.0.1",
|
||||
Port: 1081,
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
yamlStr, err := listener.GenerateConfigYAML(dbListeners)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate yaml: %v", err)
|
||||
}
|
||||
if yamlStr == "" {
|
||||
t.Fatal("expected non-empty yaml")
|
||||
}
|
||||
if !contains(yamlStr, "test-shadowsocks") {
|
||||
t.Fatal("expected yaml to contain enabled listener")
|
||||
}
|
||||
if contains(yamlStr, "disabled") {
|
||||
t.Fatal("expected yaml NOT to contain disabled listener")
|
||||
}
|
||||
if !contains(yamlStr, "cipher: aes-256-gcm") {
|
||||
t.Fatal("expected yaml to contain listener protocol properties")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYAMLRejectsExcludedProtocol(t *testing.T) {
|
||||
_, err := listener.GenerateConfigYAML([]models.Listener{{Name: "socks", Type: "socks", Protocol: "socks", Listen: "0.0.0.0", Port: 1080, Enabled: true}})
|
||||
if err == nil {
|
||||
t.Fatal("expected excluded SOCKS listener protocol to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(value, part string) bool {
|
||||
for i := 0; i+len(part) <= len(value); i++ {
|
||||
if value[i:i+len(part)] == part {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package listener
|
||||
|
||||
// MihomoConfig represents the root structure of a generated Mihomo configuration
|
||||
type MihomoConfig struct {
|
||||
Listeners []map[string]interface{} `yaml:"listeners"`
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package listener
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
dbconfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
configPath string
|
||||
mihomoApply interface{ ApplyConfig(string) error }
|
||||
}
|
||||
|
||||
// NewService constructs a listener service.
|
||||
// mihomoApply is optional; when set, config regeneration applies via Mihomo.
|
||||
func NewService(db *gorm.DB, configPath string, mihomoApply interface{ ApplyConfig(string) error }) *Service {
|
||||
return &Service{db: db, configPath: configPath, mihomoApply: mihomoApply}
|
||||
}
|
||||
|
||||
func (s *Service) Create(l *models.Listener) error {
|
||||
if err := s.db.Create(l).Error; err != nil {
|
||||
return fmt.Errorf("failed to create listener: %w", err)
|
||||
}
|
||||
if err := s.RegenerateConfig(); err != nil {
|
||||
_ = s.db.Delete(&models.Listener{}, l.ID).Error
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetAll() ([]models.Listener, error) {
|
||||
var list []models.Listener
|
||||
if err := s.db.Order("id desc").Find(&list).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch listeners: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(id uint) (*models.Listener, error) {
|
||||
var l models.Listener
|
||||
if err := s.db.First(&l, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch listener by id %d: %w", id, err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(l *models.Listener) error {
|
||||
var previous models.Listener
|
||||
if err := s.db.First(&previous, l.ID).Error; err != nil {
|
||||
return fmt.Errorf("failed to load previous listener: %w", err)
|
||||
}
|
||||
|
||||
if err := s.db.Save(l).Error; err != nil {
|
||||
return fmt.Errorf("failed to update listener: %w", err)
|
||||
}
|
||||
if err := s.RegenerateConfig(); err != nil {
|
||||
if rollbackErr := s.db.Save(&previous).Error; rollbackErr != nil {
|
||||
return fmt.Errorf("%v; rollback listener failed: %w", err, rollbackErr)
|
||||
}
|
||||
_ = s.RegenerateConfig()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(id uint) error {
|
||||
var previous models.Listener
|
||||
if err := s.db.First(&previous, id).Error; err != nil {
|
||||
return fmt.Errorf("failed to fetch listener before delete: %w", err)
|
||||
}
|
||||
|
||||
if err := s.db.Delete(&models.Listener{}, id).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete listener: %w", err)
|
||||
}
|
||||
if err := s.RegenerateConfig(); err != nil {
|
||||
if rollbackErr := s.db.Unscoped().Save(&previous).Error; rollbackErr != nil {
|
||||
return fmt.Errorf("%v; rollback deleted listener failed: %w", err, rollbackErr)
|
||||
}
|
||||
_ = s.RegenerateConfig()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegenerateConfig is the single configuration path for Listener changes.
|
||||
// It never writes a hand-built partial YAML over the full Mihomo config.
|
||||
func (s *Service) RegenerateConfig() error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("listener service not initialized")
|
||||
}
|
||||
engine := dbconfig.NewConfigEngine(s.db)
|
||||
yamlContent, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate Mihomo configuration: %w", err)
|
||||
}
|
||||
if s.mihomoApply != nil {
|
||||
return s.mihomoApply.ApplyConfig(yamlContent)
|
||||
}
|
||||
dir := filepath.Dir(s.configPath)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".config.yaml.tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary config: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(0600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.WriteString(yamlContent); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, s.configPath); err != nil {
|
||||
return fmt.Errorf("replace Mihomo config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) TriggerReload(_ uint) error { return s.RegenerateConfig() }
|
||||
@@ -0,0 +1,86 @@
|
||||
package mihomo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExternalControllerAPI wraps communication with the Mihomo External Controller REST API
|
||||
type ExternalControllerAPI struct {
|
||||
BaseURL string
|
||||
Secret string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewExternalControllerAPI(baseURL, secret string) *ExternalControllerAPI {
|
||||
return &ExternalControllerAPI{
|
||||
BaseURL: baseURL,
|
||||
Secret: secret,
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig fetches the current configuration from Mihomo's external API
|
||||
func (api *ExternalControllerAPI) GetConfig() (string, error) {
|
||||
req, err := http.NewRequest("GET", api.BaseURL+"/configs", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if api.Secret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+api.Secret)
|
||||
}
|
||||
|
||||
resp, err := api.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mihomo core API offline: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status: %s", resp.Status)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
bytesData, _ := json.MarshalIndent(data, "", " ")
|
||||
return string(bytesData), nil
|
||||
}
|
||||
|
||||
// ReloadConfig sends a PUT request to trigger config reload in Mihomo Core
|
||||
func (api *ExternalControllerAPI) ReloadConfig(payload map[string]interface{}) error {
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", api.BaseURL+"/configs?force=true", bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if api.Secret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+api.Secret)
|
||||
}
|
||||
|
||||
resp, err := api.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mihomo core API offline: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("failed to reload, status: %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Secret string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, secret string) *Client {
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
Secret: secret,
|
||||
HTTP: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(path string, out any) error {
|
||||
req, err := http.NewRequest(http.MethodGet, c.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Secret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Secret)
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("mihomo api returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package api
|
||||
|
||||
func (c *Client) Traffic() (*Traffic, error) {
|
||||
var result Traffic
|
||||
err := c.get("/traffic", &result)
|
||||
return &result, err
|
||||
}
|
||||
|
||||
func (c *Client) Connections() (*Connections, error) {
|
||||
var result Connections
|
||||
err := c.get("/connections", &result)
|
||||
return &result, err
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
type Traffic struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
|
||||
type Connections struct {
|
||||
DownloadTotal int64 `json:"downloadTotal"`
|
||||
UploadTotal int64 `json:"uploadTotal"`
|
||||
Connections []Connection `json:"connections"`
|
||||
}
|
||||
|
||||
// ConnectionMetadata mirrors the "metadata" object Mihomo's external
|
||||
// controller attaches to each entry in GET /connections. Field names match
|
||||
// Mihomo's own JSON keys so this decodes the real API response directly.
|
||||
type ConnectionMetadata struct {
|
||||
Network string `json:"network"`
|
||||
Type string `json:"type"`
|
||||
SourceIP string `json:"sourceIP"`
|
||||
DestinationIP string `json:"destinationIP"`
|
||||
SourcePort string `json:"sourcePort"`
|
||||
DestinationPort string `json:"destinationPort"`
|
||||
Host string `json:"host"`
|
||||
// InboundName is the Mihomo listener "name" (see mihomo config
|
||||
// "listeners[].name") the connection came in on. This is the join key
|
||||
// back to our Listener model.
|
||||
InboundName string `json:"inboundName"`
|
||||
// InboundUser, when Mihomo populates it, is the authenticated username
|
||||
// for the inbound connection. When present it is the only reliable,
|
||||
// non-guessed way to attribute a connection to a specific ProxyUser.
|
||||
InboundUser string `json:"inboundUser"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
ID string `json:"id"`
|
||||
Network string `json:"network"` // deprecated: Mihomo reports network under Metadata.Network; kept for backward compatibility
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
// Metadata carries the real per-connection detail Mihomo returns
|
||||
// (source/destination/host/inbound). May be nil for older/mocked
|
||||
// responses that don't include it.
|
||||
Metadata *ConnectionMetadata `json:"metadata,omitempty"`
|
||||
Chains []string `json:"chains,omitempty"`
|
||||
Rule string `json:"rule,omitempty"`
|
||||
Start string `json:"start,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
)
|
||||
|
||||
func TestValidateConfigYAML(t *testing.T) {
|
||||
// Valid YAML
|
||||
valid := `
|
||||
mode: rule
|
||||
port: 7890
|
||||
dns:
|
||||
enable: true
|
||||
`
|
||||
if err := mihomoConfig.ValidateConfigYAML(valid); err != nil {
|
||||
t.Fatalf("expected valid config to pass validation, got %v", err)
|
||||
}
|
||||
|
||||
// Invalid YAML
|
||||
invalidSyntax := `
|
||||
mode: rule: broken
|
||||
`
|
||||
if err := mihomoConfig.ValidateConfigYAML(invalidSyntax); err == nil {
|
||||
t.Fatal("expected invalid syntax to fail validation")
|
||||
}
|
||||
|
||||
// Invalid mode value
|
||||
invalidMode := `
|
||||
mode: super-proxy
|
||||
`
|
||||
if err := mihomoConfig.ValidateConfigYAML(invalidMode); err == nil {
|
||||
t.Fatal("expected invalid mode value to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigEngineGeneration(t *testing.T) {
|
||||
dbPath := "/tmp/3m-ui-config-engine-test.db"
|
||||
_ = os.Remove(dbPath)
|
||||
|
||||
db, err := database.InitDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init test db: %v", err)
|
||||
}
|
||||
|
||||
engine := mihomoConfig.NewConfigEngine(db)
|
||||
|
||||
// Add a listener to database
|
||||
listener := models.Listener{
|
||||
Name: "hk-gate",
|
||||
Type: "mixed",
|
||||
Listen: "127.0.0.1",
|
||||
Port: 8081,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.Create(&listener).Error; err != nil {
|
||||
t.Fatalf("failed to insert listener: %v", err)
|
||||
}
|
||||
|
||||
// Add a custom configuration fragment to database
|
||||
fragment := models.Config{
|
||||
Name: "dns-override",
|
||||
Type: "custom",
|
||||
Content: "dns:\n listen: 127.0.0.1:5353",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.Create(&fragment).Error; err != nil {
|
||||
t.Fatalf("failed to insert config fragment: %v", err)
|
||||
}
|
||||
|
||||
// Generate final config
|
||||
finalYaml, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate final config: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(finalYaml, "hk-gate") {
|
||||
t.Fatal("expected final config to contain listener 'hk-gate'")
|
||||
}
|
||||
|
||||
if !strings.Contains(finalYaml, "127.0.0.1:5353") {
|
||||
t.Fatal("expected final config to contain overridden dns listener port")
|
||||
}
|
||||
|
||||
_ = os.Remove(dbPath)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Credential struct{ Username, Password, UUID string }
|
||||
|
||||
var CredentialProvider func() (map[uint][]Credential, error)
|
||||
|
||||
type ConfigEngine struct{ db *gorm.DB }
|
||||
|
||||
func NewConfigEngine(db *gorm.DB) *ConfigEngine { return &ConfigEngine{db: db} }
|
||||
|
||||
func (ce *ConfigEngine) GenerateFinalConfig() (string, error) {
|
||||
baseBytes, err := yaml.Marshal(GetDefaultTemplate())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var merged map[string]interface{}
|
||||
if err := yaml.Unmarshal(baseBytes, &merged); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var customFragments []models.Config
|
||||
if err := ce.db.Where("enabled = ?", true).Find(&customFragments).Error; err != nil {
|
||||
return "", fmt.Errorf("load custom config fragments: %w", err)
|
||||
}
|
||||
for _, fragment := range customFragments {
|
||||
var fragMap map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(fragment.Content), &fragMap); err != nil {
|
||||
return "", fmt.Errorf("invalid custom config %q: %w", fragment.Name, err)
|
||||
}
|
||||
for k, v := range fragMap {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var listeners []models.Listener
|
||||
if err := ce.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil {
|
||||
return "", fmt.Errorf("load enabled listeners: %w", err)
|
||||
}
|
||||
|
||||
credentials := make(map[uint][]Credential)
|
||||
if CredentialProvider != nil {
|
||||
var err error
|
||||
credentials, err = CredentialProvider()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load listener credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
generated, err := generateListeners(listeners, credentials)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
merged["listeners"] = generated
|
||||
|
||||
finalBytes, err := yaml.Marshal(merged)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("serialize final configuration: %w", err)
|
||||
}
|
||||
return string(finalBytes), nil
|
||||
}
|
||||
|
||||
func generateListeners(listeners []models.Listener, creds map[uint][]Credential) ([]map[string]interface{}, error) {
|
||||
result := make([]map[string]interface{}, 0, len(listeners))
|
||||
for _, l := range listeners {
|
||||
if !l.Enabled {
|
||||
continue
|
||||
}
|
||||
protocol := strings.ToLower(strings.TrimSpace(l.Protocol))
|
||||
if protocol == "" {
|
||||
protocol = strings.ToLower(strings.TrimSpace(l.Type))
|
||||
}
|
||||
if !IsMihomoListenerProtocol(protocol) {
|
||||
return nil, fmt.Errorf("unsupported Mihomo listener protocol %q", protocol)
|
||||
}
|
||||
if !isValidPortString(l.Port) {
|
||||
return nil, fmt.Errorf("listener %q has invalid port %q", l.Name, l.Port)
|
||||
}
|
||||
|
||||
listen := strings.TrimSpace(l.BindAddress)
|
||||
if listen == "" {
|
||||
listen = strings.TrimSpace(l.Listen)
|
||||
}
|
||||
if listen == "" {
|
||||
listen = "0.0.0.0"
|
||||
}
|
||||
|
||||
m := map[string]interface{}{
|
||||
"name": l.Name,
|
||||
"type": protocol,
|
||||
"port": l.Port,
|
||||
"listen": listen,
|
||||
}
|
||||
|
||||
configMap, err := decodeListenerConfig(l.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listener %q: %w", l.Name, err)
|
||||
}
|
||||
|
||||
if l.Proxy != "" {
|
||||
m["proxy"] = l.Proxy
|
||||
}
|
||||
if l.Rule != "" {
|
||||
m["rule"] = l.Rule
|
||||
}
|
||||
if l.RoutingMark > 0 {
|
||||
m["routing-mark"] = l.RoutingMark
|
||||
}
|
||||
if l.UDP && listenerHasUDPOption(protocol) {
|
||||
m["udp"] = true
|
||||
}
|
||||
if value, ok := configMap["routing-mark"]; ok {
|
||||
m["routing-mark"] = value
|
||||
}
|
||||
|
||||
for k, v := range configMap {
|
||||
if listenerFieldIsManaged(k) {
|
||||
continue
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
|
||||
copyServerTLSFields(m, configMap)
|
||||
listenerCreds := creds[l.ID]
|
||||
|
||||
switch protocol {
|
||||
case "shadowsocks":
|
||||
if len(listenerCreds) > 1 {
|
||||
return nil, fmt.Errorf("listener %q: Shadowsocks supports one password; %d active credentials are bound", l.Name, len(listenerCreds))
|
||||
}
|
||||
copyOption(m, configMap, "cipher")
|
||||
if len(listenerCreds) == 1 && listenerCreds[0].Password != "" {
|
||||
m["password"] = listenerCreds[0].Password
|
||||
} else {
|
||||
copyOption(m, configMap, "password")
|
||||
}
|
||||
|
||||
case "snell":
|
||||
copyOption(m, configMap, "psk")
|
||||
copyOption(m, configMap, "version")
|
||||
|
||||
case "vmess", "vless":
|
||||
users := make([]map[string]interface{}, 0, len(listenerCreds))
|
||||
for _, cred := range listenerCreds {
|
||||
if cred.UUID == "" {
|
||||
continue
|
||||
}
|
||||
u := map[string]interface{}{"uuid": cred.UUID}
|
||||
if cred.Username != "" {
|
||||
u["username"] = cred.Username
|
||||
}
|
||||
if flow, ok := configMap["flow"]; ok && protocol == "vless" {
|
||||
u["flow"] = flow
|
||||
}
|
||||
if alterID, ok := configMap["alterId"]; ok && protocol == "vmess" {
|
||||
u["alterId"] = alterID
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
if len(users) > 0 {
|
||||
m["users"] = users
|
||||
} else if value, ok := configMap["users"]; ok {
|
||||
m["users"] = value
|
||||
}
|
||||
|
||||
case "trojan":
|
||||
users := make([]map[string]interface{}, 0, len(listenerCreds))
|
||||
for _, cred := range listenerCreds {
|
||||
if cred.Password == "" {
|
||||
continue
|
||||
}
|
||||
u := map[string]interface{}{"password": cred.Password}
|
||||
if cred.Username != "" {
|
||||
u["username"] = cred.Username
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
if len(users) > 0 {
|
||||
m["users"] = users
|
||||
} else if value, ok := configMap["users"]; ok {
|
||||
m["users"] = value
|
||||
}
|
||||
|
||||
case "hysteria2", "anytls", "mieru":
|
||||
users := make(map[string]string)
|
||||
for _, cred := range listenerCreds {
|
||||
if cred.Username != "" && cred.Password != "" {
|
||||
users[cred.Username] = cred.Password
|
||||
}
|
||||
}
|
||||
if len(users) > 0 {
|
||||
m["users"] = users
|
||||
} else if value, ok := configMap["users"]; ok {
|
||||
m["users"] = value
|
||||
}
|
||||
|
||||
case "tuic":
|
||||
if value, ok := configMap["token"]; ok {
|
||||
m["token"] = value
|
||||
} else {
|
||||
users := make(map[string]string)
|
||||
for _, cred := range listenerCreds {
|
||||
if cred.UUID != "" && cred.Password != "" {
|
||||
users[cred.UUID] = cred.Password
|
||||
}
|
||||
}
|
||||
if len(users) > 0 {
|
||||
m["users"] = users
|
||||
} else if value, ok := configMap["users"]; ok {
|
||||
m["users"] = value
|
||||
}
|
||||
}
|
||||
|
||||
case "shadowquic", "trusttunnel":
|
||||
users := make([]map[string]interface{}, 0, len(listenerCreds))
|
||||
for _, cred := range listenerCreds {
|
||||
if cred.Username != "" && cred.Password != "" {
|
||||
users = append(users, map[string]interface{}{
|
||||
"username": cred.Username,
|
||||
"password": cred.Password,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(users) > 0 {
|
||||
m["users"] = users
|
||||
} else if value, ok := configMap["users"]; ok {
|
||||
normalized, err := normalizeListenerUserList(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listener %q: %w", l.Name, err)
|
||||
}
|
||||
if normalized != nil {
|
||||
m["users"] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
case "sudoku", "hysteria2-realm":
|
||||
}
|
||||
|
||||
result = append(result, m)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isValidPortString(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(s, ",") {
|
||||
parts := strings.Split(s, ",")
|
||||
for _, p := range parts {
|
||||
if !isValidPortString(strings.TrimSpace(p)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if strings.Contains(s, "-") {
|
||||
parts := strings.SplitN(s, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err1 != nil || err2 != nil || start < 1 || end > 65535 || start > end {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
port, err := strconv.Atoi(s)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeListenerUserList(value interface{}) ([]map[string]interface{}, error) {
|
||||
switch users := value.(type) {
|
||||
case []interface{}:
|
||||
result := make([]map[string]interface{}, 0, len(users))
|
||||
for i, item := range users {
|
||||
user, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("users[%d] must be an object", i)
|
||||
}
|
||||
result = append(result, user)
|
||||
}
|
||||
return result, nil
|
||||
case map[string]interface{}:
|
||||
result := make([]map[string]interface{}, 0, len(users))
|
||||
for username, password := range users {
|
||||
result = append(result, map[string]interface{}{
|
||||
"username": username,
|
||||
"password": fmt.Sprint(password),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
case map[interface{}]interface{}:
|
||||
result := make([]map[string]interface{}, 0, len(users))
|
||||
for rawUsername, rawPassword := range users {
|
||||
username := fmt.Sprint(rawUsername)
|
||||
result = append(result, map[string]interface{}{
|
||||
"username": username,
|
||||
"password": fmt.Sprint(rawPassword),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("users must be a list of username/password objects")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeListenerConfig(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var configMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &configMap); err != nil {
|
||||
return nil, fmt.Errorf("invalid listener configuration (must be valid JSON): %w", err)
|
||||
}
|
||||
if configMap == nil {
|
||||
configMap = map[string]interface{}{}
|
||||
}
|
||||
return configMap, nil
|
||||
}
|
||||
|
||||
func listenerFieldIsManaged(key string) bool {
|
||||
switch key {
|
||||
case "users", "username", "password", "uuid", "flow", "alterId",
|
||||
"tls", "servername", "sni", "skip-cert-verify", "name-cert-verify",
|
||||
"fingerprint", "client-fingerprint", "reality-opts", "shadow-tls-opts",
|
||||
"restls-opts", "jls-opts", "ws-opts", "grpc-opts", "h2-opts",
|
||||
"http-opts", "mkcp-opts", "certificate", "private-key", "private_key":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func copyServerTLSFields(dst, src map[string]interface{}) {
|
||||
if value, ok := src["certificate"]; ok {
|
||||
dst["certificate"] = value
|
||||
}
|
||||
if value, ok := src["private-key"]; ok {
|
||||
dst["private-key"] = value
|
||||
} else if value, ok := src["private_key"]; ok {
|
||||
dst["private-key"] = value
|
||||
}
|
||||
}
|
||||
|
||||
func copyOption(dst, src map[string]interface{}, key string) {
|
||||
if value, ok := src[key]; ok {
|
||||
dst[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func listenerHasUDPOption(protocol string) bool {
|
||||
switch protocol {
|
||||
case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestGenerateListenersUsesNativeSchema(t *testing.T) {
|
||||
listeners := []models.Listener{
|
||||
{Model: gorm.Model{ID: 1}, Name: "ss", Protocol: "shadowsocks", Port: 443, BindAddress: "0.0.0.0", Enabled: true, Config: `{"cipher":"aes-256-gcm"}`},
|
||||
{Model: gorm.Model{ID: 2}, Name: "vless", Protocol: "vless", Port: 8443, BindAddress: "0.0.0.0", Enabled: true, Config: `{"flow":"xtls-rprx-vision","certificate":"cert","private-key":"key","reality-config":{"private-key":"secret"}}`},
|
||||
}
|
||||
creds := map[uint][]Credential{
|
||||
1: {{Username: "alice", Password: "ss-pass"}},
|
||||
2: {{Username: "alice", UUID: "11111111-1111-4111-8111-111111111111"}},
|
||||
}
|
||||
|
||||
result, err := generateListeners(listeners, creds)
|
||||
if err != nil {
|
||||
t.Fatalf("generateListeners failed: %v", err)
|
||||
}
|
||||
if result[0]["password"] != "ss-pass" {
|
||||
t.Fatal("Shadowsocks listener password was not generated")
|
||||
}
|
||||
vlessUsers, ok := result[1]["users"].([]map[string]interface{})
|
||||
if !ok || len(vlessUsers) != 1 || vlessUsers[0]["uuid"] == "" {
|
||||
t.Fatal("VLESS listener users were not generated")
|
||||
}
|
||||
if result[1]["tls"] != nil {
|
||||
t.Fatal("listener TLS must not use a nested tls object")
|
||||
}
|
||||
if result[1]["certificate"] != "cert" || result[1]["private-key"] != "key" {
|
||||
t.Fatal("listener certificate/private-key fields were not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShadowQUICNormalizesObjectUsers(t *testing.T) {
|
||||
listeners := []models.Listener{{
|
||||
Name: "shadowquic",
|
||||
Protocol: "shadowquic",
|
||||
Port: 8443,
|
||||
BindAddress: "0.0.0.0",
|
||||
Enabled: true,
|
||||
Config: `{"users":{"alice":"secret","bob":"secret2"}}`,
|
||||
}}
|
||||
|
||||
result, err := generateListeners(listeners, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("generateListeners failed: %v", err)
|
||||
}
|
||||
users, ok := result[0]["users"].([]map[string]interface{})
|
||||
if !ok || len(users) != 2 {
|
||||
t.Fatalf("ShadowQUIC users must be a list, got %T", result[0]["users"])
|
||||
}
|
||||
for _, user := range users {
|
||||
if user["username"] == "" || user["password"] == "" {
|
||||
t.Fatalf("invalid normalized ShadowQUIC user: %#v", user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShadowQUICUsesCredentialsAsList(t *testing.T) {
|
||||
listeners := []models.Listener{{
|
||||
Model: gorm.Model{ID: 1},
|
||||
Name: "shadowquic",
|
||||
Protocol: "shadowquic",
|
||||
Port: 8443,
|
||||
BindAddress: "0.0.0.0",
|
||||
Enabled: true,
|
||||
}}
|
||||
creds := map[uint][]Credential{
|
||||
1: {{Username: "alice", Password: "secret"}},
|
||||
}
|
||||
|
||||
result, err := generateListeners(listeners, creds)
|
||||
if err != nil {
|
||||
t.Fatalf("generateListeners failed: %v", err)
|
||||
}
|
||||
users, ok := result[0]["users"].([]map[string]interface{})
|
||||
if !ok || len(users) != 1 || users[0]["username"] != "alice" || users[0]["password"] != "secret" {
|
||||
t.Fatalf("unexpected ShadowQUIC users: %#v", result[0]["users"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateListenersRejectsExcludedProtocols(t *testing.T) {
|
||||
for _, protocol := range []string{"socks", "http", "tproxy", "redir", "mixed", "tunnel", "tun", "wireguard"} {
|
||||
_, err := generateListeners([]models.Listener{{Name: "bad", Protocol: protocol, Port: 1080, Enabled: true}}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected protocol %q to be rejected", protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateListenersSkipsDisabled(t *testing.T) {
|
||||
result, err := generateListeners([]models.Listener{
|
||||
{Name: "on", Protocol: "shadowsocks", Port: 1080, Enabled: true, Config: `{"cipher":"aes-256-gcm","password":"x"}`},
|
||||
{Name: "off", Protocol: "vless", Port: 1081, Enabled: false},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("generateListeners failed: %v", err)
|
||||
}
|
||||
if len(result) != 1 || result[0]["name"] != "on" {
|
||||
t.Fatalf("expected only enabled listener, got %#v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
import "github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
|
||||
// GenerateListenersForExport compiles persisted listeners through the same
|
||||
// protocol-aware generator used by the final Mihomo configuration. Keeping
|
||||
// listener compilation in one place prevents export and runtime config from
|
||||
// drifting apart (for example, ShadowQUIC users being emitted as a map).
|
||||
func GenerateListenersForExport(listeners []models.Listener) ([]map[string]interface{}, error) {
|
||||
return generateListeners(listeners, nil)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestGenerateListenersForExportNormalizesShadowQUICUsers(t *testing.T) {
|
||||
listeners, err := config.GenerateListenersForExport([]models.Listener{
|
||||
{
|
||||
Model: gorm.Model{ID: 1},
|
||||
Name: "shadowquic-test",
|
||||
Type: "shadowquic",
|
||||
Protocol: "shadowquic",
|
||||
Listen: "0.0.0.0",
|
||||
BindAddress: "0.0.0.0",
|
||||
Port: 443,
|
||||
Enabled: true,
|
||||
Config: `{"users":{"client":"secret"}}`,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate listeners: %v", err)
|
||||
}
|
||||
if len(listeners) != 1 {
|
||||
t.Fatalf("expected one listener, got %d", len(listeners))
|
||||
}
|
||||
|
||||
users, ok := listeners[0]["users"].([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected ShadowQUIC users to be a list, got %T", listeners[0]["users"])
|
||||
}
|
||||
if len(users) != 1 || users[0]["username"] != "client" || users[0]["password"] != "secret" {
|
||||
t.Fatalf("unexpected users: %#v", users)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package config
|
||||
|
||||
// ListenerSchema is the protocol-specific allowlist used at the backend trust boundary.
|
||||
type ListenerSchema struct {
|
||||
Protocol string
|
||||
Fields map[string]struct{}
|
||||
NestedFields map[string]map[string]struct{}
|
||||
}
|
||||
|
||||
func listenerFields(values ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(values))
|
||||
for _, v := range values {
|
||||
m[v] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func listenerNested(values ...string) map[string]map[string]struct{} {
|
||||
m := make(map[string]map[string]struct{})
|
||||
for _, value := range values {
|
||||
p := splitPath(value)
|
||||
for i := 0; i < len(p)-1; i++ {
|
||||
parent := joinPath(p[:i+1])
|
||||
if m[parent] == nil {
|
||||
m[parent] = map[string]struct{}{}
|
||||
}
|
||||
m[parent][p[i+1]] = struct{}{}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func splitPath(v string) []string {
|
||||
out := []string{}
|
||||
start := 0
|
||||
for i := 0; i <= len(v); i++ {
|
||||
if i == len(v) || v[i] == '.' {
|
||||
if i > start {
|
||||
out = append(out, v[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinPath(p []string) string {
|
||||
s := ""
|
||||
for i, v := range p {
|
||||
if i > 0 {
|
||||
s += "."
|
||||
}
|
||||
s += v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Mihomo listener schemas mirror the official listener examples. These are
|
||||
// the fields exposed at the node configuration boundary; protocol-local
|
||||
// capture endpoints such as socks/http/tun/tproxy are intentionally excluded.
|
||||
var MihomoListenerSchemas = map[string]ListenerSchema{
|
||||
"shadowsocks": {
|
||||
Protocol: "shadowsocks",
|
||||
Fields: listenerFields(
|
||||
"cipher", "password", "udp", "simple-obfs", "shadow-tls", "res-tls", "jls-config", "kcp-tun", "mux-option",
|
||||
),
|
||||
NestedFields: listenerNested(
|
||||
"simple-obfs.enable", "simple-obfs.mode",
|
||||
"shadow-tls.enable", "shadow-tls.version", "shadow-tls.password", "shadow-tls.users", "shadow-tls.handshake.dest", "shadow-tls.handshake.proxy",
|
||||
"res-tls.enable", "res-tls.dest", "res-tls.password", "res-tls.restls-script", "res-tls.min-record-len", "res-tls.proxy", "res-tls.rate-limit",
|
||||
"jls-config.enable", "jls-config.users", "jls-config.dest", "jls-config.sni", "jls-config.alpn", "jls-config.proxy", "jls-config.rate-limit",
|
||||
"kcp-tun.enable", "kcp-tun.key", "kcp-tun.crypt", "kcp-tun.mode", "kcp-tun.conn", "kcp-tun.autoexpire", "kcp-tun.scavengettl", "kcp-tun.ratelimit", "kcp-tun.mtu", "kcp-tun.sndwnd", "kcp-tun.rcvwnd", "kcp-tun.datashard", "kcp-tun.parityshard", "kcp-tun.dscp", "kcp-tun.nocomp", "kcp-tun.acknodelay", "kcp-tun.nodelay", "kcp-tun.interval", "kcp-tun.resend", "kcp-tun.sockbuf", "kcp-tun.smuxver", "kcp-tun.smuxbuf", "kcp-tun.framesize", "kcp-tun.streambuf", "kcp-tun.keepalive",
|
||||
"mux-option.enable", "mux-option.protocol", "mux-option.max-connections", "mux-option.min-streams", "mux-option.max-streams", "mux-option.padding", "mux-option.statistic", "mux-option.only-tcp", "mux-option.brutal-opts",
|
||||
),
|
||||
},
|
||||
"snell": {
|
||||
Protocol: "snell",
|
||||
Fields: listenerFields("psk", "version", "udp", "obfs-opts", "shadow-tls", "res-tls", "jls-config"),
|
||||
NestedFields: listenerNested(
|
||||
"obfs-opts.mode", "obfs-opts.host",
|
||||
"shadow-tls.enable", "shadow-tls.version", "shadow-tls.password", "shadow-tls.users", "shadow-tls.handshake.dest", "shadow-tls.handshake.proxy",
|
||||
"res-tls.enable", "res-tls.dest", "res-tls.password", "res-tls.rate-limit",
|
||||
"jls-config.enable", "jls-config.users", "jls-config.dest", "jls-config.sni", "jls-config.alpn", "jls-config.proxy", "jls-config.rate-limit",
|
||||
),
|
||||
},
|
||||
"vmess": {
|
||||
Protocol: "vmess",
|
||||
Fields: listenerFields("users", "ws-path", "grpc-service-name", "mekya-config", "mkcp-config", "shadow-tls", "res-tls", "jls-config", "reality-config", "tlsmirror-config", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key", "allow-insecure"),
|
||||
NestedFields: listenerNested(
|
||||
"mekya-config.enable", "mekya-config.max-write-size", "mekya-config.max-write-duration-ms", "mekya-config.max-simultaneous-write-connection", "mekya-config.packet-writing-buffer",
|
||||
"mekya-config.kcp.mtu", "mekya-config.kcp.tti", "mekya-config.kcp.uplink-capacity", "mekya-config.kcp.downlink-capacity", "mekya-config.kcp.congestion", "mekya-config.kcp.write-buffer", "mekya-config.kcp.read-buffer", "mekya-config.kcp.seed", "mekya-config.kcp.header",
|
||||
"mkcp-config.mtu", "mkcp-config.tti", "mkcp-config.uplink-capacity", "mkcp-config.downlink-capacity", "mkcp-config.congestion", "mkcp-config.write-buffer", "mkcp-config.read-buffer", "mkcp-config.seed", "mkcp-config.header",
|
||||
"shadow-tls.enable", "shadow-tls.version", "shadow-tls.password", "shadow-tls.users", "shadow-tls.handshake.dest", "shadow-tls.handshake.proxy",
|
||||
"res-tls.enable", "res-tls.dest", "res-tls.password", "res-tls.restls-script", "res-tls.min-record-len", "res-tls.proxy", "res-tls.rate-limit",
|
||||
"jls-config.enable", "jls-config.users", "jls-config.dest", "jls-config.sni", "jls-config.alpn", "jls-config.proxy", "jls-config.rate-limit",
|
||||
"reality-config.dest", "reality-config.private-key", "reality-config.short-id", "reality-config.server-names", "reality-config.limit-fallback-upload.after-bytes", "reality-config.limit-fallback-upload.bytes-per-sec", "reality-config.limit-fallback-upload.burst-bytes-per-sec", "reality-config.limit-fallback-download.after-bytes", "reality-config.limit-fallback-download.bytes-per-sec", "reality-config.limit-fallback-download.burst-bytes-per-sec",
|
||||
"tlsmirror-config.dest", "tlsmirror-config.primary-key", "tlsmirror-config.proxy", "tlsmirror-config.explicit-nonce-ciphersuites", "tlsmirror-config.defer-instance-derived-write-time.base-nanoseconds", "tlsmirror-config.defer-instance-derived-write-time.uniform-random-multiplier-nanoseconds", "tlsmirror-config.transport-layer-padding.enabled", "tlsmirror-config.connection-enrolment.primary-ingress-outbound", "tlsmirror-config.sequence-watermarking-enabled",
|
||||
"tlsmirror-config.embedded-traffic-generator.steps.host", "tlsmirror-config.embedded-traffic-generator.steps.path", "tlsmirror-config.embedded-traffic-generator.steps.method", "tlsmirror-config.embedded-traffic-generator.steps.connection-ready", "tlsmirror-config.embedded-traffic-generator.steps.connection-recall-exit", "tlsmirror-config.embedded-traffic-generator.steps.h2-do-not-wait-for-download-finish", "tlsmirror-config.embedded-traffic-generator.steps.wait-time.base-nanoseconds", "tlsmirror-config.embedded-traffic-generator.steps.next-step.weight", "tlsmirror-config.embedded-traffic-generator.steps.next-step.goto-location",
|
||||
),
|
||||
},
|
||||
"vless": {
|
||||
Protocol: "vless",
|
||||
Fields: listenerFields("users", "ws-path", "grpc-service-name", "xhttp-config", "decryption", "reality-config", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key", "allow-insecure", "jls-config", "shadow-tls", "res-tls"),
|
||||
NestedFields: listenerNested(
|
||||
"xhttp-config.path", "xhttp-config.host", "xhttp-config.mode", "xhttp-config.no-sse-header", "xhttp-config.x-padding-bytes", "xhttp-config.x-padding-obfs-mode", "xhttp-config.x-padding-key", "xhttp-config.x-padding-header", "xhttp-config.x-padding-placement", "xhttp-config.x-padding-method", "xhttp-config.uplink-http-method", "xhttp-config.session-placement", "xhttp-config.session-key", "xhttp-config.session-table", "xhttp-config.session-length", "xhttp-config.seq-placement", "xhttp-config.seq-key", "xhttp-config.uplink-data-placement", "xhttp-config.uplink-data-key", "xhttp-config.uplink-chunk-size", "xhttp-config.sc-max-buffered-posts", "xhttp-config.sc-stream-up-server-secs", "xhttp-config.sc-max-each-post-bytes",
|
||||
"reality-config.dest", "reality-config.private-key", "reality-config.short-id", "reality-config.server-names", "reality-config.limit-fallback-upload.after-bytes", "reality-config.limit-fallback-upload.bytes-per-sec", "reality-config.limit-fallback-upload.burst-bytes-per-sec", "reality-config.limit-fallback-download.after-bytes", "reality-config.limit-fallback-download.bytes-per-sec", "reality-config.limit-fallback-download.burst-bytes-per-sec",
|
||||
"jls-config.enable", "jls-config.users", "jls-config.dest", "jls-config.sni", "jls-config.alpn", "jls-config.proxy", "jls-config.rate-limit",
|
||||
"shadow-tls.enable", "shadow-tls.version", "shadow-tls.password", "shadow-tls.users", "shadow-tls.handshake.dest", "shadow-tls.handshake.proxy",
|
||||
"res-tls.enable", "res-tls.dest", "res-tls.password", "res-tls.restls-script", "res-tls.min-record-len", "res-tls.proxy", "res-tls.rate-limit",
|
||||
),
|
||||
},
|
||||
"trojan": {
|
||||
Protocol: "trojan",
|
||||
Fields: listenerFields("users", "ws-path", "grpc-service-name", "reality-config", "ss-option", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key", "allow-insecure", "shadow-tls", "res-tls", "jls-config"),
|
||||
NestedFields: listenerNested(
|
||||
"reality-config.dest", "reality-config.private-key", "reality-config.short-id", "reality-config.server-names", "reality-config.limit-fallback-upload.after-bytes", "reality-config.limit-fallback-upload.bytes-per-sec", "reality-config.limit-fallback-upload.burst-bytes-per-sec", "reality-config.limit-fallback-download.after-bytes", "reality-config.limit-fallback-download.bytes-per-sec", "reality-config.limit-fallback-download.burst-bytes-per-sec",
|
||||
"ss-option.enabled", "ss-option.method", "ss-option.password",
|
||||
"shadow-tls.enable", "shadow-tls.version", "shadow-tls.password", "shadow-tls.users", "shadow-tls.handshake.dest", "shadow-tls.handshake.proxy",
|
||||
"res-tls.enable", "res-tls.dest", "res-tls.password", "res-tls.restls-script", "res-tls.min-record-len", "res-tls.proxy", "res-tls.rate-limit",
|
||||
"jls-config.enable", "jls-config.users", "jls-config.dest", "jls-config.sni", "jls-config.alpn", "jls-config.proxy", "jls-config.rate-limit",
|
||||
),
|
||||
},
|
||||
"hysteria2": {
|
||||
Protocol: "hysteria2",
|
||||
Fields: listenerFields("users", "up", "down", "ignore-client-bandwidth", "obfs", "obfs-password", "obfs-min-packet-size", "obfs-max-packet-size", "masquerade", "realm-opts", "bbr-profile", "max-idle-time", "handshake-timeout", "alpn", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key"),
|
||||
NestedFields: listenerNested("realm-opts.enable", "realm-opts.server-url", "realm-opts.token", "realm-opts.realm-id", "realm-opts.stun-servers", "realm-opts.proxy", "realm-opts.skip-cert-verify", "realm-opts.fingerprint", "realm-opts.certificate", "realm-opts.private-key", "realm-opts.alpn", "realm-opts.name-cert-verify"),
|
||||
},
|
||||
"tuic": {
|
||||
Protocol: "tuic",
|
||||
Fields: listenerFields("users", "token", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key", "congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size"),
|
||||
},
|
||||
"shadowquic": {
|
||||
Protocol: "shadowquic",
|
||||
Fields: listenerFields("users", "jls-upstream", "alpn", "quic-versions", "zero-rtt", "congestion-controller", "up", "down", "ignore-client-bandwidth", "cwnd", "bbr-profile", "max-idle-time", "max-datagram-frame-size", "recv-window-conn", "recv-window", "disable-mtu-discovery"),
|
||||
NestedFields: listenerNested("jls-upstream.addr", "jls-upstream.sni", "jls-upstream.proxy", "jls-upstream.rate-limit"),
|
||||
},
|
||||
"anytls": {
|
||||
Protocol: "anytls",
|
||||
Fields: listenerFields("users", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key", "shadow-tls", "res-tls", "jls-config", "allow-insecure", "padding-scheme"),
|
||||
NestedFields: listenerNested(
|
||||
"shadow-tls.enable", "shadow-tls.version", "shadow-tls.password", "shadow-tls.users.name", "shadow-tls.users.password", "shadow-tls.handshake.dest", "shadow-tls.handshake.proxy",
|
||||
"res-tls.enable", "res-tls.dest", "res-tls.password", "res-tls.restls-script", "res-tls.min-record-len", "res-tls.proxy", "res-tls.rate-limit",
|
||||
"jls-config.enable", "jls-config.users.username", "jls-config.users.password", "jls-config.dest", "jls-config.sni", "jls-config.alpn", "jls-config.proxy", "jls-config.rate-limit",
|
||||
),
|
||||
},
|
||||
"mieru": {
|
||||
Protocol: "mieru",
|
||||
Fields: listenerFields("transport", "users", "traffic-pattern", "user-hint-is-mandatory"),
|
||||
},
|
||||
"sudoku": {
|
||||
Protocol: "sudoku",
|
||||
Fields: listenerFields("key", "aead-method", "padding-min", "padding-max", "table-type", "custom-table", "custom-tables", "handshake-timeout", "enable-pure-downlink", "httpmask", "fallback", "disable-http-mask"),
|
||||
NestedFields: listenerNested("httpmask.disable", "httpmask.mode", "httpmask.path-root", "httpmask.path_root", "httpmask.tls", "httpmask.host", "httpmask.multiplex"),
|
||||
},
|
||||
"trusttunnel": {
|
||||
Protocol: "trusttunnel",
|
||||
Fields: listenerFields("users", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key", "network", "congestion-controller", "bbr-profile"),
|
||||
},
|
||||
}
|
||||
|
||||
func GetMihomoListenerSchema(protocol string) (ListenerSchema, bool) {
|
||||
schema, ok := MihomoListenerSchemas[protocol]
|
||||
return schema, ok
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMihomoListenerSchemaRegistry(t *testing.T) {
|
||||
for _, protocol := range MihomoListenerProtocols {
|
||||
schema, ok := GetMihomoListenerSchema(protocol)
|
||||
if !ok {
|
||||
t.Fatalf("protocol %q has no schema", protocol)
|
||||
}
|
||||
if schema.Protocol != protocol {
|
||||
t.Fatalf("schema protocol mismatch: got %q want %q", schema.Protocol, protocol)
|
||||
}
|
||||
if len(schema.Fields) == 0 {
|
||||
t.Fatalf("protocol %q has no registered fields", protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMihomoListenerSchemaRejectsNonListenerProtocols(t *testing.T) {
|
||||
for _, protocol := range []string{"socks", "http", "mixed", "redir", "tproxy", "tun", "tunnel"} {
|
||||
if _, ok := GetMihomoListenerSchema(protocol); ok {
|
||||
t.Fatalf("non-distributable protocol %q must not have a node schema", protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMihomoListenerSchemaIncludesFieldsUsedByForms(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"shadowsocks": {"cipher", "password", "simple-obfs", "shadow-tls", "res-tls", "jls-config", "kcp-tun", "mux-option"},
|
||||
"snell": {"psk", "version", "obfs-opts", "shadow-tls", "res-tls", "jls-config"},
|
||||
"vless": {"users", "ws-path", "grpc-service-name", "xhttp-config", "reality-config"},
|
||||
"vmess": {"users", "ws-path", "grpc-service-name", "mekya-config", "mkcp-config", "reality-config"},
|
||||
"trojan": {"users", "ws-path", "grpc-service-name", "reality-config", "ss-option"},
|
||||
"hysteria2": {"users", "obfs", "certificate", "private-key", "alpn"},
|
||||
"tuic": {"users", "token", "certificate", "private-key", "congestion-controller"},
|
||||
"anytls": {"users", "certificate", "private-key", "padding-scheme"},
|
||||
}
|
||||
for protocol, fields := range cases {
|
||||
schema, ok := GetMihomoListenerSchema(protocol)
|
||||
if !ok {
|
||||
t.Fatalf("protocol %q has no schema", protocol)
|
||||
}
|
||||
for _, field := range fields {
|
||||
if _, ok := schema.Fields[field]; !ok {
|
||||
t.Errorf("protocol %q is missing form field %q", protocol, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package config
|
||||
|
||||
// MihomoListenerProtocols is the set of listener protocols exposed by the
|
||||
// unified 3m-ui Node page. Local proxy endpoints (socks/http/tproxy/redir/
|
||||
// mixed), Tunnel, TUN and Hysteria2 Realm are intentionally excluded because
|
||||
// they do not represent a directly distributable client proxy node.
|
||||
var MihomoListenerProtocols = []string{
|
||||
"shadowsocks",
|
||||
"snell",
|
||||
"vmess",
|
||||
"vless",
|
||||
"trojan",
|
||||
"hysteria2",
|
||||
"tuic",
|
||||
"shadowquic",
|
||||
"anytls",
|
||||
"mieru",
|
||||
"sudoku",
|
||||
"trusttunnel",
|
||||
}
|
||||
|
||||
func IsMihomoListenerProtocol(protocol string) bool {
|
||||
for _, p := range MihomoListenerProtocols {
|
||||
if p == protocol {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMihomoListenerProtocolAllowlist(t *testing.T) {
|
||||
for _, protocol := range MihomoListenerProtocols {
|
||||
if !IsMihomoListenerProtocol(protocol) {
|
||||
t.Fatalf("protocol %q was not accepted", protocol)
|
||||
}
|
||||
}
|
||||
for _, protocol := range []string{"socks", "http", "tproxy", "redir", "mixed", "tunnel", "tun", "wireguard"} {
|
||||
if IsMihomoListenerProtocol(protocol) {
|
||||
t.Fatalf("protocol %q must not be exposed as a distributable listener", protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
// MihomoConfig represents the full structure of Mihomo configuration
|
||||
type MihomoConfig struct {
|
||||
Mode string `yaml:"mode,omitempty"`
|
||||
Port int `yaml:"port,omitempty"`
|
||||
SocksPort int `yaml:"socks-port,omitempty"`
|
||||
MixedPort int `yaml:"mixed-port,omitempty"`
|
||||
AllowLan bool `yaml:"allow-lan,omitempty"`
|
||||
LogLevel string `yaml:"log-level,omitempty"`
|
||||
IPv6 bool `yaml:"ipv6,omitempty"`
|
||||
ExternalController string `yaml:"external-controller,omitempty"`
|
||||
Secret string `yaml:"secret,omitempty"`
|
||||
DNS map[string]interface{} `yaml:"dns,omitempty"`
|
||||
Listeners []map[string]interface{} `yaml:"listeners,omitempty"`
|
||||
Proxies []map[string]interface{} `yaml:"proxies,omitempty"`
|
||||
ProxyGroups []map[string]interface{} `yaml:"proxy-groups,omitempty"`
|
||||
Rules []string `yaml:"rules,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package config
|
||||
|
||||
// GetDefaultTemplate returns a deliberately minimal, localhost-safe base
|
||||
// configuration. Listener definitions are appended from the database.
|
||||
func GetDefaultTemplate() *MihomoConfig {
|
||||
return &MihomoConfig{
|
||||
Mode: "rule",
|
||||
LogLevel: "info",
|
||||
AllowLan: false,
|
||||
IPv6: false,
|
||||
ExternalController: "127.0.0.1:9090",
|
||||
// The controller is bound to loopback, so an authentication secret is
|
||||
// not required by default. Never ship a hard-coded reusable secret.
|
||||
Secret: "",
|
||||
DNS: map[string]interface{}{
|
||||
"enable": false,
|
||||
},
|
||||
Proxies: []map[string]interface{}{},
|
||||
ProxyGroups: []map[string]interface{}{},
|
||||
Rules: []string{
|
||||
"GEOIP,CN,DIRECT",
|
||||
"MATCH,DIRECT",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ValidateConfigYAML parses a YAML string and performs sanity checks on standard fields
|
||||
func ValidateConfigYAML(content string) error {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return fmt.Errorf("configuration cannot be empty")
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(content), &raw); err != nil {
|
||||
return fmt.Errorf("invalid YAML syntax: %w", err)
|
||||
}
|
||||
|
||||
// Validate standard fields if present
|
||||
if mode, exists := raw["mode"]; exists {
|
||||
modeStr, ok := mode.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field 'mode' must be a string")
|
||||
}
|
||||
modeStr = strings.ToLower(modeStr)
|
||||
if modeStr != "rule" && modeStr != "global" && modeStr != "direct" && modeStr != "script" {
|
||||
return fmt.Errorf("field 'mode' must be one of: rule, global, direct, script")
|
||||
}
|
||||
}
|
||||
|
||||
if dns, exists := raw["dns"]; exists {
|
||||
if _, ok := dns.(map[string]interface{}); !ok {
|
||||
return fmt.Errorf("field 'dns' must be a structured map")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const visualConfigName = "visual-config"
|
||||
|
||||
type VisualConfig struct {
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
LogLevel string `json:"logLevel" yaml:"log-level"`
|
||||
AllowLAN bool `json:"allowLan" yaml:"allow-lan"`
|
||||
IPv6 bool `json:"ipv6" yaml:"ipv6"`
|
||||
DNS VisualDNS `json:"dns" yaml:"dns"`
|
||||
Proxies []ProxyEntry `json:"proxies" yaml:"proxies"`
|
||||
Groups []GroupEntry `json:"proxyGroups" yaml:"proxy-groups"`
|
||||
Rules []string `json:"rules" yaml:"rules"`
|
||||
}
|
||||
|
||||
type VisualDNS struct {
|
||||
Enable bool `json:"enable" yaml:"enable"`
|
||||
EnhancedMode string `json:"enhancedMode" yaml:"enhanced-mode"`
|
||||
Listen string `json:"listen" yaml:"listen"`
|
||||
Nameserver []string `json:"nameserver" yaml:"nameserver"`
|
||||
Fallback []string `json:"fallback" yaml:"fallback,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyEntry struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Server string `json:"server" yaml:"server"`
|
||||
Port string `json:"port" yaml:"port"`
|
||||
Options map[string]interface{} `json:"options,omitempty" yaml:",inline"`
|
||||
}
|
||||
|
||||
type GroupEntry struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Proxies []string `json:"proxies" yaml:"proxies"`
|
||||
URL string `json:"url,omitempty" yaml:"url,omitempty"`
|
||||
Interval int `json:"interval,omitempty" yaml:"interval,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultVisualConfig() VisualConfig {
|
||||
return VisualConfig{
|
||||
Mode: "rule", LogLevel: "info", AllowLAN: true,
|
||||
DNS: VisualDNS{
|
||||
Enable: true, EnhancedMode: "fake-ip", Listen: "0.0.0.0:1053",
|
||||
Nameserver: []string{"119.29.29.29", "223.5.5.5"},
|
||||
},
|
||||
Proxies: []ProxyEntry{}, Groups: []GroupEntry{},
|
||||
Rules: []string{"GEOIP,CN,DIRECT", "MATCH,DIRECT"},
|
||||
}
|
||||
}
|
||||
|
||||
func GetVisualConfig(db *gorm.DB) (VisualConfig, error) {
|
||||
cfg := DefaultVisualConfig()
|
||||
var fragment models.Config
|
||||
err := db.Where("name = ?", visualConfigName).First(&fragment).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(fragment.Content), &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("invalid visual config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func SaveVisualConfig(db *gorm.DB, cfg VisualConfig) error {
|
||||
cfg.Mode = strings.ToLower(strings.TrimSpace(cfg.Mode))
|
||||
switch cfg.Mode {
|
||||
case "rule", "global", "direct", "script":
|
||||
default:
|
||||
return fmt.Errorf("invalid mode %q", cfg.Mode)
|
||||
}
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "info"
|
||||
}
|
||||
if cfg.DNS.EnhancedMode == "" {
|
||||
cfg.DNS.EnhancedMode = "fake-ip"
|
||||
}
|
||||
if cfg.DNS.Listen == "" {
|
||||
cfg.DNS.Listen = "0.0.0.0:1053"
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fragment := models.Config{Name: visualConfigName, Type: "visual", Content: string(data), Enabled: true}
|
||||
var existing models.Config
|
||||
result := db.Where("name = ?", visualConfigName).First(&existing)
|
||||
if result.Error == nil {
|
||||
existing.Type, existing.Content, existing.Enabled = fragment.Type, fragment.Content, true
|
||||
return db.Save(&existing).Error
|
||||
}
|
||||
if result.Error != gorm.ErrRecordNotFound {
|
||||
return result.Error
|
||||
}
|
||||
return db.Create(&fragment).Error
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mihomo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type ConfigManager struct{ configPath string }
|
||||
|
||||
func NewConfigManager(path string) *ConfigManager { return &ConfigManager{configPath: path} }
|
||||
|
||||
// SaveConfig atomically replaces the Mihomo YAML configuration. The old
|
||||
// configuration is kept until the new file has been fully written and synced.
|
||||
func (cm *ConfigManager) SaveConfig(content string) error {
|
||||
if cm == nil || cm.configPath == "" {
|
||||
return fmt.Errorf("mihomo config path is empty")
|
||||
}
|
||||
if content == "" {
|
||||
return fmt.Errorf("mihomo config is empty")
|
||||
}
|
||||
dir := filepath.Dir(cm.configPath)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return fmt.Errorf("create mihomo config directory: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".config.yaml.tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary mihomo config: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(0600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("set mihomo config permissions: %w", err)
|
||||
}
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write mihomo config: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync mihomo config: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close mihomo config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, cm.configPath); err != nil {
|
||||
return fmt.Errorf("replace mihomo config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cm *ConfigManager) ReadConfig() (string, error) {
|
||||
if cm == nil || cm.configPath == "" {
|
||||
return "", fmt.Errorf("mihomo config path is empty")
|
||||
}
|
||||
data, err := os.ReadFile(cm.configPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read mihomo config: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
package mihomo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProcessManager struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
pid int
|
||||
startTime time.Time
|
||||
binaryPath string
|
||||
configPath string
|
||||
done chan struct{}
|
||||
logs []string
|
||||
desired bool
|
||||
external bool
|
||||
}
|
||||
|
||||
var globalPM *ProcessManager
|
||||
var pmOnce sync.Once
|
||||
|
||||
func GetProcessManager(binary, config string) *ProcessManager {
|
||||
pmOnce.Do(func() {
|
||||
globalPM = &ProcessManager{
|
||||
binaryPath: binary,
|
||||
configPath: config,
|
||||
logs: make([]string, 0, 200),
|
||||
}
|
||||
})
|
||||
|
||||
return globalPM
|
||||
}
|
||||
|
||||
// NewProcessManager creates an independent process manager instance.
|
||||
func NewProcessManager(binary, config string) *ProcessManager {
|
||||
return &ProcessManager{
|
||||
binaryPath: binary,
|
||||
configPath: config,
|
||||
logs: make([]string, 0, 200),
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedBinaryPath restricts where the Mihomo binary can reside.
|
||||
// This prevents an attacker from tricking 3m-ui into executing an arbitrary
|
||||
// file by manipulating the configuration path.
|
||||
func isAllowedBinaryPath(path string) bool {
|
||||
allowedPrefixes := []string{
|
||||
"/usr/local/bin/",
|
||||
"/usr/bin/",
|
||||
"/opt/",
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
for _, prefix := range allowedPrefixes {
|
||||
if strings.HasPrefix(clean, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) GetVersion() (*VersionInfo, error) {
|
||||
info, err := os.Stat(pm.binaryPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("mihomo binary not found: %s", pm.binaryPath)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to stat mihomo binary: %w", err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("mihomo binary path is a directory: %s", pm.binaryPath)
|
||||
}
|
||||
|
||||
cmd := exec.Command(pm.binaryPath, "-v")
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to run mihomo -v: %w", err)
|
||||
}
|
||||
|
||||
output := strings.TrimSpace(out.String())
|
||||
parts := strings.Fields(output)
|
||||
|
||||
version := "unknown"
|
||||
if len(parts) >= 2 {
|
||||
version = parts[1]
|
||||
}
|
||||
|
||||
return &VersionInfo{
|
||||
Version: version,
|
||||
Commit: "official-build",
|
||||
Build: output,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) ValidateConfig() error {
|
||||
pm.mu.Lock()
|
||||
binaryPath := pm.binaryPath
|
||||
configPath := pm.configPath
|
||||
pm.mu.Unlock()
|
||||
|
||||
if binaryPath == "" || configPath == "" {
|
||||
return fmt.Errorf("mihomo binary or config path is empty")
|
||||
}
|
||||
|
||||
info, err := os.Stat(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mihomo binary unavailable: %w", err)
|
||||
}
|
||||
|
||||
if info.IsDir() || info.Mode()&0111 == 0 {
|
||||
return fmt.Errorf("mihomo binary is not executable: %s", binaryPath)
|
||||
}
|
||||
|
||||
if !isAllowedBinaryPath(binaryPath) {
|
||||
return fmt.Errorf("mihomo binary path is not in allowed list: %s", binaryPath)
|
||||
}
|
||||
|
||||
cfgInfo, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mihomo config not found: %s", configPath)
|
||||
}
|
||||
|
||||
if cfgInfo.IsDir() || cfgInfo.Size() == 0 {
|
||||
return fmt.Errorf("mihomo config is empty: %s", configPath)
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
binaryPath,
|
||||
"-t",
|
||||
"-d",
|
||||
filepath.Dir(configPath),
|
||||
"-f",
|
||||
configPath,
|
||||
)
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
detail := strings.TrimSpace(out.String())
|
||||
|
||||
if detail != "" {
|
||||
return fmt.Errorf(
|
||||
"mihomo configuration validation failed: %s",
|
||||
detail,
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"mihomo configuration validation failed: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findExistingProcesses 查找已经运行的、使用相同 Mihomo binary + config 的进程。
|
||||
func (pm *ProcessManager) findExistingProcesses() []int {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
binary, err := filepath.EvalSymlinks(pm.binaryPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := filepath.Clean(pm.configPath)
|
||||
|
||||
configDir := filepath.Dir(config)
|
||||
|
||||
var pids []int
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil || pid <= 0 || pid == os.Getpid() {
|
||||
continue
|
||||
}
|
||||
|
||||
base := filepath.Join("/proc", entry.Name())
|
||||
|
||||
cmdline, err := os.ReadFile(
|
||||
filepath.Join(base, "cmdline"),
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
args := strings.Split(
|
||||
string(cmdline),
|
||||
"\x00",
|
||||
)
|
||||
|
||||
if len(args) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
exe, err := filepath.EvalSymlinks(
|
||||
filepath.Join(base, "exe"),
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if exe != binary {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedDir := false
|
||||
matchedConfig := false
|
||||
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-d":
|
||||
if i+1 < len(args) {
|
||||
matchedDir =
|
||||
filepath.Clean(args[i+1]) == configDir
|
||||
}
|
||||
|
||||
case "-f":
|
||||
if i+1 < len(args) {
|
||||
matchedConfig =
|
||||
filepath.Clean(args[i+1]) == config
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matchedDir && matchedConfig {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
|
||||
return pids
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return process.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
// adoptExistingLocked 接管已经存在的 Mihomo。
|
||||
func (pm *ProcessManager) adoptExistingLocked(
|
||||
pids []int,
|
||||
) int {
|
||||
if len(pids) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
keep := pids[0]
|
||||
|
||||
for _, pid := range pids[1:] {
|
||||
if pid < keep {
|
||||
keep = pid
|
||||
}
|
||||
}
|
||||
|
||||
for _, pid := range pids {
|
||||
if pid == keep {
|
||||
continue
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(pid)
|
||||
if err == nil {
|
||||
_ = process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
|
||||
pm.appendLogLocked(
|
||||
fmt.Sprintf(
|
||||
"stopped duplicate Mihomo process PID %d; keeping PID %d",
|
||||
pid,
|
||||
keep,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pm.pid = keep
|
||||
pm.startTime = time.Now()
|
||||
pm.desired = true
|
||||
pm.external = true
|
||||
|
||||
return keep
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) Start() error {
|
||||
pm.mu.Lock()
|
||||
|
||||
if pm.isRunning() {
|
||||
pid := pm.pid
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
return fmt.Errorf(
|
||||
"mihomo is already running (PID: %d)",
|
||||
pid,
|
||||
)
|
||||
}
|
||||
|
||||
binaryPath := pm.binaryPath
|
||||
configPath := pm.configPath
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
info, err := os.Stat(binaryPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf(
|
||||
"mihomo binary not found: %s",
|
||||
binaryPath,
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"failed to stat mihomo binary: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if info.IsDir() || info.Mode()&0111 == 0 {
|
||||
return fmt.Errorf(
|
||||
"mihomo binary is not executable: %s",
|
||||
binaryPath,
|
||||
)
|
||||
}
|
||||
|
||||
if !isAllowedBinaryPath(binaryPath) {
|
||||
return fmt.Errorf(
|
||||
"mihomo binary path is not in allowed list: %s",
|
||||
binaryPath,
|
||||
)
|
||||
}
|
||||
|
||||
cfgInfo, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"mihomo config not found: %s",
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
|
||||
if cfgInfo.IsDir() || cfgInfo.Size() == 0 {
|
||||
return fmt.Errorf(
|
||||
"mihomo config is empty: %s",
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
|
||||
if pm.isRunning() {
|
||||
pid := pm.pid
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
return fmt.Errorf(
|
||||
"mihomo is already running (PID: %d)",
|
||||
pid,
|
||||
)
|
||||
}
|
||||
|
||||
existing := pm.findExistingProcesses()
|
||||
|
||||
if len(existing) > 0 {
|
||||
pid := pm.adoptExistingLocked(existing)
|
||||
|
||||
pm.appendLogLocked(
|
||||
fmt.Sprintf(
|
||||
"adopted existing Mihomo process PID %d",
|
||||
pid,
|
||||
),
|
||||
)
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
cmd := exec.Command(
|
||||
binaryPath,
|
||||
"-d",
|
||||
filepath.Dir(configPath),
|
||||
"-f",
|
||||
configPath,
|
||||
)
|
||||
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
writer := &processLogWriter{
|
||||
pm: pm,
|
||||
}
|
||||
|
||||
cmd.Stdout = writer
|
||||
cmd.Stderr = writer
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to start mihomo: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
|
||||
pm.cmd = cmd
|
||||
pm.pid = cmd.Process.Pid
|
||||
pm.startTime = time.Now()
|
||||
pm.done = make(chan struct{})
|
||||
pm.desired = true
|
||||
pm.external = false
|
||||
|
||||
done := pm.done
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
go pm.waitProcess(cmd, done)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
pm.mu.Lock()
|
||||
|
||||
if pm.cmd == cmd {
|
||||
pm.cmd = nil
|
||||
pm.pid = 0
|
||||
pm.startTime = time.Time{}
|
||||
pm.done = nil
|
||||
pm.desired = false
|
||||
}
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
return fmt.Errorf(
|
||||
"mihomo exited immediately; check logs",
|
||||
)
|
||||
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) waitProcess(
|
||||
cmd *exec.Cmd,
|
||||
done chan struct{},
|
||||
) {
|
||||
err := cmd.Wait()
|
||||
|
||||
close(done)
|
||||
|
||||
pm.mu.Lock()
|
||||
|
||||
if err != nil {
|
||||
pm.appendLogLocked(
|
||||
fmt.Sprintf(
|
||||
"process exited: %v",
|
||||
err,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
pm.appendLogLocked(
|
||||
"process exited",
|
||||
)
|
||||
}
|
||||
|
||||
restart :=
|
||||
pm.desired &&
|
||||
pm.cmd == cmd
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
if !restart {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
if pm.IsRunning() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := pm.ValidateConfig(); err != nil {
|
||||
pm.mu.Lock()
|
||||
|
||||
pm.appendLogLocked(
|
||||
fmt.Sprintf(
|
||||
"automatic restart blocked by config validation: %v",
|
||||
err,
|
||||
),
|
||||
)
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := pm.Start(); err != nil {
|
||||
pm.mu.Lock()
|
||||
|
||||
pm.appendLogLocked(
|
||||
fmt.Sprintf(
|
||||
"automatic restart failed: %v",
|
||||
err,
|
||||
),
|
||||
)
|
||||
|
||||
pm.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) Stop() error {
|
||||
pm.mu.Lock()
|
||||
|
||||
if !pm.isRunning() {
|
||||
pm.desired = false
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
return fmt.Errorf(
|
||||
"mihomo is not running",
|
||||
)
|
||||
}
|
||||
|
||||
pid := pm.pid
|
||||
cmd := pm.cmd
|
||||
done := pm.done
|
||||
|
||||
pm.desired = false
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil &&
|
||||
pgid > 0 {
|
||||
|
||||
_ = syscall.Kill(
|
||||
-pgid,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
|
||||
} else if process, err := os.FindProcess(pid); err == nil {
|
||||
_ = process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
|
||||
case <-time.After(5 * time.Second):
|
||||
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil &&
|
||||
pgid > 0 {
|
||||
|
||||
_ = syscall.Kill(
|
||||
-pgid,
|
||||
syscall.SIGKILL,
|
||||
)
|
||||
}
|
||||
|
||||
if cmd != nil &&
|
||||
cmd.Process != nil {
|
||||
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
} else {
|
||||
deadline :=
|
||||
time.Now().Add(5 * time.Second)
|
||||
|
||||
for processAlive(pid) &&
|
||||
time.Now().Before(deadline) {
|
||||
|
||||
time.Sleep(
|
||||
100 * time.Millisecond,
|
||||
)
|
||||
}
|
||||
|
||||
if processAlive(pid) {
|
||||
if process, err := os.FindProcess(pid); err == nil {
|
||||
_ = process.Kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
|
||||
if pm.pid == pid {
|
||||
pm.cmd = nil
|
||||
pm.pid = 0
|
||||
pm.startTime = time.Time{}
|
||||
pm.done = nil
|
||||
pm.external = false
|
||||
}
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) Restart() error {
|
||||
if pm.IsRunning() {
|
||||
if err := pm.Stop(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to stop before restart: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := pm.ValidateConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pm.Start()
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) IsRunning() bool {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
return pm.isRunning()
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) isRunning() bool {
|
||||
if pm.pid == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if pm.done != nil {
|
||||
select {
|
||||
case <-pm.done:
|
||||
return false
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return processAlive(pm.pid)
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) Status() (*StatusResponse, error) {
|
||||
pm.mu.Lock()
|
||||
|
||||
running := pm.isRunning()
|
||||
pid := pm.pid
|
||||
startTime := pm.startTime
|
||||
|
||||
pm.mu.Unlock()
|
||||
|
||||
versionStr := "unknown"
|
||||
|
||||
if vInfo, err := pm.GetVersion(); err == nil &&
|
||||
vInfo != nil {
|
||||
|
||||
versionStr = vInfo.Version
|
||||
}
|
||||
|
||||
uptime := "0s"
|
||||
|
||||
if running && !startTime.IsZero() {
|
||||
uptime =
|
||||
formatDuration(
|
||||
time.Since(startTime),
|
||||
)
|
||||
}
|
||||
|
||||
return &StatusResponse{
|
||||
Running: running,
|
||||
Version: versionStr,
|
||||
PID: pid,
|
||||
Uptime: uptime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) Logs() []string {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
out := make(
|
||||
[]string,
|
||||
len(pm.logs),
|
||||
)
|
||||
|
||||
copy(
|
||||
out,
|
||||
pm.logs,
|
||||
)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (pm *ProcessManager) appendLogLocked(line string) {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
|
||||
pm.logs = append(
|
||||
pm.logs,
|
||||
time.Now().Format(time.RFC3339)+" "+line,
|
||||
)
|
||||
|
||||
if len(pm.logs) > 200 {
|
||||
pm.logs =
|
||||
pm.logs[len(pm.logs)-200:]
|
||||
}
|
||||
}
|
||||
|
||||
type processLogWriter struct {
|
||||
pm *ProcessManager
|
||||
}
|
||||
|
||||
func (w *processLogWriter) Write(
|
||||
p []byte,
|
||||
) (int, error) {
|
||||
|
||||
lines :=
|
||||
strings.Split(
|
||||
string(p),
|
||||
"\n",
|
||||
)
|
||||
|
||||
w.pm.mu.Lock()
|
||||
defer w.pm.mu.Unlock()
|
||||
|
||||
for _, line := range lines {
|
||||
w.pm.appendLogLocked(line)
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
|
||||
m := d / time.Minute
|
||||
d -= m * time.Minute
|
||||
|
||||
s := d / time.Second
|
||||
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh %dm %ds", h, m, s)
|
||||
}
|
||||
|
||||
if m > 0 {
|
||||
return fmt.Sprintf("%dm %ds", m, s)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package mihomo_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
|
||||
)
|
||||
|
||||
func writeFakeMihomo(t *testing.T, dir string) (binaryPath, configPath string) {
|
||||
t.Helper()
|
||||
binaryPath = filepath.Join(dir, "fake-mihomo")
|
||||
configPath = filepath.Join(dir, "config.yaml")
|
||||
|
||||
// Behaves enough like mihomo for ProcessManager:
|
||||
// - `-v` prints a version line and exits
|
||||
// - `-t` validates config and exits 0
|
||||
// - default mode stays running (like a core process)
|
||||
script := `#!/bin/sh
|
||||
case "$1" in
|
||||
-v)
|
||||
echo "Mihomo Meta v1.0.0 test-build"
|
||||
exit 0
|
||||
;;
|
||||
-t)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
exec sleep 30
|
||||
`
|
||||
if err := os.WriteFile(binaryPath, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("write fake binary: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, []byte("mixed-port: 0\n"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return binaryPath, configPath
|
||||
}
|
||||
|
||||
func TestProcessManagerStartStop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binaryPath, configPath := writeFakeMihomo(t, dir)
|
||||
|
||||
pm := mihomo.NewProcessManager(binaryPath, configPath)
|
||||
|
||||
status, err := pm.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get status: %v", err)
|
||||
}
|
||||
if status.Running {
|
||||
t.Fatal("expected status.Running to be false initially")
|
||||
}
|
||||
|
||||
if err := pm.Start(); err != nil {
|
||||
t.Fatalf("failed to start process: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = pm.Stop() })
|
||||
|
||||
status, err = pm.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get status after start: %v", err)
|
||||
}
|
||||
if !status.Running {
|
||||
t.Fatal("expected status.Running to be true after start")
|
||||
}
|
||||
if status.PID <= 0 {
|
||||
t.Fatalf("expected positive PID, got %d", status.PID)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
status, err = pm.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get status: %v", err)
|
||||
}
|
||||
if status.Uptime == "0s" {
|
||||
t.Fatal("expected uptime to update from 0s")
|
||||
}
|
||||
|
||||
if err := pm.Stop(); err != nil {
|
||||
t.Fatalf("failed to stop process: %v", err)
|
||||
}
|
||||
|
||||
status, err = pm.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get status after stop: %v", err)
|
||||
}
|
||||
if status.Running {
|
||||
t.Fatal("expected status.Running to be false after stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerStartMissingBinary(t *testing.T) {
|
||||
pm := mihomo.NewProcessManager("/tmp/non_existent_binary_dummy", filepath.Join(t.TempDir(), "config.yaml"))
|
||||
err := pm.Start()
|
||||
if err == nil {
|
||||
t.Fatal("expected start to fail when binary is missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package mihomo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
pm *ProcessManager
|
||||
cm *ConfigManager
|
||||
}
|
||||
|
||||
// NewService constructs a Mihomo process/config service from application config.
|
||||
func NewService(cfg *config.Config) *Service {
|
||||
if cfg == nil {
|
||||
return &Service{}
|
||||
}
|
||||
return &Service{
|
||||
pm: GetProcessManager(cfg.Mihomo.Binary, cfg.Mihomo.Config),
|
||||
cm: NewConfigManager(cfg.Mihomo.Config),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) StartMihomo() error {
|
||||
if s == nil || s.pm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
if err := s.pm.ValidateConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.pm.Start()
|
||||
}
|
||||
|
||||
func (s *Service) StopMihomo() error {
|
||||
if s == nil || s.pm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
return s.pm.Stop()
|
||||
}
|
||||
|
||||
func (s *Service) RestartMihomo() error {
|
||||
if s == nil || s.pm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
return s.pm.Restart()
|
||||
}
|
||||
|
||||
func (s *Service) GetStatus() (*StatusResponse, error) {
|
||||
if s == nil || s.pm == nil {
|
||||
return nil, fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
return s.pm.Status()
|
||||
}
|
||||
|
||||
func (s *Service) SaveConfig(content string) error {
|
||||
if s == nil || s.cm == nil || s.pm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
old, readErr := s.cm.ReadConfig()
|
||||
if err := s.cm.SaveConfig(content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.pm.ValidateConfig(); err != nil {
|
||||
if readErr == nil {
|
||||
_ = s.cm.SaveConfig(old)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ApplyConfig(content string) error {
|
||||
if s == nil || s.pm == nil || s.cm == nil {
|
||||
return fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
old, readErr := s.cm.ReadConfig()
|
||||
wasRunning := s.pm.IsRunning()
|
||||
if err := s.cm.SaveConfig(content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.pm.ValidateConfig(); err != nil {
|
||||
if readErr == nil {
|
||||
_ = s.cm.SaveConfig(old)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !wasRunning {
|
||||
return s.pm.Start()
|
||||
}
|
||||
if err := s.pm.Restart(); err != nil {
|
||||
if readErr == nil {
|
||||
if restoreErr := s.cm.SaveConfig(old); restoreErr == nil {
|
||||
_ = s.pm.Restart()
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetLogs() ([]LogResponse, error) {
|
||||
if s == nil || s.pm == nil {
|
||||
return nil, fmt.Errorf("mihomo service not initialized")
|
||||
}
|
||||
lines := s.pm.Logs()
|
||||
result := make([]LogResponse, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
result = append(result, LogResponse{Timestamp: time.Now(), Level: "info", Payload: line})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package mihomo
|
||||
|
||||
import "time"
|
||||
|
||||
// StatusResponse represents the response sent to the frontend status endpoint
|
||||
type StatusResponse struct {
|
||||
Running bool `json:"running"`
|
||||
Version string `json:"version"`
|
||||
PID int `json:"pid"`
|
||||
Uptime string `json:"uptime"`
|
||||
}
|
||||
|
||||
// VersionInfo represents the parsed mihomo -v output
|
||||
type VersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
Build string `json:"build"`
|
||||
}
|
||||
|
||||
// LogResponse represents log stream or mock log records
|
||||
type LogResponse struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/converter"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
)
|
||||
|
||||
// Handler serves node/listener HTTP endpoints with injected dependencies.
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
user *user.Service
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewHandler constructs a node HTTP handler.
|
||||
func NewHandler(svc *Service, userSvc *user.Service, db *gorm.DB) *Handler {
|
||||
return &Handler{svc: svc, user: userSvc, db: db}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers node routes on the provided group.
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("", h.ListNodes)
|
||||
rg.POST("", h.CreateNode)
|
||||
rg.GET("/:id", h.GetNode)
|
||||
rg.GET("/:id/uri", h.ExportNodeURI)
|
||||
rg.PUT("/:id", h.UpdateNode)
|
||||
rg.DELETE("/:id", h.DeleteNode)
|
||||
rg.POST("/:id/reload", h.ReloadNode)
|
||||
rg.POST("/:id/client-access", h.CreateClientAccess)
|
||||
}
|
||||
|
||||
func (h *Handler) ListNodes(c *gin.Context) {
|
||||
list, err := h.svc.GetAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateNode(c *gin.Context) {
|
||||
var l models.Listener
|
||||
if err := c.ShouldBindJSON(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ValidateNode(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Create(&l); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, l)
|
||||
}
|
||||
|
||||
func (h *Handler) GetNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
l, err := h.svc.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
|
||||
// isTrustedHost validates that the provided host is acceptable for URI export.
|
||||
// It rejects path traversal, illegal characters, and hosts that do not match
|
||||
// the configured public_url (if set).
|
||||
func (h *Handler) isTrustedHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
// Strip port for comparison
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
// Reject anything that looks like a path or contains suspicious characters
|
||||
if strings.ContainsAny(host, "/\\?#@") || strings.Contains(host, "..") {
|
||||
return false
|
||||
}
|
||||
// If public_url is configured, only allow hosts that match it
|
||||
if config.GlobalConfig != nil && config.GlobalConfig.Server.PublicURL != "" {
|
||||
expected := config.GlobalConfig.Server.PublicURL
|
||||
if u, err := url.Parse(expected); err == nil && u.Host != "" {
|
||||
expected = u.Host
|
||||
}
|
||||
if eh, _, err := net.SplitHostPort(expected); err == nil {
|
||||
expected = eh
|
||||
}
|
||||
if !strings.EqualFold(host, expected) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ExportNodeURI returns one URI per active client credential. Public host
|
||||
// comes from the request Host; wildcard listener addresses are never exposed.
|
||||
func (h *Handler) ExportNodeURI(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
listener, err := h.svc.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
|
||||
return
|
||||
}
|
||||
if !listener.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer X-Forwarded-Host only when it passes trust validation.
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host != "" && !h.isTrustedHost(host) {
|
||||
host = ""
|
||||
}
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
if host != "" {
|
||||
if parsed, err := url.Parse("//" + host); err == nil && parsed.Hostname() != "" {
|
||||
host = parsed.Hostname()
|
||||
}
|
||||
}
|
||||
|
||||
credentials := []user.Credential{}
|
||||
if h.user != nil {
|
||||
byListener, err := h.user.ActiveCredentialsByListener()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load active credentials: " + err.Error()})
|
||||
return
|
||||
}
|
||||
credentials = byListener[listener.ID]
|
||||
}
|
||||
|
||||
uris, err := ClientURIsWithCredentials(*listener, strings.TrimSpace(host), credentials)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"name": listener.Name, "protocol": listener.Protocol, "uris": uris})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
var l models.Listener
|
||||
if err := c.ShouldBindJSON(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l.ID = uint(id)
|
||||
if err := ValidateNode(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Update(&l); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) ReloadNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.TriggerReload(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node reloaded"})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateClientAccess(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
db := h.db
|
||||
if db == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
|
||||
return
|
||||
}
|
||||
var listener models.Listener
|
||||
if err := db.First(&listener, uint(id)).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
|
||||
return
|
||||
}
|
||||
if !listener.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"})
|
||||
return
|
||||
}
|
||||
var existing models.AccessToken
|
||||
if err := db.Where("listener_id = ? AND enabled = ?", listener.ID, true).First(&existing).Error; err == nil {
|
||||
if existing.ExpireAt == nil || existing.ExpireAt.After(time.Now()) {
|
||||
c.JSON(http.StatusOK, h.clientAccessResponse(c, existing))
|
||||
return
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secure token"})
|
||||
return
|
||||
}
|
||||
token := models.AccessToken{Name: listener.Name, Token: hex.EncodeToString(buf), Enabled: true, ListenerID: listener.ID}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, h.clientAccessResponse(c, token))
|
||||
}
|
||||
|
||||
func (h *Handler) clientAccessResponse(c *gin.Context, token models.AccessToken) gin.H {
|
||||
cfg := config.GlobalConfig
|
||||
return gin.H{
|
||||
"id": token.ID,
|
||||
"name": token.Name,
|
||||
"token": token.Token,
|
||||
"type": "listener",
|
||||
"listener_id": token.ListenerID,
|
||||
"mihomo_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "mihomo"),
|
||||
"clash_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "clash"),
|
||||
"singbox_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "singbox"),
|
||||
"shadowrocket_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "shadowrocket"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func GenerateMihomoListeners(dbNodes []models.Listener) ([]map[string]interface{}, error) {
|
||||
list := make([]map[string]interface{}, 0, len(dbNodes))
|
||||
for _, node := range dbNodes {
|
||||
if !node.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := ValidateNode(&node); err != nil {
|
||||
return nil, fmt.Errorf("invalid listener %q: %w", node.Name, err)
|
||||
}
|
||||
protocol := strings.ToLower(strings.TrimSpace(node.Protocol))
|
||||
if !mihomoConfig.IsMihomoListenerProtocol(protocol) {
|
||||
return nil, fmt.Errorf("unsupported Mihomo listener protocol: %s", protocol)
|
||||
}
|
||||
|
||||
entry := map[string]interface{}{
|
||||
"name": node.Name,
|
||||
"type": protocol,
|
||||
"port": node.Port,
|
||||
"listen": firstNonEmpty(node.BindAddress, node.Listen, "0.0.0.0"),
|
||||
}
|
||||
if node.Rule != "" {
|
||||
entry["rule"] = node.Rule
|
||||
}
|
||||
if node.Proxy != "" {
|
||||
entry["proxy"] = node.Proxy
|
||||
}
|
||||
if node.RoutingMark > 0 {
|
||||
entry["routing-mark"] = node.RoutingMark
|
||||
}
|
||||
if node.UDP && listenerSupportsUDP(protocol) {
|
||||
entry["udp"] = true
|
||||
}
|
||||
|
||||
options, err := decodeAndExpand(node.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid config for listener %q: %w", node.Name, err)
|
||||
}
|
||||
normalizeListenerUsers(protocol, options)
|
||||
for key, value := range options {
|
||||
if value != nil {
|
||||
entry[key] = value
|
||||
}
|
||||
}
|
||||
list = append(list, entry)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func GenerateConfigYAML(dbNodes []models.Listener) (string, error) {
|
||||
listeners, err := GenerateMihomoListeners(dbNodes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := yaml.Marshal(map[string]interface{}{"listeners": listeners})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal listeners yaml: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func decodeAndExpand(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var rawMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &rawMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return expandDottedKeys(rawMap), nil
|
||||
}
|
||||
|
||||
func expandDottedKeys(src map[string]interface{}) map[string]interface{} {
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for key, value := range src {
|
||||
value = expandValue(value)
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) == 1 {
|
||||
dst[key] = value
|
||||
continue
|
||||
}
|
||||
cursor := dst
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
next, ok := cursor[part].(map[string]interface{})
|
||||
if !ok {
|
||||
next = map[string]interface{}{}
|
||||
cursor[part] = next
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
cursor[parts[len(parts)-1]] = value
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func expandValue(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
return expandDottedKeys(v)
|
||||
case []interface{}:
|
||||
for i := range v {
|
||||
v[i] = expandValue(v[i])
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeListenerUsers(protocol string, options map[string]interface{}) {
|
||||
users, ok := options["users"].([]interface{})
|
||||
if !ok || len(users) == 0 {
|
||||
return
|
||||
}
|
||||
if protocol == "anytls" || protocol == "hysteria2" || protocol == "mieru" || protocol == "tuic" {
|
||||
m := map[string]interface{}{}
|
||||
for _, raw := range users {
|
||||
row, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(fmt.Sprint(row["username"]))
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(fmt.Sprint(row["uuid"]))
|
||||
}
|
||||
password := row["password"]
|
||||
if name != "" && password != nil {
|
||||
m[name] = password
|
||||
}
|
||||
}
|
||||
if len(m) > 0 {
|
||||
options["users"] = m
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, raw := range users {
|
||||
row, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch protocol {
|
||||
case "vmess":
|
||||
delete(row, "flow")
|
||||
case "vless":
|
||||
delete(row, "alterId")
|
||||
case "trojan", "shadowquic", "trusttunnel":
|
||||
delete(row, "uuid")
|
||||
delete(row, "flow")
|
||||
delete(row, "alterId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// listenerSupportsUDP reports whether the given protocol supports the udp option
|
||||
// in Mihomo's listener schema.
|
||||
func listenerSupportsUDP(protocol string) bool {
|
||||
switch protocol {
|
||||
case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package node_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/node"
|
||||
)
|
||||
|
||||
func TestValidateNode(t *testing.T) {
|
||||
// Valid shadowsocks node
|
||||
n1 := models.Listener{
|
||||
Name: "hk-ss",
|
||||
Protocol: "shadowsocks",
|
||||
Port: 8388,
|
||||
BindAddress: "0.0.0.0",
|
||||
Config: `{"password": "mypassword"}`,
|
||||
}
|
||||
if err := node.ValidateNode(&n1); err != nil {
|
||||
t.Fatalf("expected valid ss node to pass, got %v", err)
|
||||
}
|
||||
|
||||
// Authentication is managed by ProxyUser, so an empty node-level
|
||||
// credential config is valid.
|
||||
n2 := models.Listener{
|
||||
Name: "hk-ss",
|
||||
Protocol: "shadowsocks",
|
||||
Port: 8388,
|
||||
BindAddress: "0.0.0.0",
|
||||
Config: `{}`,
|
||||
}
|
||||
if err := node.ValidateNode(&n2); err != nil {
|
||||
t.Fatalf("expected node without embedded credentials to pass, got %v", err)
|
||||
}
|
||||
|
||||
// Invalid Port
|
||||
n3 := models.Listener{
|
||||
Name: "hk-ss",
|
||||
Protocol: "shadowsocks",
|
||||
Port: 99999,
|
||||
BindAddress: "0.0.0.0",
|
||||
Config: `{"password": "pass"}`,
|
||||
}
|
||||
if err := node.ValidateNode(&n3); err == nil {
|
||||
t.Fatal("expected node with invalid port to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYAML(t *testing.T) {
|
||||
dbNodes := []models.Listener{
|
||||
{
|
||||
Name: "hk-ss",
|
||||
Protocol: "shadowsocks",
|
||||
Port: 8388,
|
||||
BindAddress: "0.0.0.0",
|
||||
Enabled: true,
|
||||
Config: `{"password": "secretpwd"}`,
|
||||
},
|
||||
{
|
||||
Name: "hk-vmess",
|
||||
Protocol: "vmess",
|
||||
Port: 10086,
|
||||
BindAddress: "127.0.0.1",
|
||||
Enabled: true,
|
||||
Config: `{"uuid": "test-uuid-1234"}`,
|
||||
},
|
||||
}
|
||||
|
||||
yamlStr, err := node.GenerateConfigYAML(dbNodes)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate config: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(yamlStr, "hk-ss") {
|
||||
t.Fatal("expected yaml to contain shadowsocks node hk-ss")
|
||||
}
|
||||
|
||||
if !strings.Contains(yamlStr, "password: secretpwd") {
|
||||
t.Fatal("expected yaml to contain SS password")
|
||||
}
|
||||
|
||||
if !strings.Contains(yamlStr, "uuid: test-uuid-1234") {
|
||||
t.Fatal("expected yaml to contain VMess UUID")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
|
||||
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
configPath string
|
||||
mihomo *mihomo.Service
|
||||
}
|
||||
|
||||
// NewService constructs a node/listener service.
|
||||
// Optional mihomoSvc is used for hot-reload after config regeneration.
|
||||
func NewService(db *gorm.DB, configPath string, mihomoSvc *mihomo.Service) *Service {
|
||||
return &Service{db: db, configPath: configPath, mihomo: mihomoSvc}
|
||||
}
|
||||
|
||||
func (s *Service) Create(l *models.Listener) error {
|
||||
if err := ValidateNode(l); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Create(l).Error; err != nil {
|
||||
return fmt.Errorf("failed to create node: %w", err)
|
||||
}
|
||||
|
||||
if err := s.RegenerateConfig(); err != nil {
|
||||
if deleteErr := s.db.Delete(&models.Listener{}, l.ID).Error; deleteErr != nil {
|
||||
return fmt.Errorf("failed to regenerate config: %v; rollback node %d failed: %w", err, l.ID, deleteErr)
|
||||
}
|
||||
return fmt.Errorf("failed to regenerate config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetAll() ([]models.Listener, error) {
|
||||
var list []models.Listener
|
||||
if err := s.db.Find(&list).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch nodes: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(id uint) (*models.Listener, error) {
|
||||
var l models.Listener
|
||||
if err := s.db.First(&l, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch node by id %d: %w", id, err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(l *models.Listener) error {
|
||||
if err := ValidateNode(l); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var previous models.Listener
|
||||
if err := s.db.First(&previous, l.ID).Error; err != nil {
|
||||
return fmt.Errorf("failed to load node %d: %w", l.ID, err)
|
||||
}
|
||||
|
||||
if err := s.db.Save(l).Error; err != nil {
|
||||
return fmt.Errorf("failed to update node: %w", err)
|
||||
}
|
||||
|
||||
if err := s.RegenerateConfig(); err != nil {
|
||||
if restoreErr := s.db.Save(&previous).Error; restoreErr != nil {
|
||||
return fmt.Errorf("failed to regenerate config: %v; rollback node %d failed: %w", err, l.ID, restoreErr)
|
||||
}
|
||||
return fmt.Errorf("failed to regenerate config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(id uint) error {
|
||||
if err := s.db.Delete(&models.Listener{}, id).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete node: %w", err)
|
||||
}
|
||||
|
||||
return s.RegenerateConfig()
|
||||
}
|
||||
|
||||
// RegenerateConfig regenerates the complete Mihomo configuration through the
|
||||
// Config Engine. Node services never write a partial listeners-only YAML file.
|
||||
func (s *Service) RegenerateConfig() error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("node service is not initialized")
|
||||
}
|
||||
engine := mihomoConfig.NewConfigEngine(s.db)
|
||||
yamlContent, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(s.configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(s.configPath, []byte(yamlContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
tmpl := mihomoConfig.GetDefaultTemplate()
|
||||
controllerURL := "http://" + tmpl.ExternalController
|
||||
if err := mihomo.NewExternalControllerAPI(controllerURL, tmpl.Secret).ReloadConfig(map[string]interface{}{
|
||||
"path": s.configPath,
|
||||
}); err != nil {
|
||||
log.Printf("node: mihomo hot reload skipped (core unreachable): %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerReload forces manual config regeneration and triggers hot reloading
|
||||
func (s *Service) TriggerReload(id uint) error {
|
||||
return s.RegenerateConfig()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package node
|
||||
|
||||
// NodeUser represents the user credentials mapping for Mihomo inbound protocols
|
||||
type NodeUser struct {
|
||||
Password string `yaml:"password,omitempty" json:"password,omitempty"`
|
||||
UUID string `yaml:"uuid,omitempty" json:"uuid,omitempty"`
|
||||
Flow string `yaml:"flow,omitempty" json:"flow,omitempty"`
|
||||
}
|
||||
|
||||
// MihomoTLSConfig represents the TLS parameters for Mihomo inbounds
|
||||
type MihomoTLSConfig struct {
|
||||
Enable bool `yaml:"enable" json:"enable"`
|
||||
Certificate string `yaml:"certificate,omitempty" json:"certificate,omitempty"`
|
||||
PrivateKey string `yaml:"private-key,omitempty" json:"private_key,omitempty"`
|
||||
}
|
||||
|
||||
// MihomoListener represents a type-safe listener configuration structure
|
||||
type MihomoListener struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"` // shadowsocks, vmess, vless, trojan, hysteria2, tuic
|
||||
Port string `yaml:"port"`
|
||||
Listen string `yaml:"listen"`
|
||||
UDP bool `yaml:"udp,omitempty"`
|
||||
TLS *MihomoTLSConfig `yaml:"tls,omitempty"`
|
||||
Users []NodeUser `yaml:"users,omitempty"`
|
||||
Extra map[string]interface{} `yaml:"-,inline"` // for protocol-specific extra fields
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
// ClientURIs exports client share links from the same Listener schema used by
|
||||
// Mihomo config generation. Server-only fields are never copied blindly.
|
||||
func ClientURIs(listener models.Listener, host string) ([]string, error) {
|
||||
host = normalizeExportHost(host, listener.BindAddress, listener.Listen)
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("cannot determine public host for listener")
|
||||
}
|
||||
cfg, err := decodeURIConfig(listener.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Listener-level flags are authoritative even when they are not duplicated
|
||||
// in the JSON config. URI generation must match the actual listener.
|
||||
cfg["_listener-tls"] = listener.TLS
|
||||
cfg["_listener-udp"] = listener.UDP
|
||||
port := listener.Port
|
||||
switch strings.ToLower(listener.Protocol) {
|
||||
case "shadowsocks":
|
||||
return shadowsocksURIs(listener.Name, host, port, cfg)
|
||||
case "snell":
|
||||
return snellURIs(listener.Name, host, port, cfg)
|
||||
case "vless":
|
||||
return vlessURIs(listener.Name, host, port, cfg)
|
||||
case "vmess":
|
||||
return vmessURIs(listener.Name, host, port, cfg)
|
||||
case "trojan":
|
||||
return trojanURIs(listener.Name, host, port, cfg)
|
||||
case "hysteria2":
|
||||
return hysteria2URIs(listener.Name, host, port, cfg)
|
||||
case "tuic":
|
||||
return tuicURIs(listener.Name, host, port, cfg)
|
||||
case "shadowquic":
|
||||
return shadowQUICURIs(listener.Name, host, port, cfg)
|
||||
case "anytls":
|
||||
return anytlsURIs(listener.Name, host, port, cfg)
|
||||
case "mieru":
|
||||
return mieruURIs(listener.Name, host, port, cfg)
|
||||
case "sudoku":
|
||||
return sudokuURIs(listener.Name, host, port, cfg)
|
||||
case "trusttunnel":
|
||||
return trustTunnelURIs(listener.Name, host, port, cfg)
|
||||
default:
|
||||
return nil, fmt.Errorf("URI export is not supported for listener protocol %q", listener.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeURIConfig(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid listener configuration: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeExportHost(requestHost, bind, listen string) string {
|
||||
for _, raw := range []string{requestHost, bind, listen} {
|
||||
h := strings.TrimSpace(raw)
|
||||
if h == "" || h == "0.0.0.0" || h == "::" || h == "*" || h == "127.0.0.1" || h == "::1" {
|
||||
continue
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(h); err == nil {
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
if u, err := url.Parse("//" + h); err == nil && u.Hostname() != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
return strings.Trim(h, "[]")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func userMap(cfg map[string]interface{}) map[string]interface{} {
|
||||
if users, ok := cfg["users"].(map[string]interface{}); ok {
|
||||
return users
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userRows(cfg map[string]interface{}) []map[string]interface{} {
|
||||
users, _ := cfg["users"].([]interface{})
|
||||
rows := make([]map[string]interface{}, 0, len(users))
|
||||
for _, raw := range users {
|
||||
if row, ok := raw.(map[string]interface{}); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func query(base string, values map[string]string) string {
|
||||
q := url.Values{}
|
||||
for k, v := range values {
|
||||
if v != "" {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
if encoded := q.Encode(); encoded != "" {
|
||||
return base + "?" + encoded
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func addName(uri, name string) string {
|
||||
if name == "" {
|
||||
return uri
|
||||
}
|
||||
return uri + "#" + url.QueryEscape(name)
|
||||
}
|
||||
|
||||
func tlsParams(cfg map[string]interface{}) map[string]string {
|
||||
params := map[string]string{}
|
||||
if enabled, ok := cfg["_listener-tls"].(bool); ok && enabled {
|
||||
params["security"] = "tls"
|
||||
}
|
||||
if certificate, ok := cfg["certificate"].(string); ok && strings.TrimSpace(certificate) != "" {
|
||||
params["security"] = "tls"
|
||||
}
|
||||
if v, ok := cfg["servername"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if v, ok := cfg["sni"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if v, ok := cfg["client-fingerprint"].(string); ok && v != "" {
|
||||
params["fp"] = v
|
||||
}
|
||||
if v, ok := cfg["fingerprint"].(string); ok && v != "" {
|
||||
params["fp"] = v
|
||||
}
|
||||
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
|
||||
params["allowInsecure"] = "1"
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func transportParams(cfg map[string]interface{}) map[string]string {
|
||||
params := map[string]string{}
|
||||
if v, ok := cfg["ws-path"].(string); ok && v != "" {
|
||||
params["type"] = "ws"
|
||||
params["path"] = v
|
||||
}
|
||||
if headers, ok := cfg["ws-headers"].(map[string]interface{}); ok {
|
||||
if host, ok := headers["Host"].(string); ok && host != "" {
|
||||
params["host"] = host
|
||||
}
|
||||
}
|
||||
if v, ok := cfg["grpc-service-name"].(string); ok && v != "" {
|
||||
params["type"] = "grpc"
|
||||
params["serviceName"] = v
|
||||
}
|
||||
if xhttp, ok := cfg["xhttp-config"].(map[string]interface{}); ok {
|
||||
params["type"] = "xhttp"
|
||||
if path, ok := xhttp["path"].(string); ok && path != "" {
|
||||
params["path"] = path
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func shadowsocksURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
cipher, _ := cfg["cipher"].(string)
|
||||
password, _ := cfg["password"].(string)
|
||||
if cipher == "" || password == "" {
|
||||
return nil, fmt.Errorf("shadowsocks listener requires cipher and password for URI export")
|
||||
}
|
||||
encoded := base64.RawStdEncoding.EncodeToString([]byte(cipher + ":" + password))
|
||||
return []string{addName("ss://"+encoded+"@"+net.JoinHostPort(host, port), name)}, nil
|
||||
}
|
||||
|
||||
func vlessURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
rows := userRows(cfg)
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("vless listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
uuid, _ := row["uuid"].(string)
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("vless user uuid is required")
|
||||
}
|
||||
params := tlsParams(cfg)
|
||||
params["type"] = "tcp"
|
||||
if flow, _ := row["flow"].(string); flow != "" {
|
||||
params["flow"] = flow
|
||||
}
|
||||
if decryption, _ := cfg["decryption"].(string); decryption != "" {
|
||||
params["encryption"] = decryption
|
||||
}
|
||||
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
|
||||
params["security"] = "reality"
|
||||
publicKey, err := realityPublicKey(reality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params["pbk"] = publicKey
|
||||
if sid, ok := reality["short-id"].(string); ok && sid != "" {
|
||||
params["sid"] = sid
|
||||
}
|
||||
if sni, ok := firstString(reality["server-names"]); ok {
|
||||
params["sni"] = sni
|
||||
}
|
||||
}
|
||||
for k, v := range transportParams(cfg) {
|
||||
params[k] = v
|
||||
}
|
||||
result = append(result, addName(query("vless://"+url.PathEscape(uuid)+"@"+net.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func vmessURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
rows := userRows(cfg)
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("vmess listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
uuid, _ := row["uuid"].(string)
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("vmess user uuid is required")
|
||||
}
|
||||
aid := stringValue(cfg["alterId"], "0")
|
||||
cipher := stringValue(cfg["cipher"], "auto")
|
||||
obj := map[string]string{"v": "2", "ps": name, "add": host, "port": port, "id": uuid, "aid": aid, "scy": cipher, "net": "tcp", "type": "none"}
|
||||
if tls, ok := tlsParams(cfg)["security"]; ok && tls == "tls" {
|
||||
obj["tls"] = "tls"
|
||||
}
|
||||
if sni := tlsParams(cfg)["sni"]; sni != "" {
|
||||
obj["sni"] = sni
|
||||
}
|
||||
if fp := tlsParams(cfg)["fp"]; fp != "" {
|
||||
obj["fp"] = fp
|
||||
}
|
||||
if ws, ok := cfg["ws-path"].(string); ok && ws != "" {
|
||||
obj["net"] = "ws"
|
||||
obj["path"] = ws
|
||||
}
|
||||
if grpc, ok := cfg["grpc-service-name"].(string); ok && grpc != "" {
|
||||
obj["net"] = "grpc"
|
||||
obj["path"] = grpc
|
||||
}
|
||||
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
|
||||
obj["tls"] = "tls"
|
||||
publicKey, err := realityPublicKey(reality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj["pbk"] = publicKey
|
||||
if sid, ok := reality["short-id"].(string); ok {
|
||||
obj["sid"] = sid
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, "vmess://"+base64.RawStdEncoding.EncodeToString(data))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func trojanURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
rows := userRows(cfg)
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("trojan listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
password, _ := row["password"].(string)
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("trojan user password is required")
|
||||
}
|
||||
params := tlsParams(cfg)
|
||||
for k, v := range transportParams(cfg) {
|
||||
params[k] = v
|
||||
}
|
||||
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
|
||||
params["security"] = "reality"
|
||||
publicKey, err := realityPublicKey(reality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params["pbk"] = publicKey
|
||||
if sid, ok := reality["short-id"].(string); ok {
|
||||
params["sid"] = sid
|
||||
}
|
||||
}
|
||||
result = append(result, addName(query("trojan://"+url.PathEscape(password)+"@"+net.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("hysteria2 listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for username, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("hysteria2 user %q has empty password", username)
|
||||
}
|
||||
params := map[string]string{}
|
||||
if v, ok := cfg["sni"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
|
||||
params["insecure"] = "1"
|
||||
}
|
||||
if v, ok := cfg["obfs"].(string); ok && v != "" {
|
||||
params["obfs"] = v
|
||||
}
|
||||
if v, ok := cfg["obfs-password"].(string); ok && v != "" {
|
||||
params["obfs-password"] = v
|
||||
}
|
||||
if v, ok := cfg["up"].(string); ok && v != "" {
|
||||
params["up"] = v
|
||||
}
|
||||
if v, ok := cfg["down"].(string); ok && v != "" {
|
||||
params["down"] = v
|
||||
}
|
||||
_ = username
|
||||
result = append(result, addName(query("hysteria2://"+url.PathEscape(password)+"@"+net.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
if token, ok := cfg["token"].(string); ok && strings.TrimSpace(token) != "" {
|
||||
return []string{addName("tuic://"+url.PathEscape(token)+"@"+net.JoinHostPort(host, port), name)}, nil
|
||||
}
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("tuic V5 listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for uuid, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("tuic user %q has empty password", uuid)
|
||||
}
|
||||
params := map[string]string{}
|
||||
for key, out := range map[string]string{"congestion-controller": "congestion_control", "bbr-profile": "bbr_profile", "udp-relay-mode": "udp_relay_mode", "max-udp-relay-packet-size": "max_udp_relay_packet_size"} {
|
||||
if v, ok := cfg[key].(string); ok && v != "" {
|
||||
params[out] = v
|
||||
}
|
||||
}
|
||||
if v, ok := cfg["alpn"].(string); ok && v != "" {
|
||||
params["alpn"] = v
|
||||
}
|
||||
if v, ok := cfg["sni"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
|
||||
params["allow_insecure"] = "1"
|
||||
}
|
||||
result = append(result, addName(query("tuic://"+url.PathEscape(uuid)+":"+url.PathEscape(password)+"@"+net.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func anytlsURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("anytls listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for username, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("anytls user %q has empty password", username)
|
||||
}
|
||||
params := map[string]string{}
|
||||
if v, ok := cfg["sni"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if v, ok := cfg["client-fingerprint"].(string); ok && v != "" {
|
||||
params["fp"] = v
|
||||
}
|
||||
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
|
||||
params["insecure"] = "1"
|
||||
}
|
||||
if v, ok := cfg["idle-session-check-interval"].(string); ok && v != "" {
|
||||
params["idle_session_check_interval"] = v
|
||||
}
|
||||
if v, ok := cfg["idle-session-timeout"].(string); ok && v != "" {
|
||||
params["idle_session_timeout"] = v
|
||||
}
|
||||
if v, ok := cfg["min-idle-session"].(string); ok && v != "" {
|
||||
params["min_idle_session"] = v
|
||||
}
|
||||
_ = username
|
||||
result = append(result, addName(query("anytls://"+url.PathEscape(password)+"@"+net.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func realityPublicKey(cfg map[string]interface{}) (string, error) {
|
||||
if public, ok := cfg["public-key"].(string); ok && strings.TrimSpace(public) != "" {
|
||||
return public, nil
|
||||
}
|
||||
private, ok := cfg["private-key"].(string)
|
||||
if !ok || strings.TrimSpace(private) == "" {
|
||||
return "", fmt.Errorf("reality listener URI export requires reality-config.public-key or private-key")
|
||||
}
|
||||
var raw []byte
|
||||
var err error
|
||||
for _, decode := range []func(string) ([]byte, error){base64.RawStdEncoding.DecodeString, base64.StdEncoding.DecodeString, base64.RawURLEncoding.DecodeString, base64.URLEncoding.DecodeString} {
|
||||
raw, err = decode(strings.TrimSpace(private))
|
||||
if err == nil && len(raw) == 32 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(raw) != 32 {
|
||||
return "", fmt.Errorf("invalid Reality private key: expected 32 decoded bytes")
|
||||
}
|
||||
public, err := curve25519.X25519(raw, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to derive Reality public key: %w", err)
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(public), nil
|
||||
}
|
||||
|
||||
func firstString(v interface{}) (string, bool) {
|
||||
if s, ok := v.(string); ok {
|
||||
return s, s != ""
|
||||
}
|
||||
if a, ok := v.([]interface{}); ok && len(a) > 0 {
|
||||
s, _ := a[0].(string)
|
||||
return s, s != ""
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
func stringValue(v interface{}, fallback string) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
)
|
||||
|
||||
// ClientURIsWithCredentials bridges node URI export with the canonical
|
||||
// credential service. Listener.Config is server configuration and is not the
|
||||
// source of truth for users managed by 3m-ui.
|
||||
func ClientURIsWithCredentials(listener models.Listener, host string, credentials []user.Credential) ([]string, error) {
|
||||
var cfg map[string]interface{}
|
||||
if listener.Config != "" {
|
||||
if err := json.Unmarshal([]byte(listener.Config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid listener configuration: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]interface{}{}
|
||||
}
|
||||
if len(credentials) > 0 {
|
||||
switch listener.Protocol {
|
||||
case "anytls", "hysteria2", "mieru", "tuic":
|
||||
users := make(map[string]interface{}, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
if credential.Username != "" {
|
||||
users[credential.Username] = credential.Password
|
||||
}
|
||||
}
|
||||
cfg["users"] = users
|
||||
default:
|
||||
users := make([]interface{}, 0, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
users = append(users, map[string]interface{}{"username": credential.Username, "password": credential.Password, "uuid": credential.UUID})
|
||||
}
|
||||
cfg["users"] = users
|
||||
}
|
||||
if listener.Protocol == "shadowsocks" {
|
||||
cfg["password"] = credentials[0].Password
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare URI configuration: %w", err)
|
||||
}
|
||||
listener.Config = string(encoded)
|
||||
return ClientURIs(listener, host)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Extended Mihomo protocols use their documented/simple sharing forms where
|
||||
// available. These links are intended for clients that understand the scheme;
|
||||
// they are not silently downgraded to another protocol.
|
||||
func snellURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
psk, _ := cfg["psk"].(string)
|
||||
if psk == "" {
|
||||
return nil, fmt.Errorf("snell listener requires psk for URI export")
|
||||
}
|
||||
params := map[string]string{"psk": psk}
|
||||
if v := stringValue(cfg["version"], "4"); v != "" {
|
||||
params["version"] = v
|
||||
}
|
||||
if obfs, ok := cfg["obfs-opts"].(map[string]interface{}); ok {
|
||||
if mode := stringValue(obfs["mode"], ""); mode != "" {
|
||||
params["obfs"] = mode
|
||||
}
|
||||
if host := stringValue(obfs["host"], ""); host != "" {
|
||||
params["obfs-host"] = host
|
||||
}
|
||||
}
|
||||
if b, ok := cfg["udp"].(bool); ok && b {
|
||||
params["udp"] = "1"
|
||||
}
|
||||
return []string{addName(query("snell://"+net.JoinHostPort(host, port), params), name)}, nil
|
||||
}
|
||||
|
||||
func shadowQUICURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("shadowquic listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for username, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("shadowquic user %q has empty password", username)
|
||||
}
|
||||
params := map[string]string{}
|
||||
for _, key := range []string{"sni", "alpn", "quic-versions", "congestion-controller", "up", "down", "cwnd", "bbr-profile", "max-datagram-frame-size", "max-open-streams", "recv-window-conn", "recv-window"} {
|
||||
if v := stringValue(cfg[key], ""); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
if b, ok := cfg["zero-rtt"].(bool); ok && b {
|
||||
params["zero-rtt"] = "1"
|
||||
}
|
||||
if b, ok := cfg["udp-over-stream"].(bool); ok && b {
|
||||
params["udp-over-stream"] = "1"
|
||||
}
|
||||
if b, ok := cfg["disable-mtu-discovery"].(bool); ok && b {
|
||||
params["disable-mtu-discovery"] = "1"
|
||||
}
|
||||
result = append(result, addName(query("shadowquic://"+url.PathEscape(username)+":"+url.PathEscape(password)+"@"+net.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mieruURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("mieru listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for username, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("mieru user %q has empty password", username)
|
||||
}
|
||||
params := map[string]string{}
|
||||
for _, key := range []string{"transport", "multiplexing", "handshake-mode", "traffic-pattern"} {
|
||||
if v := stringValue(cfg[key], ""); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
if v := stringValue(cfg["port-range"], ""); v != "" {
|
||||
params["portRange"] = v
|
||||
}
|
||||
result = append(result, addName(query("mieru://"+url.PathEscape(username)+":"+url.PathEscape(password)+"@"+net.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sudokuURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
key, _ := cfg["key"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("sudoku listener requires key for URI export")
|
||||
}
|
||||
params := map[string]string{"key": key}
|
||||
for _, keyName := range []string{"aead-method", "padding-min", "padding-max", "table-type", "custom-table", "handshake-timeout", "httpmask"} {
|
||||
if v := stringValue(cfg[keyName], ""); v != "" {
|
||||
params[keyName] = v
|
||||
}
|
||||
}
|
||||
if b, ok := cfg["enable-pure-downlink"].(bool); ok && b {
|
||||
params["enable-pure-downlink"] = "1"
|
||||
}
|
||||
return []string{addName(query("sudoku://"+net.JoinHostPort(host, port), params), name)}, nil
|
||||
}
|
||||
|
||||
func trustTunnelURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("trusttunnel listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for username, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("trusttunnel user %q has empty password", username)
|
||||
}
|
||||
params := map[string]string{}
|
||||
for _, key := range []string{"client-fingerprint", "health-check", "sni", "alpn", "congestion-controller", "bbr-profile", "max-connections", "min-streams", "max-streams"} {
|
||||
if v := stringValue(cfg[key], ""); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"udp", "quic", "skip-cert-verify", "name-cert-verify"} {
|
||||
if b, ok := cfg[key].(bool); ok && b {
|
||||
params[key] = "1"
|
||||
}
|
||||
}
|
||||
result = append(result, addName(query("trusttunnel://"+url.PathEscape(username)+":"+url.PathEscape(password)+"@"+net.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/credentials"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
)
|
||||
|
||||
func ValidateNode(l *models.Listener) error {
|
||||
l.Name = strings.TrimSpace(l.Name)
|
||||
if l.Name == "" {
|
||||
return fmt.Errorf("listener name cannot be empty")
|
||||
}
|
||||
proto := strings.ToLower(strings.TrimSpace(l.Protocol))
|
||||
if proto == "" {
|
||||
proto = strings.ToLower(strings.TrimSpace(l.Type))
|
||||
}
|
||||
if !mihomoConfig.IsMihomoListenerProtocol(proto) {
|
||||
return fmt.Errorf("unsupported Mihomo listener protocol: %s", proto)
|
||||
}
|
||||
l.Protocol, l.Type = proto, proto
|
||||
|
||||
if !isValidPortString(l.Port) {
|
||||
return fmt.Errorf("port must be a valid port number, range (e.g. 8080-8090), or comma-separated list")
|
||||
}
|
||||
|
||||
l.BindAddress = strings.TrimSpace(l.BindAddress)
|
||||
if l.BindAddress == "" {
|
||||
l.BindAddress = strings.TrimSpace(l.Listen)
|
||||
}
|
||||
if l.BindAddress == "" {
|
||||
l.BindAddress = "0.0.0.0"
|
||||
}
|
||||
|
||||
if l.RoutingMark < 0 {
|
||||
return fmt.Errorf("routing-mark must be non-negative")
|
||||
}
|
||||
|
||||
if err := credentials.EnsureListenerCredentials(l); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := decodeConfig(l.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSchema(proto, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateProtocolSpecific(proto, cfg)
|
||||
}
|
||||
|
||||
// isValidPortString validates mihomo's "ports format":
|
||||
// - single port: "8080"
|
||||
// - port range: "8080-8090"
|
||||
// - port list: "8080,8081,8082"
|
||||
func isValidPortString(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Comma-separated list
|
||||
if strings.Contains(s, ",") {
|
||||
parts := strings.Split(s, ",")
|
||||
for _, p := range parts {
|
||||
if !isValidPortString(strings.TrimSpace(p)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Range
|
||||
if strings.Contains(s, "-") {
|
||||
parts := strings.SplitN(s, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err1 != nil || err2 != nil || start < 1 || end > 65535 || start > end {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Single port
|
||||
port, err := strconv.Atoi(s)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeConfig(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid listener configuration: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validateSchema(proto string, cfg map[string]interface{}) error {
|
||||
schema, ok := mihomoConfig.GetMihomoListenerSchema(proto)
|
||||
if !ok {
|
||||
return fmt.Errorf("no listener schema registered for protocol %s", proto)
|
||||
}
|
||||
return validateObject(proto, "", cfg, schema.Fields, schema.NestedFields)
|
||||
}
|
||||
|
||||
func validateObject(proto, prefix string, value map[string]interface{}, allowed map[string]struct{}, nested map[string]map[string]struct{}) error {
|
||||
for key, child := range value {
|
||||
if _, ok := allowed[key]; !ok {
|
||||
return fmt.Errorf("%s listener: unsupported field %q", proto, joinField(prefix, key))
|
||||
}
|
||||
if childMap, ok := child.(map[string]interface{}); ok {
|
||||
if key == "users" && mapUserProtocols[proto] {
|
||||
continue
|
||||
}
|
||||
childAllowed, hasNestedSchema := nested[key]
|
||||
if !hasNestedSchema {
|
||||
return fmt.Errorf("%s listener: field %q does not accept an object", proto, joinField(prefix, key))
|
||||
}
|
||||
childNested := make(map[string]map[string]struct{})
|
||||
for nestedKey, nestedAllowed := range nested {
|
||||
if strings.HasPrefix(nestedKey, key+".") {
|
||||
childNested[strings.TrimPrefix(nestedKey, key+".")] = nestedAllowed
|
||||
}
|
||||
}
|
||||
if err := validateObject(proto, joinField(prefix, key), childMap, childAllowed, childNested); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var mapUserProtocols = map[string]bool{"anytls": true, "hysteria2": true, "mieru": true, "tuic": true}
|
||||
|
||||
func joinField(prefix, field string) string {
|
||||
if prefix == "" {
|
||||
return field
|
||||
}
|
||||
return prefix + "." + field
|
||||
}
|
||||
|
||||
func validateProtocolSpecific(proto string, cfg map[string]interface{}) error {
|
||||
switch proto {
|
||||
case "anytls":
|
||||
return validateAnyTLS(cfg)
|
||||
case "snell":
|
||||
if value, ok := numeric(cfg["version"]); ok && (value < 1 || value > 5) {
|
||||
return fmt.Errorf("snell version must be between 1 and 5")
|
||||
}
|
||||
case "hysteria2":
|
||||
if obfs, ok := cfg["obfs"].(string); ok && obfs != "" && obfs != "salamander" {
|
||||
return fmt.Errorf("hysteria2 obfs must be salamander")
|
||||
}
|
||||
if !hasCertificatePair(cfg) && !boolValue(cfg["allow-insecure"]) {
|
||||
return fmt.Errorf("hysteria2 listener requires certificate/private-key or allow-insecure")
|
||||
}
|
||||
if users, ok := cfg["users"].(map[string]interface{}); !ok || len(users) == 0 {
|
||||
return fmt.Errorf("hysteria2 listener requires at least one user")
|
||||
}
|
||||
case "trusttunnel":
|
||||
if !hasCertificatePair(cfg) {
|
||||
return fmt.Errorf("trusttunnel listener requires certificate and private-key")
|
||||
}
|
||||
case "tuic":
|
||||
users, token := hasNonEmpty(cfg["users"]), hasNonEmpty(cfg["token"])
|
||||
if users == token {
|
||||
return fmt.Errorf("tuic listener must configure exactly one of users (TUIC V5) or token (TUIC V4)")
|
||||
}
|
||||
if users {
|
||||
if values, ok := cfg["users"].(map[string]interface{}); !ok || len(values) == 0 {
|
||||
return fmt.Errorf("tuic V5 listener users must be a username-to-password map")
|
||||
}
|
||||
}
|
||||
case "vless", "vmess":
|
||||
users, ok := cfg["users"].([]interface{})
|
||||
if !ok || len(users) == 0 {
|
||||
return fmt.Errorf("%s listener requires at least one user", proto)
|
||||
}
|
||||
for i, user := range users {
|
||||
if err := validateUserRow(proto, i, user, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "trojan", "shadowquic":
|
||||
users, ok := cfg["users"].([]interface{})
|
||||
if !ok || len(users) == 0 {
|
||||
return fmt.Errorf("%s listener requires at least one user", proto)
|
||||
}
|
||||
for i, user := range users {
|
||||
if err := validateUserRow(proto, i, user, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "mieru":
|
||||
if users, ok := cfg["users"].(map[string]interface{}); !ok || len(users) == 0 {
|
||||
return fmt.Errorf("mieru listener requires at least one user")
|
||||
}
|
||||
case "sudoku":
|
||||
min, minOK := numeric(cfg["padding-min"])
|
||||
max, maxOK := numeric(cfg["padding-max"])
|
||||
if minOK && maxOK && max < min {
|
||||
return fmt.Errorf("sudoku padding-max must be greater than or equal to padding-min")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnyTLS(cfg map[string]interface{}) error {
|
||||
cert, key := hasString(cfg["certificate"]), hasString(cfg["private-key"])
|
||||
if cert != key {
|
||||
return fmt.Errorf("anytls listener requires certificate and private-key together")
|
||||
}
|
||||
shadow, res, jls := enabledObject(cfg["shadow-tls"]), enabledObject(cfg["res-tls"]), enabledObject(cfg["jls-config"])
|
||||
secureModes := 0
|
||||
if shadow {
|
||||
secureModes++
|
||||
}
|
||||
if res {
|
||||
secureModes++
|
||||
}
|
||||
if jls {
|
||||
secureModes++
|
||||
}
|
||||
if cert {
|
||||
secureModes++
|
||||
}
|
||||
if secureModes > 1 {
|
||||
return fmt.Errorf("anytls listener TLS modes are mutually exclusive: use certificate/private-key, shadow-tls, res-tls, or jls-config")
|
||||
}
|
||||
if !boolValue(cfg["allow-insecure"]) && secureModes == 0 {
|
||||
return fmt.Errorf("anytls listener requires certificate/private-key, shadow-tls, res-tls, jls-config, or allow-insecure=true")
|
||||
}
|
||||
if auth, ok := cfg["client-auth-type"].(string); ok && (auth == "verify-if-given" || auth == "require-and-verify") && !hasString(cfg["client-auth-cert"]) {
|
||||
return fmt.Errorf("anytls listener client-auth-cert is required for %s", auth)
|
||||
}
|
||||
users, ok := cfg["users"].(map[string]interface{})
|
||||
if !ok || len(users) == 0 {
|
||||
return fmt.Errorf("anytls listener requires at least one user")
|
||||
}
|
||||
for username, password := range users {
|
||||
if strings.TrimSpace(username) == "" || !hasString(password) {
|
||||
return fmt.Errorf("anytls listener users must contain non-empty username/password pairs")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enabledObject(v interface{}) bool {
|
||||
m, ok := v.(map[string]interface{})
|
||||
return ok && boolValue(m["enable"])
|
||||
}
|
||||
|
||||
func validateUserRow(proto string, index int, raw interface{}, uuidMode bool) error {
|
||||
row, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("%s listener users[%d] must be an object", proto, index)
|
||||
}
|
||||
allowed := map[string]struct{}{"username": {}}
|
||||
if uuidMode {
|
||||
allowed["uuid"] = struct{}{}
|
||||
if proto == "vmess" {
|
||||
allowed["alterId"] = struct{}{}
|
||||
} else {
|
||||
allowed["flow"] = struct{}{}
|
||||
}
|
||||
} else {
|
||||
allowed["password"] = struct{}{}
|
||||
}
|
||||
for key := range row {
|
||||
if _, ok := allowed[key]; !ok {
|
||||
return fmt.Errorf("%s listener users[%d]: unsupported field %q", proto, index, key)
|
||||
}
|
||||
}
|
||||
if !hasString(row["username"]) {
|
||||
return fmt.Errorf("%s listener users[%d] requires username", proto, index)
|
||||
}
|
||||
if uuidMode && !hasString(row["uuid"]) {
|
||||
return fmt.Errorf("%s listener users[%d] requires uuid", proto, index)
|
||||
}
|
||||
if !uuidMode && !hasString(row["password"]) {
|
||||
return fmt.Errorf("%s listener users[%d] requires password", proto, index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasCertificatePair(cfg map[string]interface{}) bool {
|
||||
return hasString(cfg["certificate"]) && hasString(cfg["private-key"])
|
||||
}
|
||||
|
||||
func hasString(value interface{}) bool {
|
||||
s, ok := value.(string)
|
||||
return ok && strings.TrimSpace(s) != ""
|
||||
}
|
||||
|
||||
func hasNonEmpty(value interface{}) bool {
|
||||
if value == nil {
|
||||
return false
|
||||
}
|
||||
if s, ok := value.(string); ok {
|
||||
return strings.TrimSpace(s) != ""
|
||||
}
|
||||
v := reflect.ValueOf(value)
|
||||
switch v.Kind() {
|
||||
case reflect.Slice, reflect.Map:
|
||||
return v.Len() > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func boolValue(v interface{}) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func numeric(v interface{}) (float64, bool) {
|
||||
n, ok := v.(float64)
|
||||
return n, ok
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
)
|
||||
|
||||
func validNode(protocol, config string) *models.Listener {
|
||||
return &models.Listener{Name: "test", Protocol: protocol, Type: protocol, Port: 443, BindAddress: "0.0.0.0", Enabled: true, Config: config}
|
||||
}
|
||||
|
||||
func TestValidateNodeRejectsUnknownField(t *testing.T) {
|
||||
err := ValidateNode(validNode("vless", `{"users":[{"username":"u","uuid":"00000000-0000-0000-0000-000000000001"}],"not-a-mihomo-field":true}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported field") {
|
||||
t.Fatalf("expected unsupported field error, got %v", err)
|
||||
}
|
||||
}
|
||||
func TestValidateNodeRejectsTLSCertificateMismatch(t *testing.T) {
|
||||
err := ValidateNode(validNode("vless", `{"users":[{"username":"u","uuid":"00000000-0000-0000-0000-000000000001"}],"certificate":"/tmp/server.crt"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "certificate and private-key together") {
|
||||
t.Fatalf("expected certificate pair error, got %v", err)
|
||||
}
|
||||
}
|
||||
func TestValidateNodeRejectsRealityAndCertificateTogether(t *testing.T) {
|
||||
err := ValidateNode(validNode("vless", `{"users":[{"username":"u","uuid":"00000000-0000-0000-0000-000000000001"}],"certificate":"/tmp/server.crt","private-key":"/tmp/server.key","reality-config":{"dest":"example.com:443"}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive TLS modes") {
|
||||
t.Fatalf("expected TLS exclusivity error, got %v", err)
|
||||
}
|
||||
}
|
||||
func TestValidateNodeRejectsVmessFlow(t *testing.T) {
|
||||
err := ValidateNode(validNode("vmess", `{"users":[{"username":"u","uuid":"00000000-0000-0000-0000-000000000001","flow":"xtls-rprx-vision"}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "vmess listener users[0] does not support flow") {
|
||||
t.Fatalf("expected VMess flow error, got %v", err)
|
||||
}
|
||||
}
|
||||
func TestValidateNodeRejectsVlessAlterID(t *testing.T) {
|
||||
err := ValidateNode(validNode("vless", `{"users":[{"username":"u","uuid":"00000000-0000-0000-0000-000000000001","alterId":0}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "vless listener users[0] does not support alterId") {
|
||||
t.Fatalf("expected VLESS alterId error, got %v", err)
|
||||
}
|
||||
}
|
||||
func TestValidateNodeRejectsTuicV4V5Mix(t *testing.T) {
|
||||
err := ValidateNode(validNode("tuic", `{"users":{"00000000-0000-0000-0000-000000000001":"password"},"token":["TOKEN"]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly one of users") {
|
||||
t.Fatalf("expected TUIC mode exclusivity error, got %v", err)
|
||||
}
|
||||
}
|
||||
func TestValidateNodeRejectsAnyTLSReality(t *testing.T) {
|
||||
err := ValidateNode(validNode("anytls", `{"users":{"u":"password"},"reality-config":{"dest":"example.com:443"}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported field") {
|
||||
t.Fatalf("expected AnyTLS schema rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
func TestValidateNodeAcceptsVlessReality(t *testing.T) {
|
||||
err := ValidateNode(validNode("vless", `{"users":[{"username":"u","uuid":"00000000-0000-0000-0000-000000000001"}],"reality-config":{"dest":"example.com:443","private-key":"key","short-id":["0123456789abcdef"],"server-names":["example.com"]}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid VLESS Reality config, got %v", err)
|
||||
}
|
||||
}
|
||||
func TestValidateNodeAcceptsTuicV5(t *testing.T) {
|
||||
err := ValidateNode(validNode("tuic", `{"users":{"00000000-0000-0000-0000-000000000001":"password"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid TUIC V5 config, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeGeneratesVlessClientCredentialWhenMissing(t *testing.T) {
|
||||
listener := validNode("vless", `{}`)
|
||||
if err := ValidateNode(listener); err != nil {
|
||||
t.Fatalf("expected empty VLESS config to be completed, got %v", err)
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(listener.Config), &cfg); err != nil {
|
||||
t.Fatalf("generated config is invalid JSON: %v", err)
|
||||
}
|
||||
users, ok := cfg["users"].([]interface{})
|
||||
if !ok || len(users) != 1 {
|
||||
t.Fatalf("expected one generated VLESS user, got %#v", cfg["users"])
|
||||
}
|
||||
row, ok := users[0].(map[string]interface{})
|
||||
if !ok || row["username"] == "" || row["uuid"] == "" {
|
||||
t.Fatalf("generated VLESS credential is incomplete: %#v", users[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterAccessTokenRoutes(rg *gin.RouterGroup, d Deps) {
|
||||
db := resolveDB(d)
|
||||
h := &AccessTokenHandler{db: db}
|
||||
rg.GET("", h.List)
|
||||
rg.POST("", h.Create)
|
||||
rg.DELETE("/:id", h.Delete)
|
||||
}
|
||||
|
||||
type AccessTokenHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (h *AccessTokenHandler) List(c *gin.Context) {
|
||||
var tokens []models.AccessToken
|
||||
if err := h.db.Find(&tokens).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
func (h *AccessTokenHandler) Create(c *gin.Context) {
|
||||
var token models.AccessToken
|
||||
if err := c.ShouldBindJSON(&token); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.Create(&token).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, token)
|
||||
}
|
||||
|
||||
func (h *AccessTokenHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Delete(&models.AccessToken{}, uint(id)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func resolveDB(d Deps) *gorm.DB {
|
||||
if d.DB != nil {
|
||||
return d.DB
|
||||
}
|
||||
// Production environments must wire dependencies correctly.
|
||||
panic("database dependency is nil: router dependencies must be wired correctly")
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
)
|
||||
|
||||
func registerConfigRoutes(api *gin.RouterGroup, d Deps, cfg *config.Config) {
|
||||
db := resolveDB(d)
|
||||
group := api.Group("/config")
|
||||
|
||||
group.GET("/proxies", func(c *gin.Context) {
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, visual.Proxies)
|
||||
})
|
||||
group.POST("/proxies", func(c *gin.Context) {
|
||||
var proxy mihomoConfig.ProxyEntry
|
||||
if err := c.ShouldBindJSON(&proxy); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if proxy.Name == "" || proxy.Type == "" || proxy.Server == "" || proxy.Port == "" || proxy.Port == "0" || proxy.Port == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "名称、协议、服务器和有效端口不能为空"})
|
||||
return
|
||||
}
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
visual.Proxies = append(visual.Proxies, proxy)
|
||||
if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, proxy)
|
||||
})
|
||||
group.PUT("/proxies/:index", func(c *gin.Context) {
|
||||
idx, err := strconv.Atoi(c.Param("index"))
|
||||
if err != nil || idx < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"})
|
||||
return
|
||||
}
|
||||
var proxy mihomoConfig.ProxyEntry
|
||||
if err = c.ShouldBindJSON(&proxy); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if idx >= len(visual.Proxies) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"})
|
||||
return
|
||||
}
|
||||
visual.Proxies[idx] = proxy
|
||||
if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, proxy)
|
||||
})
|
||||
group.DELETE("/proxies/:index", func(c *gin.Context) {
|
||||
idx, err := strconv.Atoi(c.Param("index"))
|
||||
if err != nil || idx < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"})
|
||||
return
|
||||
}
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if idx >= len(visual.Proxies) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"})
|
||||
return
|
||||
}
|
||||
visual.Proxies = append(visual.Proxies[:idx], visual.Proxies[idx+1:]...)
|
||||
if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
group.GET("/visual", func(c *gin.Context) {
|
||||
visual, err := mihomoConfig.GetVisualConfig(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, visual)
|
||||
})
|
||||
group.POST("/visual", func(c *gin.Context) {
|
||||
var visual mihomoConfig.VisualConfig
|
||||
if err := c.ShouldBindJSON(&visual); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid visual configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := mihomoConfig.SaveVisualConfig(db, visual); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Visual configuration saved"})
|
||||
})
|
||||
group.GET("", func(c *gin.Context) {
|
||||
engine := mihomoConfig.NewConfigEngine(db)
|
||||
yamlStr, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"config": yamlStr})
|
||||
})
|
||||
group.POST("/generate", func(c *gin.Context) {
|
||||
engine := mihomoConfig.NewConfigEngine(db)
|
||||
yamlStr, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(cfg.Mihomo.Config)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(cfg.Mihomo.Config, []byte(yamlStr), 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Configuration generated and written successfully"})
|
||||
})
|
||||
group.POST("/validate", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Config string `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if err := mihomoConfig.ValidateConfigYAML(req.Config); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": true})
|
||||
})
|
||||
group.GET("/download", func(c *gin.Context) {
|
||||
engine := mihomoConfig.NewConfigEngine(db)
|
||||
yamlStr, err := engine.GenerateFinalConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename=config.yaml")
|
||||
c.Data(http.StatusOK, "application/yaml", []byte(yamlStr))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORSMiddleware builds a CORS middleware from configured origins.
|
||||
// - empty list → deny cross-origin (no Access-Control-Allow-Origin header)
|
||||
// - "*" → allow any origin (explicit opt-in)
|
||||
// - otherwise → reflect the request Origin only when it matches an entry
|
||||
func CORSMiddleware(origins []string) gin.HandlerFunc {
|
||||
allowAll := false
|
||||
allowed := make(map[string]struct{}, len(origins))
|
||||
for _, o := range origins {
|
||||
o = strings.TrimSpace(o)
|
||||
if o == "" {
|
||||
continue
|
||||
}
|
||||
if o == "*" {
|
||||
allowAll = true
|
||||
continue
|
||||
}
|
||||
allowed[o] = struct{}{}
|
||||
}
|
||||
|
||||
const allowHeaders = "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With"
|
||||
const allowMethods = "POST, OPTIONS, GET, PUT, DELETE"
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if allowAll {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "false")
|
||||
} else if origin != "" {
|
||||
if _, ok := allowed[origin]; ok {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Vary", "Origin")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "false")
|
||||
}
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", allowHeaders)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", allowMethods)
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/traffic"
|
||||
)
|
||||
|
||||
func registerDashboardRoute(api *gin.RouterGroup, d Deps) {
|
||||
api.GET("/dashboard", func(c *gin.Context) {
|
||||
var mihomoStatus *mihomo.StatusResponse
|
||||
if ms := d.mihomoService(); ms != nil {
|
||||
var err error
|
||||
mihomoStatus, err = ms.GetStatus()
|
||||
if err != nil {
|
||||
mihomoStatus = &mihomo.StatusResponse{Running: false, Version: "unknown", PID: 0, Uptime: "0s"}
|
||||
}
|
||||
} else {
|
||||
mihomoStatus = &mihomo.StatusResponse{Running: false, Version: "unknown", PID: 0, Uptime: "0s"}
|
||||
}
|
||||
|
||||
var sysStatus interface{}
|
||||
if sysSvc := d.systemService(); sysSvc != nil {
|
||||
sysStatus = sysSvc.GetStatus()
|
||||
}
|
||||
|
||||
db := resolveDB(d)
|
||||
var listenerTotal int64
|
||||
var listenerEnabled int64
|
||||
if db != nil {
|
||||
db.Model(&models.Listener{}).Count(&listenerTotal)
|
||||
db.Model(&models.Listener{}).Where("enabled = ?", true).Count(&listenerEnabled)
|
||||
}
|
||||
listenerDisabled := listenerTotal - listenerEnabled
|
||||
if listenerDisabled < 0 {
|
||||
listenerDisabled = 0
|
||||
}
|
||||
|
||||
var trafficSnapshot traffic.Snapshot
|
||||
if ts := d.trafficService(); ts != nil {
|
||||
trafficSnapshot = ts.Current()
|
||||
}
|
||||
var onlineUsers int64
|
||||
if db != nil {
|
||||
db.Model(&models.ProxyUser{}).Where("online = ?", true).Count(&onlineUsers)
|
||||
}
|
||||
activeConnections := trafficSnapshot.Connections
|
||||
if col := d.trafficCollector(); col != nil {
|
||||
activeConnections = len(col.CurrentConnections())
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"mihomo": mihomoStatus,
|
||||
"system": sysStatus,
|
||||
"listeners": gin.H{
|
||||
"total": listenerTotal,
|
||||
"enabled": listenerEnabled,
|
||||
"disabled": listenerDisabled,
|
||||
},
|
||||
"traffic": gin.H{
|
||||
"uploadRate": trafficSnapshot.UploadRate,
|
||||
"downloadRate": trafficSnapshot.DownloadRate,
|
||||
"totalUpload": trafficSnapshot.UploadBytes,
|
||||
"totalDownload": trafficSnapshot.DownloadBytes,
|
||||
"onlineUsers": onlineUsers,
|
||||
"activeConnections": activeConnections,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/node"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/system"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/traffic"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Deps holds runtime dependencies for HTTP handlers.
|
||||
type Deps struct {
|
||||
DB *gorm.DB
|
||||
Config *config.Config
|
||||
Mihomo *mihomo.Service
|
||||
Traffic *traffic.Service
|
||||
TrafficCollector *traffic.Collector
|
||||
User *user.Service
|
||||
Node *node.Service
|
||||
System *system.Service
|
||||
}
|
||||
|
||||
func (d Deps) mihomoService() *mihomo.Service { return d.Mihomo }
|
||||
func (d Deps) trafficService() *traffic.Service { return d.Traffic }
|
||||
func (d Deps) trafficCollector() *traffic.Collector { return d.TrafficCollector }
|
||||
func (d Deps) userService() *user.Service { return d.User }
|
||||
func (d Deps) nodeService() *node.Service { return d.Node }
|
||||
func (d Deps) systemService() *system.Service { return d.System }
|
||||
@@ -0,0 +1,48 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func registerMihomoRoutes(api *gin.RouterGroup, d Deps) {
|
||||
group := api.Group("/mihomo")
|
||||
group.GET("/status", func(c *gin.Context) {
|
||||
status, err := d.mihomoService().GetStatus()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, status)
|
||||
})
|
||||
group.POST("/start", func(c *gin.Context) {
|
||||
if err := d.mihomoService().StartMihomo(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Mihomo started"})
|
||||
})
|
||||
group.POST("/stop", func(c *gin.Context) {
|
||||
if err := d.mihomoService().StopMihomo(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Mihomo stopped"})
|
||||
})
|
||||
group.POST("/restart", func(c *gin.Context) {
|
||||
if err := d.mihomoService().RestartMihomo(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Mihomo restarted"})
|
||||
})
|
||||
group.GET("/logs", func(c *gin.Context) {
|
||||
logs, err := d.mihomoService().GetLogs()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, logs)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/auth"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/node"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/system"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/traffic"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
)
|
||||
|
||||
// SetupRouter builds the Gin engine from config only (tests / legacy).
|
||||
func SetupRouter(cfg *config.Config) *gin.Engine {
|
||||
return SetupRouterWithDeps(Deps{Config: cfg})
|
||||
}
|
||||
|
||||
// SetupRouterWithDeps builds the Gin engine using explicit dependencies.
|
||||
// Route paths and response JSON remain unchanged for API compatibility.
|
||||
func SetupRouterWithDeps(d Deps) *gin.Engine {
|
||||
cfg := d.Config
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
db := resolveDB(d)
|
||||
|
||||
if cfg.Server.Mode == "release" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
} else {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(CORSMiddleware(cfg.Security.CORSOrigins))
|
||||
|
||||
apiV1 := r.Group("/api/v1")
|
||||
{
|
||||
auth.NewHandler(db, cfg).RegisterRoutes(apiV1.Group("/auth"))
|
||||
|
||||
apiV1.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
RegisterAccessTokenRoutes(apiV1, d)
|
||||
|
||||
apiV1.Use(auth.RequireAuth(db, cfg.JWT.Secret))
|
||||
|
||||
RegisterAccessTokenRoutes(apiV1, d)
|
||||
registerDashboardRoute(apiV1, d)
|
||||
|
||||
system.NewHandler(d.systemService()).RegisterRoutes(apiV1.Group("/system"))
|
||||
registerMihomoRoutes(apiV1, d)
|
||||
|
||||
user.NewHandler(d.userService()).RegisterRoutes(apiV1.Group("/users"))
|
||||
|
||||
nodeHandler := node.NewHandler(d.nodeService(), d.userService(), db)
|
||||
nodeHandler.RegisterRoutes(apiV1.Group("/nodes"))
|
||||
nodeHandler.RegisterRoutes(apiV1.Group("/listeners"))
|
||||
|
||||
traffic.RegisterRoutes(
|
||||
apiV1.Group("/traffic"),
|
||||
traffic.NewHandler(d.trafficService(), d.trafficCollector(), db),
|
||||
)
|
||||
|
||||
registerConfigRoutes(apiV1, d, cfg)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/auth"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/router"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/system"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/traffic"
|
||||
)
|
||||
|
||||
func TestHealthAndMihomoAPIs(t *testing.T) {
|
||||
_ = os.RemoveAll("/tmp/3m-ui-router-test")
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Server.Mode = "debug"
|
||||
cfg.Database.Path = "/tmp/3m-ui-router-test/db.sqlite"
|
||||
cfg.Mihomo.Binary = "/tmp/dummy-nonexistent"
|
||||
cfg.Mihomo.Config = "/tmp/3m-ui-router-test/config.yaml"
|
||||
cfg.JWT.Secret = "super-secret-token-key-for-testing-purposes"
|
||||
cfg.Security.CORSOrigins = nil
|
||||
|
||||
db, err := database.InitDB(cfg.Database.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
|
||||
mihomoSvc := mihomo.NewService(cfg)
|
||||
systemSvc := system.NewService()
|
||||
trafficSvc := traffic.NewService()
|
||||
|
||||
r := router.SetupRouterWithDeps(router.Deps{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
Mihomo: mihomoSvc,
|
||||
System: systemSvc,
|
||||
Traffic: trafficSvc,
|
||||
})
|
||||
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/health", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected health status 'ok', got '%v'", resp)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Fatalf("expected CORS allow-origin *, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if _, _, _, err := auth.EnsureAdmin(db, cfg.Database.Path); err != nil {
|
||||
t.Fatalf("ensure admin: %v", err)
|
||||
}
|
||||
result, err := auth.Login(db, cfg.JWT.Secret, auth.LoginInput{Username: "admin", Password: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/dashboard", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+result.Token)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 200 or 403, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if w.Code == http.StatusOK {
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
for _, key := range []string{"mihomo", "system", "listeners"} {
|
||||
if _, exists := resp[key]; !exists {
|
||||
t.Fatalf("expected aggregator to contain %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.RemoveAll("/tmp/3m-ui-router-test")
|
||||
}
|
||||
|
||||
func TestCORSConfiguredOrigin(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Server.Mode = "debug"
|
||||
cfg.Security.CORSOrigins = []string{"https://panel.example.com"}
|
||||
cfg.JWT.Secret = "test-secret"
|
||||
|
||||
r := router.SetupRouter(cfg)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("OPTIONS", "/api/v1/health", nil)
|
||||
req.Header.Set("Origin", "https://panel.example.com")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://panel.example.com" {
|
||||
t.Fatalf("expected reflected origin, got %q", got)
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
req2, _ := http.NewRequest("GET", "/api/v1/health", nil)
|
||||
req2.Header.Set("Origin", "https://evil.example.com")
|
||||
r.ServeHTTP(w2, req2)
|
||||
if got := w2.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("expected no allow-origin for disallowed origin, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var credentialKey []byte
|
||||
|
||||
// InitCredentialKey initializes the key used to encrypt recoverable Mihomo
|
||||
// credentials. The caller should provide a secret from server configuration.
|
||||
func InitCredentialKey(secret string) {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
credentialKey = sum[:]
|
||||
}
|
||||
|
||||
func Encrypt(plain string) (string, error) {
|
||||
if len(credentialKey) == 0 {
|
||||
return "", errors.New("credential encryption key is not initialized")
|
||||
}
|
||||
block, err := aes.NewCipher(credentialKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return base64.RawStdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func Decrypt(encoded string) (string, error) {
|
||||
if len(credentialKey) == 0 {
|
||||
return "", errors.New("credential encryption key is not initialized")
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(credentialKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
n := gcm.NonceSize()
|
||||
if len(raw) < n {
|
||||
return "", errors.New("invalid encrypted credential")
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:n], raw[n:], nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Handler serves system HTTP endpoints using an injected Service.
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewHandler constructs a system HTTP handler.
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers system stats routes under the provided group.
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/status", h.GetSystemStatus)
|
||||
}
|
||||
|
||||
func (h *Handler) GetSystemStatus(c *gin.Context) {
|
||||
stats := h.svc.GetStatus()
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
"github.com/shirou/gopsutil/v4/net"
|
||||
)
|
||||
|
||||
var (
|
||||
netMu sync.Mutex
|
||||
lastRecv uint64
|
||||
lastSent uint64
|
||||
lastTime time.Time
|
||||
)
|
||||
|
||||
// GetSystemStats returns the parsed dynamic system performance statistics
|
||||
func GetSystemStats() *SystemStats {
|
||||
// 1. CPU Usage
|
||||
cpuPercent := 0.0
|
||||
cpuPercents, err := cpu.Percent(time.Duration(100*time.Millisecond), false)
|
||||
if err == nil && len(cpuPercents) > 0 {
|
||||
cpuPercent = math.Round(cpuPercents[0]*10) / 10
|
||||
}
|
||||
|
||||
// 2. Memory Usage
|
||||
var memoryInfo MemoryInfo
|
||||
vMem, err := mem.VirtualMemory()
|
||||
if err == nil {
|
||||
memoryInfo = MemoryInfo{
|
||||
Used: float64(vMem.Used) / (1024 * 1024), // to MB
|
||||
Total: float64(vMem.Total) / (1024 * 1024), // to MB
|
||||
Percent: math.Round(vMem.UsedPercent*10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Disk Usage
|
||||
var diskInfo DiskInfo
|
||||
dUsage, err := disk.Usage("/")
|
||||
if err == nil {
|
||||
diskInfo = DiskInfo{
|
||||
Used: float64(dUsage.Used) / (1024 * 1024 * 1024), // to GB
|
||||
Total: float64(dUsage.Total) / (1024 * 1024 * 1024), // to GB
|
||||
Percent: math.Round(dUsage.UsedPercent*10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Network Rates (upload & download rates in bytes/sec)
|
||||
var networkInfo NetworkInfo
|
||||
netIO, err := net.IOCounters(false)
|
||||
if err == nil && len(netIO) > 0 {
|
||||
netMu.Lock()
|
||||
now := time.Now()
|
||||
currRecv := netIO[0].BytesRecv
|
||||
currSent := netIO[0].BytesSent
|
||||
|
||||
if !lastTime.IsZero() {
|
||||
duration := now.Sub(lastTime).Seconds()
|
||||
if duration > 0 {
|
||||
recvDelta := currRecv - lastRecv
|
||||
sentDelta := currSent - lastSent
|
||||
|
||||
networkInfo.Download = float64(recvDelta) / duration
|
||||
networkInfo.Upload = float64(sentDelta) / duration
|
||||
}
|
||||
}
|
||||
|
||||
lastRecv = currRecv
|
||||
lastSent = currSent
|
||||
lastTime = now
|
||||
netMu.Unlock()
|
||||
}
|
||||
|
||||
return &SystemStats{
|
||||
CPU: CPUInfo{
|
||||
Percent: cpuPercent,
|
||||
},
|
||||
Memory: memoryInfo,
|
||||
Disk: diskInfo,
|
||||
Network: networkInfo,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package system
|
||||
|
||||
type Service struct{}
|
||||
|
||||
// NewService constructs a system metrics service.
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) GetStatus() *SystemStats {
|
||||
return GetSystemStats()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package system_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/system"
|
||||
)
|
||||
|
||||
func TestGetSystemStats(t *testing.T) {
|
||||
stats := system.GetSystemStats()
|
||||
|
||||
if stats == nil {
|
||||
t.Fatal("expected non-nil stats response")
|
||||
}
|
||||
|
||||
if stats.CPU.Percent < 0 {
|
||||
t.Fatalf("unexpected CPU value: %f", stats.CPU.Percent)
|
||||
}
|
||||
|
||||
if stats.Memory.Percent < 0 || stats.Memory.Percent > 100 {
|
||||
t.Fatalf("unexpected Memory percent: %f", stats.Memory.Percent)
|
||||
}
|
||||
|
||||
if stats.Disk.Percent < 0 || stats.Disk.Percent > 100 {
|
||||
t.Fatalf("unexpected Disk percent: %f", stats.Disk.Percent)
|
||||
}
|
||||
|
||||
if stats.Network.Upload < 0 || stats.Network.Download < 0 {
|
||||
t.Fatal("unexpected Network rate metrics")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package system
|
||||
|
||||
type CPUInfo struct {
|
||||
Percent float64 `json:"percent"`
|
||||
}
|
||||
|
||||
type MemoryInfo struct {
|
||||
Used float64 `json:"used"` // in MB
|
||||
Total float64 `json:"total"` // in MB
|
||||
Percent float64 `json:"percent"` // in %
|
||||
}
|
||||
|
||||
type DiskInfo struct {
|
||||
Used float64 `json:"used"` // in GB
|
||||
Total float64 `json:"total"` // in GB
|
||||
Percent float64 `json:"percent"` // in %
|
||||
}
|
||||
|
||||
type NetworkInfo struct {
|
||||
Upload float64 `json:"upload"` // bytes/sec
|
||||
Download float64 `json:"download"` // bytes/sec
|
||||
}
|
||||
|
||||
type SystemStats struct {
|
||||
CPU CPUInfo `json:"cpu"`
|
||||
Memory MemoryInfo `json:"memory"`
|
||||
Disk DiskInfo `json:"disk"`
|
||||
Network NetworkInfo `json:"network"`
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
collector *Collector
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHandler(service *Service, collector *Collector, db *gorm.DB) *Handler {
|
||||
return &Handler{service: service, collector: collector, db: db}
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the traffic endpoints under the given group,
|
||||
// following the same RegisterRoutes(rg) convention used by the
|
||||
// user/node/subscription packages.
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler) {
|
||||
rg.GET("/status", h.Status)
|
||||
rg.GET("/users", h.Users)
|
||||
rg.GET("/connections", h.Connections)
|
||||
}
|
||||
|
||||
// Status returns the current global traffic snapshot (rate + cumulative
|
||||
// totals + active connection count), as last collected by the scheduler.
|
||||
func (h *Handler) Status(c *gin.Context) {
|
||||
if h.service == nil {
|
||||
c.JSON(http.StatusOK, Snapshot{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.service.Current())
|
||||
}
|
||||
|
||||
// Users returns per-proxy-user traffic data.
|
||||
func (h *Handler) Users(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusOK, []UserTraffic{})
|
||||
return
|
||||
}
|
||||
var users []models.ProxyUser
|
||||
if err := h.db.Order("id desc").Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]UserTraffic, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, UserTraffic{
|
||||
UserID: u.ID, Username: u.Username,
|
||||
UploadBytes: u.UploadBytes, DownloadBytes: u.DownloadBytes,
|
||||
Online: u.Online, TrafficUsed: u.TrafficUsed, TrafficLimit: u.TrafficLimit,
|
||||
ExpireTime: u.ExpireTime, LastSeen: u.LastSeen,
|
||||
Blocked: !user.IsCredentialActive(u),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// Connections returns the most recently collected, listener/user-mapped
|
||||
// connection list.
|
||||
func (h *Handler) Connections(c *gin.Context) {
|
||||
if h.collector == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"connections": []ConnectionView{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"connections": h.collector.CurrentConnections()})
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo/api"
|
||||
mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Collector periodically polls Mihomo's external controller API
|
||||
// (/traffic and /connections) and turns the result into:
|
||||
// - a global traffic Snapshot (traffic.Service)
|
||||
// - TrafficRecord history + ProxyUser counters (traffic.UserService)
|
||||
// - an in-memory, API-exposable view of current connections
|
||||
//
|
||||
// Connection -> ProxyUser attribution follows the existing architecture
|
||||
// exactly (Connection -> Listener -> ListenerUser -> ProxyUser) and never
|
||||
// invents a new permission model. When a connection cannot be attributed
|
||||
// without guessing, it is kept as unknown (ListenerID/ProxyUserID nil).
|
||||
type Collector struct {
|
||||
db *gorm.DB
|
||||
client *api.Client
|
||||
svc *Service
|
||||
userSvc *UserService
|
||||
|
||||
mu sync.Mutex
|
||||
prevConn map[string]connSample
|
||||
lastConnections []ConnectionView
|
||||
lastListenerName map[uint]string // listener ID -> name, cached for API consumers
|
||||
}
|
||||
|
||||
type connSample struct {
|
||||
upload int64
|
||||
download int64
|
||||
}
|
||||
|
||||
// NewCollector builds a Collector. baseURL/secret should point at Mihomo's
|
||||
// external-controller (see mihomo/config.GetDefaultTemplate for the
|
||||
// defaults 3m-ui writes into the generated Mihomo config).
|
||||
func NewCollector(db *gorm.DB, baseURL, secret string, svc *Service, userSvc *UserService) *Collector {
|
||||
return &Collector{
|
||||
db: db,
|
||||
client: api.NewClient(baseURL, secret),
|
||||
svc: svc,
|
||||
userSvc: userSvc,
|
||||
prevConn: make(map[string]connSample),
|
||||
}
|
||||
}
|
||||
|
||||
// NewCollectorFromDefaults builds a Collector using the same
|
||||
// external-controller address/secret the Config Engine writes into
|
||||
// Mihomo's own config (mihomo/config.GetDefaultTemplate), so the collector
|
||||
// talks to the same instance 3m-ui manages without introducing a second
|
||||
// place to configure the controller address.
|
||||
func NewCollectorFromDefaults(db *gorm.DB, svc *Service, userSvc *UserService) *Collector {
|
||||
tmpl := mihomoConfig.GetDefaultTemplate()
|
||||
return NewCollector(db, "http://"+tmpl.ExternalController, tmpl.Secret, svc, userSvc)
|
||||
}
|
||||
|
||||
// CollectOnce performs a single collection cycle: fetch, map, and persist.
|
||||
// Errors talking to Mihomo (e.g. core not running) are returned but are
|
||||
// expected to be non-fatal to the caller (the scheduler logs and retries
|
||||
// on the next tick).
|
||||
func (c *Collector) CollectOnce() error {
|
||||
connResp, connErr := c.client.Connections()
|
||||
if connErr != nil {
|
||||
return fmt.Errorf("fetch mihomo connections: %w", connErr)
|
||||
}
|
||||
|
||||
// /traffic gives an instantaneous up/down rate sample (bytes in the
|
||||
// last second) directly from Mihomo, which is more accurate than
|
||||
// deriving a rate from two /connections polls 10s apart. It's treated
|
||||
// as optional: if unavailable, the global snapshot just omits the rate
|
||||
// refinement and callers fall back to Service's own delta calculation.
|
||||
var upRate, downRate int64
|
||||
if t, err := c.client.Traffic(); err == nil {
|
||||
upRate, downRate = t.Up, t.Down
|
||||
}
|
||||
|
||||
listenerNameToID, listenerIDToName, err := c.loadListeners()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load listeners: %w", err)
|
||||
}
|
||||
listenerUsers, err := c.loadListenerUsers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load listener users: %w", err)
|
||||
}
|
||||
usernames, err := c.loadUsernames()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load proxy usernames: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
prevConn := c.prevConn
|
||||
c.mu.Unlock()
|
||||
|
||||
nextPrev := make(map[string]connSample, len(connResp.Connections))
|
||||
views := make([]ConnectionView, 0, len(connResp.Connections))
|
||||
|
||||
type userDelta struct {
|
||||
up, down int64
|
||||
}
|
||||
userDeltas := make(map[uint]userDelta)
|
||||
activeUserIDs := make([]uint, 0)
|
||||
seenUser := make(map[uint]bool)
|
||||
|
||||
for _, conn := range connResp.Connections {
|
||||
prev := prevConn[conn.ID]
|
||||
deltaUp := conn.Upload - prev.upload
|
||||
deltaDown := conn.Download - prev.download
|
||||
if deltaUp < 0 {
|
||||
deltaUp = conn.Upload // counter reset or first sighting mid-connection
|
||||
}
|
||||
if deltaDown < 0 {
|
||||
deltaDown = conn.Download
|
||||
}
|
||||
nextPrev[conn.ID] = connSample{upload: conn.Upload, download: conn.Download}
|
||||
|
||||
view := ConnectionView{
|
||||
ID: conn.ID,
|
||||
Upload: conn.Upload,
|
||||
Download: conn.Download,
|
||||
}
|
||||
|
||||
var listenerID *uint
|
||||
var listenerName string
|
||||
var network, host, sourceIP, destIP, destPort, inboundUser string
|
||||
if conn.Metadata != nil {
|
||||
network = conn.Metadata.Network
|
||||
host = conn.Metadata.Host
|
||||
sourceIP = conn.Metadata.SourceIP
|
||||
destIP = conn.Metadata.DestinationIP
|
||||
destPort = conn.Metadata.DestinationPort
|
||||
inboundUser = conn.Metadata.InboundUser
|
||||
if id, ok := listenerNameToID[conn.Metadata.InboundName]; ok {
|
||||
listenerID = &id
|
||||
listenerName = conn.Metadata.InboundName
|
||||
}
|
||||
}
|
||||
if network == "" {
|
||||
network = conn.Network // fall back to deprecated top-level field
|
||||
}
|
||||
view.Network = network
|
||||
view.Host = host
|
||||
view.SourceIP = sourceIP
|
||||
view.DestinationIP = destIP
|
||||
view.DestinationPort = destPort
|
||||
view.ListenerID = listenerID
|
||||
view.ListenerName = listenerName
|
||||
|
||||
// Attribution, in order of confidence. Never guess: if neither
|
||||
// path applies, the connection stays unattributed.
|
||||
var proxyUserID *uint
|
||||
if inboundUser != "" {
|
||||
// Mihomo told us the authenticated username directly.
|
||||
for id, name := range usernames {
|
||||
if name == inboundUser {
|
||||
uid := id
|
||||
proxyUserID = &uid
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if listenerID != nil {
|
||||
// Fall back: only attribute when the listener has exactly one
|
||||
// bound ProxyUser, since with more than one we cannot tell
|
||||
// which of them this connection belongs to.
|
||||
if ids := listenerUsers[*listenerID]; len(ids) == 1 {
|
||||
uid := ids[0]
|
||||
proxyUserID = &uid
|
||||
}
|
||||
}
|
||||
|
||||
if proxyUserID != nil {
|
||||
view.ProxyUserID = proxyUserID
|
||||
view.Username = usernames[*proxyUserID]
|
||||
d := userDeltas[*proxyUserID]
|
||||
d.up += deltaUp
|
||||
d.down += deltaDown
|
||||
userDeltas[*proxyUserID] = d
|
||||
if !seenUser[*proxyUserID] {
|
||||
seenUser[*proxyUserID] = true
|
||||
activeUserIDs = append(activeUserIDs, *proxyUserID)
|
||||
}
|
||||
}
|
||||
|
||||
views = append(views, view)
|
||||
}
|
||||
|
||||
// Persist per-user deltas and refresh online/last-seen state.
|
||||
for uid, d := range userDeltas {
|
||||
if d.up == 0 && d.down == 0 {
|
||||
continue
|
||||
}
|
||||
if err := c.userSvc.AddSample(uid, d.up, d.down, true); err != nil {
|
||||
return fmt.Errorf("record traffic sample for user %d: %w", uid, err)
|
||||
}
|
||||
}
|
||||
if err := c.userSvc.MarkOffline(activeUserIDs); err != nil {
|
||||
return fmt.Errorf("update online status: %w", err)
|
||||
}
|
||||
|
||||
c.svc.ApplySample(connResp.UploadTotal, connResp.DownloadTotal, len(connResp.Connections), upRate, downRate)
|
||||
|
||||
c.mu.Lock()
|
||||
c.prevConn = nextPrev
|
||||
c.lastConnections = views
|
||||
c.lastListenerName = listenerIDToName
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentConnections returns the most recently mapped connection snapshot.
|
||||
func (c *Collector) CurrentConnections() []ConnectionView {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]ConnectionView, len(c.lastConnections))
|
||||
copy(out, c.lastConnections)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Collector) loadListeners() (nameToID map[string]uint, idToName map[uint]string, err error) {
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Name string
|
||||
}
|
||||
if err := c.db.Model(&models.Listener{}).Select("id, name").Find(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
nameToID = make(map[string]uint, len(rows))
|
||||
idToName = make(map[uint]string, len(rows))
|
||||
for _, r := range rows {
|
||||
nameToID[r.Name] = r.ID
|
||||
idToName[r.ID] = r.Name
|
||||
}
|
||||
return nameToID, idToName, nil
|
||||
}
|
||||
|
||||
// loadListenerUsers returns, for each Listener ID, the ProxyUser IDs bound
|
||||
// to it via ListenerUser -- the existing join table. No new relationship is
|
||||
// introduced.
|
||||
func (c *Collector) loadListenerUsers() (map[uint][]uint, error) {
|
||||
var rows []struct {
|
||||
ListenerID uint
|
||||
ProxyUserID uint
|
||||
}
|
||||
if err := c.db.Model(&models.ListenerUser{}).Select("listener_id, proxy_user_id").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uint][]uint)
|
||||
for _, r := range rows {
|
||||
out[r.ListenerID] = append(out[r.ListenerID], r.ProxyUserID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Collector) loadUsernames() (map[uint]string, error) {
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Username string
|
||||
}
|
||||
if err := c.db.Model(&models.ProxyUser{}).Select("id, username").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uint]string, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.ID] = r.Username
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/node"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Enforcer watches for proxy users that cross into (or out of) a blocked
|
||||
// state -- disabled, expired, or TrafficUsed >= TrafficLimit -- and, when
|
||||
// that changes, triggers a Mihomo config regeneration so the exclusion
|
||||
// takes effect promptly instead of waiting for the next manual node edit.
|
||||
//
|
||||
// The block/allow decision itself is never duplicated here: it is made
|
||||
// exclusively by user.IsCredentialActive, the same predicate
|
||||
// user.Service.ActiveCredentialsByListener uses when building the
|
||||
// credentials that go into the generated Mihomo config. Enforcer only
|
||||
// detects when that predicate's result changed for any user.
|
||||
type Enforcer struct {
|
||||
db *gorm.DB
|
||||
nodeSvc *node.Service
|
||||
|
||||
mu sync.Mutex
|
||||
lastBlocked map[uint]bool
|
||||
}
|
||||
|
||||
// NewEnforcer builds an Enforcer. nodeSvc is used only to call
|
||||
// RegenerateConfig() (which itself calls
|
||||
// user.Service.ActiveCredentialsByListener() and attempts a Mihomo hot
|
||||
// reload) -- no additional filtering logic lives here.
|
||||
func NewEnforcer(db *gorm.DB, nodeSvc *node.Service) *Enforcer {
|
||||
return &Enforcer{db: db, nodeSvc: nodeSvc, lastBlocked: map[uint]bool{}}
|
||||
}
|
||||
|
||||
// CheckAndEnforce recomputes the blocked set and regenerates + hot-reloads
|
||||
// the Mihomo config only if it changed since the last check. Returns the
|
||||
// number of currently blocked users for callers that want to report it
|
||||
// (e.g. the dashboard), and any error encountered.
|
||||
func (e *Enforcer) CheckAndEnforce() (blockedCount int, err error) {
|
||||
var users []models.ProxyUser
|
||||
if err := e.db.Find(&users).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
current := make(map[uint]bool, len(users))
|
||||
for _, u := range users {
|
||||
if !user.IsCredentialActive(u) {
|
||||
current[u.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
changed := !equalBlockedSets(e.lastBlocked, current)
|
||||
e.lastBlocked = current
|
||||
e.mu.Unlock()
|
||||
|
||||
if !changed {
|
||||
return len(current), nil
|
||||
}
|
||||
|
||||
log.Printf("traffic: enforcement state changed (%d user(s) now blocked); regenerating Mihomo config", len(current))
|
||||
if e.nodeSvc == nil {
|
||||
return len(current), nil
|
||||
}
|
||||
if err := e.nodeSvc.RegenerateConfig(); err != nil {
|
||||
return len(current), err
|
||||
}
|
||||
return len(current), nil
|
||||
}
|
||||
|
||||
func equalBlockedSets(a, b map[uint]bool) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k := range a {
|
||||
if !b[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultInterval is how often the scheduler polls Mihomo when no interval
|
||||
// is explicitly configured.
|
||||
const DefaultInterval = 10 * time.Second
|
||||
|
||||
// Scheduler runs the traffic collector and enforcer on a fixed interval.
|
||||
// It is started once at server startup (see cmd/server/main.go) and
|
||||
// stopped gracefully on shutdown.
|
||||
type Scheduler struct {
|
||||
collector *Collector
|
||||
enforcer *Enforcer
|
||||
interval time.Duration
|
||||
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewScheduler builds a Scheduler. A zero/negative interval falls back to
|
||||
// DefaultInterval (10s).
|
||||
func NewScheduler(collector *Collector, enforcer *Enforcer, interval time.Duration) *Scheduler {
|
||||
if interval <= 0 {
|
||||
interval = DefaultInterval
|
||||
}
|
||||
return &Scheduler{
|
||||
collector: collector,
|
||||
enforcer: enforcer,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the collection loop in a background goroutine. Every tick:
|
||||
// 1. collect Mihomo traffic (Collector.CollectOnce)
|
||||
// 2. update global statistics + TrafficRecord history (done inside CollectOnce)
|
||||
// 3. update per-user counters/online state (done inside CollectOnce)
|
||||
// 4. check limits and enforce (Enforcer.CheckAndEnforce)
|
||||
func (s *Scheduler) Start() {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run an immediate first tick so dashboards aren't empty for a
|
||||
// full interval after startup.
|
||||
s.tick()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.tick()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) tick() {
|
||||
if err := s.collector.CollectOnce(); err != nil {
|
||||
// Mihomo core being stopped/unreachable is an expected, recoverable
|
||||
// condition (e.g. before the admin has started it), so this is a
|
||||
// log rather than a panic/fatal.
|
||||
log.Printf("traffic: collection skipped: %v", err)
|
||||
return
|
||||
}
|
||||
if s.enforcer == nil {
|
||||
return
|
||||
}
|
||||
if _, err := s.enforcer.CheckAndEnforce(); err != nil {
|
||||
log.Printf("traffic: enforcement check failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the collection loop to exit and blocks until it has, for
|
||||
// graceful shutdown.
|
||||
func (s *Scheduler) Stop() {
|
||||
close(s.stopCh)
|
||||
s.wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
last Snapshot
|
||||
lastTime time.Time
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{lastTime: time.Now()}
|
||||
}
|
||||
|
||||
func (s *Service) Update(totalUpload, totalDownload int64, connections int) Snapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
seconds := now.Sub(s.lastTime).Seconds()
|
||||
result := Snapshot{
|
||||
UploadBytes: totalUpload,
|
||||
DownloadBytes: totalDownload,
|
||||
Connections: connections,
|
||||
}
|
||||
|
||||
if seconds > 0 {
|
||||
result.UploadRate = int64(float64(totalUpload-s.last.UploadBytes) / seconds)
|
||||
result.DownloadRate = int64(float64(totalDownload-s.last.DownloadBytes) / seconds)
|
||||
}
|
||||
|
||||
s.last = result
|
||||
s.lastTime = now
|
||||
return result
|
||||
}
|
||||
|
||||
// ApplySample records a snapshot using an upload/download rate obtained
|
||||
// directly from Mihomo's /traffic endpoint (an instantaneous reading)
|
||||
// instead of deriving the rate from the delta between two polls. Cumulative
|
||||
// totals and connection count still come from /connections. Used by the
|
||||
// traffic collector; Update above is kept for existing callers/tests that
|
||||
// only have cumulative totals.
|
||||
func (s *Service) ApplySample(totalUpload, totalDownload int64, connections int, uploadRate, downloadRate int64) Snapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
result := Snapshot{
|
||||
UploadBytes: totalUpload,
|
||||
DownloadBytes: totalDownload,
|
||||
Connections: connections,
|
||||
UploadRate: uploadRate,
|
||||
DownloadRate: downloadRate,
|
||||
}
|
||||
s.last = result
|
||||
s.lastTime = time.Now()
|
||||
return result
|
||||
}
|
||||
|
||||
// Current returns the most recently recorded snapshot without mutating
|
||||
// state, so HTTP handlers can read it without perturbing rate calculation.
|
||||
func (s *Service) Current() Snapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.last
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package traffic
|
||||
|
||||
import "time"
|
||||
|
||||
type Snapshot struct {
|
||||
UploadBytes int64 `json:"upload_bytes"`
|
||||
DownloadBytes int64 `json:"download_bytes"`
|
||||
UploadRate int64 `json:"upload_rate"`
|
||||
DownloadRate int64 `json:"download_rate"`
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
|
||||
// UserTraffic is the per-user payload for GET /api/v1/traffic/users.
|
||||
// UserID/UploadBytes/DownloadBytes/Online are the original fields; the rest
|
||||
// are additive so existing consumers keep working.
|
||||
type UserTraffic struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
UploadBytes int64 `json:"upload_bytes"`
|
||||
DownloadBytes int64 `json:"download_bytes"`
|
||||
Online bool `json:"online"`
|
||||
TrafficUsed int64 `json:"traffic_used"`
|
||||
TrafficLimit int64 `json:"traffic_limit"`
|
||||
ExpireTime time.Time `json:"expire_time"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
Blocked bool `json:"blocked"`
|
||||
}
|
||||
|
||||
// ConnectionView is the per-connection payload for
|
||||
// GET /api/v1/traffic/connections. It reflects the ProxyUser ->
|
||||
// ListenerUser -> Listener mapping without introducing any new permission
|
||||
// model: ListenerID/ListenerName come from the existing Listener model, and
|
||||
// ProxyUserID/Username are only populated when the connection could be
|
||||
// attributed without guessing (see collector.go). Unattributed connections
|
||||
// have ProxyUserID == nil and Username == "".
|
||||
type ConnectionView struct {
|
||||
ID string `json:"id"`
|
||||
ListenerID *uint `json:"listener_id"`
|
||||
ListenerName string `json:"listener_name"`
|
||||
ProxyUserID *uint `json:"proxy_user_id"`
|
||||
Username string `json:"username"`
|
||||
Network string `json:"network"`
|
||||
Host string `json:"host"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
DestinationIP string `json:"destination_ip"`
|
||||
DestinationPort string `json:"destination_port"`
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB) *UserService {
|
||||
return &UserService{db: db}
|
||||
}
|
||||
|
||||
// AddSample records an incremental (per-tick delta, not cumulative) upload
|
||||
// and download sample for a proxy user: it appends a TrafficRecord history
|
||||
// row and updates ProxyUser's cumulative counters (TrafficUsed,
|
||||
// UploadBytes, DownloadBytes) plus LastSeen/Online when online is true.
|
||||
func (s *UserService) AddSample(userID uint, up, down int64, online bool) error {
|
||||
record := &models.TrafficRecord{
|
||||
ProxyUserID: userID,
|
||||
UploadBytes: up,
|
||||
DownloadBytes: down,
|
||||
Online: online,
|
||||
}
|
||||
if err := s.db.Create(record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"traffic_used": gorm.Expr("traffic_used + ?", up+down),
|
||||
"upload_bytes": gorm.Expr("upload_bytes + ?", up),
|
||||
"download_bytes": gorm.Expr("download_bytes + ?", down),
|
||||
}
|
||||
if online {
|
||||
now := time.Now()
|
||||
updates["last_seen"] = now
|
||||
updates["online"] = true
|
||||
}
|
||||
|
||||
return s.db.Model(&models.ProxyUser{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// MarkOffline clears the Online flag for every proxy user not present in
|
||||
// the given set of currently-active user IDs. Called once per collection
|
||||
// tick after AddSample has marked the currently-seen users online, so a
|
||||
// user's Online status always reflects the most recent tick.
|
||||
func (s *UserService) MarkOffline(activeUserIDs []uint) error {
|
||||
q := s.db.Model(&models.ProxyUser{}).Where("online = ?", true)
|
||||
if len(activeUserIDs) > 0 {
|
||||
q = q.Where("id NOT IN ?", activeUserIDs)
|
||||
}
|
||||
return q.Update("online", false).Error
|
||||
}
|
||||
|
||||
func IsExpired(u models.ProxyUser) bool {
|
||||
return !u.ExpireTime.IsZero() && u.ExpireTime.Before(time.Now())
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler serves user HTTP endpoints using an injected Service.
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewHandler constructs a user HTTP handler.
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers user routes on the provided group.
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("", h.List)
|
||||
rg.POST("", h.Create)
|
||||
rg.GET("/:id", h.Get)
|
||||
rg.PUT("/:id", h.Update)
|
||||
rg.DELETE("/:id", h.Delete)
|
||||
rg.POST("/:id/listeners", h.BindListeners)
|
||||
rg.GET("/:id/listeners", h.GetListeners)
|
||||
// /nodes is the public naming used by the server-node UI; /listeners is
|
||||
// retained as a backward-compatible alias.
|
||||
rg.POST("/:id/nodes", h.BindListeners)
|
||||
rg.GET("/:id/nodes", h.GetListeners)
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
users, err := h.svc.GetAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]SafeUser, 0, len(users))
|
||||
for i := range users {
|
||||
out = append(out, ToSafeUser(&users[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var in CreateInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := h.svc.Create(in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, ToSafeUser(u))
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u, err := h.svc.GetByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ToSafeUser(u))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in UpdateInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := h.svc.Update(id, in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ToSafeUser(u))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(id); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) BindListeners(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ListenerIDs []uint `json:"listener_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.BindListeners(id, req.ListenerIDs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "listener_ids": req.ListenerIDs})
|
||||
}
|
||||
|
||||
func (h *Handler) GetListeners(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, err := h.svc.GetListeners(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
type nodeDTO struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port string `json:"port"`
|
||||
BindAddress string `json:"bind_address"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TLS bool `json:"tls"`
|
||||
UDP bool `json:"udp"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
out := make([]nodeDTO, 0, len(list))
|
||||
for _, n := range list {
|
||||
out = append(out, nodeDTO{n.ID, n.Name, n.Protocol, n.Port, n.BindAddress, n.Enabled, n.TLS, n.UDP, n.Status})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// Keep models import referenced for future DTO extensions and schema compatibility.
|
||||
var _ = models.Listener{}
|
||||
@@ -0,0 +1,322 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/credentials"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/security"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct{ db *gorm.DB }
|
||||
|
||||
// NewService constructs a user service bound to the given database.
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
UUID string `json:"uuid"`
|
||||
TrafficLimit int64 `json:"traffic_limit"`
|
||||
ExpireTime *time.Time `json:"expire_time"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
type UpdateInput struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
UUID string `json:"uuid"`
|
||||
TrafficLimit *int64 `json:"traffic_limit"`
|
||||
ExpireTime *time.Time `json:"expire_time"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
type Credential struct{ Username, Password, UUID string }
|
||||
|
||||
func (s *Service) Create(in CreateInput) (*models.ProxyUser, error) {
|
||||
username := strings.TrimSpace(in.Username)
|
||||
if username == "" {
|
||||
return nil, errors.New("username is required")
|
||||
}
|
||||
password := in.Password
|
||||
if password == "" {
|
||||
password, _ = randomToken(24)
|
||||
}
|
||||
uuid := in.UUID
|
||||
if uuid == "" {
|
||||
var err error
|
||||
uuid, err = newUUID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
expire := time.Time{}
|
||||
if in.ExpireTime != nil {
|
||||
expire = in.ExpireTime.UTC()
|
||||
}
|
||||
enabled := true
|
||||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
encrypted, err := encryptPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := &models.ProxyUser{Username: username, PasswordEncrypted: encrypted, UUID: uuid, TrafficLimit: in.TrafficLimit, ExpireTime: expire, Enabled: enabled}
|
||||
if err := s.db.Create(u).Error; err != nil {
|
||||
return nil, fmt.Errorf("create proxy user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
func (s *Service) GetAll() ([]models.ProxyUser, error) {
|
||||
var users []models.ProxyUser
|
||||
if err := s.db.Order("id desc").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
func (s *Service) GetByID(id uint) (*models.ProxyUser, error) {
|
||||
var u models.ProxyUser
|
||||
if err := s.db.First(&u, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
func (s *Service) Update(id uint, in UpdateInput) (*models.ProxyUser, error) {
|
||||
u, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(in.Username) != "" {
|
||||
u.Username = strings.TrimSpace(in.Username)
|
||||
}
|
||||
if in.Password != "" {
|
||||
u.PasswordEncrypted, err = encryptPassword(in.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if in.UUID != "" {
|
||||
u.UUID = in.UUID
|
||||
}
|
||||
if in.TrafficLimit != nil {
|
||||
u.TrafficLimit = *in.TrafficLimit
|
||||
}
|
||||
if in.ExpireTime != nil {
|
||||
u.ExpireTime = in.ExpireTime.UTC()
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
u.Enabled = *in.Enabled
|
||||
}
|
||||
if err := s.db.Save(u).Error; err != nil {
|
||||
return nil, fmt.Errorf("update proxy user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
func (s *Service) Delete(id uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("proxy_user_id = ?", id).Delete(&models.ListenerUser{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&models.ProxyUser{}, id).Error
|
||||
})
|
||||
}
|
||||
func (s *Service) BindListeners(userID uint, listenerIDs []uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var user models.ProxyUser
|
||||
if err := tx.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
desired := make([]uint, 0, len(listenerIDs))
|
||||
seen := map[uint]struct{}{}
|
||||
for _, id := range listenerIDs {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
desired = append(desired, id)
|
||||
}
|
||||
if len(desired) > 0 {
|
||||
var count int64
|
||||
if err := tx.Model(&models.Listener{}).Where("id IN ?", desired).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != int64(len(desired)) {
|
||||
return errors.New("one or more listener ids do not exist")
|
||||
}
|
||||
if err := tx.Where("proxy_user_id = ? AND listener_id NOT IN ?", userID, desired).Delete(&models.ListenerUser{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := tx.Where("proxy_user_id = ?", userID).Delete(&models.ListenerUser{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, listenerID := range desired {
|
||||
var binding models.ListenerUser
|
||||
result := tx.Unscoped().Where("listener_id = ? AND proxy_user_id = ?", listenerID, userID).Find(&binding)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
if binding.DeletedAt.Valid {
|
||||
if err := tx.Unscoped().Model(&binding).Update("deleted_at", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := tx.Create(&models.ListenerUser{ListenerID: listenerID, ProxyUserID: userID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func (s *Service) GetListeners(userID uint) ([]models.Listener, error) {
|
||||
var listeners []models.Listener
|
||||
err := s.db.Model(&models.Listener{}).Joins("JOIN listener_users ON listener_users.listener_id = listeners.id AND listener_users.deleted_at IS NULL").Where("listener_users.proxy_user_id = ?", userID).Order("listeners.id").Find(&listeners).Error
|
||||
return listeners, err
|
||||
}
|
||||
func IsCredentialActive(u models.ProxyUser) bool {
|
||||
now := time.Now()
|
||||
if !u.Enabled {
|
||||
return false
|
||||
}
|
||||
if !u.ExpireTime.IsZero() && !u.ExpireTime.After(now) {
|
||||
return false
|
||||
}
|
||||
if u.TrafficLimit > 0 && u.TrafficUsed >= u.TrafficLimit {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) ActiveCredentialsByListener() (map[uint][]Credential, error) {
|
||||
result := make(map[uint][]Credential)
|
||||
var listeners []models.Listener
|
||||
if err := s.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, listener := range listeners {
|
||||
before := listener.Config
|
||||
if err := credentials.EnsureListenerCredentials(&listener); err != nil {
|
||||
return nil, fmt.Errorf("prepare listener %q credentials: %w", listener.Name, err)
|
||||
}
|
||||
if listener.Config != before {
|
||||
if err := s.db.Model(&models.Listener{}).Where("id = ?", listener.ID).Update("config", listener.Config).Error; err != nil {
|
||||
return nil, fmt.Errorf("save generated credentials for listener %q: %w", listener.Name, err)
|
||||
}
|
||||
}
|
||||
creds := credentialsFromListenerConfig(listener.Protocol, listener.Config)
|
||||
if len(creds) > 0 {
|
||||
result[listener.ID] = creds
|
||||
}
|
||||
}
|
||||
var rows []struct {
|
||||
ListenerID uint
|
||||
ProxyUserID uint
|
||||
}
|
||||
if err := s.db.Model(&models.ListenerUser{}).Select("listener_id, proxy_user_id").Where("deleted_at IS NULL").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if len(result[row.ListenerID]) > 0 {
|
||||
continue
|
||||
}
|
||||
u, err := s.GetByID(row.ProxyUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !IsCredentialActive(*u) {
|
||||
continue
|
||||
}
|
||||
password, err := decryptPassword(u.PasswordEncrypted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt proxy user %d password: %w", u.ID, err)
|
||||
}
|
||||
result[row.ListenerID] = append(result[row.ListenerID], Credential{Username: u.Username, Password: password, UUID: u.UUID})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func credentialsFromListenerConfig(protocol, raw string) []Credential {
|
||||
var cfg map[string]interface{}
|
||||
if strings.TrimSpace(raw) == "" || json.Unmarshal([]byte(raw), &cfg) != nil {
|
||||
return nil
|
||||
}
|
||||
users, ok := cfg["users"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := []Credential{}
|
||||
switch protocol {
|
||||
case "anytls", "hysteria2", "mieru", "tuic":
|
||||
if m, ok := users.(map[string]interface{}); ok {
|
||||
for username, value := range m {
|
||||
result = append(result, Credential{Username: username, Password: fmt.Sprint(value), UUID: username})
|
||||
}
|
||||
}
|
||||
default:
|
||||
if list, ok := users.([]interface{}); ok {
|
||||
for _, value := range list {
|
||||
row, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result = append(result, Credential{Username: fmt.Sprint(row["username"]), Password: fmt.Sprint(row["password"]), UUID: fmt.Sprint(row["uuid"])})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func safeMask(s string) string {
|
||||
if len(s) <= 8 {
|
||||
return "********"
|
||||
}
|
||||
return s[:4] + "..." + s[len(s)-4:]
|
||||
}
|
||||
|
||||
type SafeUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
UUIDMasked string `json:"uuid_masked"`
|
||||
TrafficLimit int64 `json:"traffic_limit"`
|
||||
TrafficUsed int64 `json:"traffic_used"`
|
||||
UploadBytes int64 `json:"upload_bytes"`
|
||||
DownloadBytes int64 `json:"download_bytes"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
Online bool `json:"online"`
|
||||
ExpireTime time.Time `json:"expire_time"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Blocked bool `json:"blocked"`
|
||||
}
|
||||
|
||||
func ToSafeUser(u *models.ProxyUser) SafeUser {
|
||||
return SafeUser{ID: u.ID, Username: u.Username, UUIDMasked: safeMask(u.UUID), TrafficLimit: u.TrafficLimit, TrafficUsed: u.TrafficUsed, UploadBytes: u.UploadBytes, DownloadBytes: u.DownloadBytes, LastSeen: u.LastSeen, Online: u.Online, ExpireTime: u.ExpireTime, Enabled: u.Enabled, Blocked: !IsCredentialActive(*u)}
|
||||
}
|
||||
func encryptPassword(plain string) (string, error) { return security.Encrypt(plain) }
|
||||
func decryptPassword(encoded string) (string, error) { return security.Decrypt(encoded) }
|
||||
func newUUID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/security"
|
||||
)
|
||||
|
||||
func TestCredentialEncryptionRoundTrip(t *testing.T) {
|
||||
security.InitCredentialKey("test-secret")
|
||||
encrypted, err := encryptPassword("super-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if encrypted == "super-secret" || encrypted == "" {
|
||||
t.Fatal("password was not encrypted")
|
||||
}
|
||||
decrypted, err := decryptPassword(encrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decrypted != "super-secret" {
|
||||
t.Fatalf("unexpected decrypted value: %q", decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialEncryptionIsRandomized(t *testing.T) {
|
||||
security.InitCredentialKey("test-secret")
|
||||
a, err := encryptPassword("same")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := encryptPassword("same")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("expected randomized ciphertext")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/security"
|
||||
)
|
||||
|
||||
func TestActiveCredentialsFiltering(t *testing.T) {
|
||||
security.InitCredentialKey("test-secret")
|
||||
db, err := database.InitDB(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(db)
|
||||
|
||||
l1 := models.Listener{Name: "ss", Protocol: "shadowsocks", Port: 8388, Enabled: true}
|
||||
if err := db.Create(&l1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
active, err := svc.Create(CreateInput{Username: "active", Password: "p1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expiredAt := time.Now().Add(-time.Hour)
|
||||
expired, err := svc.Create(CreateInput{Username: "expired", Password: "p2", ExpireTime: &expiredAt})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limited, err := svc.Create(CreateInput{Username: "limited", Password: "p3", TrafficLimit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(limited).Update("traffic_used", int64(10)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range []uint{active.ID, expired.ID, limited.ID} {
|
||||
if err := db.Create(&models.ListenerUser{ListenerID: l1.ID, ProxyUserID: id}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
creds, err := svc.ActiveCredentialsByListener()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(creds[l1.ID]) != 1 || creds[l1.ID][0].Password != "p1" {
|
||||
t.Fatalf("expected only active user credential, got %#v", creds[l1.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindListenersIsReplacement(t *testing.T) {
|
||||
security.InitCredentialKey("test-secret")
|
||||
db, err := database.InitDB(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(db)
|
||||
|
||||
u, err := svc.Create(CreateInput{Username: "bind-user", Password: "p"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var listeners []models.Listener
|
||||
for i := 0; i < 3; i++ {
|
||||
l := models.Listener{Name: "n", Protocol: "trojan", Port: 10000 + i, Enabled: true}
|
||||
if err := db.Create(&l).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
}
|
||||
if err := svc.BindListeners(u.ID, []uint{listeners[0].ID, listeners[1].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.BindListeners(u.ID, []uint{listeners[2].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := svc.GetListeners(u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != listeners[2].ID {
|
||||
t.Fatalf("expected replacement binding, got %#v", got)
|
||||
}
|
||||
|
||||
if err := svc.BindListeners(u.ID, []uint{listeners[2].ID, listeners[2].ID, listeners[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = svc.GetListeners(u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].ID != listeners[0].ID || got[1].ID != listeners[2].ID {
|
||||
t.Fatalf("expected deduplicated replacement binding, got %#v", got)
|
||||
}
|
||||
var rows []models.ListenerUser
|
||||
if err := db.Unscoped().Where("proxy_user_id = ?", u.ID).Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected soft-deleted bindings to be reused without duplicates, got %#v", rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user