mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Merge branch 'main' into configurable-hsts
This commit is contained in:
commit
ef1fa9cc55
124 changed files with 12867 additions and 6515 deletions
22
.vscode/launch.json
vendored
22
.vscode/launch.json
vendored
|
|
@ -10,26 +10,12 @@
|
||||||
"program": "${workspaceFolder}",
|
"program": "${workspaceFolder}",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"env": {
|
"env": {
|
||||||
"XUI_DEBUG": "true"
|
|
||||||
},
|
|
||||||
"console": "integratedTerminal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Run 3x-ui (Debug, custom env)",
|
|
||||||
"type": "go",
|
|
||||||
"request": "launch",
|
|
||||||
"mode": "auto",
|
|
||||||
"program": "${workspaceFolder}",
|
|
||||||
"cwd": "${workspaceFolder}",
|
|
||||||
"env": {
|
|
||||||
// Set to true to serve assets/templates directly from disk for development
|
|
||||||
"XUI_DEBUG": "true",
|
"XUI_DEBUG": "true",
|
||||||
// Uncomment to override DB folder location (by default uses working dir on Windows when debug)
|
"XUI_DB_FOLDER": "x-ui",
|
||||||
// "XUI_DB_FOLDER": "${workspaceFolder}",
|
"XUI_LOG_FOLDER": "x-ui",
|
||||||
// Example: override log level (debug|info|notice|warn|error)
|
"XUI_BIN_FOLDER": "x-ui"
|
||||||
// "XUI_LOG_LEVEL": "debug"
|
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
14
.vscode/tasks.json
vendored
14
.vscode/tasks.json
vendored
|
|
@ -70,6 +70,20 @@
|
||||||
"problemMatcher": [
|
"problemMatcher": [
|
||||||
"$go"
|
"$go"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "go: fmt",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "gofmt",
|
||||||
|
"args": [
|
||||||
|
"-l",
|
||||||
|
"-w",
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
225
CONTRIBUTING.md
225
CONTRIBUTING.md
|
|
@ -1,5 +1,222 @@
|
||||||
## Local Development Setup
|
# Contributing
|
||||||
|
|
||||||
- Create a directory named `x-ui` in the project root
|
Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running on your machine in a few minutes.
|
||||||
- Rename `.env.example` to `.env `
|
|
||||||
- Run `main.go`
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Go 1.26+** (the version in `go.mod`)
|
||||||
|
- **Node.js 22+** and npm (for the Vue frontend)
|
||||||
|
- **Git**
|
||||||
|
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux/macOS already ship one; on Windows see below.
|
||||||
|
|
||||||
|
### Windows: MinGW-w64
|
||||||
|
|
||||||
|
`go build` on Windows will fail with `cgo: C compiler "gcc" not found` until you install a GCC toolchain. Two options — pick whichever fits.
|
||||||
|
|
||||||
|
**Option A — standalone zip (fastest, no package manager)**
|
||||||
|
|
||||||
|
1. Grab the latest build from **<https://github.com/niXman/mingw-builds-binaries/releases>**. For most setups you want a release named like:
|
||||||
|
```
|
||||||
|
x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
|
||||||
|
```
|
||||||
|
(64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches the modern Windows defaults.)
|
||||||
|
2. Extract it somewhere stable, e.g. `C:\mingw64\`.
|
||||||
|
3. Add `C:\mingw64\bin` to your **Windows** `PATH` (System Properties → Environment Variables → Path → New).
|
||||||
|
4. Open a fresh terminal and confirm:
|
||||||
|
```powershell
|
||||||
|
gcc --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — MSYS2 (if you also want a Unix-y shell)**
|
||||||
|
|
||||||
|
1. Install MSYS2 from <https://www.msys2.org/>.
|
||||||
|
2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
|
||||||
|
```bash
|
||||||
|
pacman -Syu
|
||||||
|
```
|
||||||
|
3. Install the UCRT64 toolchain:
|
||||||
|
```bash
|
||||||
|
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
|
||||||
|
```
|
||||||
|
4. Add `C:\msys64\ucrt64\bin` to your Windows `PATH`.
|
||||||
|
5. Verify with `gcc --version` in a fresh terminal.
|
||||||
|
|
||||||
|
After either, `go build ./...` and `go run .` work normally.
|
||||||
|
|
||||||
|
> Why MinGW-w64 over MSVC: `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain doesn't lock you into a Visual Studio install. If you already have Visual Studio Build Tools installed it works too — just make sure `CC=cl` is **not** set in your environment.
|
||||||
|
|
||||||
|
The Linux SQLite cross-build from Windows (or vice versa) needs an extra cross-compiler — out of scope here; build natively on the target OS.
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/MHSanaei/3x-ui.git
|
||||||
|
cd 3x-ui
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
mkdir x-ui
|
||||||
|
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example` ships with sane defaults that point the database, logs, and xray binary at the local `x-ui/` folder so nothing escapes the project directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
XUI_DEBUG=true
|
||||||
|
XUI_DB_FOLDER=x-ui
|
||||||
|
XUI_LOG_FOLDER=x-ui
|
||||||
|
XUI_BIN_FOLDER=x-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
You need to drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` / `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows you also want `wintun.dll` if you plan to test TUN inbounds.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. You will be prompted to change the credentials on first login.
|
||||||
|
|
||||||
|
### Inside VS Code
|
||||||
|
|
||||||
|
The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if it is missing):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Run 3x-ui (Debug)",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"env": {
|
||||||
|
"XUI_DEBUG": "true",
|
||||||
|
"XUI_DB_FOLDER": "x-ui",
|
||||||
|
"XUI_LOG_FOLDER": "x-ui",
|
||||||
|
"XUI_BIN_FOLDER": "x-ui"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Working on the frontend
|
||||||
|
|
||||||
|
The panel UI is a Vue 3 + Ant Design Vue 4 app under `frontend/`. A few things worth knowing before you dive in.
|
||||||
|
|
||||||
|
### Architecture in one paragraph
|
||||||
|
|
||||||
|
It's a **multi-page app**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/sub`, `/panel/api-docs`, plus `login`) has its own HTML entry under `frontend/*.html` and its own bootstrap in `src/entries/<page>.js`. Vite builds them into `web/dist/`, and the Go binary embeds that directory at compile time with `embed.FS`. Each navigation triggers a real document load — but each page's bundle is small, so it stays snappy. There's no Vue Router and no central store; Vuex/Pinia were rejected as overkill for the panel's surface area.
|
||||||
|
|
||||||
|
### State and data flow
|
||||||
|
|
||||||
|
- **No global store.** State lives where it's used. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is on the same box and responses are cheap.
|
||||||
|
- **Composables** in `src/composables/` carry reactive logic worth sharing inside a page (theme switching, status polling, node lists). Reach for one before adding a new global.
|
||||||
|
- **Domain classes** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. The Vue components stay dumb; they ask the model "what's my link?" and render the answer.
|
||||||
|
- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, which is a thin Axios wrapper with CSRF, response toast handling, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
Locale strings live in `web/translation/<locale>.json`, not under `frontend/`. The Go side embeds the same JSON and serves it to both backend templates and `vue-i18n`. When you add a new English key, add it to **every** non-English locale too — missing keys don't fail the build, they just render the raw key in the UI.
|
||||||
|
|
||||||
|
### Two dev workflows
|
||||||
|
|
||||||
|
| When you want… | Use |
|
||||||
|
|----------------|-----|
|
||||||
|
| To iterate on UI tweaks fast | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
|
||||||
|
| To test what users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle either embedded (release mode) or from disk (debug mode). |
|
||||||
|
|
||||||
|
The Vite dev proxy auto-rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, etc.) to the matching Vite-served HTML, so the navigation feels identical to prod without round-tripping through Go. The route allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — if you add a new page, register it there too.
|
||||||
|
|
||||||
|
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML out of the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names → blank page with 404s in the browser console. Always restart `go run .` after a frontend rebuild.
|
||||||
|
|
||||||
|
### Adding a new page
|
||||||
|
|
||||||
|
1. Create `frontend/<page>.html` (copy an existing one and adjust the title + the imported entry).
|
||||||
|
2. Create `src/entries/<page>.js` — `createApp(Page).use(antd).use(i18n).mount('#app')`.
|
||||||
|
3. Create the page component under `src/pages/<page>/<Page>.vue` (kebab-case folder, PascalCase component).
|
||||||
|
4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
|
||||||
|
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
|
||||||
|
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in prod.
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
|
||||||
|
- **Ant Design Vue** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back as ugly + bloated. Small targeted UX tweaks beat sweeping rewrites; if a section *really* needs new visual language, raise it first.
|
||||||
|
- **Composition API** (`<script setup>`) everywhere. Options API survives only in components nobody has touched yet.
|
||||||
|
- **No `//` line comments** in committed JS/Vue. HTML `<!-- ... -->` is fine for template structure. Identifiers should carry the meaning; if you need a comment to explain *what* code does, rename the variable. Comments are for the *why* and only when surprising.
|
||||||
|
- **Persian / Arabic users matter.** RTL is supported via `ConfigProvider` + `dir="rtl"`. When you write Persian text in toasts or labels, keep prose clean — isolate code/identifiers on their own lines so the RTL reading flows.
|
||||||
|
- **Don't break links.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.vue` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever you touch URL generation, fallback projection, or TLS handling.
|
||||||
|
|
||||||
|
### Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── *.html — Vite entry HTML, one per panel route
|
||||||
|
├── eslint.config.js — ESLint 10 flat config (vue3-recommended)
|
||||||
|
├── vite.config.js
|
||||||
|
└── src/
|
||||||
|
├── entries/ — per-page bootstrap (createApp + mount)
|
||||||
|
├── pages/ — one folder per route (index, login, inbounds, clients, xray, settings, sub, api-docs)
|
||||||
|
├── components/ — cross-page Vue components (DateTimePicker, FinalMaskForm, …)
|
||||||
|
├── composables/ — reusable reactive logic (useTheme, useStatus, useNodeList, …)
|
||||||
|
├── api/ — Axios setup + CSRF interceptor + WebSocket client
|
||||||
|
├── i18n/ — vue-i18n bootstrap (the JSON lives in web/translation/)
|
||||||
|
├── models/ — Inbound, DBInbound, Outbound, Status, reality-targets, …
|
||||||
|
└── utils/ — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
|
||||||
|
```
|
||||||
|
|
||||||
|
Lint with `cd frontend && npm run lint`. The deeper reference is [`frontend/README.md`](frontend/README.md).
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
| Path | What lives there |
|
||||||
|
|------|------------------|
|
||||||
|
| `main.go` | Process entry point, CLI subcommands, signal handling |
|
||||||
|
| `web/` | Gin HTTP server, controllers, services, embedded frontend |
|
||||||
|
| `frontend/` | Vue 3 + Ant Design source for the panel UI |
|
||||||
|
| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
|
||||||
|
| `xray/` | Xray-core process lifecycle + gRPC API client |
|
||||||
|
| `sub/` | Subscription endpoints (raw, JSON, Clash) |
|
||||||
|
| `config/` | Environment-var helpers, paths, defaults |
|
||||||
|
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
|
||||||
|
|
||||||
|
## Sending a pull request
|
||||||
|
|
||||||
|
1. Branch off `main` (e.g. `feat/short-description`).
|
||||||
|
2. Keep the diff focused — separate refactors from feature work.
|
||||||
|
3. Run the relevant builds before pushing:
|
||||||
|
- `go build ./...`
|
||||||
|
- `go test ./...` (if you touched Go code)
|
||||||
|
- `cd frontend && npm run build` (if you touched the Vue side)
|
||||||
|
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*.
|
||||||
|
5. Open the PR against `main` with a brief description of what changed and how to test it.
|
||||||
|
|
||||||
|
## Useful environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `XUI_DEBUG` | `false` | Verbose logs + Gin debug mode + serve `/assets` from disk |
|
||||||
|
| `XUI_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
|
||||||
|
| `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
|
||||||
|
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
|
||||||
|
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary + geo files + xray `config.json` live |
|
||||||
|
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
||||||
|
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
||||||
|
|
||||||
|
## Issues and discussion
|
||||||
|
|
||||||
|
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
|
||||||
|
- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
|
||||||
|
|
||||||
|
Before filing a bug, please include your OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ RUN chmod +x \
|
||||||
/usr/bin/x-ui
|
/usr/bin/x-ui
|
||||||
|
|
||||||
ENV XUI_ENABLE_FAIL2BAN="true"
|
ENV XUI_ENABLE_FAIL2BAN="true"
|
||||||
|
# Database backend: set XUI_DB_TYPE=postgres and XUI_DB_DSN=postgres://... to use PostgreSQL.
|
||||||
|
# Default (unset) is SQLite stored under /etc/x-ui.
|
||||||
|
ENV XUI_DB_TYPE=""
|
||||||
|
ENV XUI_DB_DSN=""
|
||||||
EXPOSE 2053
|
EXPOSE 2053
|
||||||
VOLUME [ "/etc/x-ui" ]
|
VOLUME [ "/etc/x-ui" ]
|
||||||
CMD [ "./x-ui" ]
|
CMD [ "./x-ui" ]
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -30,6 +30,38 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||||
|
|
||||||
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||||
|
|
||||||
|
## Database Options
|
||||||
|
|
||||||
|
3X-UI supports two backends, chosen during the install:
|
||||||
|
|
||||||
|
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
|
||||||
|
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
|
||||||
|
|
||||||
|
At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
|
||||||
|
|
||||||
|
```
|
||||||
|
XUI_DB_TYPE=postgres
|
||||||
|
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migrating an existing SQLite install to PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||||
|
# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
|
||||||
|
systemctl restart x-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
The source SQLite file is left untouched; remove it manually once you have verified the new backend.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile postgres up -d
|
||||||
|
```
|
||||||
|
|
||||||
## A Special Thanks to
|
## A Special Thanks to
|
||||||
|
|
||||||
- [alireza0](https://github.com/alireza0/)
|
- [alireza0](https://github.com/alireza0/)
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,22 @@ func GetDBPath() string {
|
||||||
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
|
||||||
|
func GetDBKind() string {
|
||||||
|
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
|
||||||
|
switch v {
|
||||||
|
case "postgres", "postgresql", "pg":
|
||||||
|
return "postgres"
|
||||||
|
default:
|
||||||
|
return "sqlite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDBDSN returns the PostgreSQL DSN from XUI_DB_DSN. Empty for sqlite.
|
||||||
|
func GetDBDSN() string {
|
||||||
|
return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
|
||||||
|
}
|
||||||
|
|
||||||
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
|
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
|
||||||
func GetLogFolder() string {
|
func GetLogFolder() string {
|
||||||
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
||||||
|
|
|
||||||
182
database/db.go
182
database/db.go
|
|
@ -1,9 +1,10 @@
|
||||||
// Package database provides database initialization, migration, and management utilities
|
// Package database provides database initialization, migration, and management utilities
|
||||||
// for the 3x-ui panel using GORM with SQLite.
|
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -18,6 +19,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
@ -25,6 +27,28 @@ import (
|
||||||
|
|
||||||
var db *gorm.DB
|
var db *gorm.DB
|
||||||
|
|
||||||
|
const (
|
||||||
|
DialectSQLite = "sqlite"
|
||||||
|
DialectPostgres = "postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsPostgres reports whether the active connection is a PostgreSQL backend.
|
||||||
|
func IsPostgres() bool {
|
||||||
|
if db == nil {
|
||||||
|
return config.GetDBKind() == "postgres"
|
||||||
|
}
|
||||||
|
return db.Dialector.Name() == "postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialect returns the active GORM dialect name, or "" if the DB is not open.
|
||||||
|
func Dialect() string {
|
||||||
|
if db == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return db.Dialector.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultUsername = "admin"
|
defaultUsername = "admin"
|
||||||
defaultPassword = "admin"
|
defaultPassword = "admin"
|
||||||
|
|
@ -42,6 +66,9 @@ func initModels() error {
|
||||||
&model.CustomGeoResource{},
|
&model.CustomGeoResource{},
|
||||||
&model.Node{},
|
&model.Node{},
|
||||||
&model.ApiToken{},
|
&model.ApiToken{},
|
||||||
|
&model.ClientRecord{},
|
||||||
|
&model.ClientInbound{},
|
||||||
|
&model.InboundFallback{},
|
||||||
}
|
}
|
||||||
for _, mdl := range models {
|
for _, mdl := range models {
|
||||||
if err := db.AutoMigrate(mdl); err != nil {
|
if err := db.AutoMigrate(mdl); err != nil {
|
||||||
|
|
@ -61,20 +88,25 @@ func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
errMsg := strings.ToLower(err.Error())
|
errMsg := strings.ToLower(err.Error())
|
||||||
const dupPrefix = "duplicate column name:"
|
// SQLite: "duplicate column name: foo"
|
||||||
if !strings.Contains(errMsg, dupPrefix) {
|
// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
|
||||||
return false
|
const sqlitePrefix = "duplicate column name:"
|
||||||
}
|
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
|
||||||
idx := strings.Index(errMsg, dupPrefix)
|
col := strings.TrimSpace(after)
|
||||||
if idx < 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
col := strings.TrimSpace(errMsg[idx+len(dupPrefix):])
|
|
||||||
col = strings.Trim(col, "`\"[]")
|
col = strings.Trim(col, "`\"[]")
|
||||||
if col == "" {
|
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return db != nil && db.Migrator().HasColumn(mdl, col)
|
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
|
||||||
|
// Best effort: extract the column name between the first pair of double quotes.
|
||||||
|
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
|
||||||
|
rest := after
|
||||||
|
if e := strings.Index(rest, "\""); e > 0 {
|
||||||
|
col := rest[:e]
|
||||||
|
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// initUser creates a default admin user if the users table is empty.
|
// initUser creates a default admin user if the users table is empty.
|
||||||
|
|
@ -157,9 +189,91 @@ func runSeeders(isUsersEmpty bool) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(seedersHistory, "ClientsTable") {
|
||||||
|
if err := seedClientsFromInboundJSON(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedClientsFromInboundJSON() error {
|
||||||
|
var inbounds []model.Inbound
|
||||||
|
if err := db.Find(&inbounds).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
byEmail := map[string]*model.ClientRecord{}
|
||||||
|
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
if strings.TrimSpace(inbound.Settings) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||||
|
log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawList, ok := settings["clients"].([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range rawList {
|
||||||
|
obj, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(obj)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var c model.Client
|
||||||
|
if err := json.Unmarshal(blob, &c); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(c.Email)
|
||||||
|
if email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
incoming := c.ToRecord()
|
||||||
|
|
||||||
|
row, dup := byEmail[email]
|
||||||
|
if !dup {
|
||||||
|
if err := tx.Create(incoming).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byEmail[email] = incoming
|
||||||
|
row = incoming
|
||||||
|
} else {
|
||||||
|
conflicts := model.MergeClientRecord(row, incoming)
|
||||||
|
for _, x := range conflicts {
|
||||||
|
log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v",
|
||||||
|
email, x.Field, x.Old, x.New, x.Kept)
|
||||||
|
}
|
||||||
|
if err := tx.Save(row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
link := model.ClientInbound{
|
||||||
|
ClientId: row.Id,
|
||||||
|
InboundId: inbound.Id,
|
||||||
|
FlowOverride: c.Flow,
|
||||||
|
}
|
||||||
|
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
|
||||||
|
FirstOrCreate(&link).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// seedApiTokens copies the legacy `apiToken` setting into the new
|
// seedApiTokens copies the legacy `apiToken` setting into the new
|
||||||
// api_tokens table as a row named "default" so existing central panels
|
// api_tokens table as a row named "default" so existing central panels
|
||||||
// keep working after the upgrade. Idempotent — records itself in
|
// keep working after the upgrade. Idempotent — records itself in
|
||||||
|
|
@ -195,30 +309,37 @@ func isTableEmpty(tableName string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||||
|
// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
|
||||||
func InitDB(dbPath string) error {
|
func InitDB(dbPath string) error {
|
||||||
dir := path.Dir(dbPath)
|
|
||||||
err := os.MkdirAll(dir, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var gormLogger logger.Interface
|
var gormLogger logger.Interface
|
||||||
|
|
||||||
if config.IsDebug() {
|
if config.IsDebug() {
|
||||||
gormLogger = logger.Default
|
gormLogger = logger.Default
|
||||||
} else {
|
} else {
|
||||||
gormLogger = logger.Discard
|
gormLogger = logger.Discard
|
||||||
}
|
}
|
||||||
|
c := &gorm.Config{Logger: gormLogger}
|
||||||
|
|
||||||
c := &gorm.Config{
|
var err error
|
||||||
Logger: gormLogger,
|
switch config.GetDBKind() {
|
||||||
|
case "postgres":
|
||||||
|
dsn := config.GetDBDSN()
|
||||||
|
if dsn == "" {
|
||||||
|
return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
|
||||||
|
}
|
||||||
|
db, err = gorm.Open(postgres.Open(dsn), c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
dir := path.Dir(dbPath)
|
||||||
|
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||||
db, err = gorm.Open(sqlite.Open(dsn), c)
|
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -232,6 +353,12 @@ func InitDB(dbPath string) error {
|
||||||
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
sqlDB.SetMaxOpenConns(8)
|
sqlDB.SetMaxOpenConns(8)
|
||||||
sqlDB.SetMaxIdleConns(4)
|
sqlDB.SetMaxIdleConns(4)
|
||||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
@ -284,13 +411,12 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||||
|
// No-op on PostgreSQL (WAL there is managed by the server).
|
||||||
func Checkpoint() error {
|
func Checkpoint() error {
|
||||||
// Update WAL
|
if IsPostgres() {
|
||||||
err := db.Exec("PRAGMA wal_checkpoint;").Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
return db.Exec("PRAGMA wal_checkpoint;").Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||||
|
|
|
||||||
26
database/dialect.go
Normal file
26
database/dialect.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// JSONClientsFromInbound returns the FROM clause that yields one row per element
|
||||||
|
// of inbounds.settings -> clients, with a column named `client.value` whose text
|
||||||
|
// fields can be read with JSONFieldText("client.value", "<key>").
|
||||||
|
func JSONClientsFromInbound() string {
|
||||||
|
if IsPostgres() {
|
||||||
|
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
|
||||||
|
}
|
||||||
|
return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONFieldText returns a SQL expression that extracts the textual value of <key>
|
||||||
|
// from a JSON expression. On both backends the result is the raw (unquoted) string,
|
||||||
|
// so callers do NOT need to trim surrounding quotes.
|
||||||
|
func JSONFieldText(expr, key string) string {
|
||||||
|
if IsPostgres() {
|
||||||
|
return fmt.Sprintf("(%s ->> '%s')", expr, key)
|
||||||
|
}
|
||||||
|
// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
|
||||||
|
// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
|
||||||
|
// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
|
||||||
|
return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
|
||||||
|
}
|
||||||
143
database/migrate_data.go
Normal file
143
database/migrate_data.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrationModels is the FK-aware order in which tables are created and copied.
|
||||||
|
// Parents come before their children so foreign-key constraints stay satisfied
|
||||||
|
// even when checks are not explicitly disabled.
|
||||||
|
func migrationModels() []any {
|
||||||
|
return []any{
|
||||||
|
&model.User{},
|
||||||
|
&model.Setting{},
|
||||||
|
&model.HistoryOfSeeders{},
|
||||||
|
&model.CustomGeoResource{},
|
||||||
|
&model.Node{},
|
||||||
|
&model.ApiToken{},
|
||||||
|
&model.Inbound{},
|
||||||
|
&xray.ClientTraffic{},
|
||||||
|
&model.OutboundTraffics{},
|
||||||
|
&model.InboundClientIps{},
|
||||||
|
&model.ClientRecord{},
|
||||||
|
&model.ClientInbound{},
|
||||||
|
&model.InboundFallback{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateData copies every row from the configured SQLite file at srcPath into
|
||||||
|
// a fresh PostgreSQL database described by dstDSN. The destination tables are
|
||||||
|
// (re)created with AutoMigrate before the copy. Source data is left untouched.
|
||||||
|
func MigrateData(srcPath, dstDSN string) error {
|
||||||
|
if _, err := os.Stat(srcPath); err != nil {
|
||||||
|
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
if dstDSN == "" {
|
||||||
|
return errors.New("destination DSN is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
|
||||||
|
src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open sqlite source: %w", err)
|
||||||
|
}
|
||||||
|
srcSQL, err := src.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srcSQL.Close()
|
||||||
|
|
||||||
|
dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open postgres destination: %w", err)
|
||||||
|
}
|
||||||
|
dstSQL, err := dst.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstSQL.Close()
|
||||||
|
dstSQL.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
|
log.Println("Creating destination schema...")
|
||||||
|
for _, m := range migrationModels() {
|
||||||
|
if err := dst.AutoMigrate(m); err != nil {
|
||||||
|
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRows := 0
|
||||||
|
for _, m := range migrationModels() {
|
||||||
|
n, err := copyTable(src, dst, m)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy %T: %w", m, err)
|
||||||
|
}
|
||||||
|
totalRows += n
|
||||||
|
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resetPostgresSequences(dst); err != nil {
|
||||||
|
log.Printf("warning: failed to reset some postgres sequences: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
|
||||||
|
log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTable streams every row of `mdl` from src to dst in batches.
|
||||||
|
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||||
|
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
|
||||||
|
batchPtr := reflect.New(sliceType)
|
||||||
|
batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
|
||||||
|
batch := batchPtr.Elem()
|
||||||
|
if batch.Len() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total += batch.Len()
|
||||||
|
return nil
|
||||||
|
}).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetPostgresSequences advances each table's id sequence past MAX(id),
|
||||||
|
// otherwise the next INSERT-without-id would clash with copied rows.
|
||||||
|
func resetPostgresSequences(dst *gorm.DB) error {
|
||||||
|
tables := []string{
|
||||||
|
"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
|
||||||
|
"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
|
||||||
|
"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
|
||||||
|
}
|
||||||
|
for _, t := range tables {
|
||||||
|
// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
|
||||||
|
_ = dst.Exec(fmt.Sprintf(
|
||||||
|
`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
|
||||||
|
WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
|
||||||
|
t, t, t,
|
||||||
|
)).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,10 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
@ -47,7 +50,6 @@ type Inbound struct {
|
||||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||||
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` // All-time traffic usage
|
|
||||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
||||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
|
|
@ -64,6 +66,23 @@ type Inbound struct {
|
||||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||||
|
|
||||||
|
// FallbackParent is populated by the API layer when this inbound is
|
||||||
|
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
|
||||||
|
// The frontend uses it to rewrite client-share links so they advertise
|
||||||
|
// the master's externally reachable endpoint instead of the child's
|
||||||
|
// loopback listen. Not persisted.
|
||||||
|
FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FallbackParentInfo carries everything the frontend needs to rewrite a
|
||||||
|
// child inbound's client link: where to connect (the master's address
|
||||||
|
// and port) and which path matched on the master's fallbacks array.
|
||||||
|
// The frontend already has the master inbound in its dbInbounds list,
|
||||||
|
// so we only ship identifiers + the match path here.
|
||||||
|
type FallbackParentInfo struct {
|
||||||
|
MasterId int `json:"masterId"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||||
|
|
@ -82,6 +101,38 @@ type InboundClientIps struct {
|
||||||
Ips string `json:"ips" form:"ips"`
|
Ips string `json:"ips" form:"ips"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarshalJSON emits the Ips column as a real JSON array instead of an escaped
|
||||||
|
// JSON-text string. Empty or unparseable storage renders as null so API
|
||||||
|
// consumers don't have to special-case the legacy double-encoded shape.
|
||||||
|
func (ic InboundClientIps) MarshalJSON() ([]byte, error) {
|
||||||
|
type alias InboundClientIps
|
||||||
|
return json.Marshal(struct {
|
||||||
|
alias
|
||||||
|
Ips json.RawMessage `json:"ips"`
|
||||||
|
}{
|
||||||
|
alias: alias(ic),
|
||||||
|
Ips: jsonStringFieldToRaw(ic.Ips),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON accepts ips as either a JSON array (modern shape) or a
|
||||||
|
// JSON-encoded string (legacy shape), normalising back to the JSON-text the
|
||||||
|
// column stores.
|
||||||
|
func (ic *InboundClientIps) UnmarshalJSON(data []byte) error {
|
||||||
|
type alias InboundClientIps
|
||||||
|
aux := struct {
|
||||||
|
*alias
|
||||||
|
Ips json.RawMessage `json:"ips"`
|
||||||
|
}{
|
||||||
|
alias: (*alias)(ic),
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ic.Ips = jsonStringFieldFromRaw(aux.Ips)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
|
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
|
||||||
type HistoryOfSeeders struct {
|
type HistoryOfSeeders struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
|
@ -96,19 +147,86 @@ type ApiToken struct {
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
|
||||||
|
// objects rather than escaped strings, so API consumers don't need to JSON.parse
|
||||||
|
// a string inside a string. Empty fields render as null; fields whose stored
|
||||||
|
// text isn't valid JSON fall back to a JSON-encoded string so no data is lost.
|
||||||
|
func (i Inbound) MarshalJSON() ([]byte, error) {
|
||||||
|
type alias Inbound
|
||||||
|
return json.Marshal(struct {
|
||||||
|
alias
|
||||||
|
Settings json.RawMessage `json:"settings"`
|
||||||
|
StreamSettings json.RawMessage `json:"streamSettings"`
|
||||||
|
Sniffing json.RawMessage `json:"sniffing"`
|
||||||
|
}{
|
||||||
|
alias: alias(i),
|
||||||
|
Settings: jsonStringFieldToRaw(i.Settings),
|
||||||
|
StreamSettings: jsonStringFieldToRaw(i.StreamSettings),
|
||||||
|
Sniffing: jsonStringFieldToRaw(i.Sniffing),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON accepts settings, streamSettings, and sniffing as either a raw
|
||||||
|
// JSON object/array (the modern shape MarshalJSON emits) or a JSON-encoded
|
||||||
|
// string (the legacy shape). Either form is normalised back to the JSON-text
|
||||||
|
// string the DB column stores.
|
||||||
|
func (i *Inbound) UnmarshalJSON(data []byte) error {
|
||||||
|
type alias Inbound
|
||||||
|
aux := struct {
|
||||||
|
*alias
|
||||||
|
Settings json.RawMessage `json:"settings"`
|
||||||
|
StreamSettings json.RawMessage `json:"streamSettings"`
|
||||||
|
Sniffing json.RawMessage `json:"sniffing"`
|
||||||
|
}{
|
||||||
|
alias: (*alias)(i),
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.Settings = jsonStringFieldFromRaw(aux.Settings)
|
||||||
|
i.StreamSettings = jsonStringFieldFromRaw(aux.StreamSettings)
|
||||||
|
i.Sniffing = jsonStringFieldFromRaw(aux.Sniffing)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonStringFieldToRaw(s string) json.RawMessage {
|
||||||
|
trimmed := strings.TrimSpace(s)
|
||||||
|
if trimmed == "" {
|
||||||
|
return json.RawMessage("null")
|
||||||
|
}
|
||||||
|
if json.Valid([]byte(trimmed)) {
|
||||||
|
return json.RawMessage(trimmed)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonStringFieldFromRaw(r json.RawMessage) string {
|
||||||
|
trimmed := bytes.TrimSpace(r)
|
||||||
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if trimmed[0] == '"' {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(trimmed, &s); err == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||||
listen := i.Listen
|
listen := i.Listen
|
||||||
// Default to 0.0.0.0 (all interfaces) when listen is empty
|
|
||||||
// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
|
|
||||||
if listen == "" {
|
if listen == "" {
|
||||||
listen = "0.0.0.0"
|
listen = "0.0.0.0"
|
||||||
}
|
}
|
||||||
listen = fmt.Sprintf("\"%v\"", listen)
|
listen = fmt.Sprintf("\"%v\"", listen)
|
||||||
|
protocol := string(i.Protocol)
|
||||||
return &xray.InboundConfig{
|
return &xray.InboundConfig{
|
||||||
Listen: json_util.RawMessage(listen),
|
Listen: json_util.RawMessage(listen),
|
||||||
Port: i.Port,
|
Port: i.Port,
|
||||||
Protocol: string(i.Protocol),
|
Protocol: protocol,
|
||||||
Settings: json_util.RawMessage(i.Settings),
|
Settings: json_util.RawMessage(i.Settings),
|
||||||
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
||||||
Tag: i.Tag,
|
Tag: i.Tag,
|
||||||
|
|
@ -146,11 +264,17 @@ type Node struct {
|
||||||
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
||||||
LatencyMs int `json:"latencyMs"`
|
LatencyMs int `json:"latencyMs"`
|
||||||
XrayVersion string `json:"xrayVersion"`
|
XrayVersion string `json:"xrayVersion"`
|
||||||
|
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
|
||||||
CpuPct float64 `json:"cpuPct"`
|
CpuPct float64 `json:"cpuPct"`
|
||||||
MemPct float64 `json:"memPct"`
|
MemPct float64 `json:"memPct"`
|
||||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||||
LastError string `json:"lastError"`
|
LastError string `json:"lastError"`
|
||||||
|
|
||||||
|
InboundCount int `json:"inboundCount" gorm:"-"`
|
||||||
|
ClientCount int `json:"clientCount" gorm:"-"`
|
||||||
|
OnlineCount int `json:"onlineCount" gorm:"-"`
|
||||||
|
DepletedCount int `json:"depletedCount" gorm:"-"`
|
||||||
|
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
|
||||||
}
|
}
|
||||||
|
|
@ -191,3 +315,266 @@ type Client struct {
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientRecord struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||||
|
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||||
|
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Auth string `json:"auth"`
|
||||||
|
Flow string `json:"flow"`
|
||||||
|
Security string `json:"security"`
|
||||||
|
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||||
|
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||||
|
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||||
|
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||||
|
Enable bool `json:"enable" gorm:"default:true"`
|
||||||
|
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Reset int `json:"reset" gorm:"default:0"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ClientRecord) TableName() string { return "clients" }
|
||||||
|
|
||||||
|
// MarshalJSON emits the reverse column as a nested JSON object rather than an
|
||||||
|
// escaped JSON-text string, matching the same convention Inbound uses for its
|
||||||
|
// JSON-text columns. Empty storage renders as null.
|
||||||
|
func (r ClientRecord) MarshalJSON() ([]byte, error) {
|
||||||
|
type alias ClientRecord
|
||||||
|
return json.Marshal(struct {
|
||||||
|
alias
|
||||||
|
Reverse json.RawMessage `json:"reverse"`
|
||||||
|
}{
|
||||||
|
alias: alias(r),
|
||||||
|
Reverse: jsonStringFieldToRaw(r.Reverse),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON accepts reverse as either a JSON object (modern shape) or a
|
||||||
|
// JSON-encoded string (legacy shape).
|
||||||
|
func (r *ClientRecord) UnmarshalJSON(data []byte) error {
|
||||||
|
type alias ClientRecord
|
||||||
|
aux := struct {
|
||||||
|
*alias
|
||||||
|
Reverse json.RawMessage `json:"reverse"`
|
||||||
|
}{
|
||||||
|
alias: (*alias)(r),
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Reverse = jsonStringFieldFromRaw(aux.Reverse)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientInbound struct {
|
||||||
|
ClientId int `json:"clientId" gorm:"primaryKey;column:client_id;index"`
|
||||||
|
InboundId int `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
|
||||||
|
FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ClientInbound) TableName() string { return "client_inbounds" }
|
||||||
|
|
||||||
|
// InboundFallback is one routing rule on a master inbound's
|
||||||
|
// settings.fallbacks array. The master is always a VLESS or Trojan
|
||||||
|
// inbound on TCP transport with TLS or Reality. The child is any other
|
||||||
|
// inbound — its listen+port becomes the fallback dest, with optional
|
||||||
|
// SNI/ALPN/path match criteria pulled from the same row.
|
||||||
|
type InboundFallback struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
|
||||||
|
ChildId int `json:"childId" gorm:"index;not null;column:child_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Alpn string `json:"alpn"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Xver int `json:"xver"`
|
||||||
|
SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InboundFallback) TableName() string { return "inbound_fallbacks" }
|
||||||
|
|
||||||
|
func (c *Client) ToRecord() *ClientRecord {
|
||||||
|
rec := &ClientRecord{
|
||||||
|
Email: c.Email,
|
||||||
|
SubID: c.SubID,
|
||||||
|
UUID: c.ID,
|
||||||
|
Password: c.Password,
|
||||||
|
Auth: c.Auth,
|
||||||
|
Flow: c.Flow,
|
||||||
|
Security: c.Security,
|
||||||
|
LimitIP: c.LimitIP,
|
||||||
|
TotalGB: c.TotalGB,
|
||||||
|
ExpiryTime: c.ExpiryTime,
|
||||||
|
Enable: c.Enable,
|
||||||
|
TgID: c.TgID,
|
||||||
|
Comment: c.Comment,
|
||||||
|
Reset: c.Reset,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
UpdatedAt: c.UpdatedAt,
|
||||||
|
}
|
||||||
|
if c.Reverse != nil {
|
||||||
|
if b, err := json.Marshal(c.Reverse); err == nil {
|
||||||
|
rec.Reverse = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClientRecord) ToClient() *Client {
|
||||||
|
c := &Client{
|
||||||
|
ID: r.UUID,
|
||||||
|
Email: r.Email,
|
||||||
|
SubID: r.SubID,
|
||||||
|
Password: r.Password,
|
||||||
|
Auth: r.Auth,
|
||||||
|
Flow: r.Flow,
|
||||||
|
Security: r.Security,
|
||||||
|
LimitIP: r.LimitIP,
|
||||||
|
TotalGB: r.TotalGB,
|
||||||
|
ExpiryTime: r.ExpiryTime,
|
||||||
|
Enable: r.Enable,
|
||||||
|
TgID: r.TgID,
|
||||||
|
Comment: r.Comment,
|
||||||
|
Reset: r.Reset,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
}
|
||||||
|
if r.Reverse != "" {
|
||||||
|
var rev ClientReverse
|
||||||
|
if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil {
|
||||||
|
c.Reverse = &rev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientMergeConflict struct {
|
||||||
|
Field string
|
||||||
|
Old any
|
||||||
|
New any
|
||||||
|
Kept any
|
||||||
|
}
|
||||||
|
|
||||||
|
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
|
||||||
|
var conflicts []ClientMergeConflict
|
||||||
|
keep := func(field string, oldV, newV, kept any) {
|
||||||
|
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept})
|
||||||
|
}
|
||||||
|
const redacted = "<redacted>"
|
||||||
|
keepSecret := func(field string) {
|
||||||
|
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: redacted, New: redacted, Kept: redacted})
|
||||||
|
}
|
||||||
|
|
||||||
|
incomingNewer := incoming.UpdatedAt > existing.UpdatedAt ||
|
||||||
|
(incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt)
|
||||||
|
|
||||||
|
if existing.UUID != incoming.UUID && incoming.UUID != "" {
|
||||||
|
if incomingNewer || existing.UUID == "" {
|
||||||
|
existing.UUID = incoming.UUID
|
||||||
|
}
|
||||||
|
keepSecret("uuid")
|
||||||
|
}
|
||||||
|
if existing.Password != incoming.Password && incoming.Password != "" {
|
||||||
|
if incomingNewer || existing.Password == "" {
|
||||||
|
existing.Password = incoming.Password
|
||||||
|
keepSecret("password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.Auth != incoming.Auth && incoming.Auth != "" {
|
||||||
|
if incomingNewer || existing.Auth == "" {
|
||||||
|
existing.Auth = incoming.Auth
|
||||||
|
keepSecret("auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.Flow != incoming.Flow && incoming.Flow != "" {
|
||||||
|
if incomingNewer || existing.Flow == "" {
|
||||||
|
keep("flow", existing.Flow, incoming.Flow, incoming.Flow)
|
||||||
|
existing.Flow = incoming.Flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.Security != incoming.Security && incoming.Security != "" {
|
||||||
|
if incomingNewer || existing.Security == "" {
|
||||||
|
keep("security", existing.Security, incoming.Security, incoming.Security)
|
||||||
|
existing.Security = incoming.Security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.SubID != incoming.SubID && incoming.SubID != "" {
|
||||||
|
if incomingNewer || existing.SubID == "" {
|
||||||
|
existing.SubID = incoming.SubID
|
||||||
|
keepSecret("subId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.TotalGB != incoming.TotalGB {
|
||||||
|
picked := existing.TotalGB
|
||||||
|
if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) {
|
||||||
|
picked = incoming.TotalGB
|
||||||
|
}
|
||||||
|
if picked != existing.TotalGB {
|
||||||
|
keep("totalGB", existing.TotalGB, incoming.TotalGB, picked)
|
||||||
|
existing.TotalGB = picked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.ExpiryTime != incoming.ExpiryTime {
|
||||||
|
picked := existing.ExpiryTime
|
||||||
|
if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) {
|
||||||
|
picked = incoming.ExpiryTime
|
||||||
|
}
|
||||||
|
if picked != existing.ExpiryTime {
|
||||||
|
keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked)
|
||||||
|
existing.ExpiryTime = picked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 {
|
||||||
|
picked := existing.LimitIP
|
||||||
|
if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP {
|
||||||
|
picked = incoming.LimitIP
|
||||||
|
}
|
||||||
|
if picked != existing.LimitIP {
|
||||||
|
keep("limitIp", existing.LimitIP, incoming.LimitIP, picked)
|
||||||
|
existing.LimitIP = picked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.TgID != incoming.TgID && incoming.TgID != 0 {
|
||||||
|
if incomingNewer || existing.TgID == 0 {
|
||||||
|
keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
|
||||||
|
existing.TgID = incoming.TgID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.Reset != incoming.Reset && incoming.Reset != 0 {
|
||||||
|
if incomingNewer || existing.Reset == 0 {
|
||||||
|
keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
|
||||||
|
existing.Reset = incoming.Reset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
|
||||||
|
if incomingNewer || existing.Reverse == "" {
|
||||||
|
keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
|
||||||
|
existing.Reverse = incoming.Reverse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.Comment != incoming.Comment && incoming.Comment != "" {
|
||||||
|
if incomingNewer || existing.Comment == "" {
|
||||||
|
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
|
||||||
|
existing.Comment = incoming.Comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.Enable != incoming.Enable {
|
||||||
|
if incoming.Enable {
|
||||||
|
if !existing.Enable {
|
||||||
|
keep("enable", existing.Enable, incoming.Enable, true)
|
||||||
|
existing.Enable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) {
|
||||||
|
existing.CreatedAt = incoming.CreatedAt
|
||||||
|
}
|
||||||
|
if incoming.UpdatedAt > existing.UpdatedAt {
|
||||||
|
existing.UpdatedAt = incoming.UpdatedAt
|
||||||
|
}
|
||||||
|
return conflicts
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,193 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
|
||||||
|
in := Inbound{
|
||||||
|
Id: 7,
|
||||||
|
Protocol: VLESS,
|
||||||
|
Port: 443,
|
||||||
|
Settings: `{"clients":[],"decryption":"none"}`,
|
||||||
|
StreamSettings: `{"network":"tcp"}`,
|
||||||
|
Sniffing: `{"enabled":true}`,
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
||||||
|
if _, ok := parsed[field].(map[string]any); !ok {
|
||||||
|
t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(out), `"settings":"`) {
|
||||||
|
t.Errorf("settings should not be emitted as a JSON string: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
|
||||||
|
in := Inbound{Id: 1, Protocol: VLESS}
|
||||||
|
out, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
||||||
|
if parsed[field] != nil {
|
||||||
|
t.Errorf("expected %s to be null, got %v", field, parsed[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nested objects (modern)",
|
||||||
|
body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JSON-encoded strings (legacy)",
|
||||||
|
body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var in Inbound
|
||||||
|
if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(in.Settings, `"decryption":"none"`) {
|
||||||
|
t.Errorf("Settings not normalised: %q", in.Settings)
|
||||||
|
}
|
||||||
|
if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
|
||||||
|
t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
|
||||||
|
}
|
||||||
|
if !strings.Contains(in.Sniffing, `"enabled":true`) {
|
||||||
|
t.Errorf("Sniffing not normalised: %q", in.Sniffing)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
|
||||||
|
in := Inbound{Id: 1, Settings: "not json at all"}
|
||||||
|
out, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), `"settings":"not json at all"`) {
|
||||||
|
t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
|
||||||
|
rec := ClientRecord{Id: 1, Email: "alice@example.com", Reverse: `{"tag":"vless-in"}`}
|
||||||
|
out, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
obj, ok := parsed["reverse"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
|
||||||
|
}
|
||||||
|
if obj["tag"] != "vless-in" {
|
||||||
|
t.Errorf("expected tag to be preserved, got %v", obj["tag"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
|
||||||
|
rec := ClientRecord{Id: 1, Email: "alice@example.com"}
|
||||||
|
out, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if parsed["reverse"] != nil {
|
||||||
|
t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
|
||||||
|
{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var rec ClientRecord
|
||||||
|
if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
|
||||||
|
t.Errorf("Reverse not normalised: %q", rec.Reverse)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
|
||||||
|
row := InboundClientIps{Id: 1, ClientEmail: "alice@example.com", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
|
||||||
|
out, err := json.Marshal(row)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
arr, ok := parsed["ips"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
|
||||||
|
}
|
||||||
|
if len(arr) != 1 {
|
||||||
|
t.Errorf("expected 1 entry, got %d", len(arr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
|
||||||
|
{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var row InboundClientIps
|
||||||
|
if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
|
||||||
|
t.Errorf("Ips not normalised: %q", row.Ips)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsHysteria(t *testing.T) {
|
func TestIsHysteria(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,24 @@ services:
|
||||||
environment:
|
environment:
|
||||||
XRAY_VMESS_AEAD_FORCED: "false"
|
XRAY_VMESS_AEAD_FORCED: "false"
|
||||||
XUI_ENABLE_FAIL2BAN: "true"
|
XUI_ENABLE_FAIL2BAN: "true"
|
||||||
|
# To use PostgreSQL instead of the default SQLite, run:
|
||||||
|
# docker compose --profile postgres up -d
|
||||||
|
# and uncomment the two lines below.
|
||||||
|
# XUI_DB_TYPE: "postgres"
|
||||||
|
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
|
||||||
tty: true
|
tty: true
|
||||||
ports:
|
ports:
|
||||||
- "2053:2053"
|
- "2053:2053"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: 3xui_postgres
|
||||||
|
profiles: ["postgres"]
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: xui
|
||||||
|
POSTGRES_PASSWORD: xui
|
||||||
|
POSTGRES_DB: xui
|
||||||
|
volumes:
|
||||||
|
- $PWD/pgdata/:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
13
frontend/clients.html
Normal file
13
frontend/clients.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Clients</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="message"></div>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/entries/clients.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
99
frontend/package-lock.json
generated
99
frontend/package-lock.json
generated
|
|
@ -334,9 +334,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-helpers": {
|
"node_modules/@eslint/config-helpers": {
|
||||||
"version": "0.5.5",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
|
||||||
"integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
|
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -471,61 +471,61 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/core-base": {
|
"node_modules/@intlify/core-base": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.4.tgz",
|
||||||
"integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==",
|
"integrity": "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/devtools-types": "11.4.2",
|
"@intlify/devtools-types": "11.4.4",
|
||||||
"@intlify/message-compiler": "11.4.2",
|
"@intlify/message-compiler": "11.4.4",
|
||||||
"@intlify/shared": "11.4.2"
|
"@intlify/shared": "11.4.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/devtools-types": {
|
"node_modules/@intlify/devtools-types": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.4.tgz",
|
||||||
"integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==",
|
"integrity": "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.2",
|
"@intlify/core-base": "11.4.4",
|
||||||
"@intlify/shared": "11.4.2"
|
"@intlify/shared": "11.4.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/message-compiler": {
|
"node_modules/@intlify/message-compiler": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.4.tgz",
|
||||||
"integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==",
|
"integrity": "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/shared": "11.4.2",
|
"@intlify/shared": "11.4.4",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/shared": {
|
"node_modules/@intlify/shared": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.4.tgz",
|
||||||
"integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==",
|
"integrity": "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
|
@ -895,9 +895,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.13",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -944,13 +944,13 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "6.0.6",
|
"version": "6.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||||
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
|
"integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
"@rolldown/pluginutils": "^1.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
|
@ -1477,16 +1477,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "10.3.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
|
||||||
"integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
|
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@eslint/config-array": "^0.23.5",
|
"@eslint/config-array": "^0.23.5",
|
||||||
"@eslint/config-helpers": "^0.5.5",
|
"@eslint/config-helpers": "^0.6.0",
|
||||||
"@eslint/core": "^1.2.1",
|
"@eslint/core": "^1.2.1",
|
||||||
"@eslint/plugin-kit": "^0.7.1",
|
"@eslint/plugin-kit": "^0.7.1",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
|
|
@ -2694,9 +2694,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
|
|
@ -2748,13 +2748,6 @@
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/scroll-into-view-if-needed": {
|
"node_modules/scroll-into-view-if-needed": {
|
||||||
"version": "2.2.31",
|
"version": "2.2.31",
|
||||||
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
||||||
|
|
@ -3087,18 +3080,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-i18n": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "11.4.2",
|
"version": "11.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.4.tgz",
|
||||||
"integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==",
|
"integrity": "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.2",
|
"@intlify/core-base": "11.4.4",
|
||||||
"@intlify/devtools-types": "11.4.2",
|
"@intlify/devtools-types": "11.4.4",
|
||||||
"@intlify/shared": "11.4.2",
|
"@intlify/shared": "11.4.4",
|
||||||
"@vue/devtools-api": "^6.5.0"
|
"@vue/devtools-api": "^6.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,16 @@ export function setupAxios() {
|
||||||
}
|
}
|
||||||
if (config.data instanceof FormData) {
|
if (config.data instanceof FormData) {
|
||||||
config.headers['Content-Type'] = 'multipart/form-data';
|
config.headers['Content-Type'] = 'multipart/form-data';
|
||||||
|
} else {
|
||||||
|
const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
|
||||||
|
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||||
|
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||||
|
config.data = JSON.stringify(config.data);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error),
|
(error) => Promise.reject(error),
|
||||||
|
|
@ -104,9 +111,14 @@ export function setupAxios() {
|
||||||
if (token) {
|
if (token) {
|
||||||
cfg.headers = cfg.headers || {};
|
cfg.headers = cfg.headers || {};
|
||||||
cfg.headers['X-CSRF-Token'] = token;
|
cfg.headers['X-CSRF-Token'] = token;
|
||||||
// axios re-stringifies on retry, so unwind our qs.stringify before
|
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
|
||||||
// letting the same request flow through the interceptor again.
|
if (typeof cfg.data === 'string') {
|
||||||
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
|
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||||
|
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
|
||||||
|
} else {
|
||||||
|
cfg.data = qs.parse(cfg.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
return axios(cfg);
|
return axios(cfg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
TeamOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
|
|
@ -30,6 +31,7 @@ const props = defineProps({
|
||||||
const iconByName = {
|
const iconByName = {
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
user: UserOutlined,
|
user: UserOutlined,
|
||||||
|
team: TeamOutlined,
|
||||||
setting: SettingOutlined,
|
setting: SettingOutlined,
|
||||||
tool: ToolOutlined,
|
tool: ToolOutlined,
|
||||||
cluster: ClusterOutlined,
|
cluster: ClusterOutlined,
|
||||||
|
|
@ -42,6 +44,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||||
|
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
|
||||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||||
|
|
|
||||||
21
frontend/src/entries/clients.js
Normal file
21
frontend/src/entries/clients.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
|
import ClientsPage from '@/pages/clients/ClientsPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
readyI18n().then(() => {
|
||||||
|
createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,19 @@ import dayjs from 'dayjs';
|
||||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||||
import { Inbound, Protocols } from './inbound.js';
|
import { Inbound, Protocols } from './inbound.js';
|
||||||
|
|
||||||
|
export function coerceInboundJsonField(value) {
|
||||||
|
if (value == null) return {};
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
if (typeof value !== 'string') return {};
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === '') return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch (_e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class DBInbound {
|
export class DBInbound {
|
||||||
|
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
|
|
@ -10,7 +23,6 @@ export class DBInbound {
|
||||||
this.up = 0;
|
this.up = 0;
|
||||||
this.down = 0;
|
this.down = 0;
|
||||||
this.total = 0;
|
this.total = 0;
|
||||||
this.allTime = 0;
|
|
||||||
this.remark = "";
|
this.remark = "";
|
||||||
this.enable = true;
|
this.enable = true;
|
||||||
this.expiryTime = 0;
|
this.expiryTime = 0;
|
||||||
|
|
@ -28,6 +40,9 @@ export class DBInbound {
|
||||||
// Optional FK to web/runtime registered Node. null/undefined =
|
// Optional FK to web/runtime registered Node. null/undefined =
|
||||||
// local panel; otherwise the inbound lives on the named node.
|
// local panel; otherwise the inbound lives on the named node.
|
||||||
this.nodeId = null;
|
this.nodeId = null;
|
||||||
|
// Populated by the API when this inbound is a fallback child of
|
||||||
|
// a VLESS/Trojan TCP-TLS master. Shape: { masterId, path }.
|
||||||
|
this.fallbackParent = null;
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -111,20 +126,9 @@ export class DBInbound {
|
||||||
return this._cachedInbound;
|
return this._cachedInbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = {};
|
const settings = coerceInboundJsonField(this.settings);
|
||||||
if (!ObjectUtil.isEmpty(this.settings)) {
|
const streamSettings = coerceInboundJsonField(this.streamSettings);
|
||||||
settings = JSON.parse(this.settings);
|
const sniffing = coerceInboundJsonField(this.sniffing);
|
||||||
}
|
|
||||||
|
|
||||||
let streamSettings = {};
|
|
||||||
if (!ObjectUtil.isEmpty(this.streamSettings)) {
|
|
||||||
streamSettings = JSON.parse(this.streamSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sniffing = {};
|
|
||||||
if (!ObjectUtil.isEmpty(this.sniffing)) {
|
|
||||||
sniffing = JSON.parse(this.sniffing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
port: this.port,
|
port: this.port,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export const Protocols = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SSMethods = {
|
export const SSMethods = {
|
||||||
|
AES_256_GCM: 'aes-256-gcm',
|
||||||
CHACHA20_POLY1305: 'chacha20-poly1305',
|
CHACHA20_POLY1305: 'chacha20-poly1305',
|
||||||
CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
|
CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
|
||||||
XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
|
XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
|
||||||
|
|
@ -232,14 +233,20 @@ export class TcpStreamSettings extends XrayCommonClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
return {
|
const json = {};
|
||||||
acceptProxyProtocol: this.acceptProxyProtocol,
|
if (this.acceptProxyProtocol) {
|
||||||
header: {
|
json.acceptProxyProtocol = true;
|
||||||
type: this.type,
|
}
|
||||||
request: this.type === 'http' ? this.request.toJson() : undefined,
|
if (this.type === 'http') {
|
||||||
response: this.type === 'http' ? this.response.toJson() : undefined,
|
json.header = {
|
||||||
},
|
type: 'http',
|
||||||
|
request: this.request.toJson(),
|
||||||
|
response: this.response.toJson(),
|
||||||
};
|
};
|
||||||
|
} else if (this.type && this.type !== 'none') {
|
||||||
|
json.header = { type: this.type };
|
||||||
|
}
|
||||||
|
return json;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1465,7 +1472,9 @@ export class StreamSettings extends XrayCommonClass {
|
||||||
return {
|
return {
|
||||||
network: network,
|
network: network,
|
||||||
security: this.security,
|
security: this.security,
|
||||||
externalProxy: this.externalProxy,
|
externalProxy: Array.isArray(this.externalProxy) && this.externalProxy.length > 0
|
||||||
|
? this.externalProxy
|
||||||
|
: undefined,
|
||||||
tlsSettings: this.isTls ? this.tls.toJson() : undefined,
|
tlsSettings: this.isTls ? this.tls.toJson() : undefined,
|
||||||
realitySettings: this.isReality ? this.reality.toJson() : undefined,
|
realitySettings: this.isReality ? this.reality.toJson() : undefined,
|
||||||
tcpSettings: network === 'tcp' ? this.tcp.toJson() : undefined,
|
tcpSettings: network === 'tcp' ? this.tcp.toJson() : undefined,
|
||||||
|
|
@ -1514,11 +1523,14 @@ export class Sniffing extends XrayCommonClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
toJson() {
|
toJson() {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return { enabled: false };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
enabled: this.enabled,
|
enabled: true,
|
||||||
destOverride: this.destOverride,
|
destOverride: this.destOverride,
|
||||||
metadataOnly: this.metadataOnly,
|
metadataOnly: this.metadataOnly || undefined,
|
||||||
routeOnly: this.routeOnly,
|
routeOnly: this.routeOnly || undefined,
|
||||||
ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
|
ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
|
||||||
domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
|
domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
|
||||||
};
|
};
|
||||||
|
|
@ -2567,7 +2579,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
|
||||||
|
|
||||||
Inbound.VmessSettings = class extends Inbound.Settings {
|
Inbound.VmessSettings = class extends Inbound.Settings {
|
||||||
constructor(protocol,
|
constructor(protocol,
|
||||||
vmesses = [new Inbound.VmessSettings.VMESS()]) {
|
vmesses = []) {
|
||||||
super(protocol);
|
super(protocol);
|
||||||
this.vmesses = vmesses;
|
this.vmesses = vmesses;
|
||||||
}
|
}
|
||||||
|
|
@ -2635,7 +2647,7 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
|
||||||
Inbound.VLESSSettings = class extends Inbound.Settings {
|
Inbound.VLESSSettings = class extends Inbound.Settings {
|
||||||
constructor(
|
constructor(
|
||||||
protocol,
|
protocol,
|
||||||
vlesses = [new Inbound.VLESSSettings.VLESS()],
|
vlesses = [],
|
||||||
decryption = "none",
|
decryption = "none",
|
||||||
encryption = "none",
|
encryption = "none",
|
||||||
fallbacks = [],
|
fallbacks = [],
|
||||||
|
|
@ -2782,7 +2794,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
|
||||||
|
|
||||||
Inbound.TrojanSettings = class extends Inbound.Settings {
|
Inbound.TrojanSettings = class extends Inbound.Settings {
|
||||||
constructor(protocol,
|
constructor(protocol,
|
||||||
trojans = [new Inbound.TrojanSettings.Trojan()],
|
trojans = [],
|
||||||
fallbacks = [],) {
|
fallbacks = [],) {
|
||||||
super(protocol);
|
super(protocol);
|
||||||
this.trojans = trojans;
|
this.trojans = trojans;
|
||||||
|
|
@ -2864,8 +2876,8 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
|
||||||
constructor(protocol,
|
constructor(protocol,
|
||||||
method = SSMethods.BLAKE3_AES_256_GCM,
|
method = SSMethods.BLAKE3_AES_256_GCM,
|
||||||
password = RandomUtil.randomShadowsocksPassword(),
|
password = RandomUtil.randomShadowsocksPassword(),
|
||||||
network = 'tcp,udp',
|
network = 'tcp',
|
||||||
shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()],
|
shadowsockses = [],
|
||||||
ivCheck = false,
|
ivCheck = false,
|
||||||
) {
|
) {
|
||||||
super(protocol);
|
super(protocol);
|
||||||
|
|
@ -2927,7 +2939,7 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
|
||||||
};
|
};
|
||||||
|
|
||||||
Inbound.HysteriaSettings = class extends Inbound.Settings {
|
Inbound.HysteriaSettings = class extends Inbound.Settings {
|
||||||
constructor(protocol, version = 2, hysterias = [new Inbound.HysteriaSettings.Hysteria()]) {
|
constructor(protocol, version = 2, hysterias = []) {
|
||||||
super(protocol);
|
super(protocol);
|
||||||
this.version = version;
|
this.version = version;
|
||||||
this.hysterias = hysterias;
|
this.hysterias = hysterias;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { safeInlineHtml } from './endpoints.js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
section: { type: Object, required: true },
|
section: { type: Object, required: true },
|
||||||
icon: { type: Object, default: null },
|
icon: { type: [Object, Function], default: null },
|
||||||
collapsed: { type: Boolean, default: false },
|
collapsed: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,16 @@ export const sections = [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/inbounds/list',
|
path: '/panel/api/inbounds/list',
|
||||||
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
|
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": "{\\"clients\\":[...]}",\n "streamSettings": "{...}",\n "tag": "inbound-443",\n "sniffing": "{...}",\n "clientStats": [...]\n }\n ]\n}',
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/inbounds/options',
|
||||||
|
summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "protocol": "vless",\n "port": 443,\n "tlsFlowCapable": true\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -88,30 +95,12 @@ export const sections = [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
path: '/panel/api/inbounds/getClientTraffics/:email',
|
|
||||||
summary: 'Traffic counters for a client identified by email.',
|
|
||||||
params: [
|
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
|
||||||
],
|
|
||||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
path: '/panel/api/inbounds/getClientTrafficsById/:id',
|
|
||||||
summary: 'Traffic counters for a client identified by its UUID/password.',
|
|
||||||
params: [
|
|
||||||
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
|
|
||||||
],
|
|
||||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/inbounds/add',
|
path: '/panel/api/inbounds/add',
|
||||||
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
|
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
|
||||||
body:
|
body:
|
||||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
|
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}',
|
||||||
errorResponse:
|
errorResponse:
|
||||||
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
||||||
},
|
},
|
||||||
|
|
@ -140,59 +129,6 @@ export const sections = [
|
||||||
],
|
],
|
||||||
body: '{\n "enable": false\n}',
|
body: '{\n "enable": false\n}',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/clientIps/:email',
|
|
||||||
summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
|
|
||||||
params: [
|
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/clearClientIps/:email',
|
|
||||||
summary: 'Reset the recorded IP list for a client.',
|
|
||||||
params: [
|
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/addClient',
|
|
||||||
summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
|
|
||||||
body:
|
|
||||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/:id/copyClients',
|
|
||||||
summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
|
|
||||||
params: [
|
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
|
|
||||||
{ name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
|
|
||||||
{ name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
|
|
||||||
{ name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/:id/delClient/:clientId',
|
|
||||||
summary: 'Delete a client by its UUID/password from a specific inbound.',
|
|
||||||
params: [
|
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
|
||||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/updateClient/:clientId',
|
|
||||||
summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
|
|
||||||
params: [
|
|
||||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
|
||||||
],
|
|
||||||
body:
|
|
||||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/inbounds/:id/resetTraffic',
|
path: '/panel/api/inbounds/:id/resetTraffic',
|
||||||
|
|
@ -201,36 +137,11 @@ export const sections = [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
|
|
||||||
summary: 'Zero out upload + download counters for one client.',
|
|
||||||
params: [
|
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/inbounds/resetAllTraffics',
|
path: '/panel/api/inbounds/resetAllTraffics',
|
||||||
summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
|
summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/resetAllClientTraffics/:id',
|
|
||||||
summary: 'Reset traffic for every client in one inbound.',
|
|
||||||
params: [
|
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/delDepletedClients/:id',
|
|
||||||
summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
|
|
||||||
params: [
|
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/inbounds/import',
|
path: '/panel/api/inbounds/import',
|
||||||
|
|
@ -239,58 +150,26 @@ export const sections = [
|
||||||
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
|
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/onlines',
|
|
||||||
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
|
|
||||||
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/lastOnline',
|
|
||||||
summary: 'Map of client email → last-seen unix timestamp.',
|
|
||||||
response: '{\n "success": true,\n "obj": [\n { "email": "user1", "lastOnline": 1700000000 },\n { "email": "user2", "lastOnline": 1699999000 }\n ]\n}',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/inbounds/getSubLinks/:subId',
|
path: '/panel/api/inbounds/:id/fallbacks',
|
||||||
summary:
|
summary: 'List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/xver match criteria.',
|
||||||
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
|
|
||||||
params: [
|
params: [
|
||||||
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
|
||||||
],
|
],
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "masterId": 10,\n "childId": 11,\n "name": "",\n "alpn": "",\n "path": "/vlws",\n "xver": 2,\n "sortOrder": 0\n }\n ]\n}',
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
path: '/panel/api/inbounds/getClientLinks/:id/:email',
|
|
||||||
summary:
|
|
||||||
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
|
|
||||||
params: [
|
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
|
||||||
],
|
|
||||||
response:
|
|
||||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/inbounds/updateClientTraffic/:email',
|
path: '/panel/api/inbounds/:id/fallbacks',
|
||||||
summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
|
summary: 'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
|
||||||
],
|
{ name: 'fallbacks', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, xver, sortOrder} entries.' },
|
||||||
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
path: '/panel/api/inbounds/:id/delClientByEmail/:email',
|
|
||||||
summary: 'Delete a client identified by email rather than UUID.',
|
|
||||||
params: [
|
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
|
||||||
],
|
],
|
||||||
|
body: '{\n "fallbacks": [\n { "childId": 11, "path": "/vlws", "xver": 2 },\n { "childId": 12, "alpn": "h2" }\n ]\n}',
|
||||||
|
response: '{\n "success": true,\n "msg": "Inbound updated"\n}',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -494,6 +373,173 @@ export const sections = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'clients',
|
||||||
|
title: 'Clients',
|
||||||
|
description:
|
||||||
|
'Manage clients as first-class entities that can be attached to one or more inbounds. A single client row drives the settings.clients entry in every inbound it belongs to. Endpoints live under /panel/api/clients.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/clients/list',
|
||||||
|
summary: 'List every client with its attached inbound IDs and traffic record. The reverse field, if set, is returned as a nested JSON object (legacy JSON-encoded-string form is still accepted on write).',
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "email": "alice@example.com",\n "subId": "abcd1234",\n "uuid": "...",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "enable": true,\n "reverse": null,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true }\n }\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/clients/get/:email',
|
||||||
|
summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||||
|
],
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/add',
|
||||||
|
summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.',
|
||||||
|
params: [
|
||||||
|
{ name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
|
||||||
|
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
|
||||||
|
],
|
||||||
|
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}',
|
||||||
|
response: '{\n "success": true,\n "msg": "Client added"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/update/:email',
|
||||||
|
summary: 'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Current client email (unique identifier).' },
|
||||||
|
],
|
||||||
|
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "tgId": 123456789,\n "enable": true\n}',
|
||||||
|
response: '{\n "success": true,\n "msg": "Client updated"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/del/:email',
|
||||||
|
summary: 'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||||
|
{ name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' },
|
||||||
|
],
|
||||||
|
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/:email/attach',
|
||||||
|
summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||||
|
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' },
|
||||||
|
],
|
||||||
|
body: '{\n "inboundIds": [7, 9]\n}',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/:email/detach',
|
||||||
|
summary: 'Detach a client from one or more inbounds without deleting the client.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||||
|
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' },
|
||||||
|
],
|
||||||
|
body: '{\n "inboundIds": [5]\n}',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/resetAllTraffics',
|
||||||
|
summary: 'Reset the up/down counters for every client globally. Quotas and expiry are not affected. Triggers an Xray restart if any counter actually moved.',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/delDepleted',
|
||||||
|
summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "deleted": 0\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/resetTraffic/:email',
|
||||||
|
summary: 'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/updateTraffic/:email',
|
||||||
|
summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/ips/:email',
|
||||||
|
summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/clearIps/:email',
|
||||||
|
summary: 'Reset the recorded IP list for a client.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/onlines',
|
||||||
|
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
|
||||||
|
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/lastOnline',
|
||||||
|
summary: 'Map of client email → last-seen unix timestamp.',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "user1": 1700000000,\n "user2": 1699999000\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/clients/traffic/:email',
|
||||||
|
summary: 'Traffic counters for a client identified by email.',
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||||
|
],
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/clients/subLinks/:subId',
|
||||||
|
summary:
|
||||||
|
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
|
||||||
|
params: [
|
||||||
|
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
|
||||||
|
],
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/clients/links/:email',
|
||||||
|
summary:
|
||||||
|
"Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
|
||||||
|
params: [
|
||||||
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||||
|
],
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'nodes',
|
id: 'nodes',
|
||||||
title: 'Nodes',
|
title: 'Nodes',
|
||||||
|
|
@ -504,7 +550,7 @@ export const sections = [
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/nodes/list',
|
path: '/panel/api/nodes/list',
|
||||||
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "status": "online",\n "cpu": 23.5,\n "mem": 45.1\n }\n ]\n}',
|
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false,\n "status": "online",\n "lastHeartbeat": 1700000000,\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 23.5,\n "memPct": 45.1,\n "uptimeSecs": 86400,\n "lastError": "",\n "inboundCount": 5,\n "clientCount": 27,\n "onlineCount": 3,\n "depletedCount": 1,\n "createdAt": 1700000000,\n "updatedAt": 1700000000\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -517,9 +563,9 @@ export const sections = [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/nodes/add',
|
path: '/panel/api/nodes/add',
|
||||||
summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
|
summary: 'Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.',
|
||||||
body:
|
body:
|
||||||
'{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
'{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -528,7 +574,7 @@ export const sections = [
|
||||||
params: [
|
params: [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||||
],
|
],
|
||||||
body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
body: '{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -550,9 +596,9 @@ export const sections = [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/nodes/test',
|
path: '/panel/api/nodes/test',
|
||||||
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
|
summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
|
||||||
body: '{\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||||
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "cpu": 12.5,\n "mem": 45.2\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
267
frontend/src/pages/clients/ClientBulkAddModal.vue
Normal file
267
frontend/src/pages/clients/ClientBulkAddModal.vue
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||||
|
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||||
|
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||||
|
|
||||||
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
inbounds: { type: Array, default: () => [] },
|
||||||
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const delayedStart = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
emailMethod: 0,
|
||||||
|
firstNum: 1,
|
||||||
|
lastNum: 1,
|
||||||
|
emailPrefix: '',
|
||||||
|
emailPostfix: '',
|
||||||
|
quantity: 1,
|
||||||
|
subId: '',
|
||||||
|
comment: '',
|
||||||
|
flow: '',
|
||||||
|
limitIp: 0,
|
||||||
|
totalGB: 0,
|
||||||
|
expiryTime: 0,
|
||||||
|
inboundIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const flowCapableIds = computed(() => {
|
||||||
|
const ids = new Set();
|
||||||
|
for (const row of props.inbounds || []) {
|
||||||
|
if (row?.tlsFlowCapable) ids.add(row.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showFlow = computed(() =>
|
||||||
|
(form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(showFlow, (next) => {
|
||||||
|
if (!next) form.flow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiryDate = computed({
|
||||||
|
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||||
|
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delayedExpireDays = computed({
|
||||||
|
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
|
||||||
|
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||||
|
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inboundOptions = computed(() =>
|
||||||
|
(props.inbounds || [])
|
||||||
|
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
|
||||||
|
.map((ib) => ({
|
||||||
|
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||||
|
value: ib.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
form.emailMethod = 0;
|
||||||
|
form.firstNum = 1;
|
||||||
|
form.lastNum = 1;
|
||||||
|
form.emailPrefix = '';
|
||||||
|
form.emailPostfix = '';
|
||||||
|
form.quantity = 1;
|
||||||
|
form.subId = '';
|
||||||
|
form.comment = '';
|
||||||
|
form.flow = '';
|
||||||
|
form.limitIp = 0;
|
||||||
|
form.totalGB = 0;
|
||||||
|
form.expiryTime = 0;
|
||||||
|
form.inboundIds = [];
|
||||||
|
delayedStart.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmails() {
|
||||||
|
const method = form.emailMethod;
|
||||||
|
const out = [];
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
if (method > 1) {
|
||||||
|
start = form.firstNum;
|
||||||
|
end = form.lastNum + 1;
|
||||||
|
} else {
|
||||||
|
start = 0;
|
||||||
|
end = form.quantity;
|
||||||
|
}
|
||||||
|
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
||||||
|
const useNum = method > 1;
|
||||||
|
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
let email = '';
|
||||||
|
if (method !== 4) email = RandomUtil.randomLowerAndNum(6);
|
||||||
|
email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
||||||
|
out.push(email);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
|
||||||
|
message.error(t('pages.clients.selectInbound'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emails = buildEmails();
|
||||||
|
if (emails.length === 0) return;
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
const silentJsonOpts = { ...JSON_HEADERS, silent: true };
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(emails.map((email) => {
|
||||||
|
const client = {
|
||||||
|
email,
|
||||||
|
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
||||||
|
id: RandomUtil.randomUUID(),
|
||||||
|
password: RandomUtil.randomLowerAndNum(16),
|
||||||
|
auth: RandomUtil.randomLowerAndNum(16),
|
||||||
|
flow: showFlow.value ? (form.flow || '') : '',
|
||||||
|
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
|
||||||
|
expiryTime: form.expiryTime,
|
||||||
|
limitIp: Number(form.limitIp) || 0,
|
||||||
|
comment: form.comment,
|
||||||
|
enable: true,
|
||||||
|
};
|
||||||
|
const payload = { client, inboundIds: form.inboundIds };
|
||||||
|
return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts);
|
||||||
|
}));
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let firstError = '';
|
||||||
|
for (const msg of results) {
|
||||||
|
if (msg?.success) ok++;
|
||||||
|
else {
|
||||||
|
failed++;
|
||||||
|
if (!firstError && msg?.msg) firstError = msg.msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
|
||||||
|
} else {
|
||||||
|
message.warning(firstError
|
||||||
|
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
|
||||||
|
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
|
||||||
|
}
|
||||||
|
emit('saved');
|
||||||
|
close();
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="t('pages.clients.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
|
||||||
|
:confirm-loading="saving" :mask-closable="false" :width="640" @ok="submit" @cancel="close">
|
||||||
|
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
||||||
|
<a-form-item :label="t('pages.clients.attachedInbounds')" required>
|
||||||
|
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions"
|
||||||
|
:placeholder="t('pages.clients.selectInbound')" :show-search="true"
|
||||||
|
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item :label="t('pages.clients.method')">
|
||||||
|
<a-select v-model:value="form.emailMethod">
|
||||||
|
<a-select-option :value="0">Random</a-select-option>
|
||||||
|
<a-select-option :value="1">Random + Prefix</a-select-option>
|
||||||
|
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
|
||||||
|
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
|
||||||
|
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.first')">
|
||||||
|
<a-input-number v-model:value="form.firstNum" :min="1" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.last')">
|
||||||
|
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.clients.prefix')">
|
||||||
|
<a-input v-model:value="form.emailPrefix" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.clients.postfix')">
|
||||||
|
<a-input v-model:value="form.emailPostfix" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.clients.clientCount')">
|
||||||
|
<a-input-number v-model:value="form.quantity" :min="1" :max="100" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
{{ t('subscription.title') }}
|
||||||
|
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.subId" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item :label="t('comment')">
|
||||||
|
<a-input v-model:value="form.comment" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="showFlow" :label="t('pages.clients.flow')">
|
||||||
|
<a-select v-model:value="form.flow" :style="{ width: '220px' }">
|
||||||
|
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||||
|
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="ipLimitEnable" :label="t('pages.clients.limitIp')">
|
||||||
|
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item :label="t('pages.clients.totalGB')">
|
||||||
|
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item :label="t('pages.clients.delayedStart')">
|
||||||
|
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
|
||||||
|
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-else :label="t('pages.inbounds.expireDate')">
|
||||||
|
<DateTimePicker v-model:value="expiryDate" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.random-icon {
|
||||||
|
margin-left: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ant-color-primary, #1677ff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
402
frontend/src/pages/clients/ClientFormModal.vue
Normal file
402
frontend/src/pages/clients/ClientFormModal.vue
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { HttpUtil, RandomUtil } from '@/utils';
|
||||||
|
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||||
|
|
||||||
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
mode: { type: String, default: 'add' },
|
||||||
|
client: { type: Object, default: null },
|
||||||
|
inbounds: { type: Array, default: () => [] },
|
||||||
|
attachedIds: { type: Array, default: () => [] },
|
||||||
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
|
tgBotEnable: { type: Boolean, default: false },
|
||||||
|
save: { type: Function, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
const form = reactive(emptyForm());
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return {
|
||||||
|
email: '',
|
||||||
|
subId: '',
|
||||||
|
uuid: '',
|
||||||
|
password: '',
|
||||||
|
auth: '',
|
||||||
|
flow: '',
|
||||||
|
reverseTag: '',
|
||||||
|
totalGB: 0,
|
||||||
|
expiryDate: null,
|
||||||
|
delayedStart: false,
|
||||||
|
delayedDays: 0,
|
||||||
|
limitIp: 0,
|
||||||
|
tgId: 0,
|
||||||
|
comment: '',
|
||||||
|
enable: true,
|
||||||
|
inboundIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = computed(() => props.mode === 'edit');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(next) => {
|
||||||
|
if (!next) return;
|
||||||
|
Object.assign(form, emptyForm());
|
||||||
|
if (isEdit.value && props.client) {
|
||||||
|
form.email = props.client.email || '';
|
||||||
|
form.subId = props.client.subId || '';
|
||||||
|
form.uuid = props.client.uuid || '';
|
||||||
|
form.password = props.client.password || '';
|
||||||
|
form.auth = props.client.auth || '';
|
||||||
|
form.flow = props.client.flow || '';
|
||||||
|
form.reverseTag = props.client.reverse?.tag || '';
|
||||||
|
form.totalGB = bytesToGB(props.client.totalGB || 0);
|
||||||
|
const et = Number(props.client.expiryTime) || 0;
|
||||||
|
if (et < 0) {
|
||||||
|
form.delayedStart = true;
|
||||||
|
form.delayedDays = Math.round(et / -86400000);
|
||||||
|
form.expiryDate = null;
|
||||||
|
} else {
|
||||||
|
form.delayedStart = false;
|
||||||
|
form.delayedDays = 0;
|
||||||
|
form.expiryDate = et > 0 ? dayjs(et) : null;
|
||||||
|
}
|
||||||
|
form.limitIp = props.client.limitIp || 0;
|
||||||
|
form.tgId = Number(props.client.tgId) || 0;
|
||||||
|
form.comment = props.client.comment || '';
|
||||||
|
form.enable = !!props.client.enable;
|
||||||
|
form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
|
||||||
|
void loadIps();
|
||||||
|
} else {
|
||||||
|
form.email = RandomUtil.randomLowerAndNum(9);
|
||||||
|
form.uuid = RandomUtil.randomUUID();
|
||||||
|
form.subId = RandomUtil.randomLowerAndNum(16);
|
||||||
|
form.password = RandomUtil.randomLowerAndNum(16);
|
||||||
|
form.auth = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function bytesToGB(bytes) {
|
||||||
|
if (!bytes || bytes <= 0) return 0;
|
||||||
|
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gbToBytes(gb) {
|
||||||
|
if (!gb || gb <= 0) return 0;
|
||||||
|
return Math.round(gb * 1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||||
|
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inboundOptions = computed(() =>
|
||||||
|
(props.inbounds || [])
|
||||||
|
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
|
||||||
|
.map((ib) => ({
|
||||||
|
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||||
|
value: ib.id,
|
||||||
|
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const flowCapableIds = computed(() => {
|
||||||
|
const ids = new Set();
|
||||||
|
for (const row of props.inbounds || []) {
|
||||||
|
if (row?.tlsFlowCapable) ids.add(row.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showFlow = computed(() =>
|
||||||
|
(form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(showFlow, (next) => {
|
||||||
|
if (!next) form.flow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const vlessLikeIds = computed(() => {
|
||||||
|
const ids = new Set();
|
||||||
|
for (const row of props.inbounds || []) {
|
||||||
|
if (row && row.protocol === 'vless') {
|
||||||
|
ids.add(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showReverseTag = computed(() =>
|
||||||
|
(form.inboundIds || []).some((id) => vlessLikeIds.value.has(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(showReverseTag, (next) => {
|
||||||
|
if (!next) form.reverseTag = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientIps = ref([]);
|
||||||
|
const ipsLoading = ref(false);
|
||||||
|
const ipsClearing = ref(false);
|
||||||
|
|
||||||
|
async function loadIps() {
|
||||||
|
if (!isEdit.value || !props.client?.email) return;
|
||||||
|
ipsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(props.client.email)}`);
|
||||||
|
if (!msg?.success) { clientIps.value = []; return; }
|
||||||
|
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
||||||
|
clientIps.value = arr.filter((x) => typeof x === 'string' && x.length > 0);
|
||||||
|
} finally {
|
||||||
|
ipsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearIps() {
|
||||||
|
if (!isEdit.value || !props.client?.email) return;
|
||||||
|
ipsClearing.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(props.client.email)}`);
|
||||||
|
if (msg?.success) clientIps.value = [];
|
||||||
|
} finally {
|
||||||
|
ipsClearing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateUUID() {
|
||||||
|
form.uuid = RandomUtil.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function regeneratePassword() {
|
||||||
|
form.password = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateAuth() {
|
||||||
|
form.auth = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateSubId() {
|
||||||
|
form.subId = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateEmail() {
|
||||||
|
form.email = RandomUtil.randomLowerAndNum(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelayedStartToggle(next) {
|
||||||
|
if (next) {
|
||||||
|
form.expiryDate = null;
|
||||||
|
} else {
|
||||||
|
form.delayedDays = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!form.email || form.email.trim() === '') {
|
||||||
|
message.error(`${t('pages.clients.email')} *`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
|
||||||
|
message.error(t('pages.clients.selectInbound'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expiryTime = form.delayedStart
|
||||||
|
? -86400000 * (Number(form.delayedDays) || 0)
|
||||||
|
: (form.expiryDate ? form.expiryDate.valueOf() : 0);
|
||||||
|
const clientPayload = {
|
||||||
|
email: form.email.trim(),
|
||||||
|
subId: form.subId,
|
||||||
|
id: form.uuid,
|
||||||
|
password: form.password,
|
||||||
|
auth: form.auth,
|
||||||
|
flow: showFlow.value ? (form.flow || '') : '',
|
||||||
|
totalGB: gbToBytes(form.totalGB),
|
||||||
|
expiryTime,
|
||||||
|
limitIp: Number(form.limitIp) || 0,
|
||||||
|
tgId: Number(form.tgId) || 0,
|
||||||
|
comment: form.comment,
|
||||||
|
enable: !!form.enable,
|
||||||
|
};
|
||||||
|
const reverseTag = showReverseTag.value ? (form.reverseTag || '').trim() : '';
|
||||||
|
if (reverseTag) {
|
||||||
|
clientPayload.reverse = { tag: reverseTag };
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
let msg;
|
||||||
|
if (isEdit.value) {
|
||||||
|
const original = new Set(props.attachedIds || []);
|
||||||
|
const next = new Set(form.inboundIds || []);
|
||||||
|
const toAttach = [...next].filter((id) => !original.has(id));
|
||||||
|
const toDetach = [...original].filter((id) => !next.has(id));
|
||||||
|
msg = await props.save(clientPayload, {
|
||||||
|
isEdit: true,
|
||||||
|
email: props.client.email,
|
||||||
|
attach: toAttach,
|
||||||
|
detach: toDetach,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
msg = await props.save(
|
||||||
|
{ client: clientPayload, inboundIds: form.inboundIds },
|
||||||
|
{ isEdit: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (msg?.success) close();
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
|
||||||
|
:destroy-on-close="true" :ok-text="isEdit ? t('save') : t('create')" :cancel-text="t('cancel')"
|
||||||
|
:ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
|
||||||
|
<a-form layout="vertical" :model="form">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.email')" required>
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.email" :placeholder="t('pages.clients.email')" style="flex: 1" />
|
||||||
|
<a-button @click="regenerateEmail">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.subId')">
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.subId" style="flex: 1" />
|
||||||
|
<a-button @click="regenerateSubId">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.hysteriaAuth')">
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.auth" style="flex: 1" />
|
||||||
|
<a-button @click="regenerateAuth">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.password')">
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.password" style="flex: 1" />
|
||||||
|
<a-button @click="regeneratePassword">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.uuid')">
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.uuid" style="flex: 1" />
|
||||||
|
<a-button @click="regenerateUUID">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="ipLimitEnable ? 8 : 12">
|
||||||
|
<a-form-item :label="t('pages.clients.totalGB')">
|
||||||
|
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col v-if="ipLimitEnable" :span="4">
|
||||||
|
<a-form-item :label="t('pages.clients.limitIp')">
|
||||||
|
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
|
||||||
|
<a-input-number v-model:value="form.delayedDays" :min="0" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-else :label="t('pages.clients.expiryTime')">
|
||||||
|
<a-date-picker v-model:value="form.expiryDate" show-time style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.delayedStart')">
|
||||||
|
<a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row v-if="showFlow || showReverseTag" :gutter="16">
|
||||||
|
<a-col v-if="showFlow" :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.flow')">
|
||||||
|
<a-select v-model:value="form.flow">
|
||||||
|
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||||
|
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col v-if="showReverseTag" :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.reverseTag')">
|
||||||
|
<a-input v-model:value="form.reverseTag" :placeholder="t('pages.clients.reverseTagPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col v-if="tgBotEnable" :span="12">
|
||||||
|
<a-form-item :label="t('pages.clients.telegramId')">
|
||||||
|
<a-input-number v-model:value="form.tgId" :min="0" :controls="false"
|
||||||
|
:placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="tgBotEnable ? 12 : 24">
|
||||||
|
<a-form-item :label="t('pages.clients.comment')">
|
||||||
|
<a-input v-model:value="form.comment" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item :label="t('pages.clients.attachedInbounds')" :required="!isEdit">
|
||||||
|
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
|
||||||
|
:placeholder="t('pages.clients.selectInbound')"
|
||||||
|
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<a-switch v-model:checked="form.enable" />
|
||||||
|
<span style="margin-left: 8px">{{ t('enable') }}</span>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="isEdit && ipLimitEnable" :label="t('pages.clients.ipLog')">
|
||||||
|
<a-space style="margin-bottom: 8px">
|
||||||
|
<a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
|
||||||
|
<a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">
|
||||||
|
{{ t('pages.clients.clearAll') }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
<div v-if="clientIps.length > 0">
|
||||||
|
<a-tag v-for="(ip, idx) in clientIps" :key="idx" color="blue" style="margin-bottom: 4px">{{ ip }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<a-tag v-else>{{ t('tgbot.noIpRecord') }}</a-tag>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
411
frontend/src/pages/clients/ClientInfoModal.vue
Normal file
411
frontend/src/pages/clients/ClientInfoModal.vue
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { CopyOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { SizeFormatter, IntlUtil, ClipboardManager, HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
client: { type: Object, default: null },
|
||||||
|
inboundsById: { type: Object, default: () => ({}) },
|
||||||
|
isOnline: { type: Boolean, default: false },
|
||||||
|
subSettings: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
const links = ref([]);
|
||||||
|
const linksLoading = ref(false);
|
||||||
|
|
||||||
|
const traffic = computed(() => props.client?.traffic || null);
|
||||||
|
const totalBytes = computed(() => props.client?.totalGB || 0);
|
||||||
|
const used = computed(() => (traffic.value?.up || 0) + (traffic.value?.down || 0));
|
||||||
|
const remaining = computed(() => {
|
||||||
|
if (totalBytes.value <= 0) return -1;
|
||||||
|
const r = totalBytes.value - used.value;
|
||||||
|
return r > 0 ? r : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const subLink = computed(() => {
|
||||||
|
if (!props.client?.subId || !props.subSettings?.subURI) return '';
|
||||||
|
return props.subSettings.subURI + props.client.subId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const subJsonLink = computed(() => {
|
||||||
|
if (!props.client?.subId) return '';
|
||||||
|
if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
|
||||||
|
return props.subSettings.subJsonURI + props.client.subId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showSubscription = computed(
|
||||||
|
() => !!(props.subSettings?.enable && props.client?.subId),
|
||||||
|
);
|
||||||
|
|
||||||
|
function expiryLabel(ts) {
|
||||||
|
if (!ts || ts <= 0) return '∞';
|
||||||
|
return IntlUtil.formatDate(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expiryRelative(ts) {
|
||||||
|
if (!ts || ts <= 0) return '';
|
||||||
|
return IntlUtil.formatRelativeTime(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastOnlineLabel(ts) {
|
||||||
|
if (!ts || ts <= 0) return '-';
|
||||||
|
return IntlUtil.formatDate(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateLabel(ts) {
|
||||||
|
if (!ts || ts <= 0) return '-';
|
||||||
|
return IntlUtil.formatDate(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyValue(text) {
|
||||||
|
if (!text) return;
|
||||||
|
const ok = await ClipboardManager.copyText(String(text));
|
||||||
|
if (ok) message.success(t('copied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLinks() {
|
||||||
|
if (!props.client?.subId) {
|
||||||
|
links.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
linksLoading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get(
|
||||||
|
`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`,
|
||||||
|
);
|
||||||
|
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||||
|
} finally {
|
||||||
|
linksLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (next) loadLinks();
|
||||||
|
else links.value = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="640" @cancel="close">
|
||||||
|
<template v-if="client">
|
||||||
|
<table class="info-table block">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.clients.online') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') }}</a-tag>
|
||||||
|
<a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
|
||||||
|
<span class="hint">{{ t('lastOnline') }}: {{ lastOnlineLabel(traffic?.lastOnline) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('status') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag :color="client.enable ? 'green' : 'default'">
|
||||||
|
{{ client.enable ? t('enabled') : t('disabled') }}
|
||||||
|
</a-tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.clients.email') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag v-if="client.email" color="green">{{ client.email }}</a-tag>
|
||||||
|
<a-tag v-else color="red">{{ t('none') }}</a-tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.clients.subId') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag class="info-large-tag">{{ client.subId || '-' }}</a-tag>
|
||||||
|
<a-button v-if="client.subId" size="small" type="text" @click="copyValue(client.subId)">
|
||||||
|
<CopyOutlined />
|
||||||
|
</a-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="client.uuid">
|
||||||
|
<td>{{ t('pages.clients.uuid') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag class="info-large-tag">{{ client.uuid }}</a-tag>
|
||||||
|
<a-button size="small" type="text" @click="copyValue(client.uuid)">
|
||||||
|
<CopyOutlined />
|
||||||
|
</a-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="client.password">
|
||||||
|
<td>{{ t('password') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag class="info-large-tag">{{ client.password }}</a-tag>
|
||||||
|
<a-button size="small" type="text" @click="copyValue(client.password)">
|
||||||
|
<CopyOutlined />
|
||||||
|
</a-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="client.auth">
|
||||||
|
<td>{{ t('pages.clients.auth') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag class="info-large-tag">{{ client.auth }}</a-tag>
|
||||||
|
<a-button size="small" type="text" @click="copyValue(client.auth)">
|
||||||
|
<CopyOutlined />
|
||||||
|
</a-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.clients.flow') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag v-if="client.flow">{{ client.flow }}</a-tag>
|
||||||
|
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.inbounds.traffic') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag>
|
||||||
|
↑ {{ SizeFormatter.sizeFormat(traffic?.up || 0) }}
|
||||||
|
/ ↓ {{ SizeFormatter.sizeFormat(traffic?.down || 0) }}
|
||||||
|
</a-tag>
|
||||||
|
<span class="hint">
|
||||||
|
{{ SizeFormatter.sizeFormat(used) }}
|
||||||
|
/
|
||||||
|
{{ totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('remained') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag v-if="remaining < 0" color="purple">∞</a-tag>
|
||||||
|
<a-tag v-else :color="remaining > 0 ? '' : 'red'">
|
||||||
|
{{ SizeFormatter.sizeFormat(remaining) }}
|
||||||
|
</a-tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.inbounds.expireDate') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag v-if="!client.expiryTime || client.expiryTime <= 0" color="purple">∞</a-tag>
|
||||||
|
<a-tag v-else>{{ expiryLabel(client.expiryTime) }}</a-tag>
|
||||||
|
<span v-if="client.expiryTime > 0" class="hint">{{ expiryRelative(client.expiryTime) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.clients.ipLimit') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag v-if="!client.limitIp">∞</a-tag>
|
||||||
|
<a-tag v-else>{{ client.limitIp }}</a-tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.inbounds.createdAt') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag>{{ dateLabel(client.createdAt) }}</a-tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.inbounds.updatedAt') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag>{{ dateLabel(client.updatedAt) }}</a-tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="client.comment">
|
||||||
|
<td>{{ t('pages.clients.comment') }}</td>
|
||||||
|
<td>
|
||||||
|
<a-tag class="info-large-tag">{{ client.comment }}</a-tag>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ t('pages.clients.attachedInbounds') }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="chips">
|
||||||
|
<a-tag v-for="id in (client.inboundIds || [])" :key="id" color="blue">
|
||||||
|
<template v-if="inboundsById[id]">
|
||||||
|
{{ inboundsById[id].remark || `#${id}` }} ({{ inboundsById[id].protocol }}:{{ inboundsById[id].port }})
|
||||||
|
</template>
|
||||||
|
<template v-else>#{{ id }}</template>
|
||||||
|
</a-tag>
|
||||||
|
<span v-if="!client.inboundIds || client.inboundIds.length === 0" class="hint">—</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<template v-if="links.length > 0">
|
||||||
|
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
|
||||||
|
<div v-for="(link, idx) in links" :key="idx" class="link-panel">
|
||||||
|
<div class="link-panel-header">
|
||||||
|
<a-tag color="green">{{ `${t('pages.clients.link')} ${idx + 1}` }}</a-tag>
|
||||||
|
<a-tooltip :title="t('copy')">
|
||||||
|
<a-button size="small" @click="copyValue(link)">
|
||||||
|
<template #icon>
|
||||||
|
<CopyOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<code class="link-panel-text">{{ link }}</code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="showSubscription && subLink">
|
||||||
|
<a-divider>{{ t('subscription.title') }}</a-divider>
|
||||||
|
<div class="link-panel">
|
||||||
|
<div class="link-panel-header">
|
||||||
|
<a-tag color="green">{{ t('subscription.title') }}</a-tag>
|
||||||
|
<a-tooltip :title="t('copy')">
|
||||||
|
<a-button size="small" @click="copyValue(subLink)">
|
||||||
|
<template #icon>
|
||||||
|
<CopyOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="subJsonLink" class="link-panel">
|
||||||
|
<div class="link-panel-header">
|
||||||
|
<a-tag color="green">JSON</a-tag>
|
||||||
|
<a-tooltip :title="t('copy')">
|
||||||
|
<a-button size="small" @click="copyValue(subJsonLink)">
|
||||||
|
<template #icon>
|
||||||
|
<CopyOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
<a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink }}</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table.block {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table td:first-child {
|
||||||
|
width: 140px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.75;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-large-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.55;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-panel {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-panel-text {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .link-panel-text {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-panel-anchor {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--ant-color-primary, #1677ff);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: rgba(22, 119, 255, 0.4);
|
||||||
|
transition: background 120ms ease, text-decoration-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-panel-anchor:hover {
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
text-decoration-color: var(--ant-color-primary, #1677ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .link-panel-anchor {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .link-panel-anchor:hover {
|
||||||
|
background: rgba(22, 119, 255, 0.16);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
97
frontend/src/pages/clients/ClientQrModal.vue
Normal file
97
frontend/src/pages/clients/ClientQrModal.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import QrPanel from '@/pages/inbounds/QrPanel.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
client: { type: Object, default: null },
|
||||||
|
subSettings: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
const links = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const subLink = computed(() => {
|
||||||
|
if (!props.client?.subId || !props.subSettings?.enable || !props.subSettings?.subURI) return '';
|
||||||
|
return props.subSettings.subURI + props.client.subId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const subJsonLink = computed(() => {
|
||||||
|
if (!props.client?.subId || !props.subSettings?.enable) return '';
|
||||||
|
if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
|
||||||
|
return props.subSettings.subJsonURI + props.client.subId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeKeys = computed(() => {
|
||||||
|
const keys = [];
|
||||||
|
if (subLink.value) keys.push('sub');
|
||||||
|
if (subJsonLink.value) keys.push('subJson');
|
||||||
|
if (links.value.length > 0) keys.push('l0');
|
||||||
|
return keys;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAnything = computed(
|
||||||
|
() => !!subLink.value || !!subJsonLink.value || links.value.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(() => props.open, async (next) => {
|
||||||
|
if (!next || !props.client?.subId) {
|
||||||
|
links.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.get(`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`);
|
||||||
|
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="client ? client.email : t('qrCode')" :footer="null" :width="520" centered
|
||||||
|
@cancel="close">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<div v-if="!client?.subId && !loading" class="empty">
|
||||||
|
{{ t('pages.clients.noSubId') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!hasAnything && !loading" class="empty">
|
||||||
|
{{ t('pages.clients.noLinks') }}
|
||||||
|
</div>
|
||||||
|
<a-collapse v-else :active-key="activeKeys" accordion>
|
||||||
|
<a-collapse-panel v-if="subLink" key="sub" :header="t('subscription.title')">
|
||||||
|
<QrPanel :value="subLink" :remark="`${client?.email || ''} — ${t('subscription.title')}`" />
|
||||||
|
</a-collapse-panel>
|
||||||
|
<a-collapse-panel v-if="subJsonLink" key="subJson" :header="`${t('subscription.title')} (JSON)`">
|
||||||
|
<QrPanel :value="subJsonLink" :remark="`${client?.email || ''} — JSON`" />
|
||||||
|
</a-collapse-panel>
|
||||||
|
<a-collapse-panel v-for="(link, idx) in links" :key="`l${idx}`"
|
||||||
|
:header="`${t('pages.clients.link')} ${idx + 1}`">
|
||||||
|
<QrPanel :value="link" :remark="`${client?.email || ''} #${idx + 1}`" />
|
||||||
|
</a-collapse-panel>
|
||||||
|
</a-collapse>
|
||||||
|
</a-spin>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1067
frontend/src/pages/clients/ClientsPage.vue
Normal file
1067
frontend/src/pages/clients/ClientsPage.vue
Normal file
File diff suppressed because it is too large
Load diff
217
frontend/src/pages/clients/useClients.js
Normal file
217
frontend/src/pages/clients/useClients.js
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { onMounted, ref, shallowRef } from 'vue';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||||
|
|
||||||
|
export function useClients() {
|
||||||
|
const clients = shallowRef([]);
|
||||||
|
const inbounds = shallowRef([]);
|
||||||
|
const onlines = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const fetched = ref(false);
|
||||||
|
const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
|
||||||
|
const ipLimitEnable = ref(false);
|
||||||
|
const tgBotEnable = ref(false);
|
||||||
|
const expireDiff = ref(0);
|
||||||
|
const trafficDiff = ref(0);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [clientsMsg, inboundsMsg] = await Promise.all([
|
||||||
|
HttpUtil.get('/panel/api/clients/list'),
|
||||||
|
HttpUtil.get('/panel/api/inbounds/options'),
|
||||||
|
]);
|
||||||
|
if (clientsMsg?.success) {
|
||||||
|
clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : [];
|
||||||
|
}
|
||||||
|
if (inboundsMsg?.success) {
|
||||||
|
inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : [];
|
||||||
|
}
|
||||||
|
fetched.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSubSettings() {
|
||||||
|
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||||
|
if (!msg?.success) return;
|
||||||
|
const s = msg.obj || {};
|
||||||
|
subSettings.value = {
|
||||||
|
enable: !!s.subEnable,
|
||||||
|
subURI: s.subURI || '',
|
||||||
|
subJsonURI: s.subJsonURI || '',
|
||||||
|
subJsonEnable: !!s.subJsonEnable,
|
||||||
|
};
|
||||||
|
ipLimitEnable.value = !!s.ipLimitEnable;
|
||||||
|
tgBotEnable.value = !!s.tgBotEnable;
|
||||||
|
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
||||||
|
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload) {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(email, client) {
|
||||||
|
if (!email) return null;
|
||||||
|
const encoded = encodeURIComponent(email);
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(email, keepTraffic = false) {
|
||||||
|
if (!email) return null;
|
||||||
|
const encoded = encodeURIComponent(email);
|
||||||
|
const url = keepTraffic
|
||||||
|
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
|
||||||
|
: `/panel/api/clients/del/${encoded}`;
|
||||||
|
const msg = await HttpUtil.post(url);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMany(emails, keepTraffic = false) {
|
||||||
|
if (!Array.isArray(emails) || emails.length === 0) return [];
|
||||||
|
const suffix = keepTraffic ? '?keepTraffic=1' : '';
|
||||||
|
const silentOpts = { silent: true };
|
||||||
|
const results = await Promise.all(emails.map((email) => {
|
||||||
|
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
|
||||||
|
return HttpUtil.post(url, undefined, silentOpts);
|
||||||
|
}));
|
||||||
|
await refresh();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attach(email, inboundIds) {
|
||||||
|
if (!email) return null;
|
||||||
|
const encoded = encodeURIComponent(email);
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detach(email, inboundIds) {
|
||||||
|
if (!email) return null;
|
||||||
|
const encoded = encodeURIComponent(email);
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTraffic(client) {
|
||||||
|
if (!client?.email) return null;
|
||||||
|
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
|
||||||
|
const msg = await HttpUtil.post(url);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAllTraffics() {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics');
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delDepleted() {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/clients/delDepleted');
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEnable(client, enable) {
|
||||||
|
if (!client?.email) return null;
|
||||||
|
const payload = {
|
||||||
|
email: client.email,
|
||||||
|
subId: client.subId,
|
||||||
|
id: client.uuid,
|
||||||
|
password: client.password,
|
||||||
|
auth: client.auth,
|
||||||
|
totalGB: client.totalGB || 0,
|
||||||
|
expiryTime: client.expiryTime || 0,
|
||||||
|
limitIp: client.limitIp || 0,
|
||||||
|
comment: client.comment || '',
|
||||||
|
enable: !!enable,
|
||||||
|
};
|
||||||
|
return update(client.email, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTrafficEvent(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') return;
|
||||||
|
if (Array.isArray(payload.onlineClients)) {
|
||||||
|
onlines.value = payload.onlineClients;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyClientStatsEvent(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') return;
|
||||||
|
if (!Array.isArray(payload.clients) || payload.clients.length === 0) return;
|
||||||
|
const byEmail = new Map();
|
||||||
|
for (const row of payload.clients) {
|
||||||
|
if (row && row.email) byEmail.set(row.email, row);
|
||||||
|
}
|
||||||
|
let touched = false;
|
||||||
|
const next = clients.value || [];
|
||||||
|
for (let i = 0; i < next.length; i++) {
|
||||||
|
const row = next[i];
|
||||||
|
const upd = byEmail.get(row?.email);
|
||||||
|
if (!upd) continue;
|
||||||
|
const merged = { ...(row.traffic || {}) };
|
||||||
|
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||||
|
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||||
|
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||||
|
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||||
|
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||||
|
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||||
|
next[i] = { ...row, traffic: merged };
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
if (touched) clients.value = [...next];
|
||||||
|
}
|
||||||
|
|
||||||
|
let invalidateTimer = null;
|
||||||
|
function applyInvalidate(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') return;
|
||||||
|
if (payload.type !== 'inbounds' && payload.type !== 'clients') return;
|
||||||
|
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||||
|
invalidateTimer = setTimeout(() => {
|
||||||
|
invalidateTimer = null;
|
||||||
|
refresh();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([refresh(), fetchSubSettings()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
clients,
|
||||||
|
inbounds,
|
||||||
|
onlines,
|
||||||
|
loading,
|
||||||
|
fetched,
|
||||||
|
subSettings,
|
||||||
|
ipLimitEnable,
|
||||||
|
tgBotEnable,
|
||||||
|
expireDiff,
|
||||||
|
trafficDiff,
|
||||||
|
refresh,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
removeMany,
|
||||||
|
attach,
|
||||||
|
detach,
|
||||||
|
resetTraffic,
|
||||||
|
resetAllTraffics,
|
||||||
|
delDepleted,
|
||||||
|
setEnable,
|
||||||
|
applyTrafficEvent,
|
||||||
|
applyClientStatsEvent,
|
||||||
|
applyInvalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
|
||||||
|
|
||||||
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
import {
|
|
||||||
Inbound,
|
|
||||||
Protocols,
|
|
||||||
USERS_SECURITY,
|
|
||||||
TLS_FLOW_CONTROL,
|
|
||||||
} from '@/models/inbound.js';
|
|
||||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
|
||||||
|
|
||||||
// Bulk-add up to 500 clients in one go. The legacy panel offers five
|
|
||||||
// generation modes — this component preserves them all:
|
|
||||||
// 0: Random — N fully-random emails (no prefix)
|
|
||||||
// 1: Random+Prefix — N random emails preceded by `prefix`
|
|
||||||
// 2: Random+Prefix+Num — emails like `<rand><prefix><num>` for num in [first..last]
|
|
||||||
// 3: Random+Prefix+Num+Postfix — same + appended postfix
|
|
||||||
// 4: Prefix+Num+Postfix — no random part, just `<prefix><num><postfix>`
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
dbInbound: { type: Object, default: null },
|
|
||||||
subEnable: { type: Boolean, default: false },
|
|
||||||
tgBotEnable: { type: Boolean, default: false },
|
|
||||||
ipLimitEnable: { type: Boolean, default: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'saved']);
|
|
||||||
|
|
||||||
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
|
||||||
|
|
||||||
// === Reactive form state ===========================================
|
|
||||||
// Cloned inbound (so canEnableTlsFlow() works).
|
|
||||||
const inbound = ref(null);
|
|
||||||
const saving = ref(false);
|
|
||||||
const delayedStart = ref(false);
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
emailMethod: 0,
|
|
||||||
firstNum: 1,
|
|
||||||
lastNum: 1,
|
|
||||||
emailPrefix: '',
|
|
||||||
emailPostfix: '',
|
|
||||||
quantity: 1,
|
|
||||||
security: USERS_SECURITY.AUTO,
|
|
||||||
flow: '',
|
|
||||||
subId: '',
|
|
||||||
tgId: 0,
|
|
||||||
comment: '',
|
|
||||||
limitIp: 0,
|
|
||||||
totalGB: 0,
|
|
||||||
expiryTime: 0, // ms epoch; negative => delayed start days
|
|
||||||
reset: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const expiryDate = computed({
|
|
||||||
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
|
||||||
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
|
||||||
});
|
|
||||||
|
|
||||||
const delayedExpireDays = computed({
|
|
||||||
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
|
|
||||||
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => {
|
|
||||||
if (!next) return;
|
|
||||||
if (!props.dbInbound) return;
|
|
||||||
inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
|
||||||
// Reset all form fields on every open — bulk add is intentionally
|
|
||||||
// stateless between sessions (legacy resets on .show()).
|
|
||||||
form.emailMethod = 0;
|
|
||||||
form.firstNum = 1;
|
|
||||||
form.lastNum = 1;
|
|
||||||
form.emailPrefix = '';
|
|
||||||
form.emailPostfix = '';
|
|
||||||
form.quantity = 1;
|
|
||||||
form.security = USERS_SECURITY.AUTO;
|
|
||||||
form.flow = '';
|
|
||||||
form.subId = '';
|
|
||||||
form.tgId = 0;
|
|
||||||
form.comment = '';
|
|
||||||
form.limitIp = 0;
|
|
||||||
form.totalGB = 0;
|
|
||||||
form.expiryTime = 0;
|
|
||||||
form.reset = 0;
|
|
||||||
delayedStart.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeNewClient(parsed) {
|
|
||||||
switch (parsed.protocol) {
|
|
||||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
|
||||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
|
||||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
|
||||||
case Protocols.SHADOWSOCKS: {
|
|
||||||
const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
|
|
||||||
return new Inbound.ShadowsocksSettings.Shadowsocks(method);
|
|
||||||
}
|
|
||||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildClients() {
|
|
||||||
if (!inbound.value) return [];
|
|
||||||
const out = [];
|
|
||||||
const method = form.emailMethod;
|
|
||||||
let start;
|
|
||||||
let end;
|
|
||||||
if (method > 1) {
|
|
||||||
start = form.firstNum;
|
|
||||||
end = form.lastNum + 1;
|
|
||||||
} else {
|
|
||||||
start = 0;
|
|
||||||
end = form.quantity;
|
|
||||||
}
|
|
||||||
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
|
||||||
const useNum = method > 1;
|
|
||||||
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
|
||||||
|
|
||||||
for (let i = start; i < end; i++) {
|
|
||||||
const c = makeNewClient(inbound.value);
|
|
||||||
if (!c) continue;
|
|
||||||
if (method === 4) c.email = '';
|
|
||||||
c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
|
||||||
|
|
||||||
if (form.subId.length > 0) c.subId = form.subId;
|
|
||||||
c.tgId = form.tgId;
|
|
||||||
if (form.comment.length > 0) c.comment = form.comment;
|
|
||||||
c.security = form.security;
|
|
||||||
c.limitIp = form.limitIp;
|
|
||||||
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
|
||||||
// identically for bulk and single client paths).
|
|
||||||
c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
|
|
||||||
c.expiryTime = form.expiryTime;
|
|
||||||
if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
|
|
||||||
c.reset = form.reset;
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const clients = buildClients();
|
|
||||||
if (clients.length === 0) return;
|
|
||||||
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
id: props.dbInbound.id,
|
|
||||||
// Clients all serialize via toString() — same shape the single-
|
|
||||||
// client modal posts. Joining with `,` lets the Go side parse the
|
|
||||||
// outer array directly.
|
|
||||||
settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
|
|
||||||
};
|
|
||||||
const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
|
|
||||||
if (msg?.success) {
|
|
||||||
emit('saved');
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
|
|
||||||
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
|
||||||
<a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
|
||||||
<a-form-item :label="t('pages.client.method')">
|
|
||||||
<a-select v-model:value="form.emailMethod">
|
|
||||||
<a-select-option :value="0">Random</a-select-option>
|
|
||||||
<a-select-option :value="1">Random + Prefix</a-select-option>
|
|
||||||
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
|
|
||||||
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
|
|
||||||
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
|
|
||||||
<a-input-number v-model:value="form.firstNum" :min="1" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
|
|
||||||
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
|
|
||||||
<a-input v-model:value="form.emailPrefix" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
|
|
||||||
<a-input v-model:value="form.emailPostfix" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
|
|
||||||
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
|
|
||||||
<a-select v-model:value="form.security">
|
|
||||||
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
|
||||||
<a-select v-model:value="form.flow">
|
|
||||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
|
||||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="subEnable">
|
|
||||||
<template #label>
|
|
||||||
{{ t('subscription.title') }}
|
|
||||||
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
|
|
||||||
</template>
|
|
||||||
<a-input v-model:value="form.subId" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="tgBotEnable" label="Telegram ID">
|
|
||||||
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item :label="t('comment')">
|
|
||||||
<a-input v-model:value="form.comment" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
|
||||||
<a-input-number v-model:value="form.limitIp" :min="0" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<template #label>
|
|
||||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
|
||||||
</template>
|
|
||||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item :label="t('pages.client.delayedStart')">
|
|
||||||
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
|
|
||||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-else>
|
|
||||||
<template #label>
|
|
||||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
|
||||||
}}</a-tooltip>
|
|
||||||
</template>
|
|
||||||
<DateTimePicker v-model:value="expiryDate" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="form.expiryTime !== 0">
|
|
||||||
<template #label>
|
|
||||||
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
|
|
||||||
</template>
|
|
||||||
<a-input-number v-model:value="form.reset" :min="0" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.random-icon {
|
|
||||||
margin-left: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--ant-primary-color, #1890ff);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,394 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
|
||||||
|
|
||||||
import {
|
|
||||||
HttpUtil,
|
|
||||||
RandomUtil,
|
|
||||||
SizeFormatter,
|
|
||||||
ColorUtils,
|
|
||||||
} from '@/utils';
|
|
||||||
import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
|
||||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
// Add OR edit a single client on a multi-user inbound (VMess / VLess /
|
|
||||||
// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
|
|
||||||
// flows through the same modal — same here.
|
|
||||||
//
|
|
||||||
// On submit we serialize the client via its toString() (which is just
|
|
||||||
// JSON.stringify of toJson()) and post it inside a one-element clients
|
|
||||||
// array so the Go side reuses the same parsing path as the inbound
|
|
||||||
// settings update.
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
|
||||||
dbInbound: { type: Object, default: null },
|
|
||||||
clientIndex: { type: Number, default: null },
|
|
||||||
// Sidecar config from the inbounds page — controls visibility of
|
|
||||||
// the Subscription, Telegram, and IP-limit fields.
|
|
||||||
subEnable: { type: Boolean, default: false },
|
|
||||||
tgBotEnable: { type: Boolean, default: false },
|
|
||||||
ipLimitEnable: { type: Boolean, default: false },
|
|
||||||
trafficDiff: { type: Number, default: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'saved']);
|
|
||||||
|
|
||||||
// === Reactive draft =================================================
|
|
||||||
const inbound = ref(null);
|
|
||||||
const client = ref(null);
|
|
||||||
const oldClientId = ref('');
|
|
||||||
const clientStats = ref(null);
|
|
||||||
|
|
||||||
const saving = ref(false);
|
|
||||||
const delayedStart = ref(false);
|
|
||||||
|
|
||||||
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
|
||||||
|
|
||||||
const protocol = computed(() => inbound.value?.protocol);
|
|
||||||
const isVmessOrVless = computed(() =>
|
|
||||||
protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
|
|
||||||
);
|
|
||||||
const isTrojanOrSS = computed(() =>
|
|
||||||
protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const expiryDate = computed({
|
|
||||||
get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
|
|
||||||
set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
|
|
||||||
});
|
|
||||||
|
|
||||||
const delayedExpireDays = computed({
|
|
||||||
get: () => {
|
|
||||||
if (!client.value || client.value.expiryTime >= 0) return 0;
|
|
||||||
return client.value.expiryTime / -86400000;
|
|
||||||
},
|
|
||||||
set: (days) => {
|
|
||||||
if (!client.value) return;
|
|
||||||
client.value.expiryTime = -86400000 * (days || 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalGB = computed({
|
|
||||||
get: () => {
|
|
||||||
if (!client.value || !client.value.totalGB) return 0;
|
|
||||||
return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
|
|
||||||
},
|
|
||||||
set: (gb) => {
|
|
||||||
if (!client.value) return;
|
|
||||||
client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isExpired = computed(() => {
|
|
||||||
if (props.mode !== 'edit' || !client.value) return false;
|
|
||||||
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
|
|
||||||
});
|
|
||||||
const isTrafficExhausted = computed(() => {
|
|
||||||
if (!clientStats.value || clientStats.value.total <= 0) return false;
|
|
||||||
return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
|
|
||||||
});
|
|
||||||
|
|
||||||
function getClientId(proto, c) {
|
|
||||||
switch (proto) {
|
|
||||||
case Protocols.TROJAN: return c.password;
|
|
||||||
case Protocols.SHADOWSOCKS: return c.email;
|
|
||||||
case Protocols.HYSTERIA: return c.auth;
|
|
||||||
default: return c.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeNewClient(proto, parsed) {
|
|
||||||
switch (proto) {
|
|
||||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
|
||||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
|
||||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
|
||||||
case Protocols.SHADOWSOCKS: {
|
|
||||||
const method = parsed.settings.method;
|
|
||||||
return new Inbound.ShadowsocksSettings.Shadowsocks(
|
|
||||||
method,
|
|
||||||
RandomUtil.randomShadowsocksPassword(method),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => {
|
|
||||||
if (!next) return;
|
|
||||||
if (!props.dbInbound) return;
|
|
||||||
const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
|
||||||
inbound.value = parsed;
|
|
||||||
delayedStart.value = false;
|
|
||||||
|
|
||||||
if (props.mode === 'edit') {
|
|
||||||
const idx = props.clientIndex ?? 0;
|
|
||||||
client.value = parsed.clients[idx];
|
|
||||||
if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
|
|
||||||
oldClientId.value = getClientId(parsed.protocol, client.value);
|
|
||||||
} else {
|
|
||||||
const c = makeNewClient(parsed.protocol, parsed);
|
|
||||||
if (c) parsed.clients.push(c);
|
|
||||||
client.value = parsed.clients[parsed.clients.length - 1];
|
|
||||||
oldClientId.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
clientStats.value = (props.dbInbound.clientStats || []).find(
|
|
||||||
(s) => s.email === client.value?.email,
|
|
||||||
) || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomEmail() {
|
|
||||||
if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
|
|
||||||
}
|
|
||||||
function randomId() {
|
|
||||||
if (client.value) client.value.id = RandomUtil.randomUUID();
|
|
||||||
}
|
|
||||||
function randomPassword() {
|
|
||||||
if (!client.value || !inbound.value) return;
|
|
||||||
if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
|
|
||||||
client.value.password = RandomUtil.randomShadowsocksPassword(
|
|
||||||
inbound.value.settings.method,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
client.value.password = RandomUtil.randomSeq(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function randomAuth() {
|
|
||||||
if (client.value) client.value.auth = RandomUtil.randomSeq(10);
|
|
||||||
}
|
|
||||||
function randomSubId() {
|
|
||||||
if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientIpsText = ref('');
|
|
||||||
async function loadClientIps() {
|
|
||||||
if (!client.value?.email) return;
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
|
|
||||||
if (!msg?.success) {
|
|
||||||
clientIpsText.value = msg?.obj || '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let ips = msg.obj;
|
|
||||||
if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(ips);
|
|
||||||
ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
|
|
||||||
} catch (_e) {
|
|
||||||
// leave as raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clientIpsText.value = ips || '';
|
|
||||||
}
|
|
||||||
async function clearClientIps() {
|
|
||||||
if (!client.value?.email) return;
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
|
|
||||||
if (msg?.success) clientIpsText.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetClientTraffic() {
|
|
||||||
if (!clientStats.value || !client.value?.email) return;
|
|
||||||
const msg = await HttpUtil.post(
|
|
||||||
`/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
|
|
||||||
);
|
|
||||||
if (msg?.success) {
|
|
||||||
clientStats.value.up = 0;
|
|
||||||
clientStats.value.down = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!client.value || !inbound.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
id: props.dbInbound.id,
|
|
||||||
settings: `{"clients": [${client.value.toString()}]}`,
|
|
||||||
};
|
|
||||||
const url = props.mode === 'edit'
|
|
||||||
? `/panel/api/inbounds/updateClient/${oldClientId.value}`
|
|
||||||
: '/panel/api/inbounds/addClient';
|
|
||||||
const msg = await HttpUtil.post(url, payload);
|
|
||||||
if (msg?.success) {
|
|
||||||
emit('saved');
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = computed(() =>
|
|
||||||
props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :title="title"
|
|
||||||
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
|
|
||||||
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
|
||||||
<a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
|
|
||||||
{{ t('depleted') }}
|
|
||||||
</a-tag>
|
|
||||||
|
|
||||||
<a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
|
|
||||||
:wrapper-col="{ sm: { span: 14 } }">
|
|
||||||
<a-form-item :label="t('enable')">
|
|
||||||
<a-switch v-model:checked="client.enable" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<template #label>
|
|
||||||
{{ t('pages.inbounds.email') }}
|
|
||||||
<SyncOutlined class="random-icon" @click="randomEmail" />
|
|
||||||
</template>
|
|
||||||
<a-input v-model:value="client.email" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="isTrojanOrSS">
|
|
||||||
<template #label>
|
|
||||||
{{ t('password') }}
|
|
||||||
<SyncOutlined class="random-icon" @click="randomPassword" />
|
|
||||||
</template>
|
|
||||||
<a-input v-model:value="client.password" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="protocol === Protocols.HYSTERIA">
|
|
||||||
<template #label>
|
|
||||||
{{ t('password') }}
|
|
||||||
<SyncOutlined class="random-icon" @click="randomAuth" />
|
|
||||||
</template>
|
|
||||||
<a-input v-model:value="client.auth" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="isVmessOrVless">
|
|
||||||
<template #label>
|
|
||||||
ID
|
|
||||||
<SyncOutlined class="random-icon" @click="randomId" />
|
|
||||||
</template>
|
|
||||||
<a-input v-model:value="client.id" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
|
|
||||||
<a-select v-model:value="client.security">
|
|
||||||
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
|
|
||||||
{{ key }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="client.email && subEnable">
|
|
||||||
<template #label>
|
|
||||||
{{ t('subscription.title') }}
|
|
||||||
<SyncOutlined class="random-icon" @click="randomSubId" />
|
|
||||||
</template>
|
|
||||||
<a-input v-model:value="client.subId" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
|
|
||||||
<a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="client.email" :label="t('comment')">
|
|
||||||
<a-input v-model:value="client.comment" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
|
||||||
<a-input-number v-model:value="client.limitIp" :min="0" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
|
|
||||||
:label="t('pages.inbounds.IPLimitlog')">
|
|
||||||
<a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
|
|
||||||
:auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
|
|
||||||
<a-button type="link" size="small" danger @click="clearClientIps">
|
|
||||||
<template #icon>
|
|
||||||
<DeleteOutlined />
|
|
||||||
</template>
|
|
||||||
{{ t('pages.inbounds.IPLimitlogclear') }}
|
|
||||||
</a-button>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
|
||||||
<a-select v-model:value="client.flow">
|
|
||||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
|
||||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
|
|
||||||
{{ key }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
|
|
||||||
<a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<template #label>
|
|
||||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
|
||||||
</template>
|
|
||||||
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
|
|
||||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
|
|
||||||
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
|
|
||||||
{{ SizeFormatter.sizeFormat(clientStats.down) }}
|
|
||||||
({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
|
|
||||||
</a-tag>
|
|
||||||
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
|
||||||
<RetweetOutlined class="action-icon" @click="resetClientTraffic" />
|
|
||||||
</a-tooltip>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item :label="t('pages.client.delayedStart')">
|
|
||||||
<a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
|
|
||||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-else>
|
|
||||||
<template #label>
|
|
||||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
|
||||||
}}</a-tooltip>
|
|
||||||
</template>
|
|
||||||
<DateTimePicker v-model:value="expiryDate" />
|
|
||||||
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="client.expiryTime !== 0">
|
|
||||||
<template #label>
|
|
||||||
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
|
|
||||||
</template>
|
|
||||||
<a-input-number v-model:value="client.reset" :min="0" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.status-banner {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.random-icon,
|
|
||||||
.action-icon {
|
|
||||||
margin-left: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--ant-primary-color, #1890ff);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,841 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import {
|
|
||||||
EditOutlined,
|
|
||||||
InfoCircleOutlined,
|
|
||||||
QrcodeOutlined,
|
|
||||||
RetweetOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
EllipsisOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
|
||||||
import { Modal } from 'ant-design-vue';
|
|
||||||
|
|
||||||
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
|
||||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
|
||||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
|
||||||
|
|
||||||
const { datepicker } = useDatepicker();
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
// Per-inbound expand-row content. CSS-grid layout (not a nested
|
|
||||||
// <a-table>) so it sits flush inside the parent's expanded cell.
|
|
||||||
// No API calls here — events bubble to the parent's modals.
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
dbInbound: { type: Object, required: true },
|
|
||||||
isMobile: { type: Boolean, default: false },
|
|
||||||
trafficDiff: { type: Number, default: 0 },
|
|
||||||
expireDiff: { type: Number, default: 0 },
|
|
||||||
onlineClients: { type: Array, default: () => [] },
|
|
||||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
|
||||||
isDarkTheme: { type: Boolean, default: false },
|
|
||||||
pageSize: { type: Number, default: 0 },
|
|
||||||
totalClientCount: { type: Number, default: 0 },
|
|
||||||
statsVersion: { type: Number, default: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits([
|
|
||||||
'edit-client',
|
|
||||||
'qrcode-client',
|
|
||||||
'info-client',
|
|
||||||
'reset-traffic-client',
|
|
||||||
'delete-client',
|
|
||||||
'delete-clients',
|
|
||||||
'toggle-enable-client',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const inbound = computed(() => props.dbInbound.toInbound());
|
|
||||||
const clients = computed(() => inbound.value?.clients || []);
|
|
||||||
|
|
||||||
const currentPage = ref(1);
|
|
||||||
const paginatedClients = computed(() => {
|
|
||||||
if (!props.pageSize || props.pageSize <= 0) return clients.value;
|
|
||||||
const start = (currentPage.value - 1) * props.pageSize;
|
|
||||||
return clients.value.slice(start, start + props.pageSize);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch([clients, () => props.pageSize], () => {
|
|
||||||
const total = clients.value.length;
|
|
||||||
const size = props.pageSize > 0 ? props.pageSize : (total || 1);
|
|
||||||
const maxPage = Math.max(1, Math.ceil(total / size));
|
|
||||||
if (currentPage.value > maxPage) currentPage.value = maxPage;
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Per-client stats lookup =======================================
|
|
||||||
// statsVersion bumps on every ws merge so this computed re-evaluates
|
|
||||||
// (DBInbound isn't reactive — the in-place stat mutations alone don't
|
|
||||||
// trigger Vue's tracking).
|
|
||||||
const statsMap = computed(() => {
|
|
||||||
void props.statsVersion;
|
|
||||||
const m = new Map();
|
|
||||||
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
function statsFor(email) {
|
|
||||||
return email ? statsMap.value.get(email) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUp(email) { return statsFor(email)?.up || 0; }
|
|
||||||
function getDown(email) { return statsFor(email)?.down || 0; }
|
|
||||||
function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
|
|
||||||
function getRem(email) {
|
|
||||||
const s = statsFor(email);
|
|
||||||
if (!s) return 0;
|
|
||||||
const r = s.total - s.up - s.down;
|
|
||||||
return r > 0 ? r : 0;
|
|
||||||
}
|
|
||||||
function getAllTime(email) {
|
|
||||||
const s = statsFor(email);
|
|
||||||
if (!s) return 0;
|
|
||||||
// allTime is the cumulative-historical counter; never let it dip
|
|
||||||
// below up+down (manual edits / partial migrations can push it under).
|
|
||||||
const current = (s.up || 0) + (s.down || 0);
|
|
||||||
return s.allTime > current ? s.allTime : current;
|
|
||||||
}
|
|
||||||
function isClientDepleted(email) {
|
|
||||||
const s = statsFor(email);
|
|
||||||
if (!s) return false;
|
|
||||||
const total = s.total ?? 0;
|
|
||||||
const used = (s.up ?? 0) + (s.down ?? 0);
|
|
||||||
if (total > 0 && used >= total) return true;
|
|
||||||
const exp = s.expiryTime ?? 0;
|
|
||||||
if (exp > 0 && Date.now() >= exp) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function isClientOnline(email) {
|
|
||||||
return !!email && props.onlineClients.includes(email);
|
|
||||||
}
|
|
||||||
function lastOnlineLabel(email) {
|
|
||||||
const ts = props.lastOnlineMap[email];
|
|
||||||
if (!ts) return '-';
|
|
||||||
return IntlUtil.formatDate(ts, datepicker.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function statsProgress(email) {
|
|
||||||
const s = statsFor(email);
|
|
||||||
if (!s) return 0;
|
|
||||||
if (s.total === 0) return 100;
|
|
||||||
return (100 * (s.down + s.up)) / s.total;
|
|
||||||
}
|
|
||||||
function expireProgress(expTime, reset) {
|
|
||||||
const now = Date.now();
|
|
||||||
const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
|
|
||||||
const resetSec = reset * 86400;
|
|
||||||
if (remainedSec >= resetSec) return 0;
|
|
||||||
return 100 * (1 - remainedSec / resetSec);
|
|
||||||
}
|
|
||||||
function clientStatsColor(email) {
|
|
||||||
return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
|
|
||||||
}
|
|
||||||
function statsExpColor(email) {
|
|
||||||
// AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
|
|
||||||
// matches the row's traffic/expiry tags.
|
|
||||||
const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
|
|
||||||
if (!email) return PURPLE;
|
|
||||||
const s = statsFor(email);
|
|
||||||
if (!s) return PURPLE;
|
|
||||||
const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
|
|
||||||
const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
|
|
||||||
if (a === 'red' || b === 'red') return DANGER;
|
|
||||||
if (a === 'orange' || b === 'orange') return WARN;
|
|
||||||
if (a === 'green' || b === 'green') return SUCCESS;
|
|
||||||
return PURPLE;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
|
|
||||||
|
|
||||||
function totalGbDisplay(client) {
|
|
||||||
if (!client.totalGB || client.totalGB <= 0) return '';
|
|
||||||
return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
|
|
||||||
|
|
||||||
function statusBadgeColor(client) {
|
|
||||||
if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
|
|
||||||
return statsExpColor(client.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Action confirms ==============================================
|
|
||||||
function confirmReset(client) {
|
|
||||||
Modal.confirm({
|
|
||||||
title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
|
|
||||||
content: t('pages.inbounds.resetTrafficContent'),
|
|
||||||
okText: t('reset'),
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function confirmDelete(client) {
|
|
||||||
Modal.confirm({
|
|
||||||
title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
|
|
||||||
content: t('pages.inbounds.deleteClientContent'),
|
|
||||||
okText: t('delete'),
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable row key for v-for — falls back through email/id/password
|
|
||||||
// because not every protocol fills the same field.
|
|
||||||
function rowKey(client) {
|
|
||||||
return client.email || client.id || client.password || JSON.stringify(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = ref(new Set());
|
|
||||||
|
|
||||||
const allSelected = computed(() =>
|
|
||||||
clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
|
|
||||||
);
|
|
||||||
const someSelected = computed(() =>
|
|
||||||
clients.value.some((c) => selected.value.has(rowKey(c))),
|
|
||||||
);
|
|
||||||
const selectedCount = computed(() => selected.value.size);
|
|
||||||
|
|
||||||
function isSelected(key) {
|
|
||||||
return selected.value.has(key);
|
|
||||||
}
|
|
||||||
function toggleSelect(key, next) {
|
|
||||||
const s = new Set(selected.value);
|
|
||||||
if (next) s.add(key); else s.delete(key);
|
|
||||||
selected.value = s;
|
|
||||||
}
|
|
||||||
function selectAll(next) {
|
|
||||||
if (next) {
|
|
||||||
selected.value = new Set(clients.value.map(rowKey));
|
|
||||||
} else {
|
|
||||||
selected.value = new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function clearSelection() {
|
|
||||||
selected.value = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(clients, (list) => {
|
|
||||||
if (selected.value.size === 0) return;
|
|
||||||
const valid = new Set(list.map(rowKey));
|
|
||||||
const next = new Set();
|
|
||||||
for (const k of selected.value) if (valid.has(k)) next.add(k);
|
|
||||||
if (next.size !== selected.value.size) selected.value = next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const statsClient = ref(null);
|
|
||||||
function openStats(client) {
|
|
||||||
statsClient.value = client;
|
|
||||||
}
|
|
||||||
function closeStats() {
|
|
||||||
statsClient.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmBulkDelete() {
|
|
||||||
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
|
|
||||||
if (picked.length === 0) return;
|
|
||||||
|
|
||||||
const total = clients.value.length;
|
|
||||||
const keepLast = picked.length === total;
|
|
||||||
const toDelete = keepLast ? picked.slice(0, -1) : picked;
|
|
||||||
|
|
||||||
if (toDelete.length === 0) {
|
|
||||||
Modal.warning({
|
|
||||||
title: t('pages.inbounds.deleteClient'),
|
|
||||||
content: 'Inbound must keep at least one client — delete the inbound to remove all.',
|
|
||||||
okText: t('confirm'),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
|
|
||||||
content: keepLast
|
|
||||||
? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
|
|
||||||
: t('pages.inbounds.deleteClientContent'),
|
|
||||||
okText: t('delete'),
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: () => {
|
|
||||||
emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
|
|
||||||
clearSelection();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="client-list"
|
|
||||||
:class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
|
|
||||||
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
|
|
||||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
|
||||||
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
|
|
||||||
<a-button size="small" danger @click="confirmBulkDelete">
|
|
||||||
<DeleteOutlined /> {{ t('delete') }}
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ====================== Desktop: grid table ===================== -->
|
|
||||||
<template v-if="!isMobile">
|
|
||||||
<div class="client-row client-list-header">
|
|
||||||
<div v-if="isRemovable" class="cell cell-select">
|
|
||||||
<a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
|
|
||||||
@change="(e) => selectAll(e.target.checked)" />
|
|
||||||
</div>
|
|
||||||
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
|
||||||
<div class="cell cell-enable">{{ t('enable') }}</div>
|
|
||||||
<div class="cell cell-online">{{ t('online') }}</div>
|
|
||||||
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
|
||||||
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
|
||||||
<div class="cell cell-remained">{{ t('remained') }}</div>
|
|
||||||
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
|
||||||
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
|
|
||||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
|
||||||
<div v-if="isRemovable" class="cell cell-select">
|
|
||||||
<a-checkbox :checked="isSelected(rowKey(client))"
|
|
||||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
|
||||||
</div>
|
|
||||||
<div class="cell cell-actions">
|
|
||||||
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
|
||||||
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip :title="t('edit')">
|
|
||||||
<EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip :title="t('info')">
|
|
||||||
<InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
|
||||||
<RetweetOutlined class="row-icon" @click="confirmReset(client)" />
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip v-if="isRemovable" :title="t('delete')">
|
|
||||||
<DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell cell-enable">
|
|
||||||
<a-switch :checked="client.enable" size="small"
|
|
||||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell cell-online">
|
|
||||||
<a-popover>
|
|
||||||
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
|
|
||||||
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
|
||||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
|
||||||
</a-popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell cell-client">
|
|
||||||
<a-tooltip>
|
|
||||||
<template #title>
|
|
||||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
|
||||||
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
|
||||||
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
|
||||||
<template v-else>{{ t('offline') }}</template>
|
|
||||||
</template>
|
|
||||||
<a-badge :color="statusBadgeColor(client)" />
|
|
||||||
</a-tooltip>
|
|
||||||
<div class="client-id-stack">
|
|
||||||
<a-tooltip :title="client.email">
|
|
||||||
<span class="client-email">{{ client.email }}</span>
|
|
||||||
</a-tooltip>
|
|
||||||
<span v-if="client.comment && client.comment.trim()" class="client-comment">
|
|
||||||
{{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell cell-traffic">
|
|
||||||
<a-popover>
|
|
||||||
<template v-if="client.email" #content>
|
|
||||||
<table cellpadding="2">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
|
|
||||||
<td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="client.totalGB > 0">
|
|
||||||
<td>{{ t('remained') }}</td>
|
|
||||||
<td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<div class="usage-bar">
|
|
||||||
<span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
|
|
||||||
<a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
|
|
||||||
:show-info="false" :percent="statsProgress(client.email)" size="small" />
|
|
||||||
<a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
|
|
||||||
:show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
|
||||||
:percent="statsProgress(client.email)" size="small" />
|
|
||||||
<a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
|
|
||||||
<span class="usage-text">
|
|
||||||
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
|
||||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a-popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell cell-remained">
|
|
||||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
|
||||||
<InfinityIcon />
|
|
||||||
</a-tag>
|
|
||||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
|
||||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
|
||||||
</a-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell cell-alltime">
|
|
||||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell cell-expiry">
|
|
||||||
<template v-if="client.expiryTime !== 0 && client.reset > 0">
|
|
||||||
<a-popover>
|
|
||||||
<template #content>
|
|
||||||
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
|
|
||||||
<span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
|
|
||||||
</template>
|
|
||||||
<div class="usage-bar">
|
|
||||||
<span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
|
|
||||||
<a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
|
||||||
:percent="expireProgress(client.expiryTime, client.reset)" size="small" />
|
|
||||||
<span class="usage-text">{{ client.reset }}d</span>
|
|
||||||
</div>
|
|
||||||
</a-popover>
|
|
||||||
</template>
|
|
||||||
<a-popover v-else-if="client.expiryTime !== 0">
|
|
||||||
<template #content>
|
|
||||||
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
|
|
||||||
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
|
|
||||||
</template>
|
|
||||||
<a-tag :style="{ minWidth: '50px', border: 'none' }"
|
|
||||||
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
|
||||||
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
|
||||||
</a-tag>
|
|
||||||
</a-popover>
|
|
||||||
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
|
|
||||||
class="infinite-tag">
|
|
||||||
<InfinityIcon />
|
|
||||||
</a-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ====================== Mobile: card list ======================= -->
|
|
||||||
<template v-else>
|
|
||||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
|
|
||||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
|
||||||
<div class="client-card-head">
|
|
||||||
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
|
||||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
|
||||||
<a-tooltip>
|
|
||||||
<template #title>
|
|
||||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
|
||||||
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
|
||||||
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
|
||||||
<template v-else>{{ t('offline') }}</template>
|
|
||||||
</template>
|
|
||||||
<a-badge :color="statusBadgeColor(client)" />
|
|
||||||
</a-tooltip>
|
|
||||||
<a-tooltip :title="client.email">
|
|
||||||
<span class="client-email">{{ client.email }}</span>
|
|
||||||
</a-tooltip>
|
|
||||||
<div class="client-card-actions">
|
|
||||||
<a-tooltip :title="t('info')">
|
|
||||||
<InfoCircleOutlined class="row-icon" @click="openStats(client)" />
|
|
||||||
</a-tooltip>
|
|
||||||
<a-switch :checked="client.enable" size="small"
|
|
||||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
|
||||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
|
||||||
<EllipsisOutlined class="row-icon" @click.prevent />
|
|
||||||
<template #overlay>
|
|
||||||
<a-menu>
|
|
||||||
<a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
|
|
||||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item @click="emit('edit-client', { dbInbound, client })">
|
|
||||||
<EditOutlined /> {{ t('edit') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item @click="emit('info-client', { dbInbound, client })">
|
|
||||||
<InfoCircleOutlined /> {{ t('info') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="client.email" @click="confirmReset(client)">
|
|
||||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
|
|
||||||
<DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-modal :open="!!statsClient" :footer="null" :width="360" centered
|
|
||||||
:title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
|
|
||||||
<div v-if="statsClient" class="client-card-foot">
|
|
||||||
<div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
|
|
||||||
{{ statsClient.comment }}
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
|
||||||
<a-tag :color="clientStatsColor(statsClient.email)">
|
|
||||||
{{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
|
|
||||||
<InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
|
|
||||||
<template v-else>{{ totalGbDisplay(statsClient) }}</template>
|
|
||||||
</a-tag>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">{{ t('remained') }}</span>
|
|
||||||
<a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
|
||||||
<InfinityIcon />
|
|
||||||
</a-tag>
|
|
||||||
<a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
|
|
||||||
{{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
|
|
||||||
</a-tag>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
|
||||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">{{ t('online') }}</span>
|
|
||||||
<a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
|
|
||||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
|
||||||
<a-tag v-if="statsClient.expiryTime > 0"
|
|
||||||
:color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
|
|
||||||
{{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
|
|
||||||
</a-tag>
|
|
||||||
<a-tag v-else-if="statsClient.expiryTime < 0" color="green">
|
|
||||||
{{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
|
||||||
</a-tag>
|
|
||||||
<a-tag v-else color="purple">
|
|
||||||
<InfinityIcon />
|
|
||||||
</a-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
|
|
||||||
:page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
|
|
||||||
class="client-list-pagination" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.client-list {
|
|
||||||
margin: -8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 6px 16px;
|
|
||||||
background: rgba(22, 119, 255, 0.08);
|
|
||||||
border-bottom: 1px solid rgba(22, 119, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-count {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-selected {
|
|
||||||
background: rgba(22, 119, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-row {
|
|
||||||
display: grid;
|
|
||||||
/* Default — no select column (single-client inbounds). The .has-select
|
|
||||||
* modifier below prepends the 40px checkbox column. */
|
|
||||||
grid-template-columns:
|
|
||||||
140px
|
|
||||||
/* actions */
|
|
||||||
60px
|
|
||||||
/* enable */
|
|
||||||
80px
|
|
||||||
/* online */
|
|
||||||
minmax(160px, 2fr)
|
|
||||||
/* client identity */
|
|
||||||
minmax(160px, 2fr)
|
|
||||||
/* traffic */
|
|
||||||
130px
|
|
||||||
/* all-time */
|
|
||||||
130px
|
|
||||||
/* remained */
|
|
||||||
140px;
|
|
||||||
/* expiry */
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-list.has-select .client-row {
|
|
||||||
grid-template-columns:
|
|
||||||
40px
|
|
||||||
/* select */
|
|
||||||
140px
|
|
||||||
/* actions */
|
|
||||||
60px
|
|
||||||
/* enable */
|
|
||||||
80px
|
|
||||||
/* online */
|
|
||||||
minmax(160px, 2fr)
|
|
||||||
/* client identity */
|
|
||||||
minmax(160px, 2fr)
|
|
||||||
/* traffic */
|
|
||||||
130px
|
|
||||||
/* all-time */
|
|
||||||
130px
|
|
||||||
/* remained */
|
|
||||||
140px;
|
|
||||||
/* expiry */
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-row:last-child {
|
|
||||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-list-header {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.65;
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
border-top: none;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell {
|
|
||||||
min-width: 0;
|
|
||||||
/* allow grid children to shrink instead of overflowing */
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-select,
|
|
||||||
.cell-actions,
|
|
||||||
.cell-enable,
|
|
||||||
.cell-online,
|
|
||||||
.cell-alltime,
|
|
||||||
.cell-remained {
|
|
||||||
text-align: center;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-client {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-traffic,
|
|
||||||
.cell-expiry {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-list-header .cell {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-list-header .cell-actions,
|
|
||||||
.client-list-header .cell-client {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action icons */
|
|
||||||
.row-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 2px;
|
|
||||||
color: inherit;
|
|
||||||
transition: color 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-icon:hover {
|
|
||||||
color: var(--ant-color-primary, #1677ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-icon.danger {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Client identity stack (badge + email + comment) */
|
|
||||||
.client-id-stack {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-email {
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-comment {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.7;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Traffic / expiry inline bar: text | progress | text */
|
|
||||||
.usage-bar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-text {
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-bar :deep(.ant-progress) {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.infinite-tag {
|
|
||||||
min-width: 50px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Strip AD-Vue's default expanded-cell padding so the desktop grid
|
|
||||||
* sits flush against the inbound row's left/right edges. */
|
|
||||||
:deep(.ant-table-expanded-row > .ant-table-cell) {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-list-pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 10px 16px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Mobile card list =========================================== */
|
|
||||||
.client-list.is-mobile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card {
|
|
||||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(body.dark) .client-card {
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card-head .client-email {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card-actions {
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card-actions .row-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-comment-line {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.7;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card-foot {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card-foot .stat-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card-foot .stat-label {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
opacity: 0.6;
|
|
||||||
min-width: 96px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-card-foot :deep(.ant-tag) {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bigger status badge for thumb-readable state at a glance. */
|
|
||||||
.client-card-head :deep(.ant-badge-status-dot) {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { message } from 'ant-design-vue';
|
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
|
|
||||||
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
dbInbound: { type: Object, default: null },
|
|
||||||
dbInbounds: { type: Array, default: () => [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open', 'saved']);
|
|
||||||
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
|
||||||
|
|
||||||
const sourceInboundId = ref(null);
|
|
||||||
const selectedEmails = ref([]);
|
|
||||||
const flow = ref('');
|
|
||||||
const saving = ref(false);
|
|
||||||
|
|
||||||
const sources = computed(() => {
|
|
||||||
if (!props.dbInbound) return [];
|
|
||||||
return props.dbInbounds
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.id !== props.dbInbound.id &&
|
|
||||||
typeof row.isMultiUser === 'function' &&
|
|
||||||
row.isMultiUser(),
|
|
||||||
)
|
|
||||||
.map((row) => {
|
|
||||||
let count = 0;
|
|
||||||
try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
|
|
||||||
return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceInbound = computed(() => {
|
|
||||||
if (!sourceInboundId.value) return null;
|
|
||||||
return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceClients = computed(() => {
|
|
||||||
const sb = sourceInbound.value;
|
|
||||||
if (!sb) return [];
|
|
||||||
let list = [];
|
|
||||||
try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
|
|
||||||
const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
|
|
||||||
return list
|
|
||||||
.filter((c) => c.email)
|
|
||||||
.map((c) => {
|
|
||||||
const s = stats.get(c.email);
|
|
||||||
const used = s ? (s.up || 0) + (s.down || 0) : 0;
|
|
||||||
let expiryLabel = t('unlimited');
|
|
||||||
if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
|
|
||||||
else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
|
|
||||||
return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const showFlow = computed(() => {
|
|
||||||
if (!props.dbInbound) return false;
|
|
||||||
try {
|
|
||||||
const inb = props.dbInbound.toInbound();
|
|
||||||
return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
|
|
||||||
} catch (_e) { return false; }
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = computed(() => [
|
|
||||||
{ title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
|
|
||||||
{ title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
|
|
||||||
{ title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rowSelection = computed(() => ({
|
|
||||||
selectedRowKeys: selectedEmails.value,
|
|
||||||
onChange: (keys) => { selectedEmails.value = keys; },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const title = computed(() => {
|
|
||||||
if (!props.dbInbound) return t('pages.client.copyFromInbound');
|
|
||||||
const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
|
|
||||||
return `${t('pages.client.copyToInbound')} ${target}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => {
|
|
||||||
if (!next) return;
|
|
||||||
sourceInboundId.value = null;
|
|
||||||
selectedEmails.value = [];
|
|
||||||
flow.value = '';
|
|
||||||
saving.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(sourceInboundId, () => {
|
|
||||||
selectedEmails.value = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectAll() {
|
|
||||||
selectedEmails.value = sourceClients.value.map((c) => c.email);
|
|
||||||
}
|
|
||||||
function clearAll() {
|
|
||||||
selectedEmails.value = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ok() {
|
|
||||||
if (!sourceInboundId.value) {
|
|
||||||
message.error(t('pages.client.copySelectSourceFirst'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!props.dbInbound) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
sourceInboundId: sourceInboundId.value,
|
|
||||||
clientEmails: selectedEmails.value,
|
|
||||||
};
|
|
||||||
if (showFlow.value && flow.value) payload.flow = flow.value;
|
|
||||||
const msg = await HttpUtil.post(
|
|
||||||
`/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
if (!msg?.success) return;
|
|
||||||
const obj = msg.obj || {};
|
|
||||||
const addedCount = (obj.added || []).length;
|
|
||||||
const errorList = obj.errors || [];
|
|
||||||
if (addedCount > 0) {
|
|
||||||
message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
|
|
||||||
} else {
|
|
||||||
message.warning(t('pages.client.copyResultNone'));
|
|
||||||
}
|
|
||||||
if (errorList.length > 0) {
|
|
||||||
message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
|
|
||||||
}
|
|
||||||
emit('saved');
|
|
||||||
emit('update:open', false);
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (saving.value) return;
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
|
|
||||||
:confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
|
|
||||||
<a-space direction="vertical" :style="{ width: '100%' }">
|
|
||||||
<div>
|
|
||||||
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
|
|
||||||
<a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
|
|
||||||
<a-select-option v-for="item in sources" :key="item.id" :value="item.id">
|
|
||||||
{{ item.label }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="sourceInboundId">
|
|
||||||
<a-space :style="{ marginBottom: '8px' }">
|
|
||||||
<a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
|
|
||||||
<a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
|
|
||||||
</a-space>
|
|
||||||
<a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
|
|
||||||
:row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showFlow">
|
|
||||||
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
|
|
||||||
<a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
|
|
||||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
|
||||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
|
|
||||||
{{ t('pages.client.copyFlowHint') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-space>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -19,41 +19,20 @@ import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { datepicker } = useDatepicker();
|
const { datepicker } = useDatepicker();
|
||||||
|
|
||||||
// One modal handles every protocol's info / share view because the
|
|
||||||
// legacy template did the same. The big v-if forks at the top decide
|
|
||||||
// which sub-block of the body renders:
|
|
||||||
// • multi-user inbound (VMess/VLess/Trojan/SS-multi/Hysteria) → per-
|
|
||||||
// client row + share links
|
|
||||||
// • SS single-user → connection details + share link
|
|
||||||
// • WireGuard → secret/peers + per-peer config download
|
|
||||||
// • Mixed/HTTP/Tunnel → connection details only
|
|
||||||
//
|
|
||||||
// We display links via QrPanel — each link gets its own QR + copy +
|
|
||||||
// (for WireGuard configs) download button.
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
// Result of inbounds-page checkFallback() so the link-gen sees the
|
|
||||||
// root inbound's listen/port/security when the dbInbound is a
|
|
||||||
// domain-socket fallback (`@<name>`).
|
|
||||||
dbInbound: { type: Object, default: null },
|
dbInbound: { type: Object, default: null },
|
||||||
// Index into inbound.clients to focus on for multi-user inbounds.
|
|
||||||
clientIndex: { type: Number, default: 0 },
|
clientIndex: { type: Number, default: 0 },
|
||||||
// Sidecar config the legacy panel keyed off `app.*`.
|
|
||||||
remarkModel: { type: String, default: '-ieo' },
|
remarkModel: { type: String, default: '-ieo' },
|
||||||
expireDiff: { type: Number, default: 0 },
|
expireDiff: { type: Number, default: 0 },
|
||||||
trafficDiff: { type: Number, default: 0 },
|
trafficDiff: { type: Number, default: 0 },
|
||||||
ipLimitEnable: { type: Boolean, default: false },
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
tgBotEnable: { type: Boolean, default: false },
|
tgBotEnable: { type: Boolean, default: false },
|
||||||
// Address of the node hosting this inbound; '' for local. Wired
|
|
||||||
// through to share/QR link generation so node-managed inbounds
|
|
||||||
// produce links that connect to the node, not the central panel.
|
|
||||||
nodeAddress: { type: String, default: '' },
|
nodeAddress: { type: String, default: '' },
|
||||||
subSettings: {
|
subSettings: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||||
},
|
},
|
||||||
// Email -> ts (last-online unix-ms) map fetched at the page level.
|
|
||||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -137,7 +116,7 @@ async function loadClientIps() {
|
||||||
if (!clientStats.value?.email) return;
|
if (!clientStats.value?.email) return;
|
||||||
refreshing.value = true;
|
refreshing.value = true;
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${clientStats.value.email}`);
|
const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.value.email}`);
|
||||||
if (!msg?.success) {
|
if (!msg?.success) {
|
||||||
clientIpsText.value = msg?.obj || 'No IP record';
|
clientIpsText.value = msg?.obj || 'No IP record';
|
||||||
clientIpsArray.value = [];
|
clientIpsArray.value = [];
|
||||||
|
|
@ -164,7 +143,7 @@ async function loadClientIps() {
|
||||||
|
|
||||||
async function clearClientIps() {
|
async function clearClientIps() {
|
||||||
if (!clientStats.value?.email) return;
|
if (!clientStats.value?.email) return;
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${clientStats.value.email}`);
|
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.value.email}`);
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
clientIpsArray.value = [];
|
clientIpsArray.value = [];
|
||||||
clientIpsText.value = t('tgbot.noIpRecord');
|
clientIpsText.value = t('tgbot.noIpRecord');
|
||||||
|
|
@ -598,7 +577,8 @@ const showSubscriptionTab = computed(
|
||||||
<div v-if="inbound.settings.gateway?.length" class="info-row">
|
<div v-if="inbound.settings.gateway?.length" class="info-row">
|
||||||
<dt>Gateway</dt>
|
<dt>Gateway</dt>
|
||||||
<dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
|
<dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
|
||||||
class="value-tag">{{ ip }}</a-tag></dd>
|
class="value-tag">{{
|
||||||
|
ip }}</a-tag></dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="inbound.settings.dns?.length" class="info-row">
|
<div v-if="inbound.settings.dns?.length" class="info-row">
|
||||||
<dt>DNS</dt>
|
<dt>DNS</dt>
|
||||||
|
|
@ -612,7 +592,8 @@ const showSubscriptionTab = computed(
|
||||||
<div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
|
<div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
|
||||||
<dt>Auto system routes</dt>
|
<dt>Auto system routes</dt>
|
||||||
<dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
|
<dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
|
||||||
color="green">{{ cidr }}</a-tag></dd>
|
color="green">{{
|
||||||
|
cidr }}</a-tag></dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
|
@ -670,12 +651,101 @@ const showSubscriptionTab = computed(
|
||||||
<span class="account-sep">:</span>
|
<span class="account-sep">:</span>
|
||||||
<a-tag class="value-tag">{{ account.pass }}</a-tag>
|
<a-tag class="value-tag">{{ account.pass }}</a-tag>
|
||||||
<a-tooltip :title="t('copy')">
|
<a-tooltip :title="t('copy')">
|
||||||
<a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
|
<a-button size="small" type="text"
|
||||||
<template #icon>
|
@click="copyText(`${account.user}:${account.pass}`)">
|
||||||
<CopyOutlined />
|
<template #icon><CopyOutlined /></template>
|
||||||
</template>
|
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
|
<a-space :size="4" wrap class="share-buttons share-desktop">
|
||||||
|
<a-tooltip :title="`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`">
|
||||||
|
<a-button size="small"
|
||||||
|
@click="copyText(`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`)">
|
||||||
|
SOCKS5
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`">
|
||||||
|
<a-button size="small"
|
||||||
|
@click="copyText(`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`)">
|
||||||
|
HTTP
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip title="https://t.me/socks?server=...&port=...&user=...&pass=...">
|
||||||
|
<a-button size="small"
|
||||||
|
@click="copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`)">
|
||||||
|
Telegram
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</a-space>
|
||||||
|
<a-dropdown :trigger="['click']" class="share-mobile">
|
||||||
|
<a-button size="small">
|
||||||
|
<template #icon><CopyOutlined /></template>
|
||||||
|
{{ t('copy') }}
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="({ key }) => {
|
||||||
|
const h = dbInbound.address;
|
||||||
|
const port = dbInbound.port;
|
||||||
|
if (key === 'telegram') {
|
||||||
|
copyText(`https://t.me/socks?server=${encodeURIComponent(h)}&port=${port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`);
|
||||||
|
} else {
|
||||||
|
copyText(`${key}://${h}:${port}@${account.user}:${account.pass}`);
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<a-menu-item key="socks5">SOCKS5</a-menu-item>
|
||||||
|
<a-menu-item key="http">HTTP</a-menu-item>
|
||||||
|
<a-menu-item key="telegram">Telegram</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="inbound.settings.auth === 'noauth'">
|
||||||
|
<div class="info-row">
|
||||||
|
<dt>{{ t('copy') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<a-space :size="4" wrap class="share-buttons share-desktop">
|
||||||
|
<a-tooltip :title="`socks5://${dbInbound.address}:${dbInbound.port}`">
|
||||||
|
<a-button size="small"
|
||||||
|
@click="copyText(`socks5://${dbInbound.address}:${dbInbound.port}`)">
|
||||||
|
SOCKS5
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="`http://${dbInbound.address}:${dbInbound.port}`">
|
||||||
|
<a-button size="small"
|
||||||
|
@click="copyText(`http://${dbInbound.address}:${dbInbound.port}`)">
|
||||||
|
HTTP
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip title="https://t.me/socks?server=...&port=...">
|
||||||
|
<a-button size="small"
|
||||||
|
@click="copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}`)">
|
||||||
|
Telegram
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</a-space>
|
||||||
|
<a-dropdown :trigger="['click']" class="share-mobile">
|
||||||
|
<a-button size="small">
|
||||||
|
<template #icon><CopyOutlined /></template>
|
||||||
|
{{ t('copy') }}
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="({ key }) => {
|
||||||
|
const h = dbInbound.address;
|
||||||
|
const port = dbInbound.port;
|
||||||
|
if (key === 'telegram') {
|
||||||
|
copyText(`https://t.me/socks?server=${encodeURIComponent(h)}&port=${port}`);
|
||||||
|
} else {
|
||||||
|
copyText(`${key}://${h}:${port}`);
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<a-menu-item key="socks5">SOCKS5</a-menu-item>
|
||||||
|
<a-menu-item key="http">HTTP</a-menu-item>
|
||||||
|
<a-menu-item key="telegram">Telegram</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -897,6 +967,7 @@ const showSubscriptionTab = computed(
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-block {
|
.value-block {
|
||||||
|
|
@ -927,6 +998,27 @@ const showSubscriptionTab = computed(
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-buttons,
|
||||||
|
.share-mobile {
|
||||||
|
margin-inline-start: 4px;
|
||||||
|
padding-inline-start: 8px;
|
||||||
|
border-inline-start: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.share-desktop {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.share-mobile {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.security-line {
|
.security-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,24 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
SearchOutlined,
|
|
||||||
FilterOutlined,
|
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
QrcodeOutlined,
|
QrcodeOutlined,
|
||||||
UserAddOutlined,
|
|
||||||
UsergroupAddOutlined,
|
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
FileDoneOutlined,
|
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
RestOutlined,
|
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
BlockOutlined,
|
BlockOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
RightOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||||
import { DBInbound } from '@/models/dbinbound.js';
|
|
||||||
import { Inbound } from '@/models/inbound.js';
|
|
||||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||||
import ClientRowTable from './ClientRowTable.vue';
|
|
||||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||||
|
|
||||||
const { datepicker } = useDatepicker();
|
const { datepicker } = useDatepicker();
|
||||||
|
|
@ -58,117 +48,8 @@ const emit = defineEmits([
|
||||||
'add-inbound',
|
'add-inbound',
|
||||||
'general-action',
|
'general-action',
|
||||||
'row-action',
|
'row-action',
|
||||||
// Per-client events surfaced from the expand-row table.
|
|
||||||
'edit-client',
|
|
||||||
'qrcode-client',
|
|
||||||
'info-client',
|
|
||||||
'reset-traffic-client',
|
|
||||||
'delete-client',
|
|
||||||
'delete-clients',
|
|
||||||
'toggle-enable-client',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ============ Toolbar / search & filter =============================
|
|
||||||
const FILTER_STATE_KEY = 'inboundsFilterState';
|
|
||||||
const savedFilterState = (() => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
|
||||||
} catch (_e) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
const enableFilter = ref(!!savedFilterState.enableFilter);
|
|
||||||
const searchKey = ref(savedFilterState.searchKey || '');
|
|
||||||
const filterBy = ref(savedFilterState.filterBy || '');
|
|
||||||
const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
|
|
||||||
const nodeFilter = ref(savedFilterState.nodeFilter || '');
|
|
||||||
|
|
||||||
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
|
|
||||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
|
||||||
enableFilter: enableFilter.value,
|
|
||||||
searchKey: searchKey.value,
|
|
||||||
filterBy: filterBy.value,
|
|
||||||
protocolFilter: protocolFilter.value,
|
|
||||||
nodeFilter: nodeFilter.value,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle the filter mode — flip cleans the other input.
|
|
||||||
function onToggleFilter() {
|
|
||||||
if (enableFilter.value) searchKey.value = '';
|
|
||||||
else filterBy.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocolOptions = computed(() => {
|
|
||||||
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
|
|
||||||
return [...values].sort();
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeOptions = computed(() => {
|
|
||||||
const values = new Map();
|
|
||||||
if (props.dbInbounds.some((i) => i.nodeId == null)) {
|
|
||||||
values.set('local', t('pages.inbounds.localPanel'));
|
|
||||||
}
|
|
||||||
for (const dbInbound of props.dbInbounds) {
|
|
||||||
if (dbInbound.nodeId == null) continue;
|
|
||||||
const node = props.nodesById.get(dbInbound.nodeId);
|
|
||||||
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
|
|
||||||
}
|
|
||||||
return [...values.entries()].map(([value, label]) => ({ value, label }));
|
|
||||||
});
|
|
||||||
|
|
||||||
function applySecondaryFilters(rows) {
|
|
||||||
return rows.filter((dbInbound) => {
|
|
||||||
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
|
|
||||||
if (nodeFilter.value) {
|
|
||||||
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
|
|
||||||
if (nodeValue !== nodeFilter.value) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Search / filter projection =============================
|
|
||||||
// Mirrors the legacy logic: when searching, keep inbounds that match
|
|
||||||
// anywhere (deep search); when filtering, keep inbounds that have at
|
|
||||||
// least one client in the requested bucket and reduce their settings
|
|
||||||
// to that bucket.
|
|
||||||
function projectInbound(dbInbound, predicate) {
|
|
||||||
const next = new DBInbound(dbInbound);
|
|
||||||
let settings;
|
|
||||||
try {
|
|
||||||
settings = JSON.parse(dbInbound.settings || '{}');
|
|
||||||
} catch (_e) {
|
|
||||||
settings = {};
|
|
||||||
}
|
|
||||||
if (!Array.isArray(settings.clients)) return next;
|
|
||||||
const filtered = settings.clients.filter(predicate);
|
|
||||||
next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
|
|
||||||
next.invalidateCache();
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleInbounds = computed(() => {
|
|
||||||
if (enableFilter.value) {
|
|
||||||
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
|
|
||||||
const out = [];
|
|
||||||
for (const dbInbound of props.dbInbounds) {
|
|
||||||
const c = props.clientCount[dbInbound.id];
|
|
||||||
if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
|
|
||||||
const list = c[filterBy.value];
|
|
||||||
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
|
|
||||||
}
|
|
||||||
return applySecondaryFilters(out);
|
|
||||||
}
|
|
||||||
if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
|
|
||||||
const out = [];
|
|
||||||
for (const dbInbound of props.dbInbounds) {
|
|
||||||
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
|
||||||
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
|
||||||
}
|
|
||||||
return applySecondaryFilters(out);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ Sorting =================================================
|
// ============ Sorting =================================================
|
||||||
const sortState = ref({ column: null, order: null });
|
const sortState = ref({ column: null, order: null });
|
||||||
|
|
||||||
|
|
@ -189,7 +70,6 @@ const sortFns = {
|
||||||
port: (a, b) => a.port - b.port,
|
port: (a, b) => a.port - b.port,
|
||||||
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
|
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
|
||||||
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
|
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
|
||||||
allTimeInbound: (a, b) => (a.allTime || 0) - (b.allTime || 0),
|
|
||||||
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
|
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
|
||||||
node: (a, b) => {
|
node: (a, b) => {
|
||||||
const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
|
const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
|
||||||
|
|
@ -201,10 +81,10 @@ const sortFns = {
|
||||||
|
|
||||||
const sortedInbounds = computed(() => {
|
const sortedInbounds = computed(() => {
|
||||||
const { column, order } = sortState.value;
|
const { column, order } = sortState.value;
|
||||||
if (!column || !order) return visibleInbounds.value;
|
if (!column || !order) return props.dbInbounds;
|
||||||
const fn = sortFns[column];
|
const fn = sortFns[column];
|
||||||
if (!fn) return visibleInbounds.value;
|
if (!fn) return props.dbInbounds;
|
||||||
const sorted = [...visibleInbounds.value].sort(fn);
|
const sorted = [...props.dbInbounds].sort(fn);
|
||||||
return order === 'descend' ? sorted.reverse() : sorted;
|
return order === 'descend' ? sorted.reverse() : sorted;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -215,10 +95,6 @@ function onTableChange(_pag, _filters, sorter) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([searchKey, filterBy], () => {
|
|
||||||
sortState.value = { column: null, order: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ Columns =================================================
|
// ============ Columns =================================================
|
||||||
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
|
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
|
||||||
// `responsive` array still works on column defs. Computed so column
|
// `responsive` array still works on column defs. Computed so column
|
||||||
|
|
@ -244,26 +120,12 @@ const desktopColumns = computed(() => {
|
||||||
sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
|
sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
|
||||||
sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
|
sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
|
||||||
sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
|
sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
|
||||||
sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
|
|
||||||
sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
|
sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
|
||||||
);
|
);
|
||||||
return cols;
|
return cols;
|
||||||
});
|
});
|
||||||
const columns = computed(() => desktopColumns.value);
|
const columns = computed(() => desktopColumns.value);
|
||||||
|
|
||||||
// Mobile expansion state — replaces a-table's expandable() since the
|
|
||||||
// mobile branch renders a hand-rolled card list rather than a table.
|
|
||||||
const expandedIds = ref(new Set());
|
|
||||||
function toggleExpanded(id) {
|
|
||||||
const next = new Set(expandedIds.value);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
expandedIds.value = next;
|
|
||||||
}
|
|
||||||
function isExpanded(id) {
|
|
||||||
return expandedIds.value.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statsRecord = ref(null);
|
const statsRecord = ref(null);
|
||||||
function openStats(record) {
|
function openStats(record) {
|
||||||
statsRecord.value = record;
|
statsRecord.value = record;
|
||||||
|
|
@ -344,12 +206,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<a-menu-item key="resetInbounds">
|
<a-menu-item key="resetInbounds">
|
||||||
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
|
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="resetClients">
|
|
||||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
|
||||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</template>
|
</template>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
|
|
@ -357,50 +213,13 @@ function showQrCodeMenu(dbInbound) {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-space direction="vertical" :style="{ width: '100%' }">
|
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||||
<!-- Search / filter toolbar -->
|
|
||||||
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
|
|
||||||
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
|
|
||||||
<template #checkedChildren>
|
|
||||||
<SearchOutlined />
|
|
||||||
</template>
|
|
||||||
<template #unCheckedChildren>
|
|
||||||
<FilterOutlined />
|
|
||||||
</template>
|
|
||||||
</a-switch>
|
|
||||||
<a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
|
|
||||||
:size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
|
|
||||||
<a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
|
|
||||||
:size="isMobile ? 'small' : 'middle'">
|
|
||||||
<a-radio-button value="">{{ t('none') }}</a-radio-button>
|
|
||||||
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
|
|
||||||
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
|
|
||||||
<a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
|
|
||||||
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
|
|
||||||
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
|
|
||||||
</a-radio-group>
|
|
||||||
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
|
|
||||||
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
|
|
||||||
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
|
|
||||||
{{ protocol }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
|
|
||||||
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
|
|
||||||
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
|
|
||||||
{{ node.label }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ====================== Mobile: card list ======================= -->
|
<!-- ====================== Mobile: card list ======================= -->
|
||||||
<div v-if="isMobile" class="inbound-cards">
|
<div v-if="isMobile" class="inbound-cards">
|
||||||
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
<div v-if="sortedInbounds.length === 0" class="card-empty">—</div>
|
||||||
|
|
||||||
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
||||||
<!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
|
<!-- Header: id + remark + info + enable + actions -->
|
||||||
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
<div class="card-head">
|
||||||
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
|
||||||
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
|
||||||
<span class="card-id">#{{ record.id }}</span>
|
<span class="card-id">#{{ record.id }}</span>
|
||||||
<span class="tag-name">{{ record.remark }}</span>
|
<span class="tag-name">{{ record.remark }}</span>
|
||||||
<div class="card-actions" @click.stop>
|
<div class="card-actions" @click.stop>
|
||||||
|
|
@ -419,27 +238,12 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<template v-if="record.isMultiUser()">
|
<template v-if="record.isMultiUser()">
|
||||||
<a-menu-item key="addClient">
|
|
||||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="addBulkClient">
|
|
||||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="copyClients">
|
|
||||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="resetClients">
|
|
||||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="export">
|
<a-menu-item key="export">
|
||||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item v-if="subEnable" key="subs">
|
<a-menu-item v-if="subEnable" key="subs">
|
||||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
|
||||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
|
||||||
</a-menu-item>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-menu-item key="showInfo">
|
<a-menu-item key="showInfo">
|
||||||
|
|
@ -463,20 +267,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expanded client list (multi-user only) -->
|
|
||||||
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
|
||||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
|
||||||
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
|
||||||
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
|
|
||||||
:stats-version="statsVersion"
|
|
||||||
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
|
||||||
@info-client="(p) => emit('info-client', p)"
|
|
||||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
|
||||||
@delete-client="(p) => emit('delete-client', p)"
|
|
||||||
@delete-clients="(p) => emit('delete-clients', p)"
|
|
||||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -517,10 +307,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<InfinityIcon v-else />
|
<InfinityIcon v-else />
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
|
||||||
<a-tag>{{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }}</a-tag>
|
|
||||||
</div>
|
|
||||||
<div v-if="clientCount[statsRecord.id]" class="stat-row">
|
<div v-if="clientCount[statsRecord.id]" class="stat-row">
|
||||||
<span class="stat-label">{{ t('clients') }}</span>
|
<span class="stat-label">{{ t('clients') }}</span>
|
||||||
<a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
|
<a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
|
||||||
|
|
@ -550,29 +336,12 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<!-- ====================== Desktop: a-table ======================== -->
|
<!-- ====================== Desktop: a-table ======================== -->
|
||||||
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
||||||
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
|
@change="onTableChange">
|
||||||
<!-- Per-inbound client list, expanded by clicking the row's
|
|
||||||
default expand chevron. Hidden via row-class-name for
|
|
||||||
non-multi-user inbounds (matches legacy behavior). -->
|
|
||||||
<template #expandedRowRender="{ record }">
|
|
||||||
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
|
||||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
|
||||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
|
||||||
:total-client-count="clientCount[record.id]?.clients || 0"
|
|
||||||
:stats-version="statsVersion"
|
|
||||||
@edit-client="(p) => emit('edit-client', p)"
|
|
||||||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
|
||||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
|
||||||
@delete-client="(p) => emit('delete-client', p)"
|
|
||||||
@delete-clients="(p) => emit('delete-clients', p)"
|
|
||||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<!-- ============== Action dropdown ============== -->
|
<!-- ============== Action dropdown ============== -->
|
||||||
<template v-if="column.key === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
|
<a-button type="text" size="small" @click.prevent="emit('row-action', { key: 'edit', dbInbound: record })">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<EditOutlined />
|
<EditOutlined />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -590,27 +359,12 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<template v-if="record.isMultiUser()">
|
<template v-if="record.isMultiUser()">
|
||||||
<a-menu-item key="addClient">
|
|
||||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="addBulkClient">
|
|
||||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="copyClients">
|
|
||||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="resetClients">
|
|
||||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="export">
|
<a-menu-item key="export">
|
||||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item v-if="subEnable" key="subs">
|
<a-menu-item v-if="subEnable" key="subs">
|
||||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
|
||||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
|
||||||
</a-menu-item>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-menu-item key="showInfo">
|
<a-menu-item key="showInfo">
|
||||||
|
|
@ -671,14 +425,17 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<!-- ============== Clients tag + popovers ============== -->
|
<!-- ============== Clients tag + popovers ============== -->
|
||||||
<template v-else-if="column.key === 'clients'">
|
<template v-else-if="column.key === 'clients'">
|
||||||
<template v-if="clientCount[record.id]">
|
<template v-if="clientCount[record.id]">
|
||||||
<a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
|
<a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||||
|
clientCount[record.id].clients }}</a-tag>
|
||||||
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="client-email-list">
|
<div class="client-email-list">
|
||||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||||
|
clientCount[record.id].deactive.length
|
||||||
|
}}</a-tag>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|
@ -686,7 +443,8 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||||
|
clientCount[record.id].depleted.length
|
||||||
}}</a-tag>
|
}}</a-tag>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||||
|
|
@ -695,7 +453,8 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||||
|
clientCount[record.id].expiring.length
|
||||||
}}</a-tag>
|
}}</a-tag>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||||
|
|
@ -704,7 +463,8 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
|
||||||
|
clientCount[record.id].online.length }}</a-tag>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -734,11 +494,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ============== All-time inbound traffic ============== -->
|
|
||||||
<template v-else-if="column.key === 'allTimeInbound'">
|
|
||||||
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ============== Expiry ============== -->
|
<!-- ============== Expiry ============== -->
|
||||||
<template v-else-if="column.key === 'expiryTime'">
|
<template v-else-if="column.key === 'expiryTime'">
|
||||||
<a-popover v-if="record.expiryTime > 0">
|
<a-popover v-if="record.expiryTime > 0">
|
||||||
|
|
@ -759,20 +514,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar.mobile {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar.mobile>* {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -799,23 +540,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the expand chevron on rows whose inbound has no client list
|
|
||||||
* (HTTP/Mixed/Tunnel/WireGuard single-config). */
|
|
||||||
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Push the expand chevron away from the table's left edge so it has
|
|
||||||
* a little breathing room instead of being flush against the corner. */
|
|
||||||
:deep(.ant-table-tbody .ant-table-cell-with-append) {
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-table-row-expand-icon) {
|
|
||||||
margin-inline-end: 10px;
|
|
||||||
margin-inline-start: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Round the table's outer corners — AD-Vue gives .ant-table the radius
|
/* Round the table's outer corners — AD-Vue gives .ant-table the radius
|
||||||
* token, but the inner header strip and footer touch the edges, so clip
|
* token, but the inner header strip and footer touch the edges, so clip
|
||||||
* them here. */
|
* them here. */
|
||||||
|
|
@ -900,17 +624,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-expand {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: transform 150ms ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-expand.is-expanded {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-stats {
|
.card-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -937,11 +650,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-clients {
|
|
||||||
margin-top: 4px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-empty {
|
.card-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -964,16 +672,6 @@ function showQrCodeMenu(dbInbound) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar.mobile {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar.mobile>* {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-action-trigger {
|
.row-action-trigger {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,12 @@ import { Modal, message } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
PieChartOutlined,
|
PieChartOutlined,
|
||||||
HistoryOutlined,
|
|
||||||
BarsOutlined,
|
BarsOutlined,
|
||||||
TeamOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||||
import { Inbound } from '@/models/inbound.js';
|
import { Inbound } from '@/models/inbound.js';
|
||||||
|
import { coerceInboundJsonField } from '@/models/dbinbound.js';
|
||||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
|
|
@ -19,9 +18,6 @@ import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||||
import { useNodeList } from '@/composables/useNodeList.js';
|
import { useNodeList } from '@/composables/useNodeList.js';
|
||||||
import InboundList from './InboundList.vue';
|
import InboundList from './InboundList.vue';
|
||||||
import InboundFormModal from './InboundFormModal.vue';
|
import InboundFormModal from './InboundFormModal.vue';
|
||||||
import ClientFormModal from './ClientFormModal.vue';
|
|
||||||
import ClientBulkModal from './ClientBulkModal.vue';
|
|
||||||
import CopyClientsModal from './CopyClientsModal.vue';
|
|
||||||
import InboundInfoModal from './InboundInfoModal.vue';
|
import InboundInfoModal from './InboundInfoModal.vue';
|
||||||
import QrCodeModal from './QrCodeModal.vue';
|
import QrCodeModal from './QrCodeModal.vue';
|
||||||
import TextModal from '@/components/TextModal.vue';
|
import TextModal from '@/components/TextModal.vue';
|
||||||
|
|
@ -65,9 +61,11 @@ useWebSocket({
|
||||||
inbounds: applyInboundsEvent,
|
inbounds: applyInboundsEvent,
|
||||||
});
|
});
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
// Node list lives on the central panel; the Inbounds page consumes
|
|
||||||
// the id→node map for the new "Node" column. Fetched once on mount.
|
|
||||||
const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
|
const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
|
||||||
|
const hasNodeAttachedInbound = computed(() =>
|
||||||
|
(dbInbounds.value || []).some((ib) => ib?.nodeId != null),
|
||||||
|
);
|
||||||
|
const showNodeInfo = computed(() => hasNodeAttachedInbound.value || hasActiveNode.value);
|
||||||
|
|
||||||
const basePath = window.X_UI_BASE_PATH || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
const requestUri = window.location.pathname;
|
const requestUri = window.location.pathname;
|
||||||
|
|
@ -82,17 +80,6 @@ const formOpen = ref(false);
|
||||||
const formMode = ref('add');
|
const formMode = ref('add');
|
||||||
const formDbInbound = ref(null);
|
const formDbInbound = ref(null);
|
||||||
|
|
||||||
// === Client modal (single + bulk) =====================================
|
|
||||||
const clientOpen = ref(false);
|
|
||||||
const clientMode = ref('add');
|
|
||||||
const clientDbInbound = ref(null);
|
|
||||||
const clientIndex = ref(null);
|
|
||||||
|
|
||||||
const bulkOpen = ref(false);
|
|
||||||
const bulkDbInbound = ref(null);
|
|
||||||
const copyOpen = ref(false);
|
|
||||||
const copyDbInbound = ref(null);
|
|
||||||
|
|
||||||
// === Info / QR-code modals ===========================================
|
// === Info / QR-code modals ===========================================
|
||||||
const infoOpen = ref(false);
|
const infoOpen = ref(false);
|
||||||
const infoDbInbound = ref(null);
|
const infoDbInbound = ref(null);
|
||||||
|
|
@ -191,7 +178,8 @@ function exportInboundSubs(dbInbound) {
|
||||||
function exportAllLinks() {
|
function exportAllLinks() {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const ib of dbInbounds.value) {
|
for (const ib of dbInbounds.value) {
|
||||||
out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
|
const projected = checkFallback(ib);
|
||||||
|
out.push(projected.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
|
||||||
}
|
}
|
||||||
openText({
|
openText({
|
||||||
title: 'Export all inbound links',
|
title: 'Export all inbound links',
|
||||||
|
|
@ -240,8 +228,18 @@ function importInbound() {
|
||||||
// the root inbound that owns the listen address so QRs/links carry
|
// the root inbound that owns the listen address so QRs/links carry
|
||||||
// the externally-reachable host:port and the right TLS state.
|
// the externally-reachable host:port and the right TLS state.
|
||||||
function checkFallback(dbInbound) {
|
function checkFallback(dbInbound) {
|
||||||
// We don't keep parsed Inbounds in state right now (the page works
|
// Path 1: panel-tracked fallback relationship (inbound_fallbacks row).
|
||||||
// off DBInbounds); compute on the fly.
|
// The backend annotates each child inbound with fallbackParent so the
|
||||||
|
// child's client-share link advertises the master's reachable endpoint
|
||||||
|
// and inherits its TLS / Reality state.
|
||||||
|
const parent = dbInbound.fallbackParent;
|
||||||
|
if (parent?.masterId) {
|
||||||
|
const master = dbInbounds.value.find((ib) => ib.id === parent.masterId);
|
||||||
|
if (master) return projectChildThroughMaster(dbInbound, master);
|
||||||
|
}
|
||||||
|
// Path 2: legacy unix-socket convention (`@vless-ws` etc.) — walk the
|
||||||
|
// VLESS/Trojan TCP inbounds and look for one whose settings.fallbacks
|
||||||
|
// references this child's listen address.
|
||||||
if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
|
if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
|
||||||
for (const candidate of dbInbounds.value) {
|
for (const candidate of dbInbounds.value) {
|
||||||
if (candidate.id === dbInbound.id) continue;
|
if (candidate.id === dbInbound.id) continue;
|
||||||
|
|
@ -250,23 +248,30 @@ function checkFallback(dbInbound) {
|
||||||
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
||||||
const fallbacks = parsed.settings.fallbacks || [];
|
const fallbacks = parsed.settings.fallbacks || [];
|
||||||
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
|
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
|
||||||
// Build a one-off DBInbound copy with the parent's listen/port +
|
return projectChildThroughMaster(dbInbound, candidate);
|
||||||
// copied stream so the link gen sees the public endpoint.
|
|
||||||
const projected = JSON.parse(JSON.stringify(dbInbound));
|
|
||||||
projected.listen = candidate.listen;
|
|
||||||
projected.port = candidate.port;
|
|
||||||
const inheritedStream = parsed.stream;
|
|
||||||
const ownInbound = dbInbound.toInbound();
|
|
||||||
ownInbound.stream.security = inheritedStream.security;
|
|
||||||
ownInbound.stream.tls = inheritedStream.tls;
|
|
||||||
ownInbound.stream.externalProxy = inheritedStream.externalProxy;
|
|
||||||
projected.streamSettings = ownInbound.stream.toString();
|
|
||||||
// Re-wrap so callers get the same DBInbound shape they had.
|
|
||||||
return new dbInbound.constructor(projected);
|
|
||||||
}
|
}
|
||||||
return dbInbound;
|
return dbInbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectChildThroughMaster returns a one-off DBInbound copy whose
|
||||||
|
// listen/port + TLS/Reality state come from the master, while the
|
||||||
|
// protocol/transport/clients stay the child's. This is what makes a
|
||||||
|
// `vless://uuid@server:443?type=ws&path=/vlws&security=tls` link work
|
||||||
|
// for a child VLESS-WS bound to 127.0.0.1.
|
||||||
|
function projectChildThroughMaster(child, master) {
|
||||||
|
const projected = JSON.parse(JSON.stringify(child));
|
||||||
|
projected.listen = master.listen;
|
||||||
|
projected.port = master.port;
|
||||||
|
const masterStream = master.toInbound().stream;
|
||||||
|
const childInbound = child.toInbound();
|
||||||
|
childInbound.stream.security = masterStream.security;
|
||||||
|
childInbound.stream.tls = masterStream.tls;
|
||||||
|
childInbound.stream.reality = masterStream.reality;
|
||||||
|
childInbound.stream.externalProxy = masterStream.externalProxy;
|
||||||
|
projected.streamSettings = childInbound.stream.toString();
|
||||||
|
return new child.constructor(projected);
|
||||||
|
}
|
||||||
|
|
||||||
function findClientIndex(dbInbound, client) {
|
function findClientIndex(dbInbound, client) {
|
||||||
if (!client) return 0;
|
if (!client) return 0;
|
||||||
const inbound = dbInbound.toInbound();
|
const inbound = dbInbound.toInbound();
|
||||||
|
|
@ -284,73 +289,6 @@ function findClientIndex(dbInbound, client) {
|
||||||
return idx >= 0 ? idx : 0;
|
return idx >= 0 ? idx : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientId(protocol, client) {
|
|
||||||
switch (protocol) {
|
|
||||||
case 'trojan': return client.password;
|
|
||||||
case 'shadowsocks': return client.email;
|
|
||||||
case 'hysteria': return client.auth;
|
|
||||||
default: return client.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Per-client handlers (called from the expand-row table) =========
|
|
||||||
function onEditClient({ dbInbound, client }) {
|
|
||||||
clientMode.value = 'edit';
|
|
||||||
clientDbInbound.value = dbInbound;
|
|
||||||
clientIndex.value = findClientIndex(dbInbound, client);
|
|
||||||
clientOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onQrcodeClient({ dbInbound, client }) {
|
|
||||||
qrDbInbound.value = checkFallback(dbInbound);
|
|
||||||
qrClient.value = client || null;
|
|
||||||
qrOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInfoClient({ dbInbound, client }) {
|
|
||||||
infoDbInbound.value = checkFallback(dbInbound);
|
|
||||||
infoClientIndex.value = findClientIndex(dbInbound, client);
|
|
||||||
infoOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onResetTrafficClient({ dbInbound, client }) {
|
|
||||||
const msg = await HttpUtil.post(
|
|
||||||
`/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
|
|
||||||
);
|
|
||||||
if (msg?.success) await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeleteClient({ dbInbound, client }) {
|
|
||||||
const clientId = getClientId(dbInbound.protocol, client);
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
|
||||||
if (msg?.success) await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeleteClients({ dbInbound, clients }) {
|
|
||||||
for (const client of clients) {
|
|
||||||
const clientId = getClientId(dbInbound.protocol, client);
|
|
||||||
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onToggleEnableClient({ dbInbound, client, next }) {
|
|
||||||
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
|
||||||
// client, and post the whole client back through updateClient. This
|
|
||||||
// keeps the wire shape identical to the modal save path.
|
|
||||||
const inbound = dbInbound.toInbound();
|
|
||||||
const clients = inbound?.clients || [];
|
|
||||||
const idx = findClientIndex(dbInbound, client);
|
|
||||||
if (idx < 0 || !clients[idx]) return;
|
|
||||||
clients[idx].enable = next;
|
|
||||||
const clientId = getClientId(dbInbound.protocol, clients[idx]);
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
|
|
||||||
id: dbInbound.id,
|
|
||||||
settings: `{"clients": [${clients[idx].toString()}]}`,
|
|
||||||
});
|
|
||||||
if (msg?.success) await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAddInbound() {
|
function onAddInbound() {
|
||||||
formMode.value = 'add';
|
formMode.value = 'add';
|
||||||
formDbInbound.value = null;
|
formDbInbound.value = null;
|
||||||
|
|
@ -363,18 +301,6 @@ function openEdit(dbInbound) {
|
||||||
formOpen.value = true;
|
formOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddClient(dbInbound) {
|
|
||||||
clientMode.value = 'add';
|
|
||||||
clientDbInbound.value = dbInbound;
|
|
||||||
clientIndex.value = null;
|
|
||||||
clientOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAddBulkClient(dbInbound) {
|
|
||||||
bulkDbInbound.value = dbInbound;
|
|
||||||
bulkOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||||
function confirmDelete(dbInbound) {
|
function confirmDelete(dbInbound) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
|
|
@ -403,20 +329,6 @@ function confirmResetTraffic(dbInbound) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelDepleted(dbInboundId) {
|
|
||||||
Modal.confirm({
|
|
||||||
title: 'Delete depleted clients?',
|
|
||||||
content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
|
|
||||||
okText: 'Delete',
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: 'Cancel',
|
|
||||||
onOk: async () => {
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
|
|
||||||
if (msg?.success) await refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone — adds a new inbound with the same protocol+stream+sniffing
|
// Clone — adds a new inbound with the same protocol+stream+sniffing
|
||||||
// but a fresh remark/port and an empty client list.
|
// but a fresh remark/port and an empty client list.
|
||||||
function confirmClone(dbInbound) {
|
function confirmClone(dbInbound) {
|
||||||
|
|
@ -427,6 +339,14 @@ function confirmClone(dbInbound) {
|
||||||
cancelText: 'Cancel',
|
cancelText: 'Cancel',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const baseInbound = dbInbound.toInbound();
|
const baseInbound = dbInbound.toInbound();
|
||||||
|
let clonedSettings;
|
||||||
|
try {
|
||||||
|
const raw = coerceInboundJsonField(dbInbound.settings);
|
||||||
|
raw.clients = [];
|
||||||
|
clonedSettings = JSON.stringify(raw);
|
||||||
|
} catch (_e) {
|
||||||
|
clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
|
||||||
|
}
|
||||||
const data = {
|
const data = {
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
|
|
@ -437,7 +357,7 @@ function confirmClone(dbInbound) {
|
||||||
listen: '',
|
listen: '',
|
||||||
port: RandomUtil.randomInteger(10000, 60000),
|
port: RandomUtil.randomInteger(10000, 60000),
|
||||||
protocol: baseInbound.protocol,
|
protocol: baseInbound.protocol,
|
||||||
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
|
settings: clonedSettings,
|
||||||
streamSettings: baseInbound.stream.toString(),
|
streamSettings: baseInbound.stream.toString(),
|
||||||
sniffing: baseInbound.sniffing.toString(),
|
sniffing: baseInbound.sniffing.toString(),
|
||||||
};
|
};
|
||||||
|
|
@ -469,20 +389,6 @@ function onGeneralAction(key) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'resetClients':
|
|
||||||
Modal.confirm({
|
|
||||||
title: 'Reset all client traffic across all inbounds?',
|
|
||||||
okText: 'Reset',
|
|
||||||
cancelText: 'Cancel',
|
|
||||||
onOk: async () => {
|
|
||||||
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
|
|
||||||
if (msg?.success) await refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'delDepletedClients':
|
|
||||||
confirmDelDepleted(-1);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||||
}
|
}
|
||||||
|
|
@ -493,12 +399,6 @@ function onRowAction({ key, dbInbound }) {
|
||||||
case 'edit':
|
case 'edit':
|
||||||
openEdit(dbInbound);
|
openEdit(dbInbound);
|
||||||
break;
|
break;
|
||||||
case 'addClient':
|
|
||||||
openAddClient(dbInbound);
|
|
||||||
break;
|
|
||||||
case 'addBulkClient':
|
|
||||||
openAddBulkClient(dbInbound);
|
|
||||||
break;
|
|
||||||
case 'showInfo':
|
case 'showInfo':
|
||||||
infoDbInbound.value = checkFallback(dbInbound);
|
infoDbInbound.value = checkFallback(dbInbound);
|
||||||
infoClientIndex.value = findClientIndex(dbInbound, null);
|
infoClientIndex.value = findClientIndex(dbInbound, null);
|
||||||
|
|
@ -518,10 +418,6 @@ function onRowAction({ key, dbInbound }) {
|
||||||
case 'clipboard':
|
case 'clipboard':
|
||||||
exportInboundClipboard(dbInbound);
|
exportInboundClipboard(dbInbound);
|
||||||
break;
|
break;
|
||||||
case 'copyClients':
|
|
||||||
copyDbInbound.value = dbInbound;
|
|
||||||
copyOpen.value = true;
|
|
||||||
break;
|
|
||||||
case 'delete':
|
case 'delete':
|
||||||
confirmDelete(dbInbound);
|
confirmDelete(dbInbound);
|
||||||
break;
|
break;
|
||||||
|
|
@ -531,20 +427,6 @@ function onRowAction({ key, dbInbound }) {
|
||||||
case 'clone':
|
case 'clone':
|
||||||
confirmClone(dbInbound);
|
confirmClone(dbInbound);
|
||||||
break;
|
break;
|
||||||
case 'resetClients':
|
|
||||||
Modal.confirm({
|
|
||||||
title: `Reset client traffic on "${dbInbound.remark}"?`,
|
|
||||||
okText: 'Reset',
|
|
||||||
cancelText: 'Cancel',
|
|
||||||
onOk: async () => {
|
|
||||||
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
|
|
||||||
if (msg?.success) await refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'delDepletedClients':
|
|
||||||
confirmDelDepleted(dbInbound.id);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||||
}
|
}
|
||||||
|
|
@ -566,7 +448,7 @@ function onRowAction({ key, dbInbound }) {
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-card size="small" hoverable class="summary-card">
|
<a-card size="small" hoverable class="summary-card">
|
||||||
<a-row :gutter="[16, 12]">
|
<a-row :gutter="[16, 12]">
|
||||||
<a-col :xs="12" :sm="12" :md="5">
|
<a-col :xs="12" :sm="12" :md="8">
|
||||||
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
||||||
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
|
@ -574,7 +456,7 @@ function onRowAction({ key, dbInbound }) {
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="12" :sm="12" :md="5">
|
<a-col :xs="12" :sm="12" :md="8">
|
||||||
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
||||||
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
|
@ -582,63 +464,13 @@ function onRowAction({ key, dbInbound }) {
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="12" :sm="12" :md="5">
|
<a-col :xs="24" :sm="24" :md="8">
|
||||||
<CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
|
|
||||||
:value="SizeFormatter.sizeFormat(totals.allTime)">
|
|
||||||
<template #prefix>
|
|
||||||
<HistoryOutlined />
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :xs="12" :sm="12" :md="5">
|
|
||||||
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<BarsOutlined />
|
<BarsOutlined />
|
||||||
</template>
|
</template>
|
||||||
</CustomStatistic>
|
</CustomStatistic>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :sm="24" :md="4">
|
|
||||||
<CustomStatistic :title="t('clients')" value=" ">
|
|
||||||
<template #prefix>
|
|
||||||
<a-space direction="horizontal">
|
|
||||||
<TeamOutlined />
|
|
||||||
<a-tag color="green">{{ totals.clients }}</a-tag>
|
|
||||||
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
|
|
||||||
<template #content>
|
|
||||||
<div class="client-email-list">
|
|
||||||
<div v-for="email in totals.deactive" :key="email">{{ email }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<a-tag>{{ totals.deactive.length }}</a-tag>
|
|
||||||
</a-popover>
|
|
||||||
<a-popover v-if="totals.depleted.length" :title="t('depleted')">
|
|
||||||
<template #content>
|
|
||||||
<div class="client-email-list">
|
|
||||||
<div v-for="email in totals.depleted" :key="email">{{ email }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<a-tag color="red">{{ totals.depleted.length }}</a-tag>
|
|
||||||
</a-popover>
|
|
||||||
<a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
|
|
||||||
<template #content>
|
|
||||||
<div class="client-email-list">
|
|
||||||
<div v-for="email in totals.expiring" :key="email">{{ email }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<a-tag color="orange">{{ totals.expiring.length }}</a-tag>
|
|
||||||
</a-popover>
|
|
||||||
<a-popover v-if="totals.online.length" :title="t('online')">
|
|
||||||
<template #content>
|
|
||||||
<div class="client-email-list">
|
|
||||||
<div v-for="email in totals.online" :key="email">{{ email }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<a-tag color="blue">{{ totals.online.length }}</a-tag>
|
|
||||||
</a-popover>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</CustomStatistic>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
@ -648,26 +480,16 @@ function onRowAction({ key, dbInbound }) {
|
||||||
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
||||||
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||||
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
||||||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
|
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="showNodeInfo"
|
||||||
:stats-version="statsVersion"
|
:stats-version="statsVersion" @refresh="refresh" @add-inbound="onAddInbound"
|
||||||
@refresh="refresh"
|
@general-action="onGeneralAction" @row-action="onRowAction" />
|
||||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
|
||||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
|
||||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
|
||||||
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
|
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
|
|
||||||
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
|
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" :db-inbounds="dbInbounds"
|
||||||
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
|
|
||||||
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
|
|
||||||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
|
||||||
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
|
||||||
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
|
||||||
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
|
|
||||||
@saved="refresh" />
|
@saved="refresh" />
|
||||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||||
|
|
@ -735,20 +557,3 @@ function onRowAction({ key, dbInbound }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* AD-Vue popovers teleport their content to <body>, so scoped styles
|
|
||||||
don't reach them — this block has to be unscoped. */
|
|
||||||
.client-email-list {
|
|
||||||
max-height: 280px;
|
|
||||||
min-width: 160px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-email-list > div {
|
|
||||||
padding: 2px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,14 @@ export function useInbounds() {
|
||||||
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
||||||
function rollupClients(dbInbound, inbound) {
|
function rollupClients(dbInbound, inbound) {
|
||||||
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
|
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
|
||||||
const clients = inbound?.clients || [];
|
const allClients = inbound?.clients || [];
|
||||||
|
const statsEmails = new Set();
|
||||||
|
for (const s of clientStats) {
|
||||||
|
if (s && s.email) statsEmails.add(s.email);
|
||||||
|
}
|
||||||
|
const clients = clientStats.length > 0
|
||||||
|
? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
|
||||||
|
: allClients;
|
||||||
const active = [];
|
const active = [];
|
||||||
const deactive = [];
|
const deactive = [];
|
||||||
const depleted = [];
|
const depleted = [];
|
||||||
|
|
@ -126,12 +133,12 @@ export function useInbounds() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOnlineUsers() {
|
async function fetchOnlineUsers() {
|
||||||
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
|
const msg = await HttpUtil.post('/panel/api/clients/onlines');
|
||||||
if (msg?.success) onlineClients.value = msg.obj || [];
|
if (msg?.success) onlineClients.value = msg.obj || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLastOnlineMap() {
|
async function fetchLastOnlineMap() {
|
||||||
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
|
const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
|
||||||
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
|
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +202,6 @@ export function useInbounds() {
|
||||||
if (!upd) continue;
|
if (!upd) continue;
|
||||||
if (typeof upd.up === 'number') ib.up = upd.up;
|
if (typeof upd.up === 'number') ib.up = upd.up;
|
||||||
if (typeof upd.down === 'number') ib.down = upd.down;
|
if (typeof upd.down === 'number') ib.down = upd.down;
|
||||||
if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
|
|
||||||
if (typeof upd.total === 'number') ib.total = upd.total;
|
if (typeof upd.total === 'number') ib.total = upd.total;
|
||||||
if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
|
if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
|
||||||
touched = true;
|
touched = true;
|
||||||
|
|
@ -216,7 +222,6 @@ export function useInbounds() {
|
||||||
if (typeof upd.up === 'number') stat.up = upd.up;
|
if (typeof upd.up === 'number') stat.up = upd.up;
|
||||||
if (typeof upd.down === 'number') stat.down = upd.down;
|
if (typeof upd.down === 'number') stat.down = upd.down;
|
||||||
if (typeof upd.total === 'number') stat.total = upd.total;
|
if (typeof upd.total === 'number') stat.total = upd.total;
|
||||||
if (typeof upd.allTime === 'number') stat.allTime = upd.allTime;
|
|
||||||
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
|
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
|
||||||
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
|
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
|
||||||
touched = true;
|
touched = true;
|
||||||
|
|
@ -283,31 +288,14 @@ export function useInbounds() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate totals shown in the dashboard summary card. allTime falls
|
|
||||||
// back to up+down when the per-inbound counter isn't populated yet.
|
|
||||||
const totals = computed(() => {
|
const totals = computed(() => {
|
||||||
let up = 0;
|
let up = 0;
|
||||||
let down = 0;
|
let down = 0;
|
||||||
let allTime = 0;
|
|
||||||
let clients = 0;
|
|
||||||
const deactive = [];
|
|
||||||
const depleted = [];
|
|
||||||
const expiring = [];
|
|
||||||
const online = [];
|
|
||||||
for (const ib of dbInbounds.value) {
|
for (const ib of dbInbounds.value) {
|
||||||
up += ib.up || 0;
|
up += ib.up || 0;
|
||||||
down += ib.down || 0;
|
down += ib.down || 0;
|
||||||
allTime += ib.allTime || (ib.up + ib.down) || 0;
|
|
||||||
const c = clientCount.value[ib.id];
|
|
||||||
if (c) {
|
|
||||||
clients += c.clients;
|
|
||||||
deactive.push(...c.deactive);
|
|
||||||
depleted.push(...c.depleted);
|
|
||||||
expiring.push(...c.expiring);
|
|
||||||
online.push(...c.online);
|
|
||||||
}
|
}
|
||||||
}
|
return { up, down };
|
||||||
return { up, down, allTime, clients, deactive, depleted, expiring, online };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ObjectUtil reference is wired at module load — keeping a no-op import
|
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,10 @@ function isExpanded(id) {
|
||||||
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
|
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
|
||||||
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
|
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('pages.nodes.panelVersion') || 'Panel version' }}</span>
|
||||||
|
<a-tag>{{ statsNode.panelVersion || '-' }}</a-tag>
|
||||||
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
|
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
|
||||||
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
|
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
|
||||||
|
|
@ -195,6 +199,16 @@ function isExpanded(id) {
|
||||||
<template v-else>-</template>
|
<template v-else>-</template>
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">{{ t('clients') }}</span>
|
||||||
|
<a-tag color="green">{{ statsNode.clientCount || 0 }}</a-tag>
|
||||||
|
<a-tag v-if="statsNode.onlineCount" color="blue">
|
||||||
|
{{ statsNode.onlineCount }} {{ t('online') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="statsNode.depletedCount" color="red">
|
||||||
|
{{ statsNode.depletedCount }} {{ t('depleted') }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
|
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
|
||||||
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
|
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
|
||||||
|
|
@ -260,10 +274,30 @@ function isExpanded(id) {
|
||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('pages.nodes.panelVersion') || 'Panel version'" data-index="panelVersion" align="center">
|
||||||
|
<template #default="{ record }">
|
||||||
|
{{ record.panelVersion || '-' }}
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
<a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
|
<a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
|
||||||
<template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
|
<template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column :title="t('clients')" align="center" :width="160">
|
||||||
|
<template #default="{ record }">
|
||||||
|
<a-space :size="4">
|
||||||
|
<a-tag color="green">{{ record.clientCount || 0 }}</a-tag>
|
||||||
|
<a-tag v-if="record.onlineCount" color="blue">
|
||||||
|
{{ record.onlineCount }} {{ t('online') }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="record.depletedCount" color="red">
|
||||||
|
{{ record.depletedCount }} {{ t('depleted') }}
|
||||||
|
</a-tag>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
<a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
|
<a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
|
||||||
<template #default="{ record }">
|
<template #default="{ record }">
|
||||||
<span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
|
<span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
|
||||||
|
|
|
||||||
|
|
@ -71,15 +71,21 @@ export function useNodes() {
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate cards on the dashboard. Computed off the live list so a
|
|
||||||
// refresh (or a WS push) picks up new totals automatically.
|
|
||||||
const totals = computed(() => {
|
const totals = computed(() => {
|
||||||
const list = nodes.value;
|
const list = nodes.value;
|
||||||
let online = 0;
|
let online = 0;
|
||||||
let offline = 0;
|
let offline = 0;
|
||||||
let latencySum = 0;
|
let latencySum = 0;
|
||||||
let latencyCount = 0;
|
let latencyCount = 0;
|
||||||
|
let inbounds = 0;
|
||||||
|
let clients = 0;
|
||||||
|
let onlineClients = 0;
|
||||||
|
let depleted = 0;
|
||||||
for (const n of list) {
|
for (const n of list) {
|
||||||
|
inbounds += n.inboundCount || 0;
|
||||||
|
clients += n.clientCount || 0;
|
||||||
|
onlineClients += n.onlineCount || 0;
|
||||||
|
depleted += n.depletedCount || 0;
|
||||||
if (!n.enable) continue;
|
if (!n.enable) continue;
|
||||||
if (n.status === 'online') {
|
if (n.status === 'online') {
|
||||||
online += 1;
|
online += 1;
|
||||||
|
|
@ -96,6 +102,10 @@ export function useNodes() {
|
||||||
online,
|
online,
|
||||||
offline,
|
offline,
|
||||||
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||||
|
inbounds,
|
||||||
|
clients,
|
||||||
|
onlineClients,
|
||||||
|
depleted,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,16 +61,6 @@ const isValid = computed(
|
||||||
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
|
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const fallbackSupported = computed(
|
|
||||||
() => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(() => form.strategy, (next) => {
|
|
||||||
if (next !== 'leastPing' && next !== 'leastLoad') {
|
|
||||||
form.fallbackTag = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const tagValidateStatus = computed(() => {
|
const tagValidateStatus = computed(() => {
|
||||||
if (tagEmpty.value) return 'error';
|
if (tagEmpty.value) return 'error';
|
||||||
if (duplicateTag.value) return 'warning';
|
if (duplicateTag.value) return 'warning';
|
||||||
|
|
@ -97,7 +87,7 @@ const title = computed(() =>
|
||||||
: `+ ${t('pages.xray.Balancers')}`,
|
: `+ ${t('pages.xray.Balancers')}`,
|
||||||
);
|
);
|
||||||
const okText = computed(() =>
|
const okText = computed(() =>
|
||||||
isEdit.value ? t('pages.client.submitEdit') : t('create'),
|
isEdit.value ? t('pages.clients.submitEdit') : t('create'),
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -121,9 +111,8 @@ const okText = computed(() =>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="Fallback"
|
<a-form-item label="Fallback">
|
||||||
:help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
|
<a-select v-model:value="form.fallbackTag" allow-clear>
|
||||||
<a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported">
|
|
||||||
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
|
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
|
||||||
{{ tag || `(${t('none')})` }}
|
{{ tag || `(${t('none')})` }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
|
|
|
||||||
|
|
@ -133,23 +133,25 @@ function syncObservatories() {
|
||||||
delete t.observatory;
|
delete t.observatory;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
|
const burstFeeders = balancers.filter((b) => {
|
||||||
if (leastLoads.length > 0) {
|
const type = b.strategy?.type || 'random';
|
||||||
|
return type === 'leastLoad' || type === 'random' || type === 'roundRobin';
|
||||||
|
});
|
||||||
|
if (burstFeeders.length > 0) {
|
||||||
if (!t.burstObservatory) {
|
if (!t.burstObservatory) {
|
||||||
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
||||||
}
|
}
|
||||||
t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
|
t.burstObservatory.subjectSelector = collectSelectors(burstFeeders);
|
||||||
} else {
|
} else {
|
||||||
delete t.burstObservatory;
|
delete t.burstObservatory;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWireBalancer(form) {
|
function buildWireBalancer(form) {
|
||||||
const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
|
|
||||||
const out = {
|
const out = {
|
||||||
tag: form.tag,
|
tag: form.tag,
|
||||||
selector: [...form.selector],
|
selector: [...form.selector],
|
||||||
fallbackTag: supportsFallback ? form.fallbackTag : '',
|
fallbackTag: form.fallbackTag || '',
|
||||||
};
|
};
|
||||||
if (form.strategy && form.strategy !== 'random') {
|
if (form.strategy && form.strategy !== 'random') {
|
||||||
out.strategy = { type: form.strategy };
|
out.strategy = { type: form.strategy };
|
||||||
|
|
@ -218,11 +220,11 @@ const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory
|
||||||
|
|
||||||
const obsView = ref('observatory');
|
const obsView = ref('observatory');
|
||||||
|
|
||||||
// Keep the radio selection valid as observatories appear/disappear —
|
// Watch each flag individually — watching showObsEditor (OR of the two)
|
||||||
// e.g. deleting the last leastPing balancer should flip the editor to
|
// misses the case where one observatory swaps for the other in the same
|
||||||
// the burstObservatory pane instead of leaving it pointing at the
|
// tick, leaving obsView pointing at a now-deleted key and JsonEditor
|
||||||
// (now-removed) observatory key.
|
// trying to parse an empty string.
|
||||||
watch(showObsEditor, () => {
|
watch([hasObservatory, hasBurstObservatory], () => {
|
||||||
if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
|
if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
|
||||||
obsView.value = 'burstObservatory';
|
obsView.value = 'burstObservatory';
|
||||||
} else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
|
} else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
|
||||||
|
|
|
||||||
|
|
@ -340,7 +340,6 @@ const localOutboundTestUrl = computed({
|
||||||
<template #description>{{ t('pages.xray.accessLogDesc') }}</template>
|
<template #description>{{ t('pages.xray.accessLogDesc') }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model:value="accessLog" :style="{ width: '100%' }">
|
<a-select v-model:value="accessLog" :style="{ width: '100%' }">
|
||||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
|
||||||
<a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
|
<a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -351,7 +350,7 @@ const localOutboundTestUrl = computed({
|
||||||
<template #description>{{ t('pages.xray.errorLogDesc') }}</template>
|
<template #description>{{ t('pages.xray.errorLogDesc') }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model:value="errorLog" :style="{ width: '100%' }">
|
<a-select v-model:value="errorLog" :style="{ width: '100%' }">
|
||||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
<a-select-option value="">{{ t('empty') }}</a-select-option>
|
||||||
<a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
|
<a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -362,7 +361,7 @@ const localOutboundTestUrl = computed({
|
||||||
<template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
|
<template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
|
<a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
|
||||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
<a-select-option value="">{{ t('empty') }}</a-select-option>
|
||||||
<a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
|
<a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ const title = computed(() =>
|
||||||
: `+ ${t('pages.xray.Outbounds')}`,
|
: `+ ${t('pages.xray.Outbounds')}`,
|
||||||
);
|
);
|
||||||
const okText = computed(() =>
|
const okText = computed(() =>
|
||||||
isEdit.value ? t('pages.client.submitEdit') : t('create'),
|
isEdit.value ? t('pages.clients.submitEdit') : t('create'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper getters / shortcuts used by the template.
|
// Helper getters / shortcuts used by the template.
|
||||||
|
|
@ -343,8 +343,7 @@ function regenerateWgKeys() {
|
||||||
<!-- ============== Loopback ============== -->
|
<!-- ============== Loopback ============== -->
|
||||||
<template v-if="isLoopback">
|
<template v-if="isLoopback">
|
||||||
<a-form-item label="Inbound tag">
|
<a-form-item label="Inbound tag">
|
||||||
<a-input v-model:value="outbound.settings.inboundTag"
|
<a-input v-model:value="outbound.settings.inboundTag" placeholder="inbound tag using in routing rules" />
|
||||||
placeholder="inbound tag using in routing rules" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ const title = computed(() =>
|
||||||
: `+ ${t('pages.xray.Routings')}`,
|
: `+ ${t('pages.xray.Routings')}`,
|
||||||
);
|
);
|
||||||
const okText = computed(() =>
|
const okText = computed(() =>
|
||||||
isEdit.value ? t('pages.client.submitEdit') : t('create'),
|
isEdit.value ? t('pages.clients.submitEdit') : t('create'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
|
const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
|
||||||
|
|
|
||||||
|
|
@ -33,29 +33,31 @@ export class HttpUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
static async get(url, params, options = {}) {
|
static async get(url, params, options = {}) {
|
||||||
|
const { silent, ...axiosOpts } = options;
|
||||||
try {
|
try {
|
||||||
const resp = await axios.get(url, { params, ...options });
|
const resp = await axios.get(url, { params, ...axiosOpts });
|
||||||
const msg = this._respToMsg(resp);
|
const msg = this._respToMsg(resp);
|
||||||
this._handleMsg(msg);
|
if (!silent) this._handleMsg(msg);
|
||||||
return msg;
|
return msg;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GET request failed:', error);
|
console.error('GET request failed:', error);
|
||||||
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
||||||
this._handleMsg(errorMsg);
|
if (!silent) this._handleMsg(errorMsg);
|
||||||
return errorMsg;
|
return errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async post(url, data, options = {}) {
|
static async post(url, data, options = {}) {
|
||||||
|
const { silent, ...axiosOpts } = options;
|
||||||
try {
|
try {
|
||||||
const resp = await axios.post(url, data, options);
|
const resp = await axios.post(url, data, axiosOpts);
|
||||||
const msg = this._respToMsg(resp);
|
const msg = this._respToMsg(resp);
|
||||||
this._handleMsg(msg);
|
if (!silent) this._handleMsg(msg);
|
||||||
return msg;
|
return msg;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('POST request failed:', error);
|
console.error('POST request failed:', error);
|
||||||
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
||||||
this._handleMsg(errorMsg);
|
if (!silent) this._handleMsg(errorMsg);
|
||||||
return errorMsg;
|
return errorMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ const BACKEND_TARGET = 'http://localhost:2053';
|
||||||
|
|
||||||
function resolveDBPath() {
|
function resolveDBPath() {
|
||||||
const envFolder = process.env.XUI_DB_FOLDER;
|
const envFolder = process.env.XUI_DB_FOLDER;
|
||||||
if (envFolder) return path.join(envFolder, 'x-ui.db');
|
if (envFolder) {
|
||||||
|
const abs = path.isAbsolute(envFolder)
|
||||||
|
? envFolder
|
||||||
|
: path.resolve(__dirname, '..', envFolder);
|
||||||
|
return path.join(abs, 'x-ui.db');
|
||||||
|
}
|
||||||
|
const repoSubDB = path.resolve(__dirname, '..', 'x-ui', 'x-ui.db');
|
||||||
|
if (fs.existsSync(repoSubDB)) return repoSubDB;
|
||||||
const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
|
const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
|
||||||
if (fs.existsSync(repoDB)) return repoDB;
|
if (fs.existsSync(repoDB)) return repoDB;
|
||||||
return '/etc/x-ui/x-ui.db';
|
return '/etc/x-ui/x-ui.db';
|
||||||
|
|
@ -22,6 +29,8 @@ const BASE_MIGRATED_ROUTES = {
|
||||||
'panel/settings/': '/settings.html',
|
'panel/settings/': '/settings.html',
|
||||||
'panel/inbounds': '/inbounds.html',
|
'panel/inbounds': '/inbounds.html',
|
||||||
'panel/inbounds/': '/inbounds.html',
|
'panel/inbounds/': '/inbounds.html',
|
||||||
|
'panel/clients': '/clients.html',
|
||||||
|
'panel/clients/': '/clients.html',
|
||||||
'panel/xray': '/xray.html',
|
'panel/xray': '/xray.html',
|
||||||
'panel/xray/': '/xray.html',
|
'panel/xray/': '/xray.html',
|
||||||
'panel/nodes': '/nodes.html',
|
'panel/nodes': '/nodes.html',
|
||||||
|
|
@ -76,19 +85,14 @@ function injectBasePathPlugin() {
|
||||||
function bypassMigratedRoute(req) {
|
function bypassMigratedRoute(req) {
|
||||||
if (req.method !== 'GET') return undefined;
|
if (req.method !== 'GET') return undefined;
|
||||||
const url = req.url.split('?')[0];
|
const url = req.url.split('?')[0];
|
||||||
|
const basePath = refreshBasePath();
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
|
if (url === basePath) return '/login.html';
|
||||||
if (url === '/' + key) return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const m = url.match(/^\/[^/]+\/(.+)$/);
|
if (url.startsWith(basePath)) {
|
||||||
if (m) {
|
const stripped = url.slice(basePath.length);
|
||||||
const stripped = m[1];
|
|
||||||
if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
|
if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
|
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +154,7 @@ export default defineConfig({
|
||||||
login: path.resolve(__dirname, 'login.html'),
|
login: path.resolve(__dirname, 'login.html'),
|
||||||
settings: path.resolve(__dirname, 'settings.html'),
|
settings: path.resolve(__dirname, 'settings.html'),
|
||||||
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
||||||
|
clients: path.resolve(__dirname, 'clients.html'),
|
||||||
xray: path.resolve(__dirname, 'xray.html'),
|
xray: path.resolve(__dirname, 'xray.html'),
|
||||||
nodes: path.resolve(__dirname, 'nodes.html'),
|
nodes: path.resolve(__dirname, 'nodes.html'),
|
||||||
apiDocs: path.resolve(__dirname, 'api-docs.html'),
|
apiDocs: path.resolve(__dirname, 'api-docs.html'),
|
||||||
|
|
|
||||||
9
go.mod
9
go.mod
|
|
@ -12,7 +12,7 @@ require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mymmrac/telego v1.8.0
|
github.com/mymmrac/telego v1.9.0
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
|
@ -25,8 +25,9 @@ require (
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
golang.org/x/sys v0.44.0
|
golang.org/x/sys v0.44.0
|
||||||
golang.org/x/text v0.37.0
|
golang.org/x/text v0.37.0
|
||||||
google.golang.org/grpc v1.81.0
|
google.golang.org/grpc v1.81.1
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
@ -53,6 +54,10 @@ require (
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.4.0 // indirect
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
github.com/grbit/go-json v0.11.0 // indirect
|
github.com/grbit/go-json v0.11.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
|
|
||||||
19
go.sum
19
go.sum
|
|
@ -85,6 +85,14 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
|
@ -130,8 +138,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
|
||||||
github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||||
|
|
@ -169,6 +177,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
|
@ -258,8 +267,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|
@ -272,6 +281,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
|
|
||||||
32
main.go
32
main.go
|
|
@ -73,7 +73,13 @@ func runWebServer() {
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
// Trap shutdown signals
|
// Trap shutdown signals
|
||||||
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1)
|
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1, os.Interrupt)
|
||||||
|
global.SetRestartHook(func() {
|
||||||
|
select {
|
||||||
|
case sigCh <- syscall.SIGHUP:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
for {
|
for {
|
||||||
sig := <-sigCh
|
sig := <-sigCh
|
||||||
|
|
||||||
|
|
@ -439,6 +445,12 @@ func main() {
|
||||||
|
|
||||||
runCmd := flag.NewFlagSet("run", flag.ExitOnError)
|
runCmd := flag.NewFlagSet("run", flag.ExitOnError)
|
||||||
|
|
||||||
|
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
|
||||||
|
var migrateDsn string
|
||||||
|
var migrateSrc string
|
||||||
|
migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
|
||||||
|
migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
|
||||||
|
|
||||||
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
||||||
var port int
|
var port int
|
||||||
var username string
|
var username string
|
||||||
|
|
@ -482,6 +494,7 @@ func main() {
|
||||||
fmt.Println("Commands:")
|
fmt.Println("Commands:")
|
||||||
fmt.Println(" run run web panel")
|
fmt.Println(" run run web panel")
|
||||||
fmt.Println(" migrate migrate form other/old x-ui")
|
fmt.Println(" migrate migrate form other/old x-ui")
|
||||||
|
fmt.Println(" migrate-db copy data from the SQLite file into a PostgreSQL database")
|
||||||
fmt.Println(" setting set settings")
|
fmt.Println(" setting set settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -501,6 +514,23 @@ func main() {
|
||||||
runWebServer()
|
runWebServer()
|
||||||
case "migrate":
|
case "migrate":
|
||||||
migrateDb()
|
migrateDb()
|
||||||
|
case "migrate-db":
|
||||||
|
if err := migrateDbCmd.Parse(os.Args[2:]); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
src := migrateSrc
|
||||||
|
if src == "" {
|
||||||
|
src = config.GetDBPath()
|
||||||
|
}
|
||||||
|
if migrateDsn == "" {
|
||||||
|
fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := database.MigrateData(src, migrateDsn); err != nil {
|
||||||
|
fmt.Println("migration failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
case "setting":
|
case "setting":
|
||||||
err := settingCmd.Parse(os.Args[2:])
|
err := settingCmd.Parse(os.Args[2:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
|
||||||
|
|
||||||
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
|
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
|
||||||
svc := p.build(host)
|
svc := p.build(host)
|
||||||
|
svc.projectThroughFallbackMaster(inbound)
|
||||||
return splitLinkLines(svc.GetLink(inbound, email))
|
return splitLinkLines(svc.GetLink(inbound, email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
40
sub/links_test.go
Normal file
40
sub/links_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitLinkLines(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"single_line", "vless://abc", []string{"vless://abc"}},
|
||||||
|
{"two_lines", "vless://abc\nvmess://xyz", []string{"vless://abc", "vmess://xyz"}},
|
||||||
|
{"trims_each_line", " vless://abc \n\tvmess://xyz\t", []string{"vless://abc", "vmess://xyz"}},
|
||||||
|
{"skips_blank_lines", "vless://abc\n\n\nvmess://xyz\n", []string{"vless://abc", "vmess://xyz"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := splitLinkLines(c.in)
|
||||||
|
if !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Fatalf("splitLinkLines(%q) = %#v, want %#v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitLinkLines_EmptyInputIsNil(t *testing.T) {
|
||||||
|
if got := splitLinkLines(""); got != nil {
|
||||||
|
t.Fatalf("splitLinkLines(\"\") = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitLinkLines_WhitespaceOnlyHasNoEntries(t *testing.T) {
|
||||||
|
got := splitLinkLines(" \n\t \n")
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("splitLinkLines(whitespace) = %#v, want empty slice", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -207,9 +207,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
|
pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
|
||||||
if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
|
if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
|
||||||
assetsIndex := strings.Index(path, "/assets/")
|
_, after, ok := strings.Cut(path, "/assets/")
|
||||||
if assetsIndex != -1 {
|
if ok {
|
||||||
assetPath := path[assetsIndex+8:] // +8 to skip "/assets/"
|
assetPath := after // +8 to skip "/assets/"
|
||||||
if assetPath != "" {
|
if assetPath != "" {
|
||||||
c.FileFromFS(assetPath, assetsFS)
|
c.FileFromFS(assetPath, assetsFS)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
|
|
@ -49,14 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||||
if clients == nil {
|
if clients == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
|
s.SubService.projectThroughFallbackMaster(inbound)
|
||||||
listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
|
|
||||||
if err == nil {
|
|
||||||
inbound.Listen = listen
|
|
||||||
inbound.Port = port
|
|
||||||
inbound.StreamSettings = streamSettings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.SubID == subId {
|
if client.SubID == subId {
|
||||||
_, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
_, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
||||||
|
|
@ -471,8 +465,6 @@ func cloneMap(src map[string]any) map[string]any {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dst := make(map[string]any, len(src))
|
dst := make(map[string]any, len(src))
|
||||||
for k, v := range src {
|
maps.Copy(dst, src)
|
||||||
dst[k] = v
|
|
||||||
}
|
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,18 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// writeSubError translates a service-layer result into an HTTP response.
|
||||||
|
// A nil error with no rows means the subId doesn't match anything (deleted
|
||||||
|
// client, never-existed id) and becomes 404. A real error becomes 500. No
|
||||||
|
// body — VPN clients only look at the status.
|
||||||
|
func writeSubError(c *gin.Context, err error) {
|
||||||
|
if err == nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
||||||
type SUBController struct {
|
type SUBController struct {
|
||||||
subTitle string
|
subTitle string
|
||||||
|
|
@ -105,7 +117,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
||||||
subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
|
subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
|
||||||
if err != nil || len(subs) == 0 {
|
if err != nil || len(subs) == 0 {
|
||||||
c.String(400, "Error!")
|
writeSubError(c, err)
|
||||||
} else {
|
} else {
|
||||||
result := ""
|
result := ""
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
|
|
@ -240,7 +252,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
||||||
if err != nil || len(jsonSub) == 0 {
|
if err != nil || len(jsonSub) == 0 {
|
||||||
c.String(400, "Error!")
|
writeSubError(c, err)
|
||||||
} else {
|
} else {
|
||||||
profileUrl := a.subProfileUrl
|
profileUrl := a.subProfileUrl
|
||||||
if profileUrl == "" {
|
if profileUrl == "" {
|
||||||
|
|
@ -257,7 +269,7 @@ func (a *SUBController) subClashs(c *gin.Context) {
|
||||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||||
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
||||||
if err != nil || len(clashSub) == 0 {
|
if err != nil || len(clashSub) == 0 {
|
||||||
c.String(400, "Error!")
|
writeSubError(c, err)
|
||||||
} else {
|
} else {
|
||||||
profileUrl := a.subProfileUrl
|
profileUrl := a.subProfileUrl
|
||||||
if profileUrl == "" {
|
if profileUrl == "" {
|
||||||
|
|
|
||||||
|
|
@ -110,14 +110,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
||||||
if clients == nil {
|
if clients == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
|
s.SubService.projectThroughFallbackMaster(inbound)
|
||||||
listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
|
|
||||||
if err == nil {
|
|
||||||
inbound.Listen = listen
|
|
||||||
inbound.Port = port
|
|
||||||
inbound.StreamSettings = streamSettings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.SubID == subId {
|
if client.SubID == subId {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(inbounds) == 0 {
|
if len(inbounds) == 0 {
|
||||||
return nil, 0, traffic, common.NewError("No inbounds found with ", subId)
|
return nil, 0, traffic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s.datepicker, err = s.settingService.GetDatepicker()
|
s.datepicker, err = s.settingService.GetDatepicker()
|
||||||
|
|
@ -92,14 +92,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||||
if clients == nil {
|
if clients == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
|
s.projectThroughFallbackMaster(inbound)
|
||||||
listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
|
|
||||||
if err == nil {
|
|
||||||
inbound.Listen = listen
|
|
||||||
inbound.Port = port
|
|
||||||
inbound.StreamSettings = streamSettings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.SubID == subId {
|
if client.SubID == subId {
|
||||||
if client.Enable {
|
if client.Enable {
|
||||||
|
|
@ -144,15 +137,14 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||||
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var inbounds []*model.Inbound
|
var inbounds []*model.Inbound
|
||||||
// allow "hysteria2" so imports stored with the literal v2 protocol
|
|
||||||
// string still surface here (#4081)
|
|
||||||
err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
|
err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
|
||||||
SELECT DISTINCT inbounds.id
|
SELECT DISTINCT inbounds.id
|
||||||
FROM inbounds,
|
FROM inbounds
|
||||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
|
||||||
|
JOIN clients ON clients.id = client_inbounds.client_id
|
||||||
WHERE
|
WHERE
|
||||||
protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2')
|
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2')
|
||||||
AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ?
|
AND clients.sub_id = ? AND inbounds.enable = ?
|
||||||
)`, subId, true).Find(&inbounds).Error
|
)`, subId, true).Find(&inbounds).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -193,16 +185,89 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
|
||||||
return "", 0, "", err
|
return "", 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var stream map[string]any
|
return inbound.Listen, inbound.Port, mergeStreamFromMaster(streamSettings, inbound.StreamSettings), nil
|
||||||
json.Unmarshal([]byte(streamSettings), &stream)
|
}
|
||||||
var masterStream map[string]any
|
|
||||||
json.Unmarshal([]byte(inbound.StreamSettings), &masterStream)
|
|
||||||
stream["security"] = masterStream["security"]
|
|
||||||
stream["tlsSettings"] = masterStream["tlsSettings"]
|
|
||||||
stream["externalProxy"] = masterStream["externalProxy"]
|
|
||||||
modifiedStream, _ := json.MarshalIndent(stream, "", " ")
|
|
||||||
|
|
||||||
return inbound.Listen, inbound.Port, string(modifiedStream), nil
|
// projectThroughFallbackMaster mutates the inbound in place so its
|
||||||
|
// Listen/Port/StreamSettings reflect the externally reachable master
|
||||||
|
// when applicable. Covers both fallback mechanisms:
|
||||||
|
// - panel-tracked: an inbound_fallbacks row where child_id = inbound.Id
|
||||||
|
// - legacy unix-socket: inbound.Listen begins with "@" and some VLESS/
|
||||||
|
// Trojan inbound's settings.fallbacks references that listen address
|
||||||
|
//
|
||||||
|
// Returns true when a projection happened; sub services call this before
|
||||||
|
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
|
||||||
|
// master's :443 + TLS state instead of its own loopback endpoint.
|
||||||
|
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
|
||||||
|
if inbound == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
var master *model.Inbound
|
||||||
|
|
||||||
|
var rule model.InboundFallback
|
||||||
|
if err := db.Where("child_id = ?", inbound.Id).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
First(&rule).Error; err == nil {
|
||||||
|
var m model.Inbound
|
||||||
|
if err := db.Where("id = ?", rule.MasterId).First(&m).Error; err == nil {
|
||||||
|
master = &m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if master == nil && len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
|
||||||
|
var m model.Inbound
|
||||||
|
if err := db.Model(model.Inbound{}).
|
||||||
|
Where("JSON_TYPE(settings, '$.fallbacks') = 'array'").
|
||||||
|
Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", inbound.Listen).
|
||||||
|
First(&m).Error; err == nil {
|
||||||
|
master = &m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if master == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
inbound.StreamSettings = mergeStreamFromMaster(inbound.StreamSettings, master.StreamSettings)
|
||||||
|
inbound.Listen = master.Listen
|
||||||
|
inbound.Port = master.Port
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeStreamFromMaster copies the master's security + tlsSettings +
|
||||||
|
// realitySettings + externalProxy onto the child's stream so the child's
|
||||||
|
// link advertises the master's TLS / Reality state. Transport (network
|
||||||
|
// + ws/grpc/etc. settings) stays the child's.
|
||||||
|
func mergeStreamFromMaster(childStream, masterStream string) string {
|
||||||
|
var stream map[string]any
|
||||||
|
json.Unmarshal([]byte(childStream), &stream)
|
||||||
|
if stream == nil {
|
||||||
|
stream = map[string]any{}
|
||||||
|
}
|
||||||
|
var mst map[string]any
|
||||||
|
json.Unmarshal([]byte(masterStream), &mst)
|
||||||
|
if mst == nil {
|
||||||
|
return childStream
|
||||||
|
}
|
||||||
|
stream["security"] = mst["security"]
|
||||||
|
if v, ok := mst["tlsSettings"]; ok {
|
||||||
|
stream["tlsSettings"] = v
|
||||||
|
} else {
|
||||||
|
delete(stream, "tlsSettings")
|
||||||
|
}
|
||||||
|
if v, ok := mst["realitySettings"]; ok {
|
||||||
|
stream["realitySettings"] = v
|
||||||
|
} else {
|
||||||
|
delete(stream, "realitySettings")
|
||||||
|
}
|
||||||
|
if v, ok := mst["externalProxy"]; ok {
|
||||||
|
stream["externalProxy"] = v
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(stream, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return childStream
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLink dispatches to the protocol-specific generator for one (inbound, client)
|
// GetLink dispatches to the protocol-specific generator for one (inbound, client)
|
||||||
|
|
@ -536,8 +601,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||||
return strings.Join(links, "\n")
|
return strings.Join(links, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// No external proxy configured — fall back to the request host.
|
// No external proxy configured — use the inbound's resolved address so
|
||||||
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.address, inbound.Port)
|
// node-managed inbounds get the node's host instead of the central panel's.
|
||||||
|
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.resolveInboundAddress(inbound), inbound.Port)
|
||||||
url, _ := url.Parse(link)
|
url, _ := url.Parse(link)
|
||||||
q := url.Query()
|
q := url.Query()
|
||||||
for k, v := range params {
|
for k, v := range params {
|
||||||
|
|
|
||||||
480
sub/subService_test.go
Normal file
480
sub/subService_test.go
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindClientIndex(t *testing.T) {
|
||||||
|
clients := []model.Client{
|
||||||
|
{Email: "a@example.com"},
|
||||||
|
{Email: "b@example.com"},
|
||||||
|
{Email: "c@example.com"},
|
||||||
|
}
|
||||||
|
if got := findClientIndex(clients, "b@example.com"); got != 1 {
|
||||||
|
t.Fatalf("findClientIndex middle = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := findClientIndex(clients, "a@example.com"); got != 0 {
|
||||||
|
t.Fatalf("findClientIndex first = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := findClientIndex(clients, "missing@example.com"); got != -1 {
|
||||||
|
t.Fatalf("findClientIndex missing = %d, want -1", got)
|
||||||
|
}
|
||||||
|
if got := findClientIndex(nil, "x"); got != -1 {
|
||||||
|
t.Fatalf("findClientIndex on nil slice = %d, want -1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalStreamSettings(t *testing.T) {
|
||||||
|
got := unmarshalStreamSettings(`{"network":"ws","wsSettings":{"path":"/api"}}`)
|
||||||
|
if got["network"] != "ws" {
|
||||||
|
t.Fatalf("network = %v, want ws", got["network"])
|
||||||
|
}
|
||||||
|
ws, ok := got["wsSettings"].(map[string]any)
|
||||||
|
if !ok || ws["path"] != "/api" {
|
||||||
|
t.Fatalf("wsSettings = %v, want map with path=/api", got["wsSettings"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalStreamSettings_InvalidJSON(t *testing.T) {
|
||||||
|
if got := unmarshalStreamSettings("not json"); got != nil {
|
||||||
|
t.Fatalf("invalid JSON should produce nil map, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHost_StringValue(t *testing.T) {
|
||||||
|
headers := map[string]any{"Host": "example.com"}
|
||||||
|
if got := searchHost(headers); got != "example.com" {
|
||||||
|
t.Fatalf("searchHost = %q, want example.com", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHost_CaseInsensitiveKey(t *testing.T) {
|
||||||
|
headers := map[string]any{"host": "example.com"}
|
||||||
|
if got := searchHost(headers); got != "example.com" {
|
||||||
|
t.Fatalf("searchHost = %q, want example.com", got)
|
||||||
|
}
|
||||||
|
headers2 := map[string]any{"HOST": "example.com"}
|
||||||
|
if got := searchHost(headers2); got != "example.com" {
|
||||||
|
t.Fatalf("searchHost uppercase = %q, want example.com", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHost_ArrayValue(t *testing.T) {
|
||||||
|
headers := map[string]any{"Host": []any{"first.example.com", "second.example.com"}}
|
||||||
|
if got := searchHost(headers); got != "first.example.com" {
|
||||||
|
t.Fatalf("searchHost array = %q, want first.example.com", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHost_EmptyArray(t *testing.T) {
|
||||||
|
headers := map[string]any{"Host": []any{}}
|
||||||
|
if got := searchHost(headers); got != "" {
|
||||||
|
t.Fatalf("searchHost empty array = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHost_NoHostKey(t *testing.T) {
|
||||||
|
headers := map[string]any{"X-Other": "value"}
|
||||||
|
if got := searchHost(headers); got != "" {
|
||||||
|
t.Fatalf("searchHost no host = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHost_NotAMap(t *testing.T) {
|
||||||
|
if got := searchHost("not a map"); got != "" {
|
||||||
|
t.Fatalf("searchHost non-map = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := searchHost(nil); got != "" {
|
||||||
|
t.Fatalf("searchHost nil = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchKey_FoundAtTopLevel(t *testing.T) {
|
||||||
|
data := map[string]any{"foo": 42, "bar": "x"}
|
||||||
|
got, ok := searchKey(data, "foo")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected to find foo")
|
||||||
|
}
|
||||||
|
if got != 42 {
|
||||||
|
t.Fatalf("got %v, want 42", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchKey_FoundInNested(t *testing.T) {
|
||||||
|
data := map[string]any{
|
||||||
|
"outer": map[string]any{
|
||||||
|
"inner": map[string]any{
|
||||||
|
"target": "hit",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, ok := searchKey(data, "target")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected to find target in nested map")
|
||||||
|
}
|
||||||
|
if got != "hit" {
|
||||||
|
t.Fatalf("got %v, want hit", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchKey_FoundInsideArray(t *testing.T) {
|
||||||
|
data := map[string]any{
|
||||||
|
"list": []any{
|
||||||
|
map[string]any{"other": 1},
|
||||||
|
map[string]any{"needle": "found"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, ok := searchKey(data, "needle")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected to find needle in array element")
|
||||||
|
}
|
||||||
|
if got != "found" {
|
||||||
|
t.Fatalf("got %v, want found", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchKey_NotFound(t *testing.T) {
|
||||||
|
data := map[string]any{"foo": "bar"}
|
||||||
|
if _, ok := searchKey(data, "missing"); ok {
|
||||||
|
t.Fatal("expected ok=false for missing key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchKey_OnScalar(t *testing.T) {
|
||||||
|
if _, ok := searchKey(42, "anything"); ok {
|
||||||
|
t.Fatal("expected ok=false searching on a scalar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneStringMap(t *testing.T) {
|
||||||
|
src := map[string]string{"a": "1", "b": "2"}
|
||||||
|
dst := cloneStringMap(src)
|
||||||
|
if len(dst) != len(src) {
|
||||||
|
t.Fatalf("clone length = %d, want %d", len(dst), len(src))
|
||||||
|
}
|
||||||
|
for k, v := range src {
|
||||||
|
if dst[k] != v {
|
||||||
|
t.Fatalf("clone[%q] = %q, want %q", k, dst[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst["a"] = "changed"
|
||||||
|
if src["a"] == "changed" {
|
||||||
|
t.Fatal("modifying clone leaked into source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneStringMap_Empty(t *testing.T) {
|
||||||
|
dst := cloneStringMap(map[string]string{})
|
||||||
|
if dst == nil {
|
||||||
|
t.Fatal("clone of empty map should not be nil")
|
||||||
|
}
|
||||||
|
if len(dst) != 0 {
|
||||||
|
t.Fatalf("clone of empty map should be empty, got %v", dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHostFromXFH_HostOnly(t *testing.T) {
|
||||||
|
got, err := getHostFromXFH("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "example.com" {
|
||||||
|
t.Fatalf("got %q, want example.com", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHostFromXFH_HostWithPort(t *testing.T) {
|
||||||
|
got, err := getHostFromXFH("example.com:8443")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "example.com" {
|
||||||
|
t.Fatalf("got %q, want example.com", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHostFromXFH_IPv6WithPort(t *testing.T) {
|
||||||
|
got, err := getHostFromXFH("[2606:4700::1111]:443")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "2606:4700::1111" {
|
||||||
|
t.Fatalf("got %q, want 2606:4700::1111", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHostFromXFH_BadHostPort(t *testing.T) {
|
||||||
|
if _, err := getHostFromXFH("example.com:8443:9999"); err == nil {
|
||||||
|
t.Fatal("expected error for malformed host:port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPositiveInt(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in any
|
||||||
|
wantVal int
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{"int_positive", int(5), 5, true},
|
||||||
|
{"int_zero", int(0), 0, false},
|
||||||
|
{"int_negative", int(-3), -3, false},
|
||||||
|
{"int32_positive", int32(7), 7, true},
|
||||||
|
{"int64_positive", int64(99), 99, true},
|
||||||
|
{"float64_positive", float64(12), 12, true},
|
||||||
|
{"float64_zero", float64(0.0), 0, false},
|
||||||
|
{"float64_negative", float64(-1.5), -1, false},
|
||||||
|
{"float32_positive", float32(3), 3, true},
|
||||||
|
{"string", "not a number", 0, false},
|
||||||
|
{"nil", nil, 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
gotVal, gotOk := readPositiveInt(c.in)
|
||||||
|
if gotVal != c.wantVal || gotOk != c.wantOk {
|
||||||
|
t.Fatalf("readPositiveInt(%v) = (%d, %v), want (%d, %v)", c.in, gotVal, gotOk, c.wantVal, c.wantOk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetStringParam(t *testing.T) {
|
||||||
|
p := map[string]string{"existing": "value"}
|
||||||
|
|
||||||
|
setStringParam(p, "new", "hello")
|
||||||
|
if p["new"] != "hello" {
|
||||||
|
t.Fatalf("missing key after set: %v", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStringParam(p, "existing", "")
|
||||||
|
if _, ok := p["existing"]; ok {
|
||||||
|
t.Fatalf("empty value should delete the key, got %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetIntParam(t *testing.T) {
|
||||||
|
p := map[string]string{"existing": "10"}
|
||||||
|
|
||||||
|
setIntParam(p, "n", 42)
|
||||||
|
if p["n"] != "42" {
|
||||||
|
t.Fatalf("set positive int: got %v", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIntParam(p, "existing", 0)
|
||||||
|
if _, ok := p["existing"]; ok {
|
||||||
|
t.Fatalf("zero value should delete the key, got %v", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
p["other"] = "5"
|
||||||
|
setIntParam(p, "other", -1)
|
||||||
|
if _, ok := p["other"]; ok {
|
||||||
|
t.Fatalf("negative value should delete the key, got %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetStringField(t *testing.T) {
|
||||||
|
f := map[string]any{"existing": "value"}
|
||||||
|
|
||||||
|
setStringField(f, "new", "hello")
|
||||||
|
if f["new"] != "hello" {
|
||||||
|
t.Fatalf("missing key after set: %v", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStringField(f, "existing", "")
|
||||||
|
if _, ok := f["existing"]; ok {
|
||||||
|
t.Fatalf("empty value should delete the key, got %v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetIntField(t *testing.T) {
|
||||||
|
f := map[string]any{"existing": 10}
|
||||||
|
|
||||||
|
setIntField(f, "n", 7)
|
||||||
|
if f["n"] != 7 {
|
||||||
|
t.Fatalf("set positive int: got %v", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIntField(f, "existing", 0)
|
||||||
|
if _, ok := f["existing"]; ok {
|
||||||
|
t.Fatalf("zero value should delete the key, got %v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildVmessLink(t *testing.T) {
|
||||||
|
obj := map[string]any{
|
||||||
|
"v": "2",
|
||||||
|
"ps": "remark",
|
||||||
|
"add": "example.com",
|
||||||
|
"port": 443,
|
||||||
|
"net": "tcp",
|
||||||
|
}
|
||||||
|
link := buildVmessLink(obj)
|
||||||
|
if !strings.HasPrefix(link, "vmess://") {
|
||||||
|
t.Fatalf("missing vmess:// prefix: %q", link)
|
||||||
|
}
|
||||||
|
payload := strings.TrimPrefix(link, "vmess://")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64 decode failed: %v", err)
|
||||||
|
}
|
||||||
|
var roundTrip map[string]any
|
||||||
|
if err := json.Unmarshal(decoded, &roundTrip); err != nil {
|
||||||
|
t.Fatalf("decoded payload is not JSON: %v\n%s", err, decoded)
|
||||||
|
}
|
||||||
|
if roundTrip["add"] != "example.com" {
|
||||||
|
t.Fatalf("round-trip add = %v, want example.com", roundTrip["add"])
|
||||||
|
}
|
||||||
|
if roundTrip["ps"] != "remark" {
|
||||||
|
t.Fatalf("round-trip ps = %v, want remark", roundTrip["ps"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneVmessShareObj_CopiesEverythingByDefault(t *testing.T) {
|
||||||
|
base := map[string]any{
|
||||||
|
"v": "2",
|
||||||
|
"sni": "example.com",
|
||||||
|
"alpn": "h2",
|
||||||
|
"fp": "chrome",
|
||||||
|
"net": "tcp",
|
||||||
|
}
|
||||||
|
out := cloneVmessShareObj(base, "tls")
|
||||||
|
for _, key := range []string{"sni", "alpn", "fp", "net", "v"} {
|
||||||
|
if _, ok := out[key]; !ok {
|
||||||
|
t.Fatalf("expected key %q to be preserved when security=tls, got %v", key, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) {
|
||||||
|
base := map[string]any{
|
||||||
|
"v": "2",
|
||||||
|
"sni": "example.com",
|
||||||
|
"alpn": "h2",
|
||||||
|
"fp": "chrome",
|
||||||
|
"net": "tcp",
|
||||||
|
}
|
||||||
|
out := cloneVmessShareObj(base, "none")
|
||||||
|
for _, key := range []string{"sni", "alpn", "fp"} {
|
||||||
|
if _, ok := out[key]; ok {
|
||||||
|
t.Fatalf("security=none should strip %q, got %v", key, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out["v"] != "2" || out["net"] != "tcp" {
|
||||||
|
t.Fatalf("non-TLS keys should remain, got %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractKcpShareFields_Defaults(t *testing.T) {
|
||||||
|
stream := map[string]any{}
|
||||||
|
got := extractKcpShareFields(stream)
|
||||||
|
if got.headerType != "none" {
|
||||||
|
t.Fatalf("default headerType = %q, want none", got.headerType)
|
||||||
|
}
|
||||||
|
if got.seed != "" || got.mtu != 0 || got.tti != 0 {
|
||||||
|
t.Fatalf("default kcpShareFields should be zero except headerType, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractKcpShareFields_ReadsAllFields(t *testing.T) {
|
||||||
|
stream := map[string]any{
|
||||||
|
"kcpSettings": map[string]any{
|
||||||
|
"header": map[string]any{"type": "wechat-video"},
|
||||||
|
"seed": "secret-seed",
|
||||||
|
"mtu": float64(1350),
|
||||||
|
"tti": float64(50),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := extractKcpShareFields(stream)
|
||||||
|
if got.headerType != "wechat-video" {
|
||||||
|
t.Fatalf("headerType = %q, want wechat-video", got.headerType)
|
||||||
|
}
|
||||||
|
if got.seed != "secret-seed" {
|
||||||
|
t.Fatalf("seed = %q, want secret-seed", got.seed)
|
||||||
|
}
|
||||||
|
if got.mtu != 1350 {
|
||||||
|
t.Fatalf("mtu = %d, want 1350", got.mtu)
|
||||||
|
}
|
||||||
|
if got.tti != 50 {
|
||||||
|
t.Fatalf("tti = %d, want 50", got.tti)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKcpShareFields_ApplyToParams(t *testing.T) {
|
||||||
|
params := map[string]string{}
|
||||||
|
kcpShareFields{headerType: "wechat-video", seed: "s", mtu: 1350, tti: 50}.applyToParams(params)
|
||||||
|
if params["headerType"] != "wechat-video" {
|
||||||
|
t.Fatalf("headerType param = %q", params["headerType"])
|
||||||
|
}
|
||||||
|
if params["seed"] != "s" {
|
||||||
|
t.Fatalf("seed param = %q", params["seed"])
|
||||||
|
}
|
||||||
|
if params["mtu"] != "1350" {
|
||||||
|
t.Fatalf("mtu param = %q", params["mtu"])
|
||||||
|
}
|
||||||
|
if params["tti"] != "50" {
|
||||||
|
t.Fatalf("tti param = %q", params["tti"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKcpShareFields_ApplyToParams_NoneHeaderNotAdded(t *testing.T) {
|
||||||
|
params := map[string]string{}
|
||||||
|
kcpShareFields{headerType: "none"}.applyToParams(params)
|
||||||
|
if _, ok := params["headerType"]; ok {
|
||||||
|
t.Fatalf("headerType=none should not be added, got %v", params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalFinalMask_EmptyReturnsFalse(t *testing.T) {
|
||||||
|
if _, ok := marshalFinalMask(map[string]any{}); ok {
|
||||||
|
t.Fatal("expected ok=false for empty finalmask")
|
||||||
|
}
|
||||||
|
if _, ok := marshalFinalMask(nil); ok {
|
||||||
|
t.Fatal("expected ok=false for nil finalmask")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalFinalMask_WithContent(t *testing.T) {
|
||||||
|
fm := map[string]any{
|
||||||
|
"tcp": []any{
|
||||||
|
map[string]any{"type": "fragment"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, ok := marshalFinalMask(fm)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true for finalmask with valid tcp mask")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"tcp"`) {
|
||||||
|
t.Fatalf("marshaled finalmask missing tcp key: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "fragment") {
|
||||||
|
t.Fatalf("marshaled finalmask missing mask type: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) {
|
||||||
|
fm := map[string]any{
|
||||||
|
"tcp": []any{
|
||||||
|
map[string]any{"type": "not-a-real-mask"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, ok := marshalFinalMask(fm); ok {
|
||||||
|
t.Fatal("unknown mask types should be dropped, leaving nothing to marshal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasFinalMaskContent(t *testing.T) {
|
||||||
|
if hasFinalMaskContent(nil) {
|
||||||
|
t.Fatal("nil should not count as content")
|
||||||
|
}
|
||||||
|
if hasFinalMaskContent(map[string]any{}) {
|
||||||
|
t.Fatal("empty map should not count as content")
|
||||||
|
}
|
||||||
|
if !hasFinalMaskContent(map[string]any{"x": 1}) {
|
||||||
|
t.Fatal("non-empty map should count as content")
|
||||||
|
}
|
||||||
|
}
|
||||||
28
util/common/format_test.go
Normal file
28
util/common/format_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFormatTraffic(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
bytes int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"zero", 0, "0.00B"},
|
||||||
|
{"under_one_kb", 512, "512.00B"},
|
||||||
|
{"exactly_one_kb", 1024, "1.00KB"},
|
||||||
|
{"one_and_a_half_kb", 1536, "1.50KB"},
|
||||||
|
{"one_mb", 1024 * 1024, "1.00MB"},
|
||||||
|
{"one_gb", 1024 * 1024 * 1024, "1.00GB"},
|
||||||
|
{"one_tb", 1024 * 1024 * 1024 * 1024, "1.00TB"},
|
||||||
|
{"one_pb", 1024 * 1024 * 1024 * 1024 * 1024, "1.00PB"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := FormatTraffic(c.bytes)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
44
util/common/multi_error_test.go
Normal file
44
util/common/multi_error_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCombine_AllNilReturnsNil(t *testing.T) {
|
||||||
|
if err := Combine(); err != nil {
|
||||||
|
t.Fatalf("Combine() with no args = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if err := Combine(nil, nil, nil); err != nil {
|
||||||
|
t.Fatalf("Combine(nil, nil, nil) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombine_SkipsNilErrors(t *testing.T) {
|
||||||
|
e1 := errors.New("boom one")
|
||||||
|
e2 := errors.New("boom two")
|
||||||
|
|
||||||
|
err := Combine(nil, e1, nil, e2, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-nil combined error")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, "boom one") || !strings.Contains(msg, "boom two") {
|
||||||
|
t.Fatalf("combined error %q does not contain both underlying messages", msg)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(msg, "multierr: ") {
|
||||||
|
t.Fatalf("combined error %q missing %q prefix", msg, "multierr: ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombine_SingleErrorStillWrapped(t *testing.T) {
|
||||||
|
e := errors.New("only one")
|
||||||
|
err := Combine(e)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-nil error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "only one") {
|
||||||
|
t.Fatalf("combined error %q missing underlying message", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
69
util/crypto/crypto_test.go
Normal file
69
util/crypto/crypto_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashPasswordAsBcrypt_RoundTrip(t *testing.T) {
|
||||||
|
password := "correct horse battery staple"
|
||||||
|
|
||||||
|
hash, err := HashPasswordAsBcrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPasswordAsBcrypt returned error: %v", err)
|
||||||
|
}
|
||||||
|
if hash == "" {
|
||||||
|
t.Fatal("expected non-empty hash")
|
||||||
|
}
|
||||||
|
if hash == password {
|
||||||
|
t.Fatal("hash must not equal the plaintext password")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(hash, "$2") {
|
||||||
|
t.Fatalf("expected bcrypt prefix $2..., got %q", hash[:min(4, len(hash))])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !CheckPasswordHash(hash, password) {
|
||||||
|
t.Fatal("CheckPasswordHash returned false for the matching password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPasswordHash_WrongPassword(t *testing.T) {
|
||||||
|
hash, err := HashPasswordAsBcrypt("right-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPasswordAsBcrypt returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if CheckPasswordHash(hash, "wrong-password") {
|
||||||
|
t.Fatal("CheckPasswordHash returned true for a wrong password")
|
||||||
|
}
|
||||||
|
if CheckPasswordHash(hash, "") {
|
||||||
|
t.Fatal("CheckPasswordHash returned true for an empty password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPasswordHash_InvalidHash(t *testing.T) {
|
||||||
|
if CheckPasswordHash("", "anything") {
|
||||||
|
t.Fatal("empty hash must not validate")
|
||||||
|
}
|
||||||
|
if CheckPasswordHash("not-a-bcrypt-hash", "anything") {
|
||||||
|
t.Fatal("malformed hash must not validate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashPasswordAsBcrypt_DifferentHashesForSamePassword(t *testing.T) {
|
||||||
|
password := "same-password"
|
||||||
|
h1, err := HashPasswordAsBcrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first hash failed: %v", err)
|
||||||
|
}
|
||||||
|
h2, err := HashPasswordAsBcrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second hash failed: %v", err)
|
||||||
|
}
|
||||||
|
if h1 == h2 {
|
||||||
|
t.Fatal("expected bcrypt to produce different hashes (random salt) for the same password")
|
||||||
|
}
|
||||||
|
if !CheckPasswordHash(h1, password) || !CheckPasswordHash(h2, password) {
|
||||||
|
t.Fatal("both hashes should still validate the original password")
|
||||||
|
}
|
||||||
|
}
|
||||||
76
util/json_util/json_test.go
Normal file
76
util/json_util/json_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package json_util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRawMessage_MarshalEmptyIsNull(t *testing.T) {
|
||||||
|
var m RawMessage
|
||||||
|
out, err := m.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalJSON on empty returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(out, []byte("null")) {
|
||||||
|
t.Fatalf("empty RawMessage marshaled to %q, want %q", out, "null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawMessage_MarshalPassthrough(t *testing.T) {
|
||||||
|
payload := []byte(`{"a":1}`)
|
||||||
|
m := RawMessage(payload)
|
||||||
|
out, err := m.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalJSON returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(out, payload) {
|
||||||
|
t.Fatalf("MarshalJSON = %q, want %q", out, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawMessage_UnmarshalCopiesData(t *testing.T) {
|
||||||
|
var m RawMessage
|
||||||
|
src := []byte(`{"k":"v"}`)
|
||||||
|
if err := m.UnmarshalJSON(src); err != nil {
|
||||||
|
t.Fatalf("UnmarshalJSON returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(m, src) {
|
||||||
|
t.Fatalf("UnmarshalJSON stored %q, want %q", []byte(m), src)
|
||||||
|
}
|
||||||
|
|
||||||
|
src[0] = 'X'
|
||||||
|
if m[0] == 'X' {
|
||||||
|
t.Fatal("UnmarshalJSON kept a reference to the caller's buffer; expected a copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawMessage_UnmarshalNilReceiverErrors(t *testing.T) {
|
||||||
|
var m *RawMessage
|
||||||
|
if err := m.UnmarshalJSON([]byte("123")); err == nil {
|
||||||
|
t.Fatal("expected error for nil receiver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawMessage_RoundTripInsideStruct(t *testing.T) {
|
||||||
|
type wrapper struct {
|
||||||
|
Body RawMessage `json:"body"`
|
||||||
|
}
|
||||||
|
in := wrapper{Body: RawMessage(`{"x":42}`)}
|
||||||
|
encoded, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
want := `{"body":{"x":42}}`
|
||||||
|
if string(encoded) != want {
|
||||||
|
t.Fatalf("Marshal = %s, want %s", encoded, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out wrapper
|
||||||
|
if err := json.Unmarshal(encoded, &out); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if string(out.Body) != `{"x":42}` {
|
||||||
|
t.Fatalf("round-trip Body = %s, want %s", out.Body, `{"x":42}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package ldaputil
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/go-ldap/ldap/v3"
|
"github.com/go-ldap/ldap/v3"
|
||||||
)
|
)
|
||||||
|
|
@ -82,13 +83,7 @@ func FetchVlessFlags(cfg Config) (map[string]bool, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val := e.GetAttributeValue(cfg.FlagField)
|
val := e.GetAttributeValue(cfg.FlagField)
|
||||||
enabled := false
|
enabled := slices.Contains(cfg.TruthyVals, val)
|
||||||
for _, t := range cfg.TruthyVals {
|
|
||||||
if val == t {
|
|
||||||
enabled = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.Invert {
|
if cfg.Invert {
|
||||||
enabled = !enabled
|
enabled = !enabled
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
util/netsafe/netsafe_test.go
Normal file
127
util/netsafe/netsafe_test.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package netsafe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsBlockedIP(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"127.0.0.1", true},
|
||||||
|
{"::1", true},
|
||||||
|
{"10.0.0.5", true},
|
||||||
|
{"172.16.0.1", true},
|
||||||
|
{"192.168.1.1", true},
|
||||||
|
{"169.254.0.1", true},
|
||||||
|
{"0.0.0.0", true},
|
||||||
|
{"::", true},
|
||||||
|
{"8.8.8.8", false},
|
||||||
|
{"1.1.1.1", false},
|
||||||
|
{"2606:4700:4700::1111", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.ip, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(c.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("could not parse %q", c.ip)
|
||||||
|
}
|
||||||
|
if got := IsBlockedIP(ip); got != c.want {
|
||||||
|
t.Fatalf("IsBlockedIP(%s) = %v, want %v", c.ip, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowPrivateFromContext_Default(t *testing.T) {
|
||||||
|
if AllowPrivateFromContext(context.Background()) {
|
||||||
|
t.Fatal("default context should report AllowPrivate=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowPrivateFromContext_RoundTrip(t *testing.T) {
|
||||||
|
ctx := ContextWithAllowPrivate(context.Background(), true)
|
||||||
|
if !AllowPrivateFromContext(ctx) {
|
||||||
|
t.Fatal("expected AllowPrivate=true after ContextWithAllowPrivate(true)")
|
||||||
|
}
|
||||||
|
ctx = ContextWithAllowPrivate(ctx, false)
|
||||||
|
if AllowPrivateFromContext(ctx) {
|
||||||
|
t.Fatal("expected AllowPrivate=false after overriding with false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeHost_Valid(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"example.com", "example.com"},
|
||||||
|
{" example.com ", "example.com"},
|
||||||
|
{"a.b.c.example.com", "a.b.c.example.com"},
|
||||||
|
{"10.0.0.1", "10.0.0.1"},
|
||||||
|
{"[2606:4700:4700::1111]", "2606:4700:4700::1111"},
|
||||||
|
{"2606:4700:4700::1111", "2606:4700:4700::1111"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.in, func(t *testing.T) {
|
||||||
|
got, err := NormalizeHost(c.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeHost(%q) returned error: %v", c.in, err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(got, c.want) {
|
||||||
|
t.Fatalf("NormalizeHost(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeHost_Invalid(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"-leading-dash.com",
|
||||||
|
"trailing-dash-.com",
|
||||||
|
"bad host with spaces",
|
||||||
|
"under_score.example.com",
|
||||||
|
"exa$mple.com",
|
||||||
|
strings.Repeat("a", 254),
|
||||||
|
}
|
||||||
|
for _, in := range cases {
|
||||||
|
t.Run(in, func(t *testing.T) {
|
||||||
|
if _, err := NormalizeHost(in); err == nil {
|
||||||
|
t.Fatalf("NormalizeHost(%q) expected error, got nil", in)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRFGuardedDialContext_BlocksLiteralPrivateIP(t *testing.T) {
|
||||||
|
_, err := SSRFGuardedDialContext(context.Background(), "tcp", "127.0.0.1:1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected dial to 127.0.0.1 to be blocked")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "blocked") {
|
||||||
|
t.Fatalf("expected 'blocked' in error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRFGuardedDialContext_AllowPrivateBypassesGuard(t *testing.T) {
|
||||||
|
ctx := ContextWithAllowPrivate(context.Background(), true)
|
||||||
|
_, err := SSRFGuardedDialContext(ctx, "tcp", "127.0.0.1:1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("dial to a closed loopback port should still fail at the connect step")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "blocked private/internal address") {
|
||||||
|
t.Fatalf("expected guard to be bypassed when AllowPrivate=true, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSRFGuardedDialContext_BadAddress(t *testing.T) {
|
||||||
|
if _, err := SSRFGuardedDialContext(context.Background(), "tcp", "no-port"); err == nil {
|
||||||
|
t.Fatal("expected error for address without port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package random
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"math/big"
|
"math/big"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -59,3 +60,14 @@ func Num(n int) int {
|
||||||
}
|
}
|
||||||
return int(r.Int64())
|
return int(r.Int64())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Base64Bytes returns n cryptographically-random bytes encoded as standard
|
||||||
|
// base64 (with padding). Used for ss2022 keys, which xray expects as a
|
||||||
|
// base64-encoded key of a specific byte length per cipher.
|
||||||
|
func Base64Bytes(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic("crypto/rand failed: " + err.Error())
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
|
||||||
63
util/random/random_test.go
Normal file
63
util/random/random_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package random
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSeq_LengthAndAlphabet(t *testing.T) {
|
||||||
|
for _, n := range []int{0, 1, 8, 64, 256} {
|
||||||
|
s := Seq(n)
|
||||||
|
if len(s) != n {
|
||||||
|
t.Fatalf("Seq(%d) returned length %d", n, len(s))
|
||||||
|
}
|
||||||
|
for i, r := range s {
|
||||||
|
isDigit := r >= '0' && r <= '9'
|
||||||
|
isLower := r >= 'a' && r <= 'z'
|
||||||
|
isUpper := r >= 'A' && r <= 'Z'
|
||||||
|
if !(isDigit || isLower || isUpper) {
|
||||||
|
t.Fatalf("Seq(%d) byte %d = %q is not alphanumeric", n, i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeq_NotConstant(t *testing.T) {
|
||||||
|
a := Seq(32)
|
||||||
|
b := Seq(32)
|
||||||
|
if a == b {
|
||||||
|
t.Fatalf("two consecutive Seq(32) calls produced identical output: %q", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNum_InRange(t *testing.T) {
|
||||||
|
for _, upper := range []int{1, 2, 10, 1000} {
|
||||||
|
for range 200 {
|
||||||
|
v := Num(upper)
|
||||||
|
if v < 0 || v >= upper {
|
||||||
|
t.Fatalf("Num(%d) returned %d, out of [0, %d)", upper, v, upper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBase64Bytes_DecodesToRequestedSize(t *testing.T) {
|
||||||
|
for _, n := range []int{1, 16, 32, 64} {
|
||||||
|
out := Base64Bytes(n)
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Base64Bytes(%d) produced invalid base64 %q: %v", n, out, err)
|
||||||
|
}
|
||||||
|
if len(decoded) != n {
|
||||||
|
t.Fatalf("Base64Bytes(%d) decoded to %d bytes", n, len(decoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBase64Bytes_Random(t *testing.T) {
|
||||||
|
a := Base64Bytes(32)
|
||||||
|
b := Base64Bytes(32)
|
||||||
|
if a == b {
|
||||||
|
t.Fatalf("two consecutive Base64Bytes(32) calls produced identical output: %q", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
//go:build darwin
|
//go:build darwin
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package sys
|
package sys
|
||||||
|
|
||||||
|
|
@ -33,8 +32,9 @@ func GetUDPCount() (int, error) {
|
||||||
|
|
||||||
// --- CPU Utilization (macOS native) ---
|
// --- CPU Utilization (macOS native) ---
|
||||||
|
|
||||||
// sysctl kern.cp_time returns an array of 5 longs: user, nice, sys, idle, intr.
|
// sysctl kern.cp_time returns 5 longs in the BSD CPUSTATES order:
|
||||||
// We compute utilization deltas without cgo.
|
// user, nice, sys, intr, idle (CP_INTR=3, CP_IDLE=4). gopsutil reads the
|
||||||
|
// same layout in cpu_darwin_nocgo.go.
|
||||||
var (
|
var (
|
||||||
cpuMu sync.Mutex
|
cpuMu sync.Mutex
|
||||||
lastTotals [5]uint64
|
lastTotals [5]uint64
|
||||||
|
|
@ -61,13 +61,6 @@ func CPUPercentRaw() (float64, error) {
|
||||||
return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
|
return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
// user, nice, sys, idle, intr
|
|
||||||
user := out[0]
|
|
||||||
nice := out[1]
|
|
||||||
sysv := out[2]
|
|
||||||
idle := out[3]
|
|
||||||
intr := out[4]
|
|
||||||
|
|
||||||
cpuMu.Lock()
|
cpuMu.Lock()
|
||||||
defer cpuMu.Unlock()
|
defer cpuMu.Unlock()
|
||||||
|
|
||||||
|
|
@ -77,19 +70,19 @@ func CPUPercentRaw() (float64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
dUser := user - lastTotals[0]
|
var deltas [5]uint64
|
||||||
dNice := nice - lastTotals[1]
|
var totald uint64
|
||||||
dSys := sysv - lastTotals[2]
|
for i := range 5 {
|
||||||
dIdle := idle - lastTotals[3]
|
deltas[i] = out[i] - lastTotals[i]
|
||||||
dIntr := intr - lastTotals[4]
|
totald += deltas[i]
|
||||||
|
}
|
||||||
lastTotals = out
|
lastTotals = out
|
||||||
|
|
||||||
totald := dUser + dNice + dSys + dIdle + dIntr
|
|
||||||
if totald == 0 {
|
if totald == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
busy := totald - dIdle
|
idleDelta := deltas[4]
|
||||||
|
busy := totald - idleDelta
|
||||||
pct := float64(busy) / float64(totald) * 100.0
|
pct := float64(busy) / float64(totald) * 100.0
|
||||||
if pct > 100 {
|
if pct > 100 {
|
||||||
pct = 100
|
pct = 100
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
//go:build linux
|
//go:build linux
|
||||||
// +build linux
|
|
||||||
|
|
||||||
package sys
|
package sys
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -17,80 +15,63 @@ import (
|
||||||
|
|
||||||
var SIGUSR1 = syscall.SIGUSR1
|
var SIGUSR1 = syscall.SIGUSR1
|
||||||
|
|
||||||
func getLinesNum(filename string) (int, error) {
|
// countConnections returns the number of entries in a /proc/net/{tcp,udp}[6]
|
||||||
file, err := os.Open(filename)
|
// file. Returns 0 if the file is absent (e.g. /proc/net/tcp6 when IPv6 is
|
||||||
|
// disabled) and excludes the column header line.
|
||||||
|
func countConnections(path string) (int, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer f.Close()
|
||||||
|
|
||||||
sum := 0
|
sc := bufio.NewScanner(f)
|
||||||
buf := make([]byte, 8192)
|
n := 0
|
||||||
for {
|
for sc.Scan() {
|
||||||
n, err := file.Read(buf)
|
n++
|
||||||
|
|
||||||
var buffPosition int
|
|
||||||
for {
|
|
||||||
i := bytes.IndexByte(buf[buffPosition:n], '\n')
|
|
||||||
if i < 0 {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
buffPosition += i + 1
|
if err := sc.Err(); err != nil {
|
||||||
sum++
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
} else if err != nil {
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if n > 0 {
|
||||||
|
n-- // first line is the column header
|
||||||
}
|
}
|
||||||
return sum, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTCPCount returns the number of active TCP connections by reading
|
// GetTCPCount returns the number of active TCP connections by reading
|
||||||
// /proc/net/tcp and /proc/net/tcp6 when available.
|
// /proc/net/tcp and /proc/net/tcp6 when available.
|
||||||
func GetTCPCount() (int, error) {
|
func GetTCPCount() (int, error) {
|
||||||
root := HostProc()
|
root := HostProc()
|
||||||
|
tcp4, err := countConnections(root + "/net/tcp")
|
||||||
tcp4, err := safeGetLinesNum(fmt.Sprintf("%v/net/tcp", root))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
tcp6, err := safeGetLinesNum(fmt.Sprintf("%v/net/tcp6", root))
|
tcp6, err := countConnections(root + "/net/tcp6")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tcp4 + tcp6, nil
|
return tcp4 + tcp6, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUDPCount returns the number of active UDP connections by reading
|
||||||
|
// /proc/net/udp and /proc/net/udp6 when available.
|
||||||
func GetUDPCount() (int, error) {
|
func GetUDPCount() (int, error) {
|
||||||
root := HostProc()
|
root := HostProc()
|
||||||
|
udp4, err := countConnections(root + "/net/udp")
|
||||||
udp4, err := safeGetLinesNum(fmt.Sprintf("%v/net/udp", root))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
udp6, err := safeGetLinesNum(fmt.Sprintf("%v/net/udp6", root))
|
udp6, err := countConnections(root + "/net/udp6")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return udp4 + udp6, nil
|
return udp4 + udp6, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeGetLinesNum returns 0 if the file does not exist, otherwise forwards
|
|
||||||
// to getLinesNum to count the number of lines.
|
|
||||||
func safeGetLinesNum(path string) (int, error) {
|
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
return 0, nil
|
|
||||||
} else if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return getLinesNum(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CPU Utilization (Linux native) ---
|
// --- CPU Utilization (Linux native) ---
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -100,10 +81,11 @@ var (
|
||||||
hasLast bool
|
hasLast bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// CPUPercentRaw returns instantaneous total CPU utilization by reading /proc/stat.
|
// CPUPercentRaw returns instantaneous total CPU utilization by reading
|
||||||
// First call initializes and returns 0; subsequent calls return busy/total * 100.
|
// /proc/stat. First call initializes and returns 0; subsequent calls return
|
||||||
|
// busy/total * 100. Uses HostProc so HOST_PROC overrides (containers) apply.
|
||||||
func CPUPercentRaw() (float64, error) {
|
func CPUPercentRaw() (float64, error) {
|
||||||
f, err := os.Open("/proc/stat")
|
f, err := os.Open(HostProc("stat"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -114,13 +96,13 @@ func CPUPercentRaw() (float64, error) {
|
||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
// Expect line like: cpu user nice system idle iowait irq softirq steal guest guest_nice
|
// Expect: cpu user nice system idle iowait irq softirq steal guest guest_nice
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) < 5 || fields[0] != "cpu" {
|
if len(fields) < 5 || fields[0] != "cpu" {
|
||||||
return 0, fmt.Errorf("unexpected /proc/stat format")
|
return 0, fmt.Errorf("unexpected /proc/stat format")
|
||||||
}
|
}
|
||||||
|
|
||||||
var nums []uint64
|
nums := make([]uint64, 0, len(fields)-1)
|
||||||
for i := 1; i < len(fields); i++ {
|
for i := 1; i < len(fields); i++ {
|
||||||
v, err := strconv.ParseUint(fields[i], 10, 64)
|
v, err := strconv.ParseUint(fields[i], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -128,34 +110,15 @@ func CPUPercentRaw() (float64, error) {
|
||||||
}
|
}
|
||||||
nums = append(nums, v)
|
nums = append(nums, v)
|
||||||
}
|
}
|
||||||
if len(nums) < 4 { // need at least user,nice,system,idle
|
if len(nums) < 4 {
|
||||||
return 0, fmt.Errorf("insufficient cpu fields")
|
return 0, fmt.Errorf("insufficient cpu fields")
|
||||||
}
|
}
|
||||||
|
for len(nums) < 8 {
|
||||||
|
nums = append(nums, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Conform with standard Linux CPU accounting
|
user, nice, system, idle := nums[0], nums[1], nums[2], nums[3]
|
||||||
var user, nice, system, idle, iowait, irq, softirq, steal uint64
|
iowait, irq, softirq, steal := nums[4], nums[5], nums[6], nums[7]
|
||||||
user = nums[0]
|
|
||||||
if len(nums) > 1 {
|
|
||||||
nice = nums[1]
|
|
||||||
}
|
|
||||||
if len(nums) > 2 {
|
|
||||||
system = nums[2]
|
|
||||||
}
|
|
||||||
if len(nums) > 3 {
|
|
||||||
idle = nums[3]
|
|
||||||
}
|
|
||||||
if len(nums) > 4 {
|
|
||||||
iowait = nums[4]
|
|
||||||
}
|
|
||||||
if len(nums) > 5 {
|
|
||||||
irq = nums[5]
|
|
||||||
}
|
|
||||||
if len(nums) > 6 {
|
|
||||||
softirq = nums[6]
|
|
||||||
}
|
|
||||||
if len(nums) > 7 {
|
|
||||||
steal = nums[7]
|
|
||||||
}
|
|
||||||
|
|
||||||
idleAll := idle + iowait
|
idleAll := idle + iowait
|
||||||
nonIdle := user + nice + system + irq + softirq + steal
|
nonIdle := user + nice + system + irq + softirq + steal
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
//go:build windows
|
//go:build windows
|
||||||
// +build windows
|
|
||||||
|
|
||||||
package sys
|
package sys
|
||||||
|
|
||||||
|
|
@ -10,6 +9,7 @@ import (
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/net"
|
"github.com/shirou/gopsutil/v4/net"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
var SIGUSR1 = syscall.Signal(0)
|
var SIGUSR1 = syscall.Signal(0)
|
||||||
|
|
@ -19,7 +19,6 @@ func GetConnectionCount(proto string) (int, error) {
|
||||||
if proto != "tcp" && proto != "udp" {
|
if proto != "tcp" && proto != "udp" {
|
||||||
return 0, errors.New("invalid protocol")
|
return 0, errors.New("invalid protocol")
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := net.Connections(proto)
|
stats, err := net.Connections(proto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|
@ -40,7 +39,9 @@ func GetUDPCount() (int, error) {
|
||||||
// --- CPU Utilization (Windows native) ---
|
// --- CPU Utilization (Windows native) ---
|
||||||
|
|
||||||
var (
|
var (
|
||||||
modKernel32 = syscall.NewLazyDLL("kernel32.dll")
|
// NewLazySystemDLL forces the load from %SystemRoot%\System32 so a
|
||||||
|
// kernel32.dll planted next to the binary can't hijack the call.
|
||||||
|
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||||
procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
|
procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
|
||||||
|
|
||||||
cpuMu sync.Mutex
|
cpuMu sync.Mutex
|
||||||
|
|
@ -50,32 +51,25 @@ var (
|
||||||
hasLast bool
|
hasLast bool
|
||||||
)
|
)
|
||||||
|
|
||||||
type filetime struct {
|
func ftToUint64(ft windows.Filetime) uint64 {
|
||||||
LowDateTime uint32
|
|
||||||
HighDateTime uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
// ftToUint64 converts a Windows FILETIME-like struct to a uint64 for
|
|
||||||
// arithmetic and delta calculations used by CPUPercentRaw.
|
|
||||||
func ftToUint64(ft filetime) uint64 {
|
|
||||||
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
|
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CPUPercentRaw returns the instantaneous total CPU utilization percentage using
|
// CPUPercentRaw returns instantaneous total CPU utilization across all
|
||||||
// Windows GetSystemTimes across all logical processors. The first call returns 0
|
// logical processors via Windows GetSystemTimes. The first call returns 0
|
||||||
// as it initializes the baseline. Subsequent calls compute deltas.
|
// while it initializes the baseline; subsequent calls compute deltas.
|
||||||
func CPUPercentRaw() (float64, error) {
|
func CPUPercentRaw() (float64, error) {
|
||||||
var idleFT, kernelFT, userFT filetime
|
var idleFT, kernelFT, userFT windows.Filetime
|
||||||
r1, _, e1 := procGetSystemTimes.Call(
|
r1, _, e1 := procGetSystemTimes.Call(
|
||||||
uintptr(unsafe.Pointer(&idleFT)),
|
uintptr(unsafe.Pointer(&idleFT)),
|
||||||
uintptr(unsafe.Pointer(&kernelFT)),
|
uintptr(unsafe.Pointer(&kernelFT)),
|
||||||
uintptr(unsafe.Pointer(&userFT)),
|
uintptr(unsafe.Pointer(&userFT)),
|
||||||
)
|
)
|
||||||
if r1 == 0 { // failure
|
if r1 == 0 {
|
||||||
if e1 != nil {
|
if errno, _ := e1.(syscall.Errno); errno != 0 {
|
||||||
return 0, e1
|
return 0, errno
|
||||||
}
|
}
|
||||||
return 0, syscall.GetLastError()
|
return 0, errors.New("GetSystemTimes failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
idle := ftToUint64(idleFT)
|
idle := ftToUint64(idleFT)
|
||||||
|
|
@ -97,7 +91,6 @@ func CPUPercentRaw() (float64, error) {
|
||||||
kernelDelta := kernel - lastKernel
|
kernelDelta := kernel - lastKernel
|
||||||
userDelta := user - lastUser
|
userDelta := user - lastUser
|
||||||
|
|
||||||
// Update for next call
|
|
||||||
lastIdle = idle
|
lastIdle = idle
|
||||||
lastKernel = kernel
|
lastKernel = kernel
|
||||||
lastUser = user
|
lastUser = user
|
||||||
|
|
@ -106,11 +99,10 @@ func CPUPercentRaw() (float64, error) {
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
// On Windows, kernel time includes idle time; busy = total - idle
|
// kernel time includes idle on Windows; busy = total - idle
|
||||||
busy := total - idleDelta
|
busy := total - idleDelta
|
||||||
|
|
||||||
pct := float64(busy) / float64(total) * 100.0
|
pct := float64(busy) / float64(total) * 100.0
|
||||||
// lower bound not needed; ratios of uint64 are non-negative
|
|
||||||
if pct > 100 {
|
if pct > 100 {
|
||||||
pct = 100
|
pct = 100
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
|
||||||
|
|
||||||
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||||
auth := c.GetHeader("Authorization")
|
auth := c.GetHeader("Authorization")
|
||||||
if strings.HasPrefix(auth, "Bearer ") {
|
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||||
tok := strings.TrimPrefix(auth, "Bearer ")
|
tok := after
|
||||||
if a.apiTokenService.Match(tok) {
|
if a.apiTokenService.Match(tok) {
|
||||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||||
session.SetAPIAuthUser(c, u)
|
session.SetAPIAuthUser(c, u)
|
||||||
|
|
@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
||||||
inbounds := api.Group("/inbounds")
|
inbounds := api.Group("/inbounds")
|
||||||
a.inboundController = NewInboundController(inbounds)
|
a.inboundController = NewInboundController(inbounds)
|
||||||
|
|
||||||
|
clients := api.Group("/clients")
|
||||||
|
NewClientController(clients)
|
||||||
|
|
||||||
// Server API
|
// Server API
|
||||||
server := api.Group("/server")
|
server := api.Group("/server")
|
||||||
a.serverController = NewServerController(server)
|
a.serverController = NewServerController(server)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
basePath = "/panel/api"
|
basePath = "/panel/api"
|
||||||
case "inbound.go":
|
case "inbound.go":
|
||||||
basePath = "/panel/api/inbounds"
|
basePath = "/panel/api/inbounds"
|
||||||
|
case "client.go":
|
||||||
|
basePath = "/panel/api/clients"
|
||||||
case "server.go":
|
case "server.go":
|
||||||
basePath = "/panel/api/server"
|
basePath = "/panel/api/server"
|
||||||
case "node.go":
|
case "node.go":
|
||||||
|
|
@ -127,6 +129,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
// Skip SPA page routes (these are UI pages, not API endpoints)
|
// Skip SPA page routes (these are UI pages, not API endpoints)
|
||||||
spaPages := map[string]bool{
|
spaPages := map[string]bool{
|
||||||
"/": true, "/panel/": true, "/panel/inbounds": true,
|
"/": true, "/panel/": true, "/panel/inbounds": true,
|
||||||
|
"/panel/clients": true,
|
||||||
"/panel/nodes": true, "/panel/settings": true,
|
"/panel/nodes": true, "/panel/settings": true,
|
||||||
"/panel/xray": true, "/panel/api-docs": true,
|
"/panel/xray": true, "/panel/api-docs": true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
311
web/controller/client.go
Normal file
311
web/controller/client.go
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/websocket"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func notifyClientsChanged() {
|
||||||
|
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientController struct {
|
||||||
|
clientService service.ClientService
|
||||||
|
inboundService service.InboundService
|
||||||
|
xrayService service.XrayService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientController(g *gin.RouterGroup) *ClientController {
|
||||||
|
a := &ClientController{}
|
||||||
|
a.initRouter(g)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||||
|
g.GET("/list", a.list)
|
||||||
|
g.GET("/get/:email", a.get)
|
||||||
|
g.GET("/traffic/:email", a.getTrafficByEmail)
|
||||||
|
g.GET("/subLinks/:subId", a.getSubLinks)
|
||||||
|
g.GET("/links/:email", a.getClientLinks)
|
||||||
|
|
||||||
|
g.POST("/add", a.create)
|
||||||
|
g.POST("/update/:email", a.update)
|
||||||
|
g.POST("/del/:email", a.delete)
|
||||||
|
g.POST("/:email/attach", a.attach)
|
||||||
|
g.POST("/:email/detach", a.detach)
|
||||||
|
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||||
|
g.POST("/delDepleted", a.delDepleted)
|
||||||
|
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
|
||||||
|
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
|
||||||
|
g.POST("/ips/:email", a.getIps)
|
||||||
|
g.POST("/clearIps/:email", a.clearIps)
|
||||||
|
g.POST("/onlines", a.onlines)
|
||||||
|
g.POST("/lastOnline", a.lastOnline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) list(c *gin.Context) {
|
||||||
|
rows, err := a.clientService.List()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, rows, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) get(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
rec, err := a.clientService.GetRecordByEmail(nil, email)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) create(c *gin.Context) {
|
||||||
|
var payload service.ClientCreatePayload
|
||||||
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) update(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
var updated model.Client
|
||||||
|
if err := c.ShouldBindJSON(&updated); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) delete(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
keepTraffic := c.Query("keepTraffic") == "1"
|
||||||
|
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
type attachDetachBody struct {
|
||||||
|
InboundIds []int `json:"inboundIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) attach(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
var body attachDetachBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) resetAllTraffics(c *gin.Context) {
|
||||||
|
needRestart, err := a.clientService.ResetAllTraffics()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) delDepleted(c *gin.Context) {
|
||||||
|
deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, gin.H{"deleted": deleted}, nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
type trafficUpdateRequest struct {
|
||||||
|
Upload int64 `json:"upload"`
|
||||||
|
Download int64 `json:"download"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
var req trafficUpdateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.inboundService.UpdateClientTrafficByEmail(email, req.Upload, req.Download); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) getIps(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
ips, err := a.inboundService.GetInboundClientIps(email)
|
||||||
|
if err != nil || ips == "" {
|
||||||
|
jsonObj(c, "No IP Record", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type ipWithTimestamp struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
var ipsWithTime []ipWithTimestamp
|
||||||
|
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
|
||||||
|
formatted := make([]string, 0, len(ipsWithTime))
|
||||||
|
for _, item := range ipsWithTime {
|
||||||
|
if item.IP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.Timestamp > 0 {
|
||||||
|
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
||||||
|
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
formatted = append(formatted, item.IP)
|
||||||
|
}
|
||||||
|
jsonObj(c, formatted, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var oldIps []string
|
||||||
|
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
|
||||||
|
jsonObj(c, oldIps, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, ips, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) clearIps(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
if err := a.inboundService.ClearClientIps(email); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) onlines(c *gin.Context) {
|
||||||
|
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) lastOnline(c *gin.Context) {
|
||||||
|
data, err := a.inboundService.GetClientsLastOnline()
|
||||||
|
jsonObj(c, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) getTrafficByEmail(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
traffic, err := a.inboundService.GetClientTrafficByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, traffic, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) getSubLinks(c *gin.Context) {
|
||||||
|
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, links, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) getClientLinks(c *gin.Context) {
|
||||||
|
links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, links, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) detach(c *gin.Context) {
|
||||||
|
email := c.Param("email")
|
||||||
|
var body attachDetachBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
notifyClientsChanged()
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
|
@ -20,6 +19,7 @@ import (
|
||||||
type InboundController struct {
|
type InboundController struct {
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
|
fallbackService service.FallbackService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInboundController creates a new InboundController and sets up its routes.
|
// NewInboundController creates a new InboundController and sets up its routes.
|
||||||
|
|
@ -61,38 +61,18 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
|
||||||
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.GET("/list", a.getInbounds)
|
g.GET("/list", a.getInbounds)
|
||||||
|
g.GET("/options", a.getInboundOptions)
|
||||||
g.GET("/get/:id", a.getInbound)
|
g.GET("/get/:id", a.getInbound)
|
||||||
g.GET("/getClientTraffics/:email", a.getClientTraffics)
|
g.GET("/:id/fallbacks", a.getFallbacks)
|
||||||
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
|
|
||||||
g.GET("/getSubLinks/:subId", a.getSubLinks)
|
|
||||||
g.GET("/getClientLinks/:id/:email", a.getClientLinks)
|
|
||||||
|
|
||||||
g.POST("/add", a.addInbound)
|
g.POST("/add", a.addInbound)
|
||||||
g.POST("/del/:id", a.delInbound)
|
g.POST("/del/:id", a.delInbound)
|
||||||
g.POST("/update/:id", a.updateInbound)
|
g.POST("/update/:id", a.updateInbound)
|
||||||
g.POST("/setEnable/:id", a.setInboundEnable)
|
g.POST("/setEnable/:id", a.setInboundEnable)
|
||||||
g.POST("/clientIps/:email", a.getClientIps)
|
|
||||||
g.POST("/clearClientIps/:email", a.clearClientIps)
|
|
||||||
g.POST("/addClient", a.addInboundClient)
|
|
||||||
g.POST("/:id/copyClients", a.copyInboundClients)
|
|
||||||
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
|
||||||
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
|
||||||
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
||||||
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
|
||||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||||
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
|
|
||||||
g.POST("/delDepletedClients/:id", a.delDepletedClients)
|
|
||||||
g.POST("/import", a.importInbound)
|
g.POST("/import", a.importInbound)
|
||||||
g.POST("/onlines", a.onlines)
|
g.POST("/:id/fallbacks", a.setFallbacks)
|
||||||
g.POST("/lastOnline", a.lastOnline)
|
|
||||||
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
|
|
||||||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CopyInboundClientsRequest struct {
|
|
||||||
SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
|
|
||||||
ClientEmails []string `form:"clientEmails" json:"clientEmails"`
|
|
||||||
Flow string `form:"flow" json:"flow"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||||
|
|
@ -106,6 +86,19 @@ func (a *InboundController) getInbounds(c *gin.Context) {
|
||||||
jsonObj(c, inbounds, nil)
|
jsonObj(c, inbounds, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInboundOptions returns a lightweight projection of the user's inbounds
|
||||||
|
// (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
|
||||||
|
// Avoids shipping per-client settings and traffic stats just to fill a dropdown.
|
||||||
|
func (a *InboundController) getInboundOptions(c *gin.Context) {
|
||||||
|
user := session.GetLoginUser(c)
|
||||||
|
options, err := a.inboundService.GetInboundOptions(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, options, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// getInbound retrieves a specific inbound by its ID.
|
// getInbound retrieves a specific inbound by its ID.
|
||||||
func (a *InboundController) getInbound(c *gin.Context) {
|
func (a *InboundController) getInbound(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
|
@ -121,28 +114,6 @@ func (a *InboundController) getInbound(c *gin.Context) {
|
||||||
jsonObj(c, inbound, nil)
|
jsonObj(c, inbound, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientTraffics retrieves client traffic information by email.
|
|
||||||
func (a *InboundController) getClientTraffics(c *gin.Context) {
|
|
||||||
email := c.Param("email")
|
|
||||||
clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, clientTraffics, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getClientTrafficsById retrieves client traffic information by inbound ID.
|
|
||||||
func (a *InboundController) getClientTrafficsById(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, clientTraffics, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addInbound creates a new inbound configuration.
|
// addInbound creates a new inbound configuration.
|
||||||
func (a *InboundController) addInbound(c *gin.Context) {
|
func (a *InboundController) addInbound(c *gin.Context) {
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
|
|
@ -274,174 +245,6 @@ func (a *InboundController) setInboundEnable(c *gin.Context) {
|
||||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientIps retrieves the IP addresses associated with a client by email.
|
|
||||||
func (a *InboundController) getClientIps(c *gin.Context) {
|
|
||||||
email := c.Param("email")
|
|
||||||
|
|
||||||
ips, err := a.inboundService.GetInboundClientIps(email)
|
|
||||||
if err != nil || ips == "" {
|
|
||||||
jsonObj(c, "No IP Record", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer returning a normalized string list for consistent UI rendering
|
|
||||||
type ipWithTimestamp struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var ipsWithTime []ipWithTimestamp
|
|
||||||
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
|
|
||||||
formatted := make([]string, 0, len(ipsWithTime))
|
|
||||||
for _, item := range ipsWithTime {
|
|
||||||
if item.IP == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if item.Timestamp > 0 {
|
|
||||||
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
|
||||||
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
formatted = append(formatted, item.IP)
|
|
||||||
}
|
|
||||||
jsonObj(c, formatted, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var oldIps []string
|
|
||||||
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
|
|
||||||
jsonObj(c, oldIps, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If parsing fails, return as string
|
|
||||||
jsonObj(c, ips, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearClientIps clears the IP addresses for a client by email.
|
|
||||||
func (a *InboundController) clearClientIps(c *gin.Context) {
|
|
||||||
email := c.Param("email")
|
|
||||||
|
|
||||||
err := a.inboundService.ClearClientIps(email)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addInboundClient adds a new client to an existing inbound.
|
|
||||||
func (a *InboundController) addInboundClient(c *gin.Context) {
|
|
||||||
data := &model.Inbound{}
|
|
||||||
err := c.ShouldBind(data)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
needRestart, err := a.inboundService.AddInboundClient(data)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
|
||||||
if needRestart {
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyInboundClients copies clients from source inbound to target inbound.
|
|
||||||
func (a *InboundController) copyInboundClients(c *gin.Context) {
|
|
||||||
targetID, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &CopyInboundClientsRequest{}
|
|
||||||
err = c.ShouldBind(req)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.SourceInboundID <= 0 {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, result, nil)
|
|
||||||
if needRestart {
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
|
|
||||||
func (a *InboundController) delInboundClient(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clientId := c.Param("clientId")
|
|
||||||
|
|
||||||
needRestart, err := a.inboundService.DelInboundClient(id, clientId)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
|
||||||
if needRestart {
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateInboundClient updates a client's configuration in an inbound.
|
|
||||||
func (a *InboundController) updateInboundClient(c *gin.Context) {
|
|
||||||
clientId := c.Param("clientId")
|
|
||||||
|
|
||||||
inbound := &model.Inbound{}
|
|
||||||
err := c.ShouldBind(inbound)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
|
||||||
if needRestart {
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetClientTraffic resets the traffic counter for a specific client in an inbound.
|
|
||||||
func (a *InboundController) resetClientTraffic(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
email := c.Param("email")
|
|
||||||
|
|
||||||
needRestart, err := a.inboundService.ResetClientTraffic(id, email)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
|
|
||||||
if needRestart {
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetInboundTraffic resets traffic counters for a specific inbound.
|
// resetInboundTraffic resets traffic counters for a specific inbound.
|
||||||
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
|
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
|
@ -472,24 +275,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetAllClientTraffics resets traffic counters for all clients in a specific inbound.
|
|
||||||
func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.inboundService.ResetAllClientTraffics(id)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// importInbound imports an inbound configuration from provided data.
|
// importInbound imports an inbound configuration from provided data.
|
||||||
func (a *InboundController) importInbound(c *gin.Context) {
|
func (a *InboundController) importInbound(c *gin.Context) {
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
|
|
@ -522,79 +307,6 @@ func (a *InboundController) importInbound(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// delDepletedClients deletes clients in an inbound who have exhausted their traffic limits.
|
|
||||||
func (a *InboundController) delDepletedClients(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = a.inboundService.DelDepletedClients(id)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// onlines retrieves the list of currently online clients.
|
|
||||||
func (a *InboundController) onlines(c *gin.Context) {
|
|
||||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastOnline retrieves the last online timestamps for clients.
|
|
||||||
func (a *InboundController) lastOnline(c *gin.Context) {
|
|
||||||
data, err := a.inboundService.GetClientsLastOnline()
|
|
||||||
jsonObj(c, data, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateClientTraffic updates the traffic statistics for a client by email.
|
|
||||||
func (a *InboundController) updateClientTraffic(c *gin.Context) {
|
|
||||||
email := c.Param("email")
|
|
||||||
|
|
||||||
// Define the request structure for traffic update
|
|
||||||
type TrafficUpdateRequest struct {
|
|
||||||
Upload int64 `json:"upload"`
|
|
||||||
Download int64 `json:"download"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var request TrafficUpdateRequest
|
|
||||||
err := c.ShouldBindJSON(&request)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.inboundService.UpdateClientTrafficByEmail(email, request.Upload, request.Download)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// delInboundClientByEmail deletes a client from an inbound by email address.
|
|
||||||
func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
|
||||||
inboundId, err := strconv.Atoi(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, "Invalid inbound ID", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
email := c.Param("email")
|
|
||||||
needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email)
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, "Failed to delete client by email", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonMsg(c, "Client deleted successfully", nil)
|
|
||||||
if needRestart {
|
|
||||||
a.xrayService.SetToNeedRestart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
|
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
|
||||||
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
|
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
|
||||||
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
|
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
|
||||||
|
|
@ -621,30 +333,42 @@ func resolveHost(c *gin.Context) string {
|
||||||
return c.Request.Host
|
return c.Request.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSubLinks returns every protocol URL produced for the given subscription
|
// getFallbacks returns the fallback rules attached to the master inbound.
|
||||||
// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
|
func (a *InboundController) getFallbacks(c *gin.Context) {
|
||||||
func (a *InboundController) getSubLinks(c *gin.Context) {
|
|
||||||
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
|
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, links, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getClientLinks returns the URL(s) for one client on one inbound — the same
|
|
||||||
// string the Copy URL button copies in the panel UI. Empty array when the
|
|
||||||
// protocol has no URL form, or when the email isn't found on the inbound.
|
|
||||||
func (a *InboundController) getClientLinks(c *gin.Context) {
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
|
rows, err := a.fallbackService.GetByMaster(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonObj(c, links, nil)
|
jsonObj(c, rows, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setFallbacks atomically replaces the master inbound's fallback list
|
||||||
|
// and triggers an Xray restart so the new settings.fallbacks take effect.
|
||||||
|
func (a *InboundController) setFallbacks(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type body struct {
|
||||||
|
Fallbacks []service.FallbackInput `json:"fallbacks"`
|
||||||
|
}
|
||||||
|
var b body
|
||||||
|
if err := c.ShouldBindJSON(&b); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.fallbackService.SetByMaster(id, b.Fallbacks); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ func (a *NodeController) history(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,6 @@ type ServerController struct {
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
panelService service.PanelService
|
panelService service.PanelService
|
||||||
xrayMetricsService service.XrayMetricsService
|
xrayMetricsService service.XrayMetricsService
|
||||||
|
|
||||||
lastStatus *service.Status
|
|
||||||
|
|
||||||
lastVersions []string
|
|
||||||
lastGetVersionsTime int64 // unix seconds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
|
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
|
||||||
|
|
@ -74,63 +69,43 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||||
g.POST("/getNewEchCert", a.getNewEchCert)
|
g.POST("/getNewEchCert", a.getNewEchCert)
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshStatus updates the cached server status and collects time-series
|
// startTask registers the @2s ticker that refreshes server status, samples
|
||||||
// metrics. CPU/Mem/Net/Online/Load are all written in one call so the
|
// xray metrics, and pushes the new snapshot to all websocket subscribers.
|
||||||
// SystemHistoryModal's tabs share an identical x-axis.
|
// State + sampling live in ServerService; the controller only orchestrates
|
||||||
func (a *ServerController) refreshStatus() {
|
// the cross-service side effects (xrayMetrics sample + websocket broadcast).
|
||||||
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
|
|
||||||
if a.lastStatus != nil {
|
|
||||||
now := time.Now()
|
|
||||||
a.serverService.AppendStatusSample(now, a.lastStatus)
|
|
||||||
a.xrayMetricsService.Sample(now)
|
|
||||||
// Broadcast status update via WebSocket
|
|
||||||
websocket.BroadcastStatus(a.lastStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startTask initiates background tasks for continuous status monitoring.
|
|
||||||
func (a *ServerController) startTask() {
|
func (a *ServerController) startTask() {
|
||||||
webServer := global.GetWebServer()
|
c := global.GetWebServer().GetCron()
|
||||||
c := webServer.GetCron()
|
|
||||||
c.AddFunc("@every 2s", func() {
|
c.AddFunc("@every 2s", func() {
|
||||||
// Always refresh to keep CPU history collected continuously.
|
status := a.serverService.RefreshStatus()
|
||||||
// Sampling is lightweight and capped to ~6 hours in memory.
|
if status == nil {
|
||||||
a.refreshStatus()
|
return
|
||||||
|
}
|
||||||
|
a.xrayMetricsService.Sample(time.Now())
|
||||||
|
websocket.BroadcastStatus(status)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// status returns the current server status information.
|
// status returns the current server status information.
|
||||||
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
|
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
|
||||||
|
|
||||||
// allowedHistoryBuckets is the bucket-second whitelist shared by both
|
func parseHistoryBucket(c *gin.Context) (int, bool) {
|
||||||
// /cpuHistory/:bucket and /history/:metric/:bucket. Restricting it
|
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||||
// prevents callers from triggering arbitrary aggregation work and keeps
|
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
||||||
// the front-end's bucket selector self-documenting.
|
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||||
var allowedHistoryBuckets = map[int]bool{
|
return 0, false
|
||||||
2: true, // Real-time view
|
}
|
||||||
30: true, // 30s intervals
|
return bucket, true
|
||||||
60: true, // 1m intervals
|
|
||||||
120: true, // 2m intervals
|
|
||||||
180: true, // 3m intervals
|
|
||||||
300: true, // 5m intervals
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
||||||
// Kept for back-compat; new callers should use /history/cpu/:bucket which
|
// Kept for back-compat; new callers should use /history/cpu/:bucket which
|
||||||
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
|
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
|
||||||
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
||||||
bucketStr := c.Param("bucket")
|
bucket, ok := parseHistoryBucket(c)
|
||||||
bucket, err := strconv.Atoi(bucketStr)
|
if !ok {
|
||||||
if err != nil || bucket <= 0 {
|
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !allowedHistoryBuckets[bucket] {
|
jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
points := a.serverService.AggregateCpuHistory(bucket, 60)
|
|
||||||
jsonObj(c, points, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getMetricHistoryBucket returns up to 60 buckets of history for a single
|
// getMetricHistoryBucket returns up to 60 buckets of history for a single
|
||||||
|
|
@ -142,9 +117,8 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
|
||||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
bucket, ok := parseHistoryBucket(c)
|
||||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
if !ok {
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
|
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
|
||||||
|
|
@ -160,9 +134,8 @@ func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
|
||||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
bucket, ok := parseHistoryBucket(c)
|
||||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
if !ok {
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
|
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
|
||||||
|
|
@ -178,37 +151,19 @@ func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
|
||||||
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
|
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
bucket, ok := parseHistoryBucket(c)
|
||||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
if !ok {
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
|
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ServerController) getXrayVersion(c *gin.Context) {
|
func (a *ServerController) getXrayVersion(c *gin.Context) {
|
||||||
const cacheTTLSeconds = 15 * 60
|
versions, err := a.serverService.GetXrayVersionsCached()
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
if a.lastVersions != nil && now-a.lastGetVersionsTime <= cacheTTLSeconds {
|
|
||||||
jsonObj(c, a.lastVersions, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
versions, err := a.serverService.GetXrayVersions()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if a.lastVersions != nil {
|
|
||||||
logger.Warning("getXrayVersion failed; serving cached list:", err)
|
|
||||||
jsonObj(c, a.lastVersions, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "getVersion"), err)
|
jsonMsg(c, I18nWeb(c, "getVersion"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.lastVersions = versions
|
|
||||||
a.lastGetVersionsTime = now
|
|
||||||
|
|
||||||
jsonObj(c, versions, nil)
|
jsonObj(c, versions, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,7 +195,6 @@ func (a *ServerController) updatePanel(c *gin.Context) {
|
||||||
func (a *ServerController) updateGeofile(c *gin.Context) {
|
func (a *ServerController) updateGeofile(c *gin.Context) {
|
||||||
fileName := c.Param("fileName")
|
fileName := c.Param("fileName")
|
||||||
|
|
||||||
// Validate the filename for security (prevent path traversal attacks)
|
|
||||||
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
|
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
|
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
|
||||||
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
|
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
|
||||||
|
|
@ -287,55 +241,22 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
|
||||||
|
|
||||||
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
||||||
func (a *ServerController) getLogs(c *gin.Context) {
|
func (a *ServerController) getLogs(c *gin.Context) {
|
||||||
count := c.Param("count")
|
logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
|
||||||
level := c.PostForm("level")
|
|
||||||
syslog := c.PostForm("syslog")
|
|
||||||
logs := a.serverService.GetLogs(count, level, syslog)
|
|
||||||
jsonObj(c, logs, nil)
|
jsonObj(c, logs, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
|
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
|
||||||
func (a *ServerController) getXrayLogs(c *gin.Context) {
|
func (a *ServerController) getXrayLogs(c *gin.Context) {
|
||||||
count := c.Param("count")
|
freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
|
||||||
filter := c.PostForm("filter")
|
logs := a.serverService.GetXrayLogs(
|
||||||
showDirect := c.PostForm("showDirect")
|
c.Param("count"),
|
||||||
showBlocked := c.PostForm("showBlocked")
|
c.PostForm("filter"),
|
||||||
showProxy := c.PostForm("showProxy")
|
c.PostForm("showDirect"),
|
||||||
|
c.PostForm("showBlocked"),
|
||||||
var freedoms []string
|
c.PostForm("showProxy"),
|
||||||
var blackholes []string
|
freedoms,
|
||||||
|
blackholes,
|
||||||
//getting tags for freedom and blackhole outbounds
|
)
|
||||||
config, err := a.settingService.GetDefaultXrayConfig()
|
|
||||||
if err == nil && config != nil {
|
|
||||||
if cfgMap, ok := config.(map[string]any); ok {
|
|
||||||
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
|
|
||||||
for _, outbound := range outbounds {
|
|
||||||
if obMap, ok := outbound.(map[string]any); ok {
|
|
||||||
switch obMap["protocol"] {
|
|
||||||
case "freedom":
|
|
||||||
if tag, ok := obMap["tag"].(string); ok {
|
|
||||||
freedoms = append(freedoms, tag)
|
|
||||||
}
|
|
||||||
case "blackhole":
|
|
||||||
if tag, ok := obMap["tag"].(string); ok {
|
|
||||||
blackholes = append(blackholes, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(freedoms) == 0 {
|
|
||||||
freedoms = []string{"direct"}
|
|
||||||
}
|
|
||||||
if len(blackholes) == 0 {
|
|
||||||
blackholes = []string{"blocked"}
|
|
||||||
}
|
|
||||||
|
|
||||||
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes)
|
|
||||||
jsonObj(c, logs, nil)
|
jsonObj(c, logs, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,36 +279,25 @@ func (a *ServerController) getDb(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := "x-ui.db"
|
filename := "x-ui.db"
|
||||||
|
if !filenameRegex.MatchString(filename) {
|
||||||
if !isValidFilename(filename) {
|
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the headers for the response
|
|
||||||
c.Header("Content-Type", "application/octet-stream")
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||||
|
|
||||||
// Write the file contents to the response
|
|
||||||
c.Writer.Write(db)
|
c.Writer.Write(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValidFilename(filename string) bool {
|
|
||||||
// Validate that the filename only contains allowed characters
|
|
||||||
return filenameRegex.MatchString(filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
// importDB imports a database file and restarts the Xray service.
|
// importDB imports a database file and restarts the Xray service.
|
||||||
func (a *ServerController) importDB(c *gin.Context) {
|
func (a *ServerController) importDB(c *gin.Context) {
|
||||||
// Get the file from the request body
|
|
||||||
file, _, err := c.Request.FormFile("db")
|
file, _, err := c.Request.FormFile("db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
err = a.serverService.ImportDB(file)
|
if err := a.serverService.ImportDB(file); err != nil {
|
||||||
if err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -416,8 +326,7 @@ func (a *ServerController) getNewmldsa65(c *gin.Context) {
|
||||||
|
|
||||||
// getNewEchCert generates a new ECH certificate for the given SNI.
|
// getNewEchCert generates a new ECH certificate for the given SNI.
|
||||||
func (a *ServerController) getNewEchCert(c *gin.Context) {
|
func (a *ServerController) getNewEchCert(c *gin.Context) {
|
||||||
sni := c.PostForm("sni")
|
cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
|
||||||
cert, err := a.serverService.GetNewEchCert(sni)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, "get ech certificate", err)
|
jsonMsg(c, "get ech certificate", err)
|
||||||
return
|
return
|
||||||
|
|
@ -442,7 +351,6 @@ func (a *ServerController) getNewUUID(c *gin.Context) {
|
||||||
jsonMsg(c, "Failed to generate UUID", err)
|
jsonMsg(c, "Failed to generate UUID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonObj(c, uuidResp, nil)
|
jsonObj(c, uuidResp, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ func getRemoteIp(c *gin.Context) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||||
for _, part := range strings.Split(xff, ",") {
|
for part := range strings.SplitSeq(xff, ",") {
|
||||||
if ip, ok := extractTrustedIP(part); ok {
|
if ip, ok := extractTrustedIP(part); ok {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ func isTrustedProxy(ip string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
trusted := trustedProxyCIDRs()
|
trusted := trustedProxyCIDRs()
|
||||||
for _, value := range strings.Split(trusted, ",") {
|
for value := range strings.SplitSeq(trusted, ",") {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.GET("/inbounds", a.inbounds)
|
g.GET("/inbounds", a.inbounds)
|
||||||
|
g.GET("/clients", a.clients)
|
||||||
g.GET("/nodes", a.nodes)
|
g.GET("/nodes", a.nodes)
|
||||||
g.GET("/settings", a.settings)
|
g.GET("/settings", a.settings)
|
||||||
g.GET("/xray", a.xraySettings)
|
g.GET("/xray", a.xraySettings)
|
||||||
|
|
@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
|
||||||
serveDistPage(c, "inbounds.html")
|
serveDistPage(c, "inbounds.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *XUIController) clients(c *gin.Context) {
|
||||||
|
serveDistPage(c, "clients.html")
|
||||||
|
}
|
||||||
|
|
||||||
// nodes renders the multi-panel nodes management page.
|
// nodes renders the multi-panel nodes management page.
|
||||||
func (a *XUIController) nodes(c *gin.Context) {
|
func (a *XUIController) nodes(c *gin.Context) {
|
||||||
serveDistPage(c, "nodes.html")
|
serveDistPage(c, "nodes.html")
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ func (s *AllSetting) CheckValid() error {
|
||||||
s.SubClashPath += "/"
|
s.SubClashPath += "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
|
for cidr := range strings.SplitSeq(s.TrustedProxyCIDRs, ",") {
|
||||||
cidr = strings.TrimSpace(cidr)
|
cidr = strings.TrimSpace(cidr)
|
||||||
if cidr == "" {
|
if cidr == "" {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package global
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync"
|
||||||
_ "unsafe"
|
_ "unsafe"
|
||||||
|
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
|
|
@ -11,6 +12,9 @@ import (
|
||||||
var (
|
var (
|
||||||
webServer WebServer
|
webServer WebServer
|
||||||
subServer SubServer
|
subServer SubServer
|
||||||
|
|
||||||
|
restartHookMu sync.RWMutex
|
||||||
|
restartHook func()
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebServer interface defines methods for accessing the web server instance.
|
// WebServer interface defines methods for accessing the web server instance.
|
||||||
|
|
@ -44,3 +48,24 @@ func SetSubServer(s SubServer) {
|
||||||
func GetSubServer() SubServer {
|
func GetSubServer() SubServer {
|
||||||
return subServer
|
return subServer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRestartHook registers a callback that triggers an in-process panel
|
||||||
|
// restart. main.go sets this up to push SIGHUP into its own signal channel
|
||||||
|
// so the restart path works on Windows (where p.Signal(SIGHUP) is unsupported).
|
||||||
|
func SetRestartHook(fn func()) {
|
||||||
|
restartHookMu.Lock()
|
||||||
|
defer restartHookMu.Unlock()
|
||||||
|
restartHook = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerRestart fires the registered restart hook. Returns false if none is set.
|
||||||
|
func TriggerRestart() bool {
|
||||||
|
restartHookMu.RLock()
|
||||||
|
fn := restartHook
|
||||||
|
restartHookMu.RUnlock()
|
||||||
|
if fn == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fn()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
package job
|
package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"strings"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
|
ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
|
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
|
||||||
|
|
@ -20,6 +17,7 @@ var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
|
||||||
type LdapSyncJob struct {
|
type LdapSyncJob struct {
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
|
clientService service.ClientService
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,20 +133,31 @@ func (j *LdapSyncJob) Run() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Execute batch create ---
|
|
||||||
for tag, newClients := range clientsToCreate {
|
for tag, newClients := range clientsToCreate {
|
||||||
if len(newClients) == 0 {
|
if len(newClients) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
payload := &model.Inbound{Id: inboundMap[tag].Id}
|
ib := inboundMap[tag]
|
||||||
payload.Settings = j.clientsToJSON(newClients)
|
created := 0
|
||||||
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
|
restartNeeded := false
|
||||||
logger.Warningf("Failed to add clients for tag %s: %v", tag, err)
|
for _, c := range newClients {
|
||||||
} else {
|
nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c)
|
||||||
logger.Infof("LDAP auto-create: %d clients for %s", len(newClients), tag)
|
if err != nil {
|
||||||
|
logger.Warningf("Failed to add client %s for tag %s: %v", c.Email, tag, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
if nr {
|
||||||
|
restartNeeded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if created > 0 {
|
||||||
|
logger.Infof("LDAP auto-create: %d clients for %s", created, tag)
|
||||||
|
if restartNeeded {
|
||||||
j.xrayService.SetToNeedRestart()
|
j.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Execute enable/disable batch ---
|
// --- Execute enable/disable batch ---
|
||||||
for tag, emails := range clientsToEnable {
|
for tag, emails := range clientsToEnable {
|
||||||
|
|
@ -206,34 +215,31 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// batchSetEnable enables/disables clients in batch through a single call
|
|
||||||
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
|
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
|
||||||
if len(emails) == 0 {
|
if len(emails) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
restartNeeded := false
|
||||||
// Prepare JSON for mass update
|
changed := 0
|
||||||
clients := make([]model.Client, 0, len(emails))
|
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
clients = append(clients, model.Client{
|
ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
|
||||||
Email: email,
|
if err != nil {
|
||||||
Enable: enable,
|
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
|
||||||
})
|
continue
|
||||||
}
|
}
|
||||||
|
if ok {
|
||||||
payload := &model.Inbound{
|
changed++
|
||||||
Id: ib.Id,
|
|
||||||
Settings: j.clientsToJSON(clients),
|
|
||||||
}
|
}
|
||||||
|
if needRestart {
|
||||||
// Use a single AddInboundClient call to update enable
|
restartNeeded = true
|
||||||
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
|
|
||||||
logger.Warningf("Batch set enable failed for inbound %s: %v", ib.Tag, err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, len(emails), ib.Tag)
|
if changed > 0 {
|
||||||
|
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
|
||||||
|
}
|
||||||
|
if restartNeeded {
|
||||||
j.xrayService.SetToNeedRestart()
|
j.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
|
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
|
||||||
|
|
@ -269,90 +275,28 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete in batches
|
|
||||||
for i := 0; i < len(toDelete); i += batchSize {
|
for i := 0; i < len(toDelete); i += batchSize {
|
||||||
end := min(i+batchSize, len(toDelete))
|
end := min(i+batchSize, len(toDelete))
|
||||||
batch := toDelete[i:end]
|
batch := toDelete[i:end]
|
||||||
|
|
||||||
for _, c := range batch {
|
for _, c := range batch {
|
||||||
var clientKey string
|
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
|
||||||
switch ib.Protocol {
|
if err != nil {
|
||||||
case model.Trojan:
|
|
||||||
clientKey = c.Password
|
|
||||||
case model.Shadowsocks:
|
|
||||||
clientKey = c.Email
|
|
||||||
default: // vless/vmess
|
|
||||||
clientKey = c.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := j.inboundService.DelInboundClient(ib.Id, clientKey); err != nil {
|
|
||||||
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
|
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
|
||||||
c.Email, ib.Id, ib.Tag, err)
|
c.Email, ib.Id, ib.Tag, err)
|
||||||
} else {
|
continue
|
||||||
|
}
|
||||||
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
|
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
|
||||||
c.Email, ib.Id, ib.Tag)
|
c.Email, ib.Id, ib.Tag)
|
||||||
// do not restart here
|
if nr {
|
||||||
restartNeeded = true
|
restartNeeded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One time after all batches
|
|
||||||
if restartNeeded {
|
if restartNeeded {
|
||||||
j.xrayService.SetToNeedRestart()
|
j.xrayService.SetToNeedRestart()
|
||||||
logger.Info("Xray restart scheduled after batch deletion")
|
logger.Info("Xray restart scheduled after batch deletion")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientsToJSON serializes an array of clients to JSON
|
|
||||||
func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string {
|
|
||||||
b := strings.Builder{}
|
|
||||||
b.WriteString("{\"clients\":[")
|
|
||||||
for i, c := range clients {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(",")
|
|
||||||
}
|
|
||||||
b.WriteString(j.clientToJSON(c))
|
|
||||||
}
|
|
||||||
b.WriteString("]}")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientToJSON serializes minimal client fields to JSON object string without extra deps
|
|
||||||
func (j *LdapSyncJob) clientToJSON(c model.Client) string {
|
|
||||||
// construct minimal JSON manually to avoid importing json for simple case
|
|
||||||
b := strings.Builder{}
|
|
||||||
b.WriteString("{")
|
|
||||||
if c.ID != "" {
|
|
||||||
b.WriteString("\"id\":\"")
|
|
||||||
b.WriteString(c.ID)
|
|
||||||
b.WriteString("\",")
|
|
||||||
}
|
|
||||||
if c.Password != "" {
|
|
||||||
b.WriteString("\"password\":\"")
|
|
||||||
b.WriteString(c.Password)
|
|
||||||
b.WriteString("\",")
|
|
||||||
}
|
|
||||||
b.WriteString("\"email\":\"")
|
|
||||||
b.WriteString(c.Email)
|
|
||||||
b.WriteString("\",")
|
|
||||||
b.WriteString("\"enable\":")
|
|
||||||
if c.Enable {
|
|
||||||
b.WriteString("true")
|
|
||||||
} else {
|
|
||||||
b.WriteString("false")
|
|
||||||
}
|
|
||||||
b.WriteString(",")
|
|
||||||
b.WriteString("\"limitIp\":")
|
|
||||||
b.WriteString(strconv.Itoa(c.LimitIP))
|
|
||||||
b.WriteString(",")
|
|
||||||
b.WriteString("\"totalGB\":")
|
|
||||||
b.WriteString(strconv.FormatInt(c.TotalGB, 10))
|
|
||||||
if c.ExpiryTime > 0 {
|
|
||||||
b.WriteString(",\"expiryTime\":")
|
|
||||||
b.WriteString(strconv.FormatInt(c.ExpiryTime, 10))
|
|
||||||
}
|
|
||||||
b.WriteString("}")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ const (
|
||||||
type NodeTrafficSyncJob struct {
|
type NodeTrafficSyncJob struct {
|
||||||
nodeService service.NodeService
|
nodeService service.NodeService
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
|
settingService service.SettingService
|
||||||
|
xrayService service.XrayService
|
||||||
running sync.Mutex
|
running sync.Mutex
|
||||||
structural atomicBool
|
structural atomicBool
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +85,22 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
|
_, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("node traffic sync: depletion check failed:", err)
|
||||||
|
}
|
||||||
|
if clientsDisabled {
|
||||||
|
if restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable(); settingErr == nil && restartOnDisable {
|
||||||
|
if err := j.xrayService.RestartXray(true); err != nil {
|
||||||
|
logger.Warning("node traffic sync: restart xray after disabling clients failed:", err)
|
||||||
|
j.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
} else if settingErr != nil {
|
||||||
|
logger.Warning("node traffic sync: get RestartXrayOnClientDisable failed:", settingErr)
|
||||||
|
}
|
||||||
|
j.structural.set()
|
||||||
|
}
|
||||||
|
|
||||||
if !websocket.HasClients() {
|
if !websocket.HasClients() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +141,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
|
|
||||||
if j.structural.takeAndReset() {
|
if j.structural.takeAndReset() {
|
||||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||||
|
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
69
web/job/node_traffic_sync_job_test.go
Normal file
69
web/job/node_traffic_sync_job_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAtomicBool_DefaultIsFalse(t *testing.T) {
|
||||||
|
var a atomicBool
|
||||||
|
if a.takeAndReset() {
|
||||||
|
t.Fatal("default atomicBool should report false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicBool_SetThenTakeReturnsTrueOnce(t *testing.T) {
|
||||||
|
var a atomicBool
|
||||||
|
a.set()
|
||||||
|
if !a.takeAndReset() {
|
||||||
|
t.Fatal("takeAndReset after set should return true")
|
||||||
|
}
|
||||||
|
if a.takeAndReset() {
|
||||||
|
t.Fatal("second takeAndReset should return false (state was reset)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicBool_SetIsIdempotent(t *testing.T) {
|
||||||
|
var a atomicBool
|
||||||
|
a.set()
|
||||||
|
a.set()
|
||||||
|
a.set()
|
||||||
|
if !a.takeAndReset() {
|
||||||
|
t.Fatal("repeated set should still leave the flag true")
|
||||||
|
}
|
||||||
|
if a.takeAndReset() {
|
||||||
|
t.Fatal("flag should be cleared after the first take")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) {
|
||||||
|
var a atomicBool
|
||||||
|
const setters = 100
|
||||||
|
const readers = 20
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range setters {
|
||||||
|
wg.Go(func() {
|
||||||
|
a.set()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
trueCount := 0
|
||||||
|
var rwg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
for range readers {
|
||||||
|
rwg.Go(func() {
|
||||||
|
if a.takeAndReset() {
|
||||||
|
mu.Lock()
|
||||||
|
trueCount++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rwg.Wait()
|
||||||
|
|
||||||
|
if trueCount != 1 {
|
||||||
|
t.Fatalf("expected exactly one reader to observe true, got %d", trueCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ type Period string
|
||||||
// PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period.
|
// PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period.
|
||||||
type PeriodicTrafficResetJob struct {
|
type PeriodicTrafficResetJob struct {
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
|
clientService service.ClientService
|
||||||
period Period
|
period Period
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +43,7 @@ func (j *PeriodicTrafficResetJob) Run() {
|
||||||
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
resetClientErr := j.inboundService.ResetAllClientTraffics(inbound.Id)
|
resetClientErr := j.clientService.ResetAllClientTraffics(&j.inboundService, inbound.Id)
|
||||||
if resetClientErr != nil {
|
if resetClientErr != nil {
|
||||||
logger.Warning("Failed to reset traffic for all users of inbound", inbound.Id, ":", resetClientErr)
|
logger.Warning("Failed to reset traffic for all users of inbound", inbound.Id, ":", resetClientErr)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
|
@ -78,6 +79,54 @@ func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
|
||||||
|
if !client.Enable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
user := map[string]any{
|
||||||
|
"email": client.Email,
|
||||||
|
"id": client.ID,
|
||||||
|
"security": client.Security,
|
||||||
|
"flow": client.Flow,
|
||||||
|
"auth": client.Auth,
|
||||||
|
"password": client.Password,
|
||||||
|
}
|
||||||
|
return l.AddUser(ctx, ib, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
|
||||||
|
if email == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := l.RemoveUser(ctx, ib, email); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
|
||||||
|
if oldEmail != "" {
|
||||||
|
if err := l.RemoveUser(ctx, ib, oldEmail); err != nil && !strings.Contains(err.Error(), "not found") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !payload.Enable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
user := map[string]any{
|
||||||
|
"email": payload.Email,
|
||||||
|
"id": payload.ID,
|
||||||
|
"security": payload.Security,
|
||||||
|
"flow": payload.Flow,
|
||||||
|
"auth": payload.Auth,
|
||||||
|
"password": payload.Password,
|
||||||
|
}
|
||||||
|
return l.AddUser(ctx, ib, user)
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Local) RestartXray(_ context.Context) error {
|
func (l *Local) RestartXray(_ context.Context) error {
|
||||||
if l.deps.SetNeedRestart != nil {
|
if l.deps.SetNeedRestart != nil {
|
||||||
l.deps.SetNeedRestart()
|
l.deps.SetNeedRestart()
|
||||||
|
|
@ -89,10 +138,6 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Local) ResetInboundClientTraffics(_ context.Context, _ *model.Inbound) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Local) ResetAllTraffics(_ context.Context) error {
|
func (l *Local) ResetAllTraffics(_ context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,31 +257,58 @@ func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) er
|
||||||
return r.UpdateInbound(ctx, ib, ib)
|
return r.UpdateInbound(ctx, ib, ib)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
|
||||||
|
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remote AddClient: resolve tag %q: %w", ib.Tag, err)
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"client": client,
|
||||||
|
"inboundIds": []int{id},
|
||||||
|
}
|
||||||
|
if _, err := r.do(ctx, http.MethodPost, "panel/api/clients/add", payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser is idempotent: master's per-inbound Delete loop may call it
|
||||||
|
// multiple times for the same node, and "not found" on the follow-ups is
|
||||||
|
// the expected success path.
|
||||||
|
func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
|
||||||
|
if email == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.do(ctx, http.MethodPost,
|
||||||
|
"panel/api/clients/del/"+url.PathEscape(email), nil)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
|
||||||
|
if oldEmail == "" {
|
||||||
|
oldEmail = payload.Email
|
||||||
|
}
|
||||||
|
if _, err := r.do(ctx, http.MethodPost,
|
||||||
|
"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Remote) RestartXray(ctx context.Context) error {
|
func (r *Remote) RestartXray(ctx context.Context) error {
|
||||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
|
_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Remote) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error {
|
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
|
||||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
_, err := r.do(ctx, http.MethodPost,
|
||||||
if err != nil {
|
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
|
||||||
logger.Warning("remote ResetClientTraffic: tag", ib.Tag, "not found on", r.node.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err = r.do(ctx, http.MethodPost,
|
|
||||||
fmt.Sprintf("panel/api/inbounds/%d/resetClientTraffic/%s", id, url.PathEscape(email)),
|
|
||||||
nil)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Remote) ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error {
|
|
||||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("remote ResetInboundClientTraffics: tag", ib.Tag, "not found on", r.node.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err = r.do(ctx, http.MethodPost,
|
|
||||||
fmt.Sprintf("panel/api/inbounds/resetAllClientTraffics/%d", id), nil)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,14 +334,14 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
|
||||||
return nil, fmt.Errorf("decode inbound list: %w", err)
|
return nil, fmt.Errorf("decode inbound list: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/onlines", nil)
|
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
|
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
|
||||||
} else if len(envOnlines.Obj) > 0 {
|
} else if len(envOnlines.Obj) > 0 {
|
||||||
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
|
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
|
||||||
}
|
}
|
||||||
|
|
||||||
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/lastOnline", nil)
|
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
|
logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
|
||||||
} else if len(envLastOnline.Obj) > 0 {
|
} else if len(envLastOnline.Obj) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,15 @@ type Runtime interface {
|
||||||
AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) error
|
AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) error
|
||||||
RemoveUser(ctx context.Context, ib *model.Inbound, email string) error
|
RemoveUser(ctx context.Context, ib *model.Inbound, email string) error
|
||||||
|
|
||||||
|
// Per-client operations that route through the node's clients API on
|
||||||
|
// Remote (instead of pushing the whole inbound) so the node applies
|
||||||
|
// per-user xray API calls without a DelInbound+AddInbound cycle.
|
||||||
|
UpdateUser(ctx context.Context, ib *model.Inbound, email string, payload model.Client) error
|
||||||
|
DeleteUser(ctx context.Context, ib *model.Inbound, email string) error
|
||||||
|
AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error
|
||||||
|
|
||||||
RestartXray(ctx context.Context) error
|
RestartXray(ctx context.Context) error
|
||||||
|
|
||||||
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
|
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
|
||||||
ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error
|
|
||||||
ResetAllTraffics(ctx context.Context) error
|
ResetAllTraffics(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1963
web/service/client.go
Normal file
1963
web/service/client.go
Normal file
File diff suppressed because it is too large
Load diff
59
web/service/client_test.go
Normal file
59
web/service/client_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) {
|
||||||
|
c := ClientWithAttachments{
|
||||||
|
ClientRecord: model.ClientRecord{Id: 1, Email: "alice@example.com"},
|
||||||
|
InboundIds: []int{3, 5},
|
||||||
|
Traffic: &xray.ClientTraffic{Email: "alice@example.com", Up: 1024, Down: 4096, Enable: true},
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if parsed["email"] != "alice@example.com" {
|
||||||
|
t.Errorf("expected ClientRecord fields to survive, got %v", parsed)
|
||||||
|
}
|
||||||
|
ids, ok := parsed["inboundIds"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected inboundIds to be present as an array, got %T (%s)", parsed["inboundIds"], out)
|
||||||
|
}
|
||||||
|
if len(ids) != 2 {
|
||||||
|
t.Errorf("expected 2 inbound ids, got %d", len(ids))
|
||||||
|
}
|
||||||
|
if _, ok := parsed["traffic"].(map[string]any); !ok {
|
||||||
|
t.Errorf("expected traffic to be present as an object, got %T", parsed["traffic"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientWithAttachmentsMarshalJSONOmitsAbsentTraffic(t *testing.T) {
|
||||||
|
c := ClientWithAttachments{
|
||||||
|
ClientRecord: model.ClientRecord{Id: 1, Email: "bob@example.com"},
|
||||||
|
InboundIds: nil,
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if _, present := parsed["traffic"]; present {
|
||||||
|
t.Errorf("expected traffic to be omitted when nil, got %v", parsed["traffic"])
|
||||||
|
}
|
||||||
|
if _, present := parsed["inboundIds"]; !present {
|
||||||
|
t.Errorf("expected inboundIds key to always be present, got %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
web/service/fallback.go
Normal file
147
web/service/fallback.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FallbackService struct{}
|
||||||
|
|
||||||
|
// FallbackInput is the payload shape POSTed by the inbound form.
|
||||||
|
type FallbackInput struct {
|
||||||
|
ChildId int `json:"childId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Alpn string `json:"alpn"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Xver int `json:"xver"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByMaster returns every fallback rule attached to the master inbound.
|
||||||
|
func (s *FallbackService) GetByMaster(masterId int) ([]model.InboundFallback, error) {
|
||||||
|
var rows []model.InboundFallback
|
||||||
|
err := database.GetDB().
|
||||||
|
Where("master_id = ?", masterId).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetParentForChild finds the first fallback rule that points at childId.
|
||||||
|
// Used by client-link generation: when a child inbound is attached as a
|
||||||
|
// fallback, its client links should advertise the master's address+port
|
||||||
|
// and TLS instead of the child's loopback listen.
|
||||||
|
func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback, error) {
|
||||||
|
var row model.InboundFallback
|
||||||
|
err := database.GetDB().
|
||||||
|
Where("child_id = ?", childId).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
First(&row).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetByMaster replaces the master's entire fallback list atomically.
|
||||||
|
func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallback{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, c := range items {
|
||||||
|
if c.ChildId <= 0 || c.ChildId == masterId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := model.InboundFallback{
|
||||||
|
MasterId: masterId,
|
||||||
|
ChildId: c.ChildId,
|
||||||
|
Name: c.Name,
|
||||||
|
Alpn: c.Alpn,
|
||||||
|
Path: c.Path,
|
||||||
|
Xver: c.Xver,
|
||||||
|
SortOrder: c.SortOrder,
|
||||||
|
}
|
||||||
|
if row.SortOrder == 0 {
|
||||||
|
row.SortOrder = i
|
||||||
|
}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFallbacksJSON resolves the master's fallback rows into Xray's
|
||||||
|
// expected settings.fallbacks shape, looking up each child's listen+port
|
||||||
|
// to fill the dest field. Returns nil when the master has no rules.
|
||||||
|
func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[string]any, error) {
|
||||||
|
if tx == nil {
|
||||||
|
tx = database.GetDB()
|
||||||
|
}
|
||||||
|
var rows []model.InboundFallback
|
||||||
|
err := tx.Where("master_id = ?", masterId).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
childIds := make([]int, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
childIds = append(childIds, rows[i].ChildId)
|
||||||
|
}
|
||||||
|
var children []model.Inbound
|
||||||
|
if err := tx.Where("id IN ?", childIds).Find(&children).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byId := make(map[int]*model.Inbound, len(children))
|
||||||
|
for i := range children {
|
||||||
|
byId[children[i].Id] = &children[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
child, ok := byId[r.ChildId]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
listen := strings.TrimSpace(child.Listen)
|
||||||
|
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
|
||||||
|
listen = "127.0.0.1"
|
||||||
|
}
|
||||||
|
entry := map[string]any{
|
||||||
|
"dest": fmt.Sprintf("%s:%d", listen, child.Port),
|
||||||
|
}
|
||||||
|
if r.Name != "" {
|
||||||
|
entry["name"] = r.Name
|
||||||
|
}
|
||||||
|
if r.Alpn != "" {
|
||||||
|
entry["alpn"] = r.Alpn
|
||||||
|
}
|
||||||
|
if r.Path != "" {
|
||||||
|
entry["path"] = r.Path
|
||||||
|
}
|
||||||
|
if r.Xver > 0 {
|
||||||
|
entry["xver"] = r.Xver
|
||||||
|
}
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -124,7 +124,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
|
||||||
}
|
}
|
||||||
|
|
||||||
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
|
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
|
||||||
// fed by ServerController.refreshStatus every 2s. nodeMetrics holds
|
// fed by ServerService.RefreshStatus every 2s. nodeMetrics holds
|
||||||
// per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
|
// per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
|
||||||
// process-local — survival across panel restart is not required.
|
// process-local — survival across panel restart is not required.
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ type HeartbeatPatch struct {
|
||||||
LastHeartbeat int64
|
LastHeartbeat int64
|
||||||
LatencyMs int
|
LatencyMs int
|
||||||
XrayVersion string
|
XrayVersion string
|
||||||
|
PanelVersion string
|
||||||
CpuPct float64
|
CpuPct float64
|
||||||
MemPct float64
|
MemPct float64
|
||||||
UptimeSecs uint64
|
UptimeSecs uint64
|
||||||
|
|
@ -45,7 +46,105 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var nodes []*model.Node
|
var nodes []*model.Node
|
||||||
err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
|
err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
|
||||||
|
if err != nil || len(nodes) == 0 {
|
||||||
return nodes, err
|
return nodes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboundRow struct {
|
||||||
|
Id int
|
||||||
|
NodeID int `gorm:"column:node_id"`
|
||||||
|
}
|
||||||
|
var inboundRows []inboundRow
|
||||||
|
if err := db.Table("inbounds").
|
||||||
|
Select("id, node_id").
|
||||||
|
Where("node_id IS NOT NULL").
|
||||||
|
Scan(&inboundRows).Error; err != nil {
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
if len(inboundRows) == 0 {
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
inboundsByNode := make(map[int][]int, len(nodes))
|
||||||
|
nodeByInbound := make(map[int]int, len(inboundRows))
|
||||||
|
for _, row := range inboundRows {
|
||||||
|
inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
|
||||||
|
nodeByInbound[row.Id] = row.NodeID
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientCountRow struct {
|
||||||
|
NodeID int `gorm:"column:node_id"`
|
||||||
|
Count int `gorm:"column:count"`
|
||||||
|
}
|
||||||
|
var clientCounts []clientCountRow
|
||||||
|
if err := db.Raw(`
|
||||||
|
SELECT inbounds.node_id AS node_id, COUNT(DISTINCT client_inbounds.client_id) AS count
|
||||||
|
FROM inbounds
|
||||||
|
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
|
||||||
|
WHERE inbounds.node_id IS NOT NULL
|
||||||
|
GROUP BY inbounds.node_id
|
||||||
|
`).Scan(&clientCounts).Error; err == nil {
|
||||||
|
for _, row := range clientCounts {
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Id == row.NodeID {
|
||||||
|
n.ClientCount = row.Count
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
type trafficRow struct {
|
||||||
|
InboundID int `gorm:"column:inbound_id"`
|
||||||
|
Email string
|
||||||
|
Enable bool
|
||||||
|
Total int64
|
||||||
|
Up int64
|
||||||
|
Down int64
|
||||||
|
ExpiryTime int64 `gorm:"column:expiry_time"`
|
||||||
|
}
|
||||||
|
var trafficRows []trafficRow
|
||||||
|
inboundIDs := make([]int, 0, len(nodeByInbound))
|
||||||
|
for id := range nodeByInbound {
|
||||||
|
inboundIDs = append(inboundIDs, id)
|
||||||
|
}
|
||||||
|
if err := db.Table("client_traffics").
|
||||||
|
Select("inbound_id, email, enable, total, up, down, expiry_time").
|
||||||
|
Where("inbound_id IN ?", inboundIDs).
|
||||||
|
Scan(&trafficRows).Error; err == nil {
|
||||||
|
online := make(map[string]struct{})
|
||||||
|
for _, email := range s.onlineEmails() {
|
||||||
|
online[email] = struct{}{}
|
||||||
|
}
|
||||||
|
depletedByNode := make(map[int]int)
|
||||||
|
onlineByNode := make(map[int]int)
|
||||||
|
for _, row := range trafficRows {
|
||||||
|
nodeID, ok := nodeByInbound[row.InboundID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
|
||||||
|
exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
|
||||||
|
if expired || exhausted || !row.Enable {
|
||||||
|
depletedByNode[nodeID]++
|
||||||
|
}
|
||||||
|
if _, ok := online[row.Email]; ok {
|
||||||
|
onlineByNode[nodeID]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
n.InboundCount = len(inboundsByNode[n.Id])
|
||||||
|
n.DepletedCount = depletedByNode[n.Id]
|
||||||
|
n.OnlineCount = onlineByNode[n.Id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) onlineEmails() []string {
|
||||||
|
svc := InboundService{}
|
||||||
|
return svc.GetOnlineClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *NodeService) GetById(id int) (*model.Node, error) {
|
func (s *NodeService) GetById(id int) (*model.Node, error) {
|
||||||
|
|
@ -154,6 +253,7 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
||||||
"last_heartbeat": p.LastHeartbeat,
|
"last_heartbeat": p.LastHeartbeat,
|
||||||
"latency_ms": p.LatencyMs,
|
"latency_ms": p.LatencyMs,
|
||||||
"xray_version": p.XrayVersion,
|
"xray_version": p.XrayVersion,
|
||||||
|
"panel_version": p.PanelVersion,
|
||||||
"cpu_pct": p.CpuPct,
|
"cpu_pct": p.CpuPct,
|
||||||
"mem_pct": p.MemPct,
|
"mem_pct": p.MemPct,
|
||||||
"uptime_secs": p.UptimeSecs,
|
"uptime_secs": p.UptimeSecs,
|
||||||
|
|
@ -238,6 +338,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
|
||||||
Xray struct {
|
Xray struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
} `json:"xray"`
|
} `json:"xray"`
|
||||||
|
PanelVersion string `json:"panelVersion"`
|
||||||
Uptime uint64 `json:"uptime"`
|
Uptime uint64 `json:"uptime"`
|
||||||
} `json:"obj"`
|
} `json:"obj"`
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +356,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
|
||||||
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
|
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
|
||||||
}
|
}
|
||||||
patch.XrayVersion = o.Xray.Version
|
patch.XrayVersion = o.Xray.Version
|
||||||
|
patch.PanelVersion = o.PanelVersion
|
||||||
patch.UptimeSecs = o.Uptime
|
patch.UptimeSecs = o.Uptime
|
||||||
return patch, nil
|
return patch, nil
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +365,7 @@ type ProbeResultUI struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
LatencyMs int `json:"latencyMs"`
|
LatencyMs int `json:"latencyMs"`
|
||||||
XrayVersion string `json:"xrayVersion"`
|
XrayVersion string `json:"xrayVersion"`
|
||||||
|
PanelVersion string `json:"panelVersion"`
|
||||||
CpuPct float64 `json:"cpuPct"`
|
CpuPct float64 `json:"cpuPct"`
|
||||||
MemPct float64 `json:"memPct"`
|
MemPct float64 `json:"memPct"`
|
||||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||||
|
|
@ -273,6 +376,7 @@ func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
|
||||||
r := ProbeResultUI{
|
r := ProbeResultUI{
|
||||||
LatencyMs: p.LatencyMs,
|
LatencyMs: p.LatencyMs,
|
||||||
XrayVersion: p.XrayVersion,
|
XrayVersion: p.XrayVersion,
|
||||||
|
PanelVersion: p.PanelVersion,
|
||||||
CpuPct: p.CpuPct,
|
CpuPct: p.CpuPct,
|
||||||
MemPct: p.MemPct,
|
MemPct: p.MemPct,
|
||||||
UptimeSecs: p.UptimeSecs,
|
UptimeSecs: p.UptimeSecs,
|
||||||
|
|
|
||||||
162
web/service/node_test.go
Normal file
162
web/service/node_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeBasePath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", "/"},
|
||||||
|
{" ", "/"},
|
||||||
|
{"/", "/"},
|
||||||
|
{"/panel", "/panel/"},
|
||||||
|
{"panel", "/panel/"},
|
||||||
|
{"panel/", "/panel/"},
|
||||||
|
{"/panel/", "/panel/"},
|
||||||
|
{" /panel ", "/panel/"},
|
||||||
|
{"/a/b/c", "/a/b/c/"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.in, func(t *testing.T) {
|
||||||
|
got := normalizeBasePath(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("normalizeBasePath(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeMetricKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
id int
|
||||||
|
metric string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{1, "cpu", "node:1:cpu"},
|
||||||
|
{42, "mem", "node:42:mem"},
|
||||||
|
{0, "anything", "node:0:anything"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := nodeMetricKey(c.id, c.metric)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("nodeMetricKey(%d, %q) = %q, want %q", c.id, c.metric, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatPatch_ToUI_OnlineCopiesFields(t *testing.T) {
|
||||||
|
p := HeartbeatPatch{
|
||||||
|
Status: "ignored-source",
|
||||||
|
LatencyMs: 42,
|
||||||
|
XrayVersion: "1.8.4",
|
||||||
|
PanelVersion: "3.0.0",
|
||||||
|
CpuPct: 12.5,
|
||||||
|
MemPct: 33.3,
|
||||||
|
UptimeSecs: 12345,
|
||||||
|
LastError: "",
|
||||||
|
}
|
||||||
|
ui := p.ToUI(true)
|
||||||
|
if ui.Status != "online" {
|
||||||
|
t.Fatalf("Status = %q, want online", ui.Status)
|
||||||
|
}
|
||||||
|
if ui.LatencyMs != 42 || ui.XrayVersion != "1.8.4" || ui.PanelVersion != "3.0.0" {
|
||||||
|
t.Fatalf("scalar copy mismatch: %+v", ui)
|
||||||
|
}
|
||||||
|
if ui.CpuPct != 12.5 || ui.MemPct != 33.3 || ui.UptimeSecs != 12345 {
|
||||||
|
t.Fatalf("metric copy mismatch: %+v", ui)
|
||||||
|
}
|
||||||
|
if ui.Error != "" {
|
||||||
|
t.Fatalf("Error = %q, want empty", ui.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatPatch_ToUI_OfflinePreservesError(t *testing.T) {
|
||||||
|
p := HeartbeatPatch{LastError: "connection refused"}
|
||||||
|
ui := p.ToUI(false)
|
||||||
|
if ui.Status != "offline" {
|
||||||
|
t.Fatalf("Status = %q, want offline", ui.Status)
|
||||||
|
}
|
||||||
|
if ui.Error != "connection refused" {
|
||||||
|
t.Fatalf("Error = %q, want %q", ui.Error, "connection refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeService_Normalize_Valid(t *testing.T) {
|
||||||
|
s := &NodeService{}
|
||||||
|
n := &model.Node{
|
||||||
|
Name: " primary ",
|
||||||
|
ApiToken: " abc ",
|
||||||
|
Address: "example.com",
|
||||||
|
Port: 8443,
|
||||||
|
Scheme: "",
|
||||||
|
BasePath: "panel",
|
||||||
|
}
|
||||||
|
if err := s.normalize(n); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if n.Name != "primary" {
|
||||||
|
t.Fatalf("Name not trimmed: %q", n.Name)
|
||||||
|
}
|
||||||
|
if n.ApiToken != "abc" {
|
||||||
|
t.Fatalf("ApiToken not trimmed: %q", n.ApiToken)
|
||||||
|
}
|
||||||
|
if n.Scheme != "https" {
|
||||||
|
t.Fatalf("empty Scheme should default to https, got %q", n.Scheme)
|
||||||
|
}
|
||||||
|
if n.BasePath != "/panel/" {
|
||||||
|
t.Fatalf("BasePath = %q, want /panel/", n.BasePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeService_Normalize_KeepsValidScheme(t *testing.T) {
|
||||||
|
s := &NodeService{}
|
||||||
|
n := &model.Node{Name: "n", Address: "example.com", Port: 80, Scheme: "http"}
|
||||||
|
if err := s.normalize(n); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if n.Scheme != "http" {
|
||||||
|
t.Fatalf("Scheme = %q, want http", n.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeService_Normalize_RejectsEmptyName(t *testing.T) {
|
||||||
|
s := &NodeService{}
|
||||||
|
n := &model.Node{Name: " ", Address: "example.com", Port: 443}
|
||||||
|
if err := s.normalize(n); err == nil {
|
||||||
|
t.Fatal("expected error for empty name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeService_Normalize_RejectsBadHost(t *testing.T) {
|
||||||
|
s := &NodeService{}
|
||||||
|
n := &model.Node{Name: "n", Address: "bad host name with spaces", Port: 443}
|
||||||
|
if err := s.normalize(n); err == nil {
|
||||||
|
t.Fatal("expected error for invalid host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeService_Normalize_RejectsOutOfRangePort(t *testing.T) {
|
||||||
|
s := &NodeService{}
|
||||||
|
for _, port := range []int{0, -1, 65536, 100000} {
|
||||||
|
n := &model.Node{Name: "n", Address: "example.com", Port: port}
|
||||||
|
if err := s.normalize(n); err == nil {
|
||||||
|
t.Fatalf("expected error for port %d", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
|
||||||
|
s := &NodeService{}
|
||||||
|
n := &model.Node{Name: "n", Address: "example.com", Port: 443, Scheme: "ftp"}
|
||||||
|
if err := s.normalize(n); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if n.Scheme != "https" {
|
||||||
|
t.Fatalf("Scheme = %q, want https", n.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/config"
|
"github.com/mhsanaei/3x-ui/v3/config"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/global"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PanelService provides business logic for panel management operations.
|
// PanelService provides business logic for panel management operations.
|
||||||
|
|
@ -35,14 +36,21 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
||||||
p, err := os.FindProcess(syscall.Getpid())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
err := p.Signal(syscall.SIGHUP)
|
if global.TriggerRestart() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
logger.Error("panel restart: no restart hook registered (SIGHUP unsupported on Windows)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := os.FindProcess(syscall.Getpid())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Error("panel restart: FindProcess failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.Signal(syscall.SIGHUP); err != nil {
|
||||||
logger.Error("failed to send SIGHUP signal:", err)
|
logger.Error("failed to send SIGHUP signal:", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -213,7 +221,7 @@ func compareVersionStrings(a string, b string) (int, bool) {
|
||||||
if !okA || !okB {
|
if !okA || !okB {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
for i := 0; i < len(aParts); i++ {
|
for i := range len(aParts) {
|
||||||
if aParts[i] > bParts[i] {
|
if aParts[i] > bParts[i] {
|
||||||
return 1, true
|
return 1, true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
|
||||||
// "udp", or "tcp,udp". if it's set, it wins outright.
|
// "udp", or "tcp,udp". if it's set, it wins outright.
|
||||||
if n, ok := st["network"].(string); ok && n != "" {
|
if n, ok := st["network"].(string); ok && n != "" {
|
||||||
bits = 0
|
bits = 0
|
||||||
for _, part := range strings.Split(n, ",") {
|
for part := range strings.SplitSeq(n, ",") {
|
||||||
switch strings.TrimSpace(part) {
|
switch strings.TrimSpace(part) {
|
||||||
case "tcp":
|
case "tcp":
|
||||||
bits |= transportTCP
|
bits |= transportTCP
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protoco
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func intPtr(v int) *int { return &v }
|
//go:fix inline
|
||||||
|
func intPtr(v int) *int { return new(v) }
|
||||||
|
|
||||||
func TestInboundTransports(t *testing.T) {
|
func TestInboundTransports(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|
@ -360,7 +361,7 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
|
||||||
func TestCheckPortConflict_NodeScope(t *testing.T) {
|
func TestCheckPortConflict_NodeScope(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
||||||
seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, intPtr(1))
|
seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
|
||||||
|
|
||||||
svc := &InboundService{}
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
|
@ -370,8 +371,8 @@ func TestCheckPortConflict_NodeScope(t *testing.T) {
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"new local same port + tcp clashes with local", nil, true},
|
{"new local same port + tcp clashes with local", nil, true},
|
||||||
{"new remote on different node from local is fine", intPtr(2), false},
|
{"new remote on different node from local is fine", new(2), false},
|
||||||
{"new remote on existing node 1 clashes", intPtr(1), true},
|
{"new remote on existing node 1 clashes", new(1), true},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ type Status struct {
|
||||||
ErrorMsg string `json:"errorMsg"`
|
ErrorMsg string `json:"errorMsg"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
} `json:"xray"`
|
} `json:"xray"`
|
||||||
|
PanelVersion string `json:"panelVersion"`
|
||||||
Uptime uint64 `json:"uptime"`
|
Uptime uint64 `json:"uptime"`
|
||||||
Loads []float64 `json:"loads"`
|
Loads []float64 `json:"loads"`
|
||||||
TcpCount int `json:"tcpCount"`
|
TcpCount int `json:"tcpCount"`
|
||||||
|
|
@ -104,6 +105,7 @@ type Release struct {
|
||||||
type ServerService struct {
|
type ServerService struct {
|
||||||
xrayService XrayService
|
xrayService XrayService
|
||||||
inboundService InboundService
|
inboundService InboundService
|
||||||
|
settingService SettingService
|
||||||
cachedIPv4 string
|
cachedIPv4 string
|
||||||
cachedIPv6 string
|
cachedIPv6 string
|
||||||
noIPv6 bool
|
noIPv6 bool
|
||||||
|
|
@ -114,6 +116,128 @@ type ServerService struct {
|
||||||
emaCPU float64
|
emaCPU float64
|
||||||
cachedCpuSpeedMhz float64
|
cachedCpuSpeedMhz float64
|
||||||
lastCpuInfoAttempt time.Time
|
lastCpuInfoAttempt time.Time
|
||||||
|
|
||||||
|
lastStatusMu sync.RWMutex
|
||||||
|
lastStatus *Status
|
||||||
|
|
||||||
|
versionsCacheMu sync.Mutex
|
||||||
|
versionsCache *cachedXrayVersions
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedXrayVersions struct {
|
||||||
|
versions []string
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// xrayVersionsCacheTTL bounds how often /getXrayVersion hits GitHub. The list
|
||||||
|
// is purely informational (rendered in the "switch Xray version" picker) so a
|
||||||
|
// quarter-hour staleness window is fine and saves the API budget.
|
||||||
|
const xrayVersionsCacheTTL = 15 * time.Minute
|
||||||
|
|
||||||
|
// allowedHistoryBuckets is the bucket-second whitelist for time-series
|
||||||
|
// aggregation endpoints (server + node metrics). Restricting it prevents
|
||||||
|
// callers from triggering arbitrary aggregation work and keeps the
|
||||||
|
// frontend's bucket selector self-documenting.
|
||||||
|
var allowedHistoryBuckets = map[int]bool{
|
||||||
|
2: true, // Real-time view
|
||||||
|
30: true, // 30s intervals
|
||||||
|
60: true, // 1m intervals
|
||||||
|
120: true, // 2m intervals
|
||||||
|
180: true, // 3m intervals
|
||||||
|
300: true, // 5m intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
|
||||||
|
// whitelist used by /server/history, /server/cpuHistory, /server/xrayMetricsHistory,
|
||||||
|
// /server/xrayObservatoryHistory, and /nodes/history.
|
||||||
|
func IsAllowedHistoryBucket(bucketSeconds int) bool {
|
||||||
|
return allowedHistoryBuckets[bucketSeconds]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastStatus returns the most recent Status snapshot collected by
|
||||||
|
// RefreshStatus. Safe for concurrent readers.
|
||||||
|
func (s *ServerService) LastStatus() *Status {
|
||||||
|
s.lastStatusMu.RLock()
|
||||||
|
defer s.lastStatusMu.RUnlock()
|
||||||
|
return s.lastStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshStatus collects a new system snapshot, stores it as LastStatus, and
|
||||||
|
// appends it to the system-metrics time series. Returns the new snapshot (may
|
||||||
|
// be nil if collection failed). Called by the background ticker; the caller is
|
||||||
|
// responsible for any side effects (websocket broadcast, xray metrics sample).
|
||||||
|
func (s *ServerService) RefreshStatus() *Status {
|
||||||
|
next := s.GetStatus(s.LastStatus())
|
||||||
|
if next == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.lastStatusMu.Lock()
|
||||||
|
s.lastStatus = next
|
||||||
|
s.lastStatusMu.Unlock()
|
||||||
|
s.AppendStatusSample(time.Now(), next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetXrayVersionsCached wraps GetXrayVersions with a TTL cache. On fetch
|
||||||
|
// failure we serve the last successful list (if any) so the UI doesn't go
|
||||||
|
// blank during a GitHub API hiccup; if there's no cache at all the underlying
|
||||||
|
// error is surfaced.
|
||||||
|
func (s *ServerService) GetXrayVersionsCached() ([]string, error) {
|
||||||
|
s.versionsCacheMu.Lock()
|
||||||
|
cache := s.versionsCache
|
||||||
|
s.versionsCacheMu.Unlock()
|
||||||
|
if cache != nil && time.Since(cache.fetchedAt) <= xrayVersionsCacheTTL {
|
||||||
|
return cache.versions, nil
|
||||||
|
}
|
||||||
|
versions, err := s.GetXrayVersions()
|
||||||
|
if err != nil {
|
||||||
|
if cache != nil {
|
||||||
|
logger.Warning("GetXrayVersionsCached: serving stale list:", err)
|
||||||
|
return cache.versions, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.versionsCacheMu.Lock()
|
||||||
|
s.versionsCache = &cachedXrayVersions{versions: versions, fetchedAt: time.Now()}
|
||||||
|
s.versionsCacheMu.Unlock()
|
||||||
|
return versions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultLogOutboundTags scans the default Xray config for freedom and
|
||||||
|
// blackhole outbound tags so /getXrayLogs can colour-code log lines without
|
||||||
|
// the controller re-doing the JSON walk. Falls back to the historical
|
||||||
|
// "direct"/"blocked" defaults when the config can't be read.
|
||||||
|
func (s *ServerService) GetDefaultLogOutboundTags() (freedoms, blackholes []string) {
|
||||||
|
config, err := s.settingService.GetDefaultXrayConfig()
|
||||||
|
if err == nil && config != nil {
|
||||||
|
if cfgMap, ok := config.(map[string]any); ok {
|
||||||
|
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
|
||||||
|
for _, outbound := range outbounds {
|
||||||
|
obMap, ok := outbound.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag, _ := obMap["tag"].(string)
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch obMap["protocol"] {
|
||||||
|
case "freedom":
|
||||||
|
freedoms = append(freedoms, tag)
|
||||||
|
case "blackhole":
|
||||||
|
blackholes = append(blackholes, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(freedoms) == 0 {
|
||||||
|
freedoms = []string{"direct"}
|
||||||
|
}
|
||||||
|
if len(blackholes) == 0 {
|
||||||
|
blackholes = []string{"blocked"}
|
||||||
|
}
|
||||||
|
return freedoms, blackholes
|
||||||
}
|
}
|
||||||
|
|
||||||
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
|
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
|
||||||
|
|
@ -360,6 +484,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||||
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
|
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
|
||||||
}
|
}
|
||||||
status.Xray.Version = s.xrayService.GetXrayVersion()
|
status.Xray.Version = s.xrayService.GetXrayVersion()
|
||||||
|
status.PanelVersion = config.GetVersion()
|
||||||
|
|
||||||
// Application stats
|
// Application stats
|
||||||
var rtm runtime.MemStats
|
var rtm runtime.MemStats
|
||||||
|
|
@ -383,8 +508,8 @@ func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
|
||||||
|
|
||||||
// AppendStatusSample writes one tick of every metric we keep — CPU, memory
|
// AppendStatusSample writes one tick of every metric we keep — CPU, memory
|
||||||
// percent, network throughput (bytes/s), online client count, and the three
|
// percent, network throughput (bytes/s), online client count, and the three
|
||||||
// load averages. Called by ServerController.refreshStatus on the same @2s
|
// load averages. Called by RefreshStatus on the same @2s cadence as
|
||||||
// cadence as AppendCpuSample, so all series stay aligned.
|
// AppendCpuSample, so all series stay aligned.
|
||||||
func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
|
func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
|
||||||
if status == nil {
|
if status == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
|
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
|
||||||
typ := reflect.TypeOf(LoginAttempt{})
|
typ := reflect.TypeFor[LoginAttempt]()
|
||||||
if _, ok := typ.FieldByName("Password"); ok {
|
if _, ok := typ.FieldByName("Password"); ok {
|
||||||
t.Fatal("LoginAttempt must not carry attempted passwords")
|
t.Fatal("LoginAttempt must not carry attempted passwords")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
|
|
@ -116,57 +118,101 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||||
if inbound.NodeID != nil {
|
if inbound.NodeID != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// get settings clients
|
|
||||||
settings := map[string]any{}
|
settings := map[string]any{}
|
||||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
clients, ok := settings["clients"].([]any)
|
|
||||||
if ok {
|
dbClients, listErr := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
|
||||||
// Fast O(N) lookup map for client traffic enablement
|
if listErr != nil {
|
||||||
|
return nil, listErr
|
||||||
|
}
|
||||||
|
|
||||||
clientStats := inbound.ClientStats
|
clientStats := inbound.ClientStats
|
||||||
enableMap := make(map[string]bool, len(clientStats))
|
enableMap := make(map[string]bool, len(clientStats))
|
||||||
for _, clientTraffic := range clientStats {
|
for _, clientTraffic := range clientStats {
|
||||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter and clean clients
|
var finalClients []any
|
||||||
var final_clients []any
|
for i := range dbClients {
|
||||||
for _, client := range clients {
|
c := dbClients[i]
|
||||||
c, ok := client.(map[string]any)
|
if enable, exists := enableMap[c.Email]; exists && !enable {
|
||||||
if !ok {
|
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !c.Enable {
|
||||||
email, _ := c["email"].(string)
|
|
||||||
|
|
||||||
// check users active or not via stats
|
|
||||||
if enable, exists := enableMap[email]; exists && !enable {
|
|
||||||
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", email)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
flow := c.Flow
|
||||||
// check manual disabled flag
|
if flow == "xtls-rprx-vision-udp443" {
|
||||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
flow = "xtls-rprx-vision"
|
||||||
continue
|
}
|
||||||
|
entry := map[string]any{"email": c.Email}
|
||||||
|
switch inbound.Protocol {
|
||||||
|
case model.VLESS:
|
||||||
|
if c.ID != "" {
|
||||||
|
entry["id"] = c.ID
|
||||||
|
}
|
||||||
|
if flow != "" {
|
||||||
|
entry["flow"] = flow
|
||||||
|
}
|
||||||
|
if c.Reverse != nil {
|
||||||
|
entry["reverse"] = c.Reverse
|
||||||
|
}
|
||||||
|
case model.VMESS:
|
||||||
|
if c.ID != "" {
|
||||||
|
entry["id"] = c.ID
|
||||||
|
}
|
||||||
|
if c.Security != "" {
|
||||||
|
entry["security"] = c.Security
|
||||||
|
}
|
||||||
|
case model.Trojan:
|
||||||
|
if c.Password != "" {
|
||||||
|
entry["password"] = c.Password
|
||||||
|
}
|
||||||
|
if flow != "" {
|
||||||
|
entry["flow"] = flow
|
||||||
|
}
|
||||||
|
case model.Shadowsocks:
|
||||||
|
if c.Password != "" {
|
||||||
|
entry["password"] = c.Password
|
||||||
|
}
|
||||||
|
if c.Security != "" {
|
||||||
|
entry["method"] = c.Security
|
||||||
|
}
|
||||||
|
case model.Hysteria, model.Hysteria2:
|
||||||
|
if c.Auth != "" {
|
||||||
|
entry["auth"] = c.Auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalClients = append(finalClients, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear client config for additional parameters
|
_, hadClients := settings["clients"]
|
||||||
for key := range c {
|
mutated := hadClients || len(finalClients) > 0
|
||||||
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" && key != "reverse" {
|
if mutated {
|
||||||
delete(c, key)
|
settings["clients"] = finalClients
|
||||||
}
|
|
||||||
if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {
|
|
||||||
c["flow"] = "xtls-rprx-vision"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final_clients = append(final_clients, any(c))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
settings["clients"] = final_clients
|
if inboundCanHostFallbacks(inbound) {
|
||||||
|
fallbacks, fbErr := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
|
||||||
|
if fbErr != nil {
|
||||||
|
return nil, fbErr
|
||||||
|
}
|
||||||
|
if len(fallbacks) > 0 {
|
||||||
|
generic := make([]any, 0, len(fallbacks))
|
||||||
|
for _, f := range fallbacks {
|
||||||
|
generic = append(generic, f)
|
||||||
|
}
|
||||||
|
settings["fallbacks"] = generic
|
||||||
|
mutated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mutated {
|
||||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
inbound.Settings = string(modifiedSettings)
|
inbound.Settings = string(modifiedSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,12 +241,62 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||||
inbound.StreamSettings = string(newStream)
|
inbound.StreamSettings = string(newStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if inbound.Protocol == model.Shadowsocks {
|
||||||
|
if healed, ok := healShadowsocksClientMethods(inbound.Settings); ok {
|
||||||
|
inbound.Settings = healed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
inboundConfig := inbound.GenXrayInboundConfig()
|
inboundConfig := inbound.GenXrayInboundConfig()
|
||||||
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
|
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
|
||||||
}
|
}
|
||||||
return xrayConfig, nil
|
return xrayConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// healShadowsocksClientMethods is the same idea as applyShadowsocksClientMethod
|
||||||
|
// (see client.go) but applied at xray-config-build time, to backfill the
|
||||||
|
// per-client method field for legacy shadowsocks inbounds whose clients were
|
||||||
|
// stored before applyShadowsocksClientMethod existed. Returns the rewritten
|
||||||
|
// settings string and true when anything actually changed.
|
||||||
|
func healShadowsocksClientMethods(settings string) (string, bool) {
|
||||||
|
if settings == "" {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
method, _ := parsed["method"].(string)
|
||||||
|
if method == "" || strings.HasPrefix(method, "2022-blake3-") {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
clients, ok := parsed["clients"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for i := range clients {
|
||||||
|
cm, ok := clients[i].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, _ := cm["method"].(string); existing != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cm["method"] = method
|
||||||
|
clients[i] = cm
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(parsed, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
return string(out), true
|
||||||
|
}
|
||||||
|
|
||||||
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
|
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
|
||||||
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
|
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
|
||||||
if !s.IsXrayRunning() {
|
if !s.IsXrayRunning() {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package service
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
@ -55,7 +56,7 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||||
// If `raw` does not look like a wrapper, it is returned unchanged.
|
// If `raw` does not look like a wrapper, it is returned unchanged.
|
||||||
func UnwrapXrayTemplateConfig(raw string) string {
|
func UnwrapXrayTemplateConfig(raw string) string {
|
||||||
const maxDepth = 8 // defensive cap against pathological multi-nest values
|
const maxDepth = 8 // defensive cap against pathological multi-nest values
|
||||||
for i := 0; i < maxDepth; i++ {
|
for range maxDepth {
|
||||||
var top map[string]json.RawMessage
|
var top map[string]json.RawMessage
|
||||||
if err := json.Unmarshal([]byte(raw), &top); err != nil {
|
if err := json.Unmarshal([]byte(raw), &top); err != nil {
|
||||||
return raw
|
return raw
|
||||||
|
|
@ -190,11 +191,9 @@ func findApiRule(rules []map[string]any) int {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case []string:
|
case []string:
|
||||||
for _, s := range tags {
|
if slices.Contains(tags, "api") {
|
||||||
if s == "api" {
|
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
|
||||||
case string:
|
case string:
|
||||||
if tags == "api" {
|
if tags == "api" {
|
||||||
return i
|
return i
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ func TestUnwrapXrayTemplateConfig(t *testing.T) {
|
||||||
// non-wrapped, and confirm we end up at some valid JSON (we
|
// non-wrapped, and confirm we end up at some valid JSON (we
|
||||||
// don't loop forever and we don't blow the stack).
|
// don't loop forever and we don't blow the stack).
|
||||||
s := real
|
s := real
|
||||||
for i := 0; i < 16; i++ {
|
for range 16 {
|
||||||
s = `{"xraySetting":` + s + `}`
|
s = `{"xraySetting":` + s + `}`
|
||||||
}
|
}
|
||||||
got := UnwrapXrayTemplateConfig(s)
|
got := UnwrapXrayTemplateConfig(s)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue