From 5b4cb63a73091b24d53e2dd1d28eb325df0fac21 Mon Sep 17 00:00:00 2001 From: dzx941 Date: Sun, 16 Aug 2026 08:43:07 +0800 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 56 ++ .github/workflows/release.yml | 118 +++ AGENTS.md | 321 +++++++ LICENSE | 277 ++++++ Makefile | 35 + README.md | 252 ++++++ THIRD-PARTY-NOTICES.md | 19 + backend/.gitignore | 12 + backend/config/config.yaml | 18 + backend/go.mod | 60 ++ backend/go.sum | 174 ++++ backend/internal/app/app.go | 152 ++++ backend/internal/app/container.go | 73 ++ backend/internal/auth/api.go | 271 ++++++ backend/internal/auth/bcrypt.go | 20 + backend/internal/auth/bcrypt_test.go | 32 + backend/internal/auth/jwt.go | 83 ++ backend/internal/auth/service.go | 112 +++ backend/internal/config/config.go | 82 ++ backend/internal/config/config_test.go | 45 + backend/internal/converter/client.go | 566 +++++++++++++ backend/internal/converter/converter_test.go | 144 ++++ backend/internal/converter/exporter.go | 10 + .../converter/listener_schema_test.go | 74 ++ backend/internal/converter/subconverter.go | 54 ++ backend/internal/credentials/generator.go | 110 +++ backend/internal/database/database.go | 47 ++ .../internal/database/models/access_token.go | 18 + backend/internal/database/models/config.go | 11 + backend/internal/database/models/listener.go | 21 + .../internal/database/models/listener_user.go | 17 + .../internal/database/models/proxy_user.go | 33 + .../internal/database/models/subscription.go | 15 + .../database/models/traffic_record.go | 14 + backend/internal/database/models/user.go | 12 + .../internal/database/sqlite_driver_mattn.go | 5 + .../database/sqlite_driver_modernc.go | 7 + backend/internal/listener/api.go | 110 +++ backend/internal/listener/generator.go | 25 + backend/internal/listener/listener_test.go | 64 ++ backend/internal/listener/model.go | 6 + backend/internal/listener/service.go | 135 +++ backend/internal/mihomo/api.go | 86 ++ backend/internal/mihomo/api/client.go | 44 + backend/internal/mihomo/api/mihomo.go | 13 + backend/internal/mihomo/api/types.go | 47 ++ backend/internal/mihomo/config/config_test.go | 91 ++ backend/internal/mihomo/config/generator.go | 374 +++++++++ .../config/generator_listener_schema_test.go | 107 +++ .../internal/mihomo/config/listener_export.go | 11 + .../mihomo/config/listener_export_test.go | 39 + .../mihomo/config/listener_schema_registry.go | 164 ++++ .../config/listener_schema_registry_test.go | 50 ++ .../mihomo/config/mihomo_listener_schema.go | 29 + .../config/mihomo_listener_schema_test.go | 16 + backend/internal/mihomo/config/models.go | 19 + backend/internal/mihomo/config/template.go | 25 + backend/internal/mihomo/config/validator.go | 40 + backend/internal/mihomo/config/visual.go | 109 +++ backend/internal/mihomo/manager.go | 62 ++ backend/internal/mihomo/process.go | 786 ++++++++++++++++++ backend/internal/mihomo/process_test.go | 100 +++ backend/internal/mihomo/service.go | 113 +++ backend/internal/mihomo/types.go | 25 + backend/internal/node/api.go | 269 ++++++ backend/internal/node/generator.go | 181 ++++ backend/internal/node/node_test.go | 86 ++ backend/internal/node/service.go | 127 +++ backend/internal/node/types.go | 27 + backend/internal/node/uri.go | 460 ++++++++++ backend/internal/node/uri_credentials.go | 51 ++ backend/internal/node/uri_extended.go | 133 +++ backend/internal/node/validator.go | 336 ++++++++ backend/internal/node/validator_test.go | 87 ++ backend/internal/router/access_token.go | 65 ++ backend/internal/router/config_routes.go | 168 ++++ backend/internal/router/cors.go | 53 ++ backend/internal/router/dashboard.go | 73 ++ backend/internal/router/deps.go | 30 + backend/internal/router/mihomo_routes.go | 48 ++ backend/internal/router/router.go | 71 ++ backend/internal/router/router_test.go | 124 +++ backend/internal/security/credentials.go | 67 ++ backend/internal/system/api.go | 27 + backend/internal/system/metrics.go | 86 ++ backend/internal/system/service.go | 12 + backend/internal/system/system_test.go | 31 + backend/internal/system/types.go | 29 + backend/internal/traffic/api.go | 73 ++ backend/internal/traffic/collector.go | 273 ++++++ backend/internal/traffic/enforcement.go | 85 ++ backend/internal/traffic/scheduler.go | 87 ++ backend/internal/traffic/service.go | 68 ++ backend/internal/traffic/types.go | 49 ++ backend/internal/traffic/user.go | 63 ++ backend/internal/user/api.go | 169 ++++ backend/internal/user/service.go | 322 +++++++ backend/internal/user/service_test.go | 40 + backend/internal/user/user_test.go | 108 +++ frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/README.md | 32 + frontend/index.html | 14 + frontend/package.json | 32 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 57 ++ frontend/src/api/auth.ts | 31 + frontend/src/api/client.ts | 49 ++ frontend/src/api/listeners.ts | 4 + frontend/src/api/nodes.ts | 22 + frontend/src/api/request.ts | 3 + frontend/src/api/system.ts | 21 + frontend/src/api/users.ts | 14 + frontend/src/components/AppLayout.tsx | 35 + frontend/src/components/Header.tsx | 29 + frontend/src/components/ProtectedRoute.tsx | 23 + frontend/src/components/Sidebar.tsx | 74 ++ frontend/src/hooks/useAuth.ts | 12 + frontend/src/hooks/useTheme.ts | 19 + frontend/src/layouts/SidebarLayout.tsx | 8 + frontend/src/main.tsx | 10 + frontend/src/pages/ChangePassword.tsx | 53 ++ frontend/src/pages/Config.tsx | 3 + frontend/src/pages/Core.tsx | 3 + frontend/src/pages/Dashboard.tsx | 119 +++ frontend/src/pages/Listeners.tsx | 269 ++++++ frontend/src/pages/Login.tsx | 53 ++ frontend/src/pages/Logs.tsx | 29 + frontend/src/pages/Settings.tsx | 3 + frontend/src/pages/Users.tsx | 3 + frontend/src/stores/authStore.ts | 36 + frontend/src/types/index.ts | 11 + frontend/src/utils/format.ts | 20 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.app.json | 24 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 15 + scripts/3m-ui | 135 +++ scripts/3m-ui.sh | 144 ++++ scripts/install.sh | 224 +++++ scripts/uninstall.sh | 63 ++ scripts/update.sh | 154 ++++ 143 files changed, 11975 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 THIRD-PARTY-NOTICES.md create mode 100644 backend/.gitignore create mode 100644 backend/config/config.yaml create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/app/app.go create mode 100644 backend/internal/app/container.go create mode 100644 backend/internal/auth/api.go create mode 100644 backend/internal/auth/bcrypt.go create mode 100644 backend/internal/auth/bcrypt_test.go create mode 100644 backend/internal/auth/jwt.go create mode 100644 backend/internal/auth/service.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/converter/client.go create mode 100644 backend/internal/converter/converter_test.go create mode 100644 backend/internal/converter/exporter.go create mode 100644 backend/internal/converter/listener_schema_test.go create mode 100644 backend/internal/converter/subconverter.go create mode 100644 backend/internal/credentials/generator.go create mode 100644 backend/internal/database/database.go create mode 100644 backend/internal/database/models/access_token.go create mode 100644 backend/internal/database/models/config.go create mode 100644 backend/internal/database/models/listener.go create mode 100644 backend/internal/database/models/listener_user.go create mode 100644 backend/internal/database/models/proxy_user.go create mode 100644 backend/internal/database/models/subscription.go create mode 100644 backend/internal/database/models/traffic_record.go create mode 100644 backend/internal/database/models/user.go create mode 100644 backend/internal/database/sqlite_driver_mattn.go create mode 100644 backend/internal/database/sqlite_driver_modernc.go create mode 100644 backend/internal/listener/api.go create mode 100644 backend/internal/listener/generator.go create mode 100644 backend/internal/listener/listener_test.go create mode 100644 backend/internal/listener/model.go create mode 100644 backend/internal/listener/service.go create mode 100644 backend/internal/mihomo/api.go create mode 100644 backend/internal/mihomo/api/client.go create mode 100644 backend/internal/mihomo/api/mihomo.go create mode 100644 backend/internal/mihomo/api/types.go create mode 100644 backend/internal/mihomo/config/config_test.go create mode 100644 backend/internal/mihomo/config/generator.go create mode 100644 backend/internal/mihomo/config/generator_listener_schema_test.go create mode 100644 backend/internal/mihomo/config/listener_export.go create mode 100644 backend/internal/mihomo/config/listener_export_test.go create mode 100644 backend/internal/mihomo/config/listener_schema_registry.go create mode 100644 backend/internal/mihomo/config/listener_schema_registry_test.go create mode 100644 backend/internal/mihomo/config/mihomo_listener_schema.go create mode 100644 backend/internal/mihomo/config/mihomo_listener_schema_test.go create mode 100644 backend/internal/mihomo/config/models.go create mode 100644 backend/internal/mihomo/config/template.go create mode 100644 backend/internal/mihomo/config/validator.go create mode 100644 backend/internal/mihomo/config/visual.go create mode 100644 backend/internal/mihomo/manager.go create mode 100644 backend/internal/mihomo/process.go create mode 100644 backend/internal/mihomo/process_test.go create mode 100644 backend/internal/mihomo/service.go create mode 100644 backend/internal/mihomo/types.go create mode 100644 backend/internal/node/api.go create mode 100644 backend/internal/node/generator.go create mode 100644 backend/internal/node/node_test.go create mode 100644 backend/internal/node/service.go create mode 100644 backend/internal/node/types.go create mode 100644 backend/internal/node/uri.go create mode 100644 backend/internal/node/uri_credentials.go create mode 100644 backend/internal/node/uri_extended.go create mode 100644 backend/internal/node/validator.go create mode 100644 backend/internal/node/validator_test.go create mode 100644 backend/internal/router/access_token.go create mode 100644 backend/internal/router/config_routes.go create mode 100644 backend/internal/router/cors.go create mode 100644 backend/internal/router/dashboard.go create mode 100644 backend/internal/router/deps.go create mode 100644 backend/internal/router/mihomo_routes.go create mode 100644 backend/internal/router/router.go create mode 100644 backend/internal/router/router_test.go create mode 100644 backend/internal/security/credentials.go create mode 100644 backend/internal/system/api.go create mode 100644 backend/internal/system/metrics.go create mode 100644 backend/internal/system/service.go create mode 100644 backend/internal/system/system_test.go create mode 100644 backend/internal/system/types.go create mode 100644 backend/internal/traffic/api.go create mode 100644 backend/internal/traffic/collector.go create mode 100644 backend/internal/traffic/enforcement.go create mode 100644 backend/internal/traffic/scheduler.go create mode 100644 backend/internal/traffic/service.go create mode 100644 backend/internal/traffic/types.go create mode 100644 backend/internal/traffic/user.go create mode 100644 backend/internal/user/api.go create mode 100644 backend/internal/user/service.go create mode 100644 backend/internal/user/service_test.go create mode 100644 backend/internal/user/user_test.go create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/listeners.ts create mode 100644 frontend/src/api/nodes.ts create mode 100644 frontend/src/api/request.ts create mode 100644 frontend/src/api/system.ts create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/components/AppLayout.tsx create mode 100644 frontend/src/components/Header.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/hooks/useTheme.ts create mode 100644 frontend/src/layouts/SidebarLayout.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/ChangePassword.tsx create mode 100644 frontend/src/pages/Config.tsx create mode 100644 frontend/src/pages/Core.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Listeners.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Logs.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/Users.tsx create mode 100644 frontend/src/stores/authStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/format.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 scripts/3m-ui create mode 100644 scripts/3m-ui.sh create mode 100644 scripts/install.sh create mode 100644 scripts/uninstall.sh create mode 100644 scripts/update.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b8057e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d9629a2 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..90412c3 --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0cd7c9f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..abd234d --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a69a71 --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# 3M-UI — Mihomo Core Management Console + +

+ English | 中文 +

+ +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. + +--- + +--- + +

+ +

3M-UI — Mihomo 核心管理控制台

+ +

+ English | 中文 +

+ +[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)。 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..8255f58 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -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. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b48150a --- /dev/null +++ b/backend/.gitignore @@ -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 diff --git a/backend/config/config.yaml b/backend/config/config.yaml new file mode 100644 index 0000000..4c39a2e --- /dev/null +++ b/backend/config/config.yaml @@ -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" diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..0a5b347 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..ff27f68 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go new file mode 100644 index 0000000..a1b410c --- /dev/null +++ b/backend/internal/app/app.go @@ -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 +} diff --git a/backend/internal/app/container.go b/backend/internal/app/container.go new file mode 100644 index 0000000..0873319 --- /dev/null +++ b/backend/internal/app/container.go @@ -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, + } +} diff --git a/backend/internal/auth/api.go b/backend/internal/auth/api.go new file mode 100644 index 0000000..cdcc130 --- /dev/null +++ b/backend/internal/auth/api.go @@ -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 +} diff --git a/backend/internal/auth/bcrypt.go b/backend/internal/auth/bcrypt.go new file mode 100644 index 0000000..78552f2 --- /dev/null +++ b/backend/internal/auth/bcrypt.go @@ -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 +} diff --git a/backend/internal/auth/bcrypt_test.go b/backend/internal/auth/bcrypt_test.go new file mode 100644 index 0000000..3ab1677 --- /dev/null +++ b/backend/internal/auth/bcrypt_test.go @@ -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") + } +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..1e459c9 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -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 +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..c0fdabf --- /dev/null +++ b/backend/internal/auth/service.go @@ -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)) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..3141d6e --- /dev/null +++ b/backend/internal/config/config.go @@ -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"` +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..8fb8139 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -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) + } + } +} diff --git a/backend/internal/converter/client.go b/backend/internal/converter/client.go new file mode 100644 index 0000000..0d6667d --- /dev/null +++ b/backend/internal/converter/client.go @@ -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 +} diff --git a/backend/internal/converter/converter_test.go b/backend/internal/converter/converter_test.go new file mode 100644 index 0000000..b4c33f1 --- /dev/null +++ b/backend/internal/converter/converter_test.go @@ -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") + } +} diff --git a/backend/internal/converter/exporter.go b/backend/internal/converter/exporter.go new file mode 100644 index 0000000..35fc63d --- /dev/null +++ b/backend/internal/converter/exporter.go @@ -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) +} diff --git a/backend/internal/converter/listener_schema_test.go b/backend/internal/converter/listener_schema_test.go new file mode 100644 index 0000000..6227cda --- /dev/null +++ b/backend/internal/converter/listener_schema_test.go @@ -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") + } + }) + } +} diff --git a/backend/internal/converter/subconverter.go b/backend/internal/converter/subconverter.go new file mode 100644 index 0000000..7fd343e --- /dev/null +++ b/backend/internal/converter/subconverter.go @@ -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 +} diff --git a/backend/internal/credentials/generator.go b/backend/internal/credentials/generator.go new file mode 100644 index 0000000..a2a20ad --- /dev/null +++ b/backend/internal/credentials/generator.go @@ -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 +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..ea16a69 --- /dev/null +++ b/backend/internal/database/database.go @@ -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 +} diff --git a/backend/internal/database/models/access_token.go b/backend/internal/database/models/access_token.go new file mode 100644 index 0000000..ed3cfde --- /dev/null +++ b/backend/internal/database/models/access_token.go @@ -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"` +} diff --git a/backend/internal/database/models/config.go b/backend/internal/database/models/config.go new file mode 100644 index 0000000..fc51d85 --- /dev/null +++ b/backend/internal/database/models/config.go @@ -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"` +} diff --git a/backend/internal/database/models/listener.go b/backend/internal/database/models/listener.go new file mode 100644 index 0000000..c769a2d --- /dev/null +++ b/backend/internal/database/models/listener.go @@ -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"` +} diff --git a/backend/internal/database/models/listener_user.go b/backend/internal/database/models/listener_user.go new file mode 100644 index 0000000..85de32c --- /dev/null +++ b/backend/internal/database/models/listener_user.go @@ -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:"-"` +} diff --git a/backend/internal/database/models/proxy_user.go b/backend/internal/database/models/proxy_user.go new file mode 100644 index 0000000..f2f597d --- /dev/null +++ b/backend/internal/database/models/proxy_user.go @@ -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"` +} diff --git a/backend/internal/database/models/subscription.go b/backend/internal/database/models/subscription.go new file mode 100644 index 0000000..f83dec0 --- /dev/null +++ b/backend/internal/database/models/subscription.go @@ -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"` +} diff --git a/backend/internal/database/models/traffic_record.go b/backend/internal/database/models/traffic_record.go new file mode 100644 index 0000000..578a1d5 --- /dev/null +++ b/backend/internal/database/models/traffic_record.go @@ -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"` +} diff --git a/backend/internal/database/models/user.go b/backend/internal/database/models/user.go new file mode 100644 index 0000000..08cd978 --- /dev/null +++ b/backend/internal/database/models/user.go @@ -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:"-"` +} diff --git a/backend/internal/database/sqlite_driver_mattn.go b/backend/internal/database/sqlite_driver_mattn.go new file mode 100644 index 0000000..d73f0e9 --- /dev/null +++ b/backend/internal/database/sqlite_driver_mattn.go @@ -0,0 +1,5 @@ +//go:build !sqlite_modernc + +package database + +const sqliteDriverName = "sqlite3" diff --git a/backend/internal/database/sqlite_driver_modernc.go b/backend/internal/database/sqlite_driver_modernc.go new file mode 100644 index 0000000..179bedc --- /dev/null +++ b/backend/internal/database/sqlite_driver_modernc.go @@ -0,0 +1,7 @@ +//go:build sqlite_modernc + +package database + +import _ "modernc.org/sqlite" + +const sqliteDriverName = "sqlite" diff --git a/backend/internal/listener/api.go b/backend/internal/listener/api.go new file mode 100644 index 0000000..3153223 --- /dev/null +++ b/backend/internal/listener/api.go @@ -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"}) +} diff --git a/backend/internal/listener/generator.go b/backend/internal/listener/generator.go new file mode 100644 index 0000000..3e37267 --- /dev/null +++ b/backend/internal/listener/generator.go @@ -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 +} diff --git a/backend/internal/listener/listener_test.go b/backend/internal/listener/listener_test.go new file mode 100644 index 0000000..70dcbbd --- /dev/null +++ b/backend/internal/listener/listener_test.go @@ -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 +} diff --git a/backend/internal/listener/model.go b/backend/internal/listener/model.go new file mode 100644 index 0000000..4d61563 --- /dev/null +++ b/backend/internal/listener/model.go @@ -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"` +} diff --git a/backend/internal/listener/service.go b/backend/internal/listener/service.go new file mode 100644 index 0000000..8b47168 --- /dev/null +++ b/backend/internal/listener/service.go @@ -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() } diff --git a/backend/internal/mihomo/api.go b/backend/internal/mihomo/api.go new file mode 100644 index 0000000..ea34228 --- /dev/null +++ b/backend/internal/mihomo/api.go @@ -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 +} diff --git a/backend/internal/mihomo/api/client.go b/backend/internal/mihomo/api/client.go new file mode 100644 index 0000000..25c087c --- /dev/null +++ b/backend/internal/mihomo/api/client.go @@ -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) +} diff --git a/backend/internal/mihomo/api/mihomo.go b/backend/internal/mihomo/api/mihomo.go new file mode 100644 index 0000000..ca8d355 --- /dev/null +++ b/backend/internal/mihomo/api/mihomo.go @@ -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 +} diff --git a/backend/internal/mihomo/api/types.go b/backend/internal/mihomo/api/types.go new file mode 100644 index 0000000..3375d0e --- /dev/null +++ b/backend/internal/mihomo/api/types.go @@ -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"` +} diff --git a/backend/internal/mihomo/config/config_test.go b/backend/internal/mihomo/config/config_test.go new file mode 100644 index 0000000..f9cb8a7 --- /dev/null +++ b/backend/internal/mihomo/config/config_test.go @@ -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) +} diff --git a/backend/internal/mihomo/config/generator.go b/backend/internal/mihomo/config/generator.go new file mode 100644 index 0000000..187be75 --- /dev/null +++ b/backend/internal/mihomo/config/generator.go @@ -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 + } +} diff --git a/backend/internal/mihomo/config/generator_listener_schema_test.go b/backend/internal/mihomo/config/generator_listener_schema_test.go new file mode 100644 index 0000000..0603516 --- /dev/null +++ b/backend/internal/mihomo/config/generator_listener_schema_test.go @@ -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) + } +} diff --git a/backend/internal/mihomo/config/listener_export.go b/backend/internal/mihomo/config/listener_export.go new file mode 100644 index 0000000..a500f6a --- /dev/null +++ b/backend/internal/mihomo/config/listener_export.go @@ -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) +} diff --git a/backend/internal/mihomo/config/listener_export_test.go b/backend/internal/mihomo/config/listener_export_test.go new file mode 100644 index 0000000..88f88cb --- /dev/null +++ b/backend/internal/mihomo/config/listener_export_test.go @@ -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) + } +} diff --git a/backend/internal/mihomo/config/listener_schema_registry.go b/backend/internal/mihomo/config/listener_schema_registry.go new file mode 100644 index 0000000..2c35ce4 --- /dev/null +++ b/backend/internal/mihomo/config/listener_schema_registry.go @@ -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 +} diff --git a/backend/internal/mihomo/config/listener_schema_registry_test.go b/backend/internal/mihomo/config/listener_schema_registry_test.go new file mode 100644 index 0000000..b220c66 --- /dev/null +++ b/backend/internal/mihomo/config/listener_schema_registry_test.go @@ -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) + } + } + } +} diff --git a/backend/internal/mihomo/config/mihomo_listener_schema.go b/backend/internal/mihomo/config/mihomo_listener_schema.go new file mode 100644 index 0000000..e4fd73f --- /dev/null +++ b/backend/internal/mihomo/config/mihomo_listener_schema.go @@ -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 +} diff --git a/backend/internal/mihomo/config/mihomo_listener_schema_test.go b/backend/internal/mihomo/config/mihomo_listener_schema_test.go new file mode 100644 index 0000000..831b227 --- /dev/null +++ b/backend/internal/mihomo/config/mihomo_listener_schema_test.go @@ -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) + } + } +} diff --git a/backend/internal/mihomo/config/models.go b/backend/internal/mihomo/config/models.go new file mode 100644 index 0000000..c063e6d --- /dev/null +++ b/backend/internal/mihomo/config/models.go @@ -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"` +} diff --git a/backend/internal/mihomo/config/template.go b/backend/internal/mihomo/config/template.go new file mode 100644 index 0000000..b564221 --- /dev/null +++ b/backend/internal/mihomo/config/template.go @@ -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", + }, + } +} diff --git a/backend/internal/mihomo/config/validator.go b/backend/internal/mihomo/config/validator.go new file mode 100644 index 0000000..60eede6 --- /dev/null +++ b/backend/internal/mihomo/config/validator.go @@ -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 +} diff --git a/backend/internal/mihomo/config/visual.go b/backend/internal/mihomo/config/visual.go new file mode 100644 index 0000000..37d8948 --- /dev/null +++ b/backend/internal/mihomo/config/visual.go @@ -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 +} diff --git a/backend/internal/mihomo/manager.go b/backend/internal/mihomo/manager.go new file mode 100644 index 0000000..92a85b2 --- /dev/null +++ b/backend/internal/mihomo/manager.go @@ -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 +} diff --git a/backend/internal/mihomo/process.go b/backend/internal/mihomo/process.go new file mode 100644 index 0000000..2bafc45 --- /dev/null +++ b/backend/internal/mihomo/process.go @@ -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) +} diff --git a/backend/internal/mihomo/process_test.go b/backend/internal/mihomo/process_test.go new file mode 100644 index 0000000..2feda8e --- /dev/null +++ b/backend/internal/mihomo/process_test.go @@ -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") + } +} diff --git a/backend/internal/mihomo/service.go b/backend/internal/mihomo/service.go new file mode 100644 index 0000000..cf22572 --- /dev/null +++ b/backend/internal/mihomo/service.go @@ -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 +} diff --git a/backend/internal/mihomo/types.go b/backend/internal/mihomo/types.go new file mode 100644 index 0000000..ae36908 --- /dev/null +++ b/backend/internal/mihomo/types.go @@ -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"` +} diff --git a/backend/internal/node/api.go b/backend/internal/node/api.go new file mode 100644 index 0000000..807434f --- /dev/null +++ b/backend/internal/node/api.go @@ -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"), + } +} diff --git a/backend/internal/node/generator.go b/backend/internal/node/generator.go new file mode 100644 index 0000000..b5acfff --- /dev/null +++ b/backend/internal/node/generator.go @@ -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 + } +} diff --git a/backend/internal/node/node_test.go b/backend/internal/node/node_test.go new file mode 100644 index 0000000..8a80db3 --- /dev/null +++ b/backend/internal/node/node_test.go @@ -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") + } +} diff --git a/backend/internal/node/service.go b/backend/internal/node/service.go new file mode 100644 index 0000000..f3d027b --- /dev/null +++ b/backend/internal/node/service.go @@ -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() +} diff --git a/backend/internal/node/types.go b/backend/internal/node/types.go new file mode 100644 index 0000000..5ec3dba --- /dev/null +++ b/backend/internal/node/types.go @@ -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 +} diff --git a/backend/internal/node/uri.go b/backend/internal/node/uri.go new file mode 100644 index 0000000..5af77e3 --- /dev/null +++ b/backend/internal/node/uri.go @@ -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 +} diff --git a/backend/internal/node/uri_credentials.go b/backend/internal/node/uri_credentials.go new file mode 100644 index 0000000..9759f58 --- /dev/null +++ b/backend/internal/node/uri_credentials.go @@ -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) +} diff --git a/backend/internal/node/uri_extended.go b/backend/internal/node/uri_extended.go new file mode 100644 index 0000000..ea6032a --- /dev/null +++ b/backend/internal/node/uri_extended.go @@ -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 +} diff --git a/backend/internal/node/validator.go b/backend/internal/node/validator.go new file mode 100644 index 0000000..1f94186 --- /dev/null +++ b/backend/internal/node/validator.go @@ -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 +} diff --git a/backend/internal/node/validator_test.go b/backend/internal/node/validator_test.go new file mode 100644 index 0000000..bcac1c9 --- /dev/null +++ b/backend/internal/node/validator_test.go @@ -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]) + } +} diff --git a/backend/internal/router/access_token.go b/backend/internal/router/access_token.go new file mode 100644 index 0000000..fca1f90 --- /dev/null +++ b/backend/internal/router/access_token.go @@ -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") +} diff --git a/backend/internal/router/config_routes.go b/backend/internal/router/config_routes.go new file mode 100644 index 0000000..46337a8 --- /dev/null +++ b/backend/internal/router/config_routes.go @@ -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)) + }) +} diff --git a/backend/internal/router/cors.go b/backend/internal/router/cors.go new file mode 100644 index 0000000..8852aaa --- /dev/null +++ b/backend/internal/router/cors.go @@ -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() + } +} diff --git a/backend/internal/router/dashboard.go b/backend/internal/router/dashboard.go new file mode 100644 index 0000000..8d91a0f --- /dev/null +++ b/backend/internal/router/dashboard.go @@ -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, + }, + }) + }) +} diff --git a/backend/internal/router/deps.go b/backend/internal/router/deps.go new file mode 100644 index 0000000..5c0c5c2 --- /dev/null +++ b/backend/internal/router/deps.go @@ -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 } diff --git a/backend/internal/router/mihomo_routes.go b/backend/internal/router/mihomo_routes.go new file mode 100644 index 0000000..be83d93 --- /dev/null +++ b/backend/internal/router/mihomo_routes.go @@ -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) + }) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go new file mode 100644 index 0000000..7fe6639 --- /dev/null +++ b/backend/internal/router/router.go @@ -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 +} diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go new file mode 100644 index 0000000..2fd71ce --- /dev/null +++ b/backend/internal/router/router_test.go @@ -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) + } +} diff --git a/backend/internal/security/credentials.go b/backend/internal/security/credentials.go new file mode 100644 index 0000000..606e9e0 --- /dev/null +++ b/backend/internal/security/credentials.go @@ -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 +} diff --git a/backend/internal/system/api.go b/backend/internal/system/api.go new file mode 100644 index 0000000..24f2b96 --- /dev/null +++ b/backend/internal/system/api.go @@ -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) +} diff --git a/backend/internal/system/metrics.go b/backend/internal/system/metrics.go new file mode 100644 index 0000000..a5fb184 --- /dev/null +++ b/backend/internal/system/metrics.go @@ -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, + } +} diff --git a/backend/internal/system/service.go b/backend/internal/system/service.go new file mode 100644 index 0000000..9425ff1 --- /dev/null +++ b/backend/internal/system/service.go @@ -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() +} diff --git a/backend/internal/system/system_test.go b/backend/internal/system/system_test.go new file mode 100644 index 0000000..b15b4e9 --- /dev/null +++ b/backend/internal/system/system_test.go @@ -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") + } +} diff --git a/backend/internal/system/types.go b/backend/internal/system/types.go new file mode 100644 index 0000000..ede884e --- /dev/null +++ b/backend/internal/system/types.go @@ -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"` +} diff --git a/backend/internal/traffic/api.go b/backend/internal/traffic/api.go new file mode 100644 index 0000000..705d218 --- /dev/null +++ b/backend/internal/traffic/api.go @@ -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()}) +} diff --git a/backend/internal/traffic/collector.go b/backend/internal/traffic/collector.go new file mode 100644 index 0000000..69a1bea --- /dev/null +++ b/backend/internal/traffic/collector.go @@ -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 +} diff --git a/backend/internal/traffic/enforcement.go b/backend/internal/traffic/enforcement.go new file mode 100644 index 0000000..cba3319 --- /dev/null +++ b/backend/internal/traffic/enforcement.go @@ -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 +} diff --git a/backend/internal/traffic/scheduler.go b/backend/internal/traffic/scheduler.go new file mode 100644 index 0000000..847338d --- /dev/null +++ b/backend/internal/traffic/scheduler.go @@ -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() +} diff --git a/backend/internal/traffic/service.go b/backend/internal/traffic/service.go new file mode 100644 index 0000000..d7095bb --- /dev/null +++ b/backend/internal/traffic/service.go @@ -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 +} diff --git a/backend/internal/traffic/types.go b/backend/internal/traffic/types.go new file mode 100644 index 0000000..5128072 --- /dev/null +++ b/backend/internal/traffic/types.go @@ -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"` +} diff --git a/backend/internal/traffic/user.go b/backend/internal/traffic/user.go new file mode 100644 index 0000000..6dfd897 --- /dev/null +++ b/backend/internal/traffic/user.go @@ -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()) +} diff --git a/backend/internal/user/api.go b/backend/internal/user/api.go new file mode 100644 index 0000000..17bb7fe --- /dev/null +++ b/backend/internal/user/api.go @@ -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{} diff --git a/backend/internal/user/service.go b/backend/internal/user/service.go new file mode 100644 index 0000000..ac14bde --- /dev/null +++ b/backend/internal/user/service.go @@ -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 +} diff --git a/backend/internal/user/service_test.go b/backend/internal/user/service_test.go new file mode 100644 index 0000000..32f48f1 --- /dev/null +++ b/backend/internal/user/service_test.go @@ -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") + } +} diff --git a/backend/internal/user/user_test.go b/backend/internal/user/user_test.go new file mode 100644 index 0000000..f3f619a --- /dev/null +++ b/backend/internal/user/user_test.go @@ -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) + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4b6f226 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + 3m-ui · Mihomo Core Manager UI + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8561aed --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^6.3.2", + "@monaco-editor/react": "^4.7.0", + "antd": "^6.5.4", + "axios": "^1.7.9", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "dayjs": "^1.11.19", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0" + } +} diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7193e82 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { ConfigProvider, App as AntApp } from 'antd'; +import AppLayout from './components/AppLayout'; +import ProtectedRoute from './components/ProtectedRoute'; +import Login from './pages/Login'; +import ChangePassword from './pages/ChangePassword'; +import Dashboard from './pages/Dashboard'; +import Listeners from './pages/Listeners'; +import Users from './pages/Users'; +import Core from './pages/Core'; +import Logs from './pages/Logs'; +import ConfigPage from './pages/Config'; +import Settings from './pages/Settings'; + +const App: React.FC = () => { + return ( + + + + + } /> + + + + } + /> + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + + ); +}; + +export default App; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..66a21eb --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,31 @@ +import client from './client'; +import { useAuthStore } from '../stores/authStore'; + +export interface LoginInput { + username: string; + password: string; +} + +export async function login(input: LoginInput) { + const { data } = await client.post<{ + token: string; + username: string; + must_change_password: boolean; + }>('/auth/login', input); + useAuthStore.getState().login(data.token, data.username, data.must_change_password); + return data; +} + +export async function changePassword(current: string, next: string) { + const { data } = await client.post('/auth/password', { + current_password: current, + new_password: next, + }); + useAuthStore.getState().setMustChangePassword(false); + return data; +} + +export async function fetchMe() { + const { data } = await client.get('/auth/me'); + return data; +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..dfb9081 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,49 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { useAuthStore } from '../stores/authStore'; + +const client = axios.create({ + baseURL: '/api/v1', + timeout: 15000, + headers: { Accept: 'application/json' }, +}); + +// Request interceptor: inject JWT +client.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = useAuthStore.getState().token; + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Response interceptor: handle auth errors globally +client.interceptors.response.use( + (res) => res, + (err: AxiosError<{ error?: string; code?: string }>) => { + if (!err.response) { + return Promise.reject(new Error('Backend service unreachable')); + } + const { status, data } = err.response; + + if (status === 401) { + useAuthStore.getState().logout(); + window.location.href = '/login'; + return Promise.reject(new Error('Session expired')); + } + if (status === 403 && data?.code === 'PASSWORD_CHANGE_REQUIRED') { + if (window.location.pathname !== '/change-password') { + useAuthStore.getState().setMustChangePassword(true); + window.location.href = '/change-password'; + } + return Promise.reject(new Error('Password change required')); + } + if (status === 410) { + return Promise.reject(new Error('Resource expired or gone')); + } + + const msg = data?.error || `Request failed (${status})`; + return Promise.reject(new Error(msg)); + } +); + +export default client; diff --git a/frontend/src/api/listeners.ts b/frontend/src/api/listeners.ts new file mode 100644 index 0000000..32cc4f7 --- /dev/null +++ b/frontend/src/api/listeners.ts @@ -0,0 +1,4 @@ +import { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI, Listener } from './nodes'; + +export { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI }; +export type { Listener }; diff --git a/frontend/src/api/nodes.ts b/frontend/src/api/nodes.ts new file mode 100644 index 0000000..fea9010 --- /dev/null +++ b/frontend/src/api/nodes.ts @@ -0,0 +1,22 @@ +import client from './client'; + +export interface Listener { + id: number; + name: string; + protocol: string; + port: number; + bind_address: string; + enabled: boolean; + udp: boolean; + tls: boolean; + config: string; + status: string; + created_at?: string; +} + +export const fetchListeners = () => client.get('/nodes').then(r => r.data); +export const createListener = (payload: Partial) => client.post('/nodes', payload).then(r => r.data); +export const updateListener = (id: number, payload: Partial) => client.put(`/nodes/${id}`, payload).then(r => r.data); +export const deleteListener = (id: number) => client.delete(`/nodes/${id}`); +export const reloadListener = (id: number) => client.post(`/nodes/${id}/reload`); +export const exportNodeURI = (id: number) => client.get<{ name: string; protocol: string; uris: string[] }>(`/nodes/${id}/uri`).then(r => r.data); diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..1c219da --- /dev/null +++ b/frontend/src/api/request.ts @@ -0,0 +1,3 @@ +import client from './client'; +export default client; +export { client as request }; diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts new file mode 100644 index 0000000..e754636 --- /dev/null +++ b/frontend/src/api/system.ts @@ -0,0 +1,21 @@ +import client from './client'; + +export interface SystemStatus { + cpu: { percent: number }; + memory: { used: number; total: number; percent: number }; + disk: { used: number; total: number; percent: number }; + network: { upload: number; download: number }; +} + +export interface MihomoStatus { + running: boolean; + version: string; + pid: number; + uptime: string; +} + +export const fetchDashboard = () => client.get('/dashboard').then(r => r.data); +export const startMihomo = () => client.post('/mihomo/start'); +export const stopMihomo = () => client.post('/mihomo/stop'); +export const restartMihomo = () => client.post('/mihomo/restart'); +export const fetchLogs = () => client.get<{ logs: string[] }>('/mihomo/logs').then(r => r.data); diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..ac9fdca --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,14 @@ +import client from './client'; + +export interface ProxyUser { + id: number; + username: string; + password?: string; + enabled: boolean; + created_at?: string; +} + +export const fetchUsers = () => client.get('/users').then(r => r.data); +export const createUser = (payload: Partial) => client.post('/users', payload).then(r => r.data); +export const updateUser = (id: number, payload: Partial) => client.put(`/users/${id}`, payload).then(r => r.data); +export const deleteUser = (id: number) => client.delete(`/users/${id}`); diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..08902fa --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react'; +import { Layout, theme } from 'antd'; +import Sidebar from './Sidebar'; +import HeaderBar from './Header'; + +const { Content } = Layout; + +const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [collapsed, setCollapsed] = useState(false); + const { + token: { colorBgContainer, borderRadiusLG }, + } = theme.useToken(); + + return ( + + + + + + {children} + + + + ); +}; + +export default AppLayout; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..cfa5148 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Layout, Button, theme, Space, Tag } from 'antd'; +import { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined } from '@ant-design/icons'; +import { useAuthStore } from '../stores/authStore'; + +const { Header } = Layout; + +const HeaderBar: React.FC<{ + collapsed: boolean; + setCollapsed: (v: boolean) => void; +}> = ({ collapsed, setCollapsed }) => { + const { token } = theme.useToken(); + const username = useAuthStore((s) => s.username); + + return ( +
+
+ ); +}; + +export default HeaderBar; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..ca7e8a4 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; + +const ProtectedRoute: React.FC<{ children: React.ReactNode; allowUnchanged?: boolean }> = ({ + children, + allowUnchanged = false, +}) => { + const location = useLocation(); + const { isAuthenticated, mustChangePassword } = useAuthStore(); + + if (!isAuthenticated()) { + return ; + } + + if (mustChangePassword && !allowUnchanged && location.pathname !== '/change-password') { + return ; + } + + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..cb6c9cc --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Layout, Menu } from 'antd'; +import { + DashboardOutlined, + NodeIndexOutlined, + UserOutlined, + SettingOutlined, + CodeOutlined, + FileTextOutlined, + ApiOutlined, + LogoutOutlined, +} from '@ant-design/icons'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; + +const { Sider } = Layout; + +const items = [ + { key: '/', icon: , label: 'Dashboard' }, + { key: '/listeners', icon: , label: 'Listeners' }, + { key: '/users', icon: , label: 'Users' }, + { key: '/core', icon: , label: 'Core' }, + { key: '/logs', icon: , label: 'Logs' }, + { key: '/config', icon: , label: 'Config' }, + { key: '/settings', icon: , label: 'Settings' }, +]; + +const Sidebar: React.FC<{ collapsed: boolean }> = ({ collapsed }) => { + const navigate = useNavigate(); + const location = useLocation(); + const logout = useAuthStore((s) => s.logout); + + return ( + +
+ 3M-UI +
+ navigate(key)} + /> + , + label: 'Logout', + onClick: () => { + logout(); + navigate('/login'); + }, + }, + ]} + style={{ position: 'absolute', bottom: 0, width: '100%' }} + /> + + ); +}; + +export default Sidebar; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..2f22299 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,12 @@ +import { useAuthStore } from '../stores/authStore'; + +export function useAuth() { + const store = useAuthStore(); + return { + isAuthenticated: store.isAuthenticated(), + username: store.username, + mustChangePassword: store.mustChangePassword, + login: store.login, + logout: store.logout, + }; +} diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000..ffe1ec6 --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react'; + +type Theme = 'light' | 'dark'; + +export function useTheme() { + const [theme, setTheme] = useState(() => { + const saved = localStorage.getItem('3m-ui-theme'); + return (saved as Theme) || 'light'; + }); + + useEffect(() => { + localStorage.setItem('3m-ui-theme', theme); + document.documentElement.setAttribute('data-theme', theme); + }, [theme]); + + const toggle = () => setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); + + return { theme, toggle, isDark: theme === 'dark' }; +} diff --git a/frontend/src/layouts/SidebarLayout.tsx b/frontend/src/layouts/SidebarLayout.tsx new file mode 100644 index 0000000..9f30721 --- /dev/null +++ b/frontend/src/layouts/SidebarLayout.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import AppLayout from '../components/AppLayout'; + +const SidebarLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return {children}; +}; + +export default SidebarLayout; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..7da97d7 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import 'antd/dist/reset.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/ChangePassword.tsx b/frontend/src/pages/ChangePassword.tsx new file mode 100644 index 0000000..eb5d6b5 --- /dev/null +++ b/frontend/src/pages/ChangePassword.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, Form, Input, Button, Typography, Alert, message } from 'antd'; +import { changePassword } from '../api/auth'; + +const { Title } = Typography; + +const ChangePassword: React.FC = () => { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + + const onFinish = async (values: { current_password: string; new_password: string; confirm: string }) => { + if (values.new_password !== values.confirm) { + message.error('Passwords do not match'); + return; + } + setLoading(true); + try { + await changePassword(values.current_password, values.new_password); + message.success('Password updated'); + navigate('/'); + } catch (e: any) { + message.error(e.message || 'Failed to change password'); + } finally { + setLoading(false); + } + }; + + return ( +
+ + Change Password + +
+ + + + + + + + + + +
+
+
+ ); +}; + +export default ChangePassword; diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx new file mode 100644 index 0000000..2500214 --- /dev/null +++ b/frontend/src/pages/Config.tsx @@ -0,0 +1,3 @@ +import React from 'react'; +const ConfigPage: React.FC = () =>

Config

Placeholder for YAML config editor.

; +export default ConfigPage; diff --git a/frontend/src/pages/Core.tsx b/frontend/src/pages/Core.tsx new file mode 100644 index 0000000..3e91a94 --- /dev/null +++ b/frontend/src/pages/Core.tsx @@ -0,0 +1,3 @@ +import React from 'react'; +const Core: React.FC = () =>

Core

Placeholder for core settings.

; +export default Core; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..bcd5648 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react'; +import { Row, Col, Card, Statistic, Button, Space, Tag, message } from 'antd'; +import { + PlayCircleOutlined, + PauseCircleOutlined, + ReloadOutlined, + NodeIndexOutlined, + ClockCircleOutlined, +} from '@ant-design/icons'; +import { fetchDashboard, startMihomo, stopMihomo, restartMihomo } from '../api/system'; +import { fetchListeners } from '../api/nodes'; + +const Dashboard: React.FC = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + + const load = async () => { + setLoading(true); + try { + const [dash, nodes] = await Promise.all([fetchDashboard(), fetchListeners()]); + const total = nodes.length; + const enabled = nodes.filter((n: any) => n.enabled).length; + setData({ ...dash, listeners: { total, enabled, disabled: total - enabled } }); + } catch (e: any) { + message.error(e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + const id = setInterval(load, 10000); + return () => clearInterval(id); + }, []); + + const act = async (action: 'start' | 'stop' | 'restart') => { + setBusy(true); + try { + if (action === 'start') await startMihomo(); + else if (action === 'stop') await stopMihomo(); + else await restartMihomo(); + message.success(`${action} succeeded`); + load(); + } catch (e: any) { + message.error(e.message); + } finally { + setBusy(false); + } + }; + + const mihomo = data?.mihomo; + const running = mihomo?.running; + + return ( +
+

Dashboard

+ + + + +
+ {mihomo?.version || '-'} + }>{mihomo?.uptime || '-'} +
+ + {!running && ( + + )} + {running && ( + + )} + + +
+ + + + + } + /> + + + + + + + + + + + + +
+ {((data?.memory?.used || 0) / 1024).toFixed(1)} GB / {((data?.memory?.total || 0) / 1024).toFixed(1)} GB +
+
+ +
+
+ ); +}; + +export default Dashboard; diff --git a/frontend/src/pages/Listeners.tsx b/frontend/src/pages/Listeners.tsx new file mode 100644 index 0000000..6a26650 --- /dev/null +++ b/frontend/src/pages/Listeners.tsx @@ -0,0 +1,269 @@ +import React, { useEffect, useState } from 'react'; +import { + Table, + Button, + Space, + Tag, + Modal, + Form, + Input, + InputNumber, + Select, + Switch, + message, + Popconfirm, + Tooltip, + Card, +} from 'antd'; +import { + PlusOutlined, + ReloadOutlined, + QrcodeOutlined, + DeleteOutlined, + EditOutlined, + CopyOutlined, +} from '@ant-design/icons'; +import { + fetchListeners, + createListener, + updateListener, + deleteListener, + reloadListener, + exportNodeURI, + Listener, +} from '../api/nodes'; + +const PROTOCOLS = [ + 'shadowsocks', 'snell', 'vmess', 'vless', 'trojan', + 'hysteria2', 'tuic', 'shadowquic', 'anytls', 'mieru', + 'sudoku', 'trusttunnel', +]; + +const Listeners: React.FC = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const [uris, setUris] = useState([]); + const [uriModal, setUriModal] = useState(false); + + const load = async () => { + setLoading(true); + try { + const res = await fetchListeners(); + setData(res); + } catch (e: any) { + message.error(e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const onSubmit = async (values: any) => { + try { + const payload = { + ...values, + config: values.config ? JSON.stringify(JSON.parse(values.config)) : '{}', + }; + if (editing) { + await updateListener(editing.id, payload); + message.success('Updated'); + } else { + await createListener(payload); + message.success('Created'); + } + setModalOpen(false); + setEditing(null); + form.resetFields(); + load(); + } catch (e: any) { + message.error(e.message); + } + }; + + const onDelete = async (id: number) => { + try { + await deleteListener(id); + message.success('Deleted'); + load(); + } catch (e: any) { + message.error(e.message); + } + }; + + const onReload = async (id: number) => { + try { + await reloadListener(id); + message.success('Reloaded'); + } catch (e: any) { + message.error(e.message); + } + }; + + const showURIs = async (id: number) => { + try { + const res = await exportNodeURI(id); + setUris(res.uris); + setUriModal(true); + } catch (e: any) { + message.error(e.message); + } + }; + + const columns = [ + { title: 'Name', dataIndex: 'name', key: 'name' }, + { title: 'Protocol', dataIndex: 'protocol', key: 'protocol', render: (p: string) => {p} }, + { title: 'Port', dataIndex: 'port', key: 'port' }, + { + title: 'Status', + dataIndex: 'enabled', + key: 'enabled', + render: (v: boolean) => ( + {v ? 'Enabled' : 'Disabled'} + ), + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: Listener) => ( + + + + + + + + + + { + setModalOpen(false); + setEditing(null); + form.resetFields(); + }} + onOk={() => form.submit()} + width={640} + > +
+ + + + + + + + + + + + + + + + { + if (!v) return Promise.resolve(); + try { + JSON.parse(v); + return Promise.resolve(); + } catch { + return Promise.reject(new Error('Invalid JSON')); + } + }, + }, + ]} + > + + + +
+ + setUriModal(false)} + footer={null} + > + + {uris.map((uri, i) => ( + } + type="text" + onClick={() => { + navigator.clipboard.writeText(uri); + message.success('Copied'); + }} + /> + } + /> + ))} + + + + ); +}; + +export default Listeners; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..21b15c2 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { Card, Form, Input, Button, Typography, message } from 'antd'; +import { UserOutlined, LockOutlined } from '@ant-design/icons'; +import { login } from '../api/auth'; + +const { Title } = Typography; + +const Login: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [loading, setLoading] = useState(false); + const from = (location.state as any)?.from?.pathname || '/'; + + const onFinish = async (values: { username: string; password: string }) => { + setLoading(true); + try { + await login(values); + message.success('Welcome back'); + navigate(from, { replace: true }); + } catch (e: any) { + message.error(e.message || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+ 3M-UI + Mihomo Core Management +
+
+ + } placeholder="Username" size="large" /> + + + } placeholder="Password" size="large" /> + + + + + +
+
+ ); +}; + +export default Login; diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..635d393 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -0,0 +1,29 @@ +import React, { useEffect, useState } from 'react'; +import { Card, List, Tag } from 'antd'; +import { fetchLogs } from '../api/system'; + +const Logs: React.FC = () => { + const [logs, setLogs] = useState([]); + useEffect(() => { + fetchLogs().then((d) => setLogs(d.logs || [])); + }, []); + return ( +
+

Logs

+ + ( + + INFO + {item} + + )} + /> + +
+ ); +}; + +export default Logs; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..54652ec --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,3 @@ +import React from 'react'; +const Settings: React.FC = () =>

Settings

Placeholder for system settings.

; +export default Settings; diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx new file mode 100644 index 0000000..6db4344 --- /dev/null +++ b/frontend/src/pages/Users.tsx @@ -0,0 +1,3 @@ +import React from 'react'; +const Users: React.FC = () =>

Users

Placeholder for proxy user management.

; +export default Users; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..0c2eae2 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; + +interface AuthState { + token: string | null; + username: string | null; + mustChangePassword: boolean; + isAuthenticated: () => boolean; + login: (token: string, username: string, mustChange: boolean) => void; + logout: () => void; + setMustChangePassword: (v: boolean) => void; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + token: null, + username: null, + mustChangePassword: false, + + isAuthenticated: () => !!get().token, + + login: (token, username, mustChange) => + set({ token, username, mustChangePassword: mustChange }), + + logout: () => + set({ token: null, username: null, mustChangePassword: false }), + + setMustChangePassword: (v) => set({ mustChangePassword: v }), + }), + { + name: '3m-ui-auth', + storage: createJSONStorage(() => sessionStorage), + } + ) +); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..5dee0da --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,11 @@ +export interface APIResponse { + data: T; + message?: string; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + page_size: number; +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..d761303 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,20 @@ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${h}h ${m}m ${s}s`; +} + +export function formatPercent(value: number): string { + return value.toFixed(1) + '%'; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..8202fc7 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": false, + "noImplicitAny": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e7afb3f --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + }, + }, + }, +}) diff --git a/scripts/3m-ui b/scripts/3m-ui new file mode 100644 index 0000000..bd47303 --- /dev/null +++ b/scripts/3m-ui @@ -0,0 +1,135 @@ +#!/bin/sh +# 3m-ui unified management entrypoint. +# Installed as /usr/local/bin/3m-ui. +# The actual Go application binary is /usr/local/lib/3m-ui/3m-ui-bin. + +set -eu + +BASE="/usr/local/lib/3m-ui" +MANAGER="$BASE/3m-ui.sh" +INSTALLER="$BASE/install.sh" +UPDATER="$BASE/update.sh" +UNINSTALLER="$BASE/uninstall.sh" +APP_BIN="$BASE/3m-ui-bin" + +run_script() { + script="$1" + shift + [ -x "$script" ] || { + printf '%s\n' "3m-ui management script is missing: $script" >&2 + exit 1 + } + exec "$script" "$@" +} + +menu() { + while :; do + printf '\n' + printf '%s\n' '╭────────────────────────────────────╮' + printf '%s\n' '│ 3m-ui 管理控制台 │' + printf '%s\n' '│ Mihomo Core Management UI │' + printf '%s\n' '╰────────────────────────────────────╯' + printf '\n' + printf '%s\n' ' 1. 安装 / 修复 3m-ui' + printf '%s\n' ' 2. 更新 3m-ui' + printf '%s\n' ' 3. 查看运行状态' + printf '%s\n' ' 4. 启动 3m-ui' + printf '%s\n' ' 5. 重启 3m-ui' + printf '%s\n' ' 6. 停止 3m-ui' + printf '%s\n' ' 7. 查看日志' + printf '%s\n' ' 8. 查看版本' + printf '%s\n' ' 9. 卸载 3m-ui' + printf '%s\n' ' 0. 退出' + printf '\n请选择 [0-9]: ' + IFS= read -r choice || exit 0 + case "$choice" in + 1) run_script "$INSTALLER" ;; + 2) run_script "$UPDATER" ;; + 3) run_script "$MANAGER" status ;; + 4) run_script "$MANAGER" start ;; + 5) run_script "$MANAGER" restart ;; + 6) run_script "$MANAGER" stop ;; + 7) run_script "$MANAGER" logs ;; + 8) run_script "$MANAGER" version ;; + 9) run_script "$UNINSTALLER" ;; + 0) exit 0 ;; + *) printf '%s\n' '无效选择,请重新输入。' ;; + esac + done +} + +init_system() { + if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + printf '%s' systemd + elif command -v rc-service >/dev/null 2>&1; then + printf '%s' openrc + else + printf '%s' unsupported + fi +} + +service_action() { + action="$1" + case "$(init_system)" in + systemd) systemctl "$action" 3m-ui ;; + openrc) rc-service 3m-ui "$action" ;; + *) printf '%s\n' '未检测到 systemd 或 OpenRC。' >&2; exit 1 ;; + esac +} + +status() { + case "$(init_system)" in + systemd) systemctl --no-pager --full status 3m-ui || true ;; + openrc) rc-service 3m-ui status || true ;; + *) printf '%s\n' 'init: unsupported' ;; + esac + printf '\n' + if [ -x "$APP_BIN" ]; then + printf '%s\n' "应用程序: $APP_BIN" + "$APP_BIN" --version 2>/dev/null || true + else + printf '%s\n' '应用程序二进制不存在。' + fi +} + +logs() { + case "$(init_system)" in + systemd) journalctl -u 3m-ui -n 100 --no-pager ;; + openrc) tail -n 100 /var/log/3m-ui/3m-ui.log 2>/dev/null || printf '%s\n' '未找到日志文件。' ;; + *) printf '%s\n' '未找到可用的日志系统。' ;; + esac +} + +version() { + if [ -x "$APP_BIN" ]; then + "$APP_BIN" --version + else + printf '%s\n' '3m-ui 未安装。' >&2 + exit 1 + fi +} + +need_root() { + [ "$(id -u)" -eq 0 ] || { printf '%s\n' '请使用 root 运行 3m-ui 管理命令。' >&2; exit 1; } +} + +main() { + need_root + cmd=${1:-} + case "$cmd" in + status) status ;; + start) service_action start ;; + restart) service_action restart ;; + stop) service_action stop ;; + logs) logs ;; + version) version ;; + help|-h|--help) + printf '%s\n' '用法: 3m-ui [status|start|restart|stop|logs|version|help]' + printf '%s\n' '不带参数直接运行 3m-ui 将进入交互式管理菜单。' + ;; + '') menu ;; + *) printf '%s\n' "未知命令: $cmd" >&2; exit 2 ;; + esac +} + +main "$@" diff --git a/scripts/3m-ui.sh b/scripts/3m-ui.sh new file mode 100644 index 0000000..a0d8332 --- /dev/null +++ b/scripts/3m-ui.sh @@ -0,0 +1,144 @@ +#!/bin/sh +# 3m-ui unified management logic +# The command entrypoint is /usr/local/bin/3m-ui. +# The actual Go application is /usr/local/lib/3m-ui/3m-ui-bin. + +set -eu + +APP_NAME="3m-ui" +BASE="/usr/local/lib/3m-ui" +APP_BIN="$BASE/3m-ui-bin" +VERSION_FILE="$BASE/VERSION" +DATA_DIR="/var/lib/3m-ui" +CONFIG_DIR="/etc/3m-ui" +SERVICE="3m-ui" + +say() { printf '%s\n' "$*"; } +err() { say "Error: $*" >&2; exit 1; } + +need_root() { + [ "$(id -u)" = 0 ] || err "Please run as root." +} + +init_system() { + if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + printf '%s' systemd + elif command -v rc-service >/dev/null 2>&1; then + printf '%s' openrc + else + printf '%s' unsupported + fi +} + +service_action() { + action="$1" + case "$(init_system)" in + systemd) systemctl "$action" "$SERVICE" ;; + openrc) rc-service "$SERVICE" "$action" ;; + *) err "No supported init system found." ;; + esac +} + +status() { + case "$(init_system)" in + systemd) systemctl --no-pager --full status "$SERVICE" || true ;; + openrc) rc-service "$SERVICE" status || true ;; + *) say "init: unsupported" ;; + esac + say "" + if [ -x "$APP_BIN" ]; then + say "Application: $APP_BIN" + if ! "$APP_BIN" --version 2>/dev/null; then + if [ -s "$VERSION_FILE" ]; then + say "Version: $(cat "$VERSION_FILE")" + else + say "Version: unavailable (installed binary does not expose --version)" + fi + fi + else + say "Application binary not found: $APP_BIN" + fi +} + +logs() { + case "$(init_system)" in + systemd) journalctl -u "$SERVICE" -n 100 --no-pager ;; + openrc) tail -n 100 "$DATA_DIR/logs/3m-ui.log" 2>/dev/null || tail -n 100 "/var/log/3m-ui/3m-ui.log" 2>/dev/null || say "No log file found." ;; + *) say "No supported log backend found." ;; + esac +} + +version() { + [ -x "$APP_BIN" ] || err "3m-ui application is not installed." + if "$APP_BIN" --version 2>/dev/null; then + return 0 + fi + if [ -s "$VERSION_FILE" ]; then + say "3m-ui $(cat "$VERSION_FILE")" + return 0 + fi + err "Unable to determine installed version." +} + +uninstall() { + service_action stop 2>/dev/null || true + case "$(init_system)" in + systemd) + systemctl disable "$SERVICE" 2>/dev/null || true + rm -f "/etc/systemd/system/$SERVICE.service" + systemctl daemon-reload + ;; + openrc) + rc-update del "$SERVICE" default 2>/dev/null || true + rm -f "/etc/init.d/$SERVICE" + ;; + esac + rm -f /usr/local/bin/3m-ui + rm -rf "$BASE" + say "3m-ui application and management command removed." + say "Data preserved at: $DATA_DIR" + say "Configuration preserved at: $CONFIG_DIR" + say "Mihomo was not removed." +} + +usage() { + cat < + +Commands: + status Show service and application status + start Start 3m-ui + restart Restart 3m-ui + stop Stop 3m-ui + logs Show recent service logs + version Show installed 3m-ui version + uninstall Remove 3m-ui but preserve data/config + help Show this help + +Run '3m-ui' without arguments to open the interactive management menu. +EOF +} + +main() { + need_root + cmd=${1:-} + case "$cmd" in + status) status ;; + start) service_action start ;; + restart) service_action restart ;; + stop) service_action stop ;; + logs) logs ;; + version) version ;; + uninstall) uninstall ;; + help|-h|--help) usage ;; + '') + if [ -x /usr/local/bin/3m-ui ] && [ "$(readlink -f /usr/local/bin/3m-ui 2>/dev/null || true)" = "$APP_BIN" ]; then + err "Invalid installation: management entrypoint points to the application binary. Re-run the latest installer to migrate it." + fi + exec /usr/local/bin/3m-ui + ;; + *) err "Unknown command: $cmd" ;; + esac +} + +main "$@" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..c996a80 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env sh +set -eu +umask 077 + +REPO="dzx941/3m-ui" +BASE="/usr/local/lib/3m-ui" +APP_BIN="$BASE/3m-ui-bin" +VERSION_FILE="$BASE/VERSION" +MODE_FILE="$BASE/BUILD_MODE" +ENTRY="/usr/local/bin/3m-ui" +CONFIG_DIR="/etc/3m-ui" +DATA_DIR="/var/lib/3m-ui" +LOG_DIR="/var/log/3m-ui" +MIHOMO_BIN="/usr/local/bin/mihomo" +SERVICE_NAME="3m-ui" +YES=0 +INSTALL_MIHOMO=1 +STATIC="${THREE_M_UI_STATIC:-0}" +REQUESTED_VERSION="" +RELEASE_TAG="" + +say(){ printf '%s\n' "$*"; } +err(){ say "Error: $*" >&2; exit 1; } +command_exists(){ command -v "$1" >/dev/null 2>&1; } +need_root(){ [ "$(id -u)" -eq 0 ] || err "Please run this script as root."; } + +usage(){ cat </dev/null | sed 's#.*/##; s/[[:space:][:cntrl:]]*$//')" || true + case "$tag" in + v[0-9]*|manual-[0-9]*) printf '%s' "$tag";; + *) + tag="$(curl -fsSL "${1}/releases/latest" 2>/dev/null | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" || true + printf '%s' "$tag" + ;; + esac +} +random_hex(){ dd if=/dev/urandom bs=1 count="${1:-32}" 2>/dev/null | od -An -tx1 | tr -d ' \n'; } + +write_config(){ + mkdir -p "$CONFIG_DIR" "$DATA_DIR/mihomo" "$LOG_DIR" + [ -f "$CONFIG_DIR/config.yaml" ] && { chmod 0600 "$CONFIG_DIR/config.yaml"; return; } + cat > "$CONFIG_DIR/config.yaml" </dev/null 2>&1; then say "Existing Mihomo detected: $MIHOMO_BIN"; return; fi + tag="$(latest_tag https://github.com/MetaCubeX/mihomo)"; [ -n "$tag" ] || err "Unable to determine latest Mihomo release." + tmp="$(mktemp)"; trap 'rm -f "$tmp" "$tmp.gz"' EXIT INT TERM + url="https://github.com/MetaCubeX/mihomo/releases/download/${tag}/$(mihomo_asset)-${tag}.gz" + say "Downloading Mihomo $tag..."; download "$url" "$tmp.gz"; gzip -dc "$tmp.gz" > "$tmp"; chmod 0755 "$tmp" + "$tmp" -v >/dev/null 2>&1 || err "Downloaded Mihomo failed validation." + install -m 0755 "$tmp" "$MIHOMO_BIN" + rm -f "$tmp" "$tmp.gz"; trap - EXIT INT TERM +} + +install_release_asset(){ + tag="$1"; file="$2"; destination="$3" + tmp="$(mktemp)" + if download "https://github.com/$REPO/releases/download/${tag}/${file}" "$tmp" 2>/dev/null; then + : + else + say "Release $tag does not contain $file; using the matching main-branch management script." + rm -f "$tmp" + download "https://raw.githubusercontent.com/$REPO/main/scripts/$file" "$tmp" + fi + install -m 0755 "$tmp" "$destination" + rm -f "$tmp" +} + +install_helpers(){ + tag="$1" + mkdir -p "$BASE" + install_release_asset "$tag" 3m-ui.sh "$BASE/3m-ui.sh" + install_release_asset "$tag" install.sh "$BASE/install.sh" + install_release_asset "$tag" update.sh "$BASE/update.sh" + install_release_asset "$tag" uninstall.sh "$BASE/uninstall.sh" + install_release_asset "$tag" 3m-ui "$ENTRY" +} + +install_panel(){ + RELEASE_TAG="${REQUESTED_VERSION:-$(latest_tag https://github.com/$REPO)}" + [ -n "$RELEASE_TAG" ] || err "Unable to determine latest 3m-ui release." + suffix=""; [ "$STATIC" = "1" ] && suffix="-static" + asset="3m-ui-linux-$(arch)${suffix}" + tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT INT TERM + url="https://github.com/$REPO/releases/download/${RELEASE_TAG}/${asset}" + say "Downloading 3m-ui $RELEASE_TAG ($asset)..."; download "$url" "$tmp"; chmod 0755 "$tmp" + "$tmp" --version >/dev/null 2>&1 || err "Downloaded 3m-ui failed executable validation." + install -m 0755 "$tmp" "$APP_BIN" + printf '%s\n' "$RELEASE_TAG" > "$VERSION_FILE" + printf '%s\n' "$([ "$STATIC" = "1" ] && echo static || echo dynamic)" > "$MODE_FILE" + chmod 0600 "$VERSION_FILE" "$MODE_FILE" + rm -f "$tmp"; trap - EXIT INT TERM +} + +write_systemd(){ cat > /etc/systemd/system/$SERVICE_NAME.service </dev/null; systemctl restart "$SERVICE_NAME"; } + +write_openrc(){ cat > /etc/init.d/$SERVICE_NAME </dev/null 2>&1 || true; rc-service "$SERVICE_NAME" restart; } + +install_service(){ case "$(init_system)" in systemd) write_systemd;; openrc) write_openrc;; *) err "Unsupported init system. Supported: systemd and OpenRC.";; esac; } + +main(){ + need_root; install_deps + mkdir -p "$BASE" "$CONFIG_DIR" "$DATA_DIR/mihomo" "$LOG_DIR" + if [ -t 0 ] && [ "$YES" -ne 1 ]; then + say "3m-ui Installer"; say "OS: $(os_id) Architecture: $(arch) Init: $(init_system)" + printf 'Install Mihomo Core too? [Y/n] '; read -r answer || true + case "$answer" in n|N|no|NO) INSTALL_MIHOMO=0;; esac + fi + write_config + if [ -x "$APP_BIN" ]; then + case "$(init_system)" in systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true;; openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true;; esac + fi + install_panel + install_mihomo + install_helpers "$RELEASE_TAG" + install_service + say "" + say "3m-ui installed successfully." + say "Command: 3m-ui" + say "Panel: http://SERVER_IP:8080/" + say "Default administrator credentials are unchanged; first login requires a password change." +} +main "$@" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..69b83cc --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env sh +set -eu +umask 077 + +BASE="/usr/local/lib/3m-ui" +APP_BIN="$BASE/3m-ui-bin" +ENTRY="/usr/local/bin/3m-ui" +CONFIG_DIR="/etc/3m-ui" +DATA_DIR="/var/lib/3m-ui" +LOG_DIR="/var/log/3m-ui" +MIHOMO_BIN="/usr/local/bin/mihomo" +SERVICE_NAME="3m-ui" +PURGE=0 +YES=0 + +for arg in "$@"; do + case "$arg" in + -y|--yes) YES=1;; + --purge) PURGE=1;; + -h|--help) printf '%s\n' 'Usage: uninstall.sh [--yes] [--purge]'; exit 0;; + *) echo "Unknown option: $arg" >&2; exit 2;; + esac +done + +[ "$(id -u)" -eq 0 ] || { echo "Error: please run as root." >&2; exit 1; } +init_system(){ if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then echo systemd; elif command -v rc-service >/dev/null 2>&1; then echo openrc; else echo unsupported; fi; } + +if [ "$YES" -ne 1 ]; then + [ -t 0 ] || { echo "Non-interactive uninstall requires --yes." >&2; exit 1; } + echo "3m-ui uninstall" + echo " Command: $ENTRY" + echo " Application: $APP_BIN" + echo " Config: $CONFIG_DIR" + if [ "$PURGE" -eq 1 ]; then echo " Data: $DATA_DIR [WILL BE DELETED]"; else echo " Data: $DATA_DIR [KEPT]"; fi + printf 'Continue? [y/N] '; read -r answer + case "$answer" in y|Y|yes|YES) ;; *) echo "Aborted."; exit 0;; esac +fi + +case "$(init_system)" in + systemd) + systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true + rm -f "/etc/systemd/system/$SERVICE_NAME.service" + systemctl daemon-reload >/dev/null 2>&1 || true + ;; + openrc) + rc-service "$SERVICE_NAME" stop >/dev/null 2>&1 || true + rc-update del "$SERVICE_NAME" default >/dev/null 2>&1 || true + rm -f "/etc/init.d/$SERVICE_NAME" + ;; +esac + +rm -f "$ENTRY" +rm -rf "$BASE" "$CONFIG_DIR" "$LOG_DIR" + +if [ "$PURGE" -eq 1 ]; then + rm -rf "$DATA_DIR" + echo "3m-ui uninstalled and application data purged." +else + echo "3m-ui uninstalled. Persistent data kept at: $DATA_DIR" +fi + +echo "Mihomo was left untouched: $MIHOMO_BIN" +echo "Done." diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..74eb462 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env sh +set -eu +umask 077 + +REPO="dzx941/3m-ui" +BASE="/usr/local/lib/3m-ui" +APP_BIN="$BASE/3m-ui-bin" +VERSION_FILE="$BASE/VERSION" +MODE_FILE="$BASE/BUILD_MODE" +ENTRY="/usr/local/bin/3m-ui" +CONFIG_DIR="/etc/3m-ui" +DATA_DIR="/var/lib/3m-ui" +LOG_DIR="/var/log/3m-ui" +MIHOMO_BIN="/usr/local/bin/mihomo" +SERVICE_NAME="3m-ui" +UPDATE_MIHOMO=1 + +for arg in "$@"; do + case "$arg" in + -y|--yes) :;; + --no-mihomo) UPDATE_MIHOMO=0;; + -h|--help) printf '%s\n' 'Usage: update.sh [--yes] [--no-mihomo]'; exit 0;; + *) printf '%s\n' "Unknown option: $arg" >&2; exit 2;; + esac +done + +[ "$(id -u)" -eq 0 ] || { echo "Error: please run as root." >&2; exit 1; } +[ -x "$APP_BIN" ] || { echo "Error: 3m-ui application is not installed at $APP_BIN" >&2; exit 1; } +command_exists(){ command -v "$1" >/dev/null 2>&1; } +arch(){ case "$(uname -m)" in x86_64|amd64) echo amd64;; aarch64|arm64) echo arm64;; armv7l|armv7*) echo armv7;; *) echo "Error: unsupported architecture" >&2; exit 1;; esac; } +init_system(){ if [ -d /run/systemd/system ] && command_exists systemctl; then echo systemd; elif command_exists rc-service; then echo openrc; else echo unsupported; fi; } +download(){ if command_exists curl; then curl -fL --retry 3 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" -o "$2"; else wget -qO "$2" "$1"; fi; } +latest_tag(){ + tag="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$1/releases/latest" 2>/dev/null | sed 's#.*/##; s/[[:space:][:cntrl:]]*$//')" || true + case "$tag" in + v[0-9]*|manual-[0-9]*) printf '%s' "$tag";; + *) curl -fsSL "$1/releases/latest" 2>/dev/null | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 || true;; + esac +} + +stop(){ case "$(init_system)" in systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true;; openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true;; esac; } +start(){ case "$(init_system)" in systemd) systemctl start "$SERVICE_NAME";; openrc) rc-service "$SERVICE_NAME" start;; *) return 1;; esac; } +ok(){ case "$(init_system)" in systemd) systemctl is-active --quiet "$SERVICE_NAME";; openrc) rc-service "$SERVICE_NAME" status >/dev/null 2>&1;; *) return 1;; esac; } + +backup="$DATA_DIR/backups/$(date +%Y%m%d%H%M%S)"; mkdir -p "$backup" +cp -a "$BASE" "$backup/base" +[ -d "$CONFIG_DIR" ] && cp -a "$CONFIG_DIR" "$backup/config" +[ -f "$DATA_DIR/3m-ui.db" ] && cp -p "$DATA_DIR/3m-ui.db" "$backup/3m-ui.db" +[ -x "$MIHOMO_BIN" ] && cp -p "$MIHOMO_BIN" "$backup/mihomo" || true + +panel_tmp="$(mktemp)"; manager_tmp="$(mktemp)"; installer_tmp="$(mktemp)"; updater_tmp="$(mktemp)"; uninstall_tmp="$(mktemp)"; entry_tmp="$(mktemp)"; mihomo_tmp=""; staging="$(mktemp -d)" +cleanup(){ rm -f "$panel_tmp" "$manager_tmp" "$installer_tmp" "$updater_tmp" "$uninstall_tmp" "$entry_tmp" "$mihomo_tmp" "$mihomo_tmp.gz"; rm -rf "$staging"; } +trap cleanup EXIT INT TERM + +current_mode="dynamic" +if [ -s "$MODE_FILE" ]; then + current_mode="$(cat "$MODE_FILE")" +elif command_exists file; then + description="$(file "$APP_BIN" 2>/dev/null || true)" + case "$description" in *"statically linked"*) current_mode="static";; esac +fi +case "$current_mode" in static) suffix="-static";; dynamic) suffix="";; *) echo "Warning: unknown build mode '$current_mode'; defaulting to dynamic." >&2; suffix="";; esac + +tag="$(latest_tag https://github.com/$REPO)"; [ -n "$tag" ] || { echo "Error: unable to determine latest 3m-ui release." >&2; exit 1; } +echo "[1/5] Downloading 3m-ui $tag ($current_mode)..." +download "https://github.com/$REPO/releases/download/${tag}/3m-ui-linux-$(arch)${suffix}" "$panel_tmp" +chmod 0755 "$panel_tmp" +"$panel_tmp" --version >/dev/null 2>&1 || { echo "Error: downloaded 3m-ui failed validation." >&2; exit 1; } + +download_release_script(){ + file="$1"; out="$2" + if download "https://github.com/$REPO/releases/download/${tag}/${file}" "$out" 2>/dev/null; then + : + else + echo "Release $tag does not contain $file; using the current main-branch script." >&2 + rm -f "$out" + download "https://raw.githubusercontent.com/$REPO/main/scripts/$file" "$out" + fi + chmod 0755 "$out" +} + +for spec in "3m-ui.sh:$manager_tmp" "install.sh:$installer_tmp" "update.sh:$updater_tmp" "uninstall.sh:$uninstall_tmp" "3m-ui:$entry_tmp"; do + file="${spec%%:*}"; out="${spec#*:}" + download_release_script "$file" "$out" +done + +if [ "$UPDATE_MIHOMO" -eq 1 ] && [ -x "$MIHOMO_BIN" ]; then + mtag="$(latest_tag https://github.com/MetaCubeX/mihomo)" || true + if [ -n "$mtag" ]; then + mihomo_tmp="$(mktemp)" + case "$(uname -m)" in + x86_64|amd64) mihomo_asset="mihomo-linux-amd64-compatible";; + aarch64|arm64) mihomo_asset="mihomo-linux-arm64";; + armv7l|armv7*) mihomo_asset="mihomo-linux-armv7";; + *) mihomo_asset="";; + esac + if [ -n "$mihomo_asset" ] && download "https://github.com/MetaCubeX/mihomo/releases/download/${mtag}/${mihomo_asset}-${mtag}.gz" "$mihomo_tmp.gz" && gzip -dc "$mihomo_tmp.gz" > "$mihomo_tmp"; then + chmod 0755 "$mihomo_tmp" + "$mihomo_tmp" -v >/dev/null 2>&1 || mihomo_tmp="" + else mihomo_tmp=""; fi + fi +fi + +echo "[2/5] Validating management scripts..." +sh -n "$manager_tmp" "$installer_tmp" "$updater_tmp" "$uninstall_tmp" "$entry_tmp" + +echo "[3/5] Installing release-consistent files..." +stop +mkdir -p "$staging" +install -m 0755 "$panel_tmp" "$staging/3m-ui-bin" +install -m 0755 "$manager_tmp" "$staging/3m-ui.sh" +install -m 0755 "$installer_tmp" "$staging/install.sh" +install -m 0755 "$updater_tmp" "$staging/update.sh" +install -m 0755 "$uninstall_tmp" "$staging/uninstall.sh" +install -m 0755 "$entry_tmp" "$staging/3m-ui" +printf '%s\n' "$tag" > "$staging/VERSION" +printf '%s\n' "$current_mode" > "$staging/BUILD_MODE" + +rm -rf "$BASE" +mkdir -p "$BASE" +cp -a "$staging/." "$BASE/" +install -m 0755 "$BASE/3m-ui" "$ENTRY" +if [ -n "$mihomo_tmp" ] && [ -s "$mihomo_tmp" ]; then install -m 0755 "$mihomo_tmp" "$MIHOMO_BIN"; fi +chmod 0600 "$VERSION_FILE" "$MODE_FILE" + +if ! start || ! ok; then + echo "[ERROR] New version failed to start; restoring the complete previous installation." >&2 + stop + rm -rf "$BASE" + cp -a "$backup/base" "$BASE" + [ -f "$backup/3m-ui.db" ] && cp -p "$backup/3m-ui.db" "$DATA_DIR/3m-ui.db" || true + [ -f "$backup/mihomo" ] && install -m 0755 "$backup/mihomo" "$MIHOMO_BIN" || true + start || true + exit 1 +fi + +echo "[4/5] Verifying service health..." +sleep 1 +if ! ok; then + echo "[ERROR] Service stopped immediately after update; restoring backup." >&2 + stop + rm -rf "$BASE" + cp -a "$backup/base" "$BASE" + [ -f "$backup/3m-ui.db" ] && cp -p "$backup/3m-ui.db" "$DATA_DIR/3m-ui.db" || true + [ -f "$backup/mihomo" ] && install -m 0755 "$backup/mihomo" "$MIHOMO_BIN" || true + start || true + exit 1 +fi + +echo "[5/5] Update completed successfully." +echo "Version: $tag" +echo "Build mode: $current_mode" +echo "Backup: $backup" +echo "Command: 3m-ui"