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:
dzx941 committed 2026-08-16 09:21:42 +08:00
commit 5b4cb63a73
143 files changed
+11975

No files matched your search

+56
View File
@@ -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
+118
View File
@@ -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
+321
View File
@@ -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
+277
View File
@@ -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.
+35
View File
@@ -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)
+252
View File
@@ -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)。
+19
View File
@@ -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.
+12
View File
@@ -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
+18
View File
@@ -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"
+60
View File
@@ -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
View File
@@ -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=
+152
View File
@@ -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
}
+73
View File
@@ -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,
}
}
+271
View File
@@ -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
}
+20
View File
@@ -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
}
+32
View File
@@ -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")
}
}
+83
View File
@@ -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
}
+112
View File
@@ -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))
}
+82
View File
@@ -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"`
}
+45
View File
@@ -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)
}
}
}
+566
View File
@@ -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")
}
}
+10
View File
@@ -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
}
+110
View File
@@ -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
}
+47
View File
@@ -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"`
}
+12
View File
@@ -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"
+110
View File
@@ -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"})
}
+25
View File
@@ -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
}
+6
View File
@@ -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"`
}
+135
View File
@@ -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() }
+86
View File
@@ -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
}
+44
View File
@@ -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)
}
+13
View File
@@ -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
}
+47
View File
@@ -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)
}
+374
View File
@@ -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)
}
}
}
+19
View File
@@ -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
}
+109
View File
@@ -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
}
+62
View File
@@ -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
}
+786
View File
@@ -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)
}
+100
View File
@@ -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")
}
}
+113
View File
@@ -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
}
+25
View File
@@ -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"`
}
+269
View File
@@ -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"),
}
}
+181
View File
@@ -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
}
}
+86
View File
@@ -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")
}
}
+127
View File
@@ -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()
}
+27
View File
@@ -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
}
+460
View File
@@ -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
}
+51
View File
@@ -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)
}
+133
View File
@@ -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
}
+336
View File
@@ -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
}
+87
View File
@@ -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])
}
}
+65
View File
@@ -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")
}
+168
View File
@@ -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))
})
}
+53
View File
@@ -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()
}
}
+73
View File
@@ -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,
},
})
})
}
+30
View File
@@ -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 }
+48
View File
@@ -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)
})
}
+71
View File
@@ -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
}
+124
View File
@@ -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)
}
}
+67
View File
@@ -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
}
+27
View File
@@ -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)
}
+86
View File
@@ -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,
}
}
+12
View File
@@ -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()
}
+31
View File
@@ -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")
}
}
+29
View File
@@ -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"`
}
+73
View File
@@ -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()})
}
+273
View File
@@ -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
}
+85
View File
@@ -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
}
+87
View File
@@ -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()
}
+68
View File
@@ -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
}
+49
View File
@@ -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"`
}
+63
View File
@@ -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())
}
+169
View File
@@ -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{}
+322
View File
@@ -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
}
+40
View File
@@ -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")
}
}
+108
View File
@@ -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)
}
}
+24
View File
@@ -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