diff --git a/.vscode/launch.json b/.vscode/launch.json index 8a969702..29426d7c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,26 +10,12 @@ "program": "${workspaceFolder}", "cwd": "${workspaceFolder}", "env": { - "XUI_DEBUG": "true" + "XUI_DEBUG": "true", + "XUI_DB_FOLDER": "x-ui", + "XUI_LOG_FOLDER": "x-ui", + "XUI_BIN_FOLDER": "x-ui" }, "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", - // Uncomment to override DB folder location (by default uses working dir on Windows when debug) - // "XUI_DB_FOLDER": "${workspaceFolder}", - // Example: override log level (debug|info|notice|warn|error) - // "XUI_LOG_LEVEL": "debug" - }, - "console": "integratedTerminal" - } ] -} +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 337d694f..0644bb9d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -70,6 +70,20 @@ "problemMatcher": [ "$go" ] + }, + { + "label": "go: fmt", + "type": "shell", + "command": "gofmt", + "args": [ + "-l", + "-w", + "." + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] } ] } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5455e8b5..9ea49667 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,222 @@ -## Local Development Setup +# Contributing -- Create a directory named `x-ui` in the project root -- Rename `.env.example` to `.env ` -- Run `main.go` \ No newline at end of file +Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running on your machine in a few minutes. + +## 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 ****. For most setups you want a release named like: + ``` + x86_64--release-posix-seh-ucrt-rt_-rev.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 . +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/.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/.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/.html` (copy an existing one and adjust the title + the imported entry). +2. Create `src/entries/.js` — `createApp(Page).use(antd).use(i18n).mount('#app')`. +3. Create the page component under `src/pages//.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/`, add `` to `MIGRATED_ROUTES` so dev-mode navigation works. +6. Wire a Go controller route that calls `serveDistPage(c, ".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** (` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7da007d9..af20a3a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -334,9 +334,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -471,61 +471,61 @@ } }, "node_modules/@intlify/core-base": { - "version": "11.4.2", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz", - "integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==", + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.4.tgz", + "integrity": "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==", "license": "MIT", "dependencies": { - "@intlify/devtools-types": "11.4.2", - "@intlify/message-compiler": "11.4.2", - "@intlify/shared": "11.4.2" + "@intlify/devtools-types": "11.4.4", + "@intlify/message-compiler": "11.4.4", + "@intlify/shared": "11.4.4" }, "engines": { - "node": ">= 16" + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" } }, "node_modules/@intlify/devtools-types": { - "version": "11.4.2", - "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz", - "integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==", + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.4.tgz", + "integrity": "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==", "license": "MIT", "dependencies": { - "@intlify/core-base": "11.4.2", - "@intlify/shared": "11.4.2" + "@intlify/core-base": "11.4.4", + "@intlify/shared": "11.4.4" }, "engines": { - "node": ">= 16" + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" } }, "node_modules/@intlify/message-compiler": { - "version": "11.4.2", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz", - "integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==", + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.4.tgz", + "integrity": "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==", "license": "MIT", "dependencies": { - "@intlify/shared": "11.4.2", + "@intlify/shared": "11.4.4", "source-map-js": "^1.0.2" }, "engines": { - "node": ">= 16" + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" } }, "node_modules/@intlify/shared": { - "version": "11.4.2", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz", - "integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==", + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.4.tgz", + "integrity": "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==", "license": "MIT", "engines": { - "node": ">= 16" + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" @@ -895,9 +895,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", - "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "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" }, @@ -944,13 +944,13 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", - "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.13" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1477,16 +1477,16 @@ } }, "node_modules/eslint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", - "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", + "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", @@ -2694,9 +2694,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -2748,13 +2748,6 @@ "@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": { "version": "2.2.31", "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": { - "version": "11.4.2", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz", - "integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==", + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.4.tgz", + "integrity": "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==", "license": "MIT", "dependencies": { - "@intlify/core-base": "11.4.2", - "@intlify/devtools-types": "11.4.2", - "@intlify/shared": "11.4.2", + "@intlify/core-base": "11.4.4", + "@intlify/devtools-types": "11.4.4", + "@intlify/shared": "11.4.4", "@vue/devtools-api": "^6.5.0" }, "engines": { - "node": ">= 16" + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index 3055e883..258c26ee 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -76,7 +76,14 @@ export function setupAxios() { if (config.data instanceof FormData) { config.headers['Content-Type'] = 'multipart/form-data'; } else { - config.data = qs.stringify(config.data, { arrayFormat: 'repeat' }); + 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 { + config.data = qs.stringify(config.data, { arrayFormat: 'repeat' }); + } } return config; }, @@ -104,9 +111,14 @@ export function setupAxios() { if (token) { cfg.headers = cfg.headers || {}; cfg.headers['X-CSRF-Token'] = token; - // axios re-stringifies on retry, so unwind our qs.stringify before - // letting the same request flow through the interceptor again. - if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data); + const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || ''); + if (typeof cfg.data === 'string') { + 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); } } diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index bf625f65..1fd4dfc9 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'; import { DashboardOutlined, UserOutlined, + TeamOutlined, SettingOutlined, ToolOutlined, ClusterOutlined, @@ -30,6 +31,7 @@ const props = defineProps({ const iconByName = { dashboard: DashboardOutlined, user: UserOutlined, + team: TeamOutlined, setting: SettingOutlined, tool: ToolOutlined, cluster: ClusterOutlined, @@ -42,6 +44,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base const tabs = computed(() => [ { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') }, { 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/settings`, icon: 'setting', title: t('menu.settings') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, diff --git a/frontend/src/entries/clients.js b/frontend/src/entries/clients.js new file mode 100644 index 00000000..fc9fc161 --- /dev/null +++ b/frontend/src/entries/clients.js @@ -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'); +}); diff --git a/frontend/src/models/dbinbound.js b/frontend/src/models/dbinbound.js index d7a9483e..49c19eaf 100644 --- a/frontend/src/models/dbinbound.js +++ b/frontend/src/models/dbinbound.js @@ -2,6 +2,19 @@ import dayjs from 'dayjs'; import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils'; 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 { constructor(data) { @@ -10,7 +23,6 @@ export class DBInbound { this.up = 0; this.down = 0; this.total = 0; - this.allTime = 0; this.remark = ""; this.enable = true; this.expiryTime = 0; @@ -28,6 +40,9 @@ export class DBInbound { // Optional FK to web/runtime registered Node. null/undefined = // local panel; otherwise the inbound lives on the named node. 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) { return; } @@ -111,20 +126,9 @@ export class DBInbound { return this._cachedInbound; } - let settings = {}; - if (!ObjectUtil.isEmpty(this.settings)) { - settings = JSON.parse(this.settings); - } - - 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 settings = coerceInboundJsonField(this.settings); + const streamSettings = coerceInboundJsonField(this.streamSettings); + const sniffing = coerceInboundJsonField(this.sniffing); const config = { port: this.port, diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index 830dc9a9..f333c628 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -16,6 +16,7 @@ export const Protocols = { }; export const SSMethods = { + AES_256_GCM: 'aes-256-gcm', CHACHA20_POLY1305: 'chacha20-poly1305', CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305', XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305', @@ -232,14 +233,20 @@ export class TcpStreamSettings extends XrayCommonClass { } toJson() { - return { - acceptProxyProtocol: this.acceptProxyProtocol, - header: { - type: this.type, - request: this.type === 'http' ? this.request.toJson() : undefined, - response: this.type === 'http' ? this.response.toJson() : undefined, - }, - }; + const json = {}; + if (this.acceptProxyProtocol) { + json.acceptProxyProtocol = true; + } + if (this.type === 'http') { + 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 { network: network, 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, realitySettings: this.isReality ? this.reality.toJson() : undefined, tcpSettings: network === 'tcp' ? this.tcp.toJson() : undefined, @@ -1514,11 +1523,14 @@ export class Sniffing extends XrayCommonClass { } toJson() { + if (!this.enabled) { + return { enabled: false }; + } return { - enabled: this.enabled, + enabled: true, destOverride: this.destOverride, - metadataOnly: this.metadataOnly, - routeOnly: this.routeOnly, + metadataOnly: this.metadataOnly || undefined, + routeOnly: this.routeOnly || undefined, ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined, domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined, }; @@ -2567,7 +2579,7 @@ Inbound.ClientBase = class extends XrayCommonClass { Inbound.VmessSettings = class extends Inbound.Settings { constructor(protocol, - vmesses = [new Inbound.VmessSettings.VMESS()]) { + vmesses = []) { super(protocol); this.vmesses = vmesses; } @@ -2635,7 +2647,7 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase { Inbound.VLESSSettings = class extends Inbound.Settings { constructor( protocol, - vlesses = [new Inbound.VLESSSettings.VLESS()], + vlesses = [], decryption = "none", encryption = "none", fallbacks = [], @@ -2782,7 +2794,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass { Inbound.TrojanSettings = class extends Inbound.Settings { constructor(protocol, - trojans = [new Inbound.TrojanSettings.Trojan()], + trojans = [], fallbacks = [],) { super(protocol); this.trojans = trojans; @@ -2864,8 +2876,8 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings { constructor(protocol, method = SSMethods.BLAKE3_AES_256_GCM, password = RandomUtil.randomShadowsocksPassword(), - network = 'tcp,udp', - shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()], + network = 'tcp', + shadowsockses = [], ivCheck = false, ) { super(protocol); @@ -2927,7 +2939,7 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase { }; Inbound.HysteriaSettings = class extends Inbound.Settings { - constructor(protocol, version = 2, hysterias = [new Inbound.HysteriaSettings.Hysteria()]) { + constructor(protocol, version = 2, hysterias = []) { super(protocol); this.version = version; this.hysterias = hysterias; diff --git a/frontend/src/pages/api-docs/EndpointSection.vue b/frontend/src/pages/api-docs/EndpointSection.vue index 307f0a41..795e9bb6 100644 --- a/frontend/src/pages/api-docs/EndpointSection.vue +++ b/frontend/src/pages/api-docs/EndpointSection.vue @@ -9,7 +9,7 @@ import { safeInlineHtml } from './endpoints.js'; const props = defineProps({ section: { type: Object, required: true }, - icon: { type: Object, default: null }, + icon: { type: [Object, Function], default: null }, collapsed: { type: Boolean, default: false }, }); diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 70415ce5..431e1e08 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -76,9 +76,16 @@ export const sections = [ { method: 'GET', 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: - '{\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', @@ -88,30 +95,12 @@ export const sections = [ { 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', 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: - '{\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: '{\n "success": false,\n "msg": "Port 443 is already in use"\n}', }, @@ -140,59 +129,6 @@ export const sections = [ ], 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', path: '/panel/api/inbounds/:id/resetTraffic', @@ -201,36 +137,11 @@ export const sections = [ { 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', path: '/panel/api/inbounds/resetAllTraffics', 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', path: '/panel/api/inbounds/import', @@ -239,58 +150,26 @@ export const sections = [ { 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', - path: '/panel/api/inbounds/getSubLinks/:subId', - summary: - 'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/, 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.', + path: '/panel/api/inbounds/:id/fallbacks', + 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.', 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: - '{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\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}', + '{\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: 'POST', - path: '/panel/api/inbounds/updateClientTraffic/:email', - summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.', + path: '/panel/api/inbounds/:id/fallbacks', + summary: 'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.', params: [ - { name: 'email', in: 'path', type: 'string', desc: 'Client email.' }, - ], - 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.' }, + { 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 "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/, 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', title: 'Nodes', @@ -504,7 +550,7 @@ export const sections = [ method: 'GET', path: '/panel/api/nodes/list', 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', @@ -517,9 +563,9 @@ export const sections = [ { method: 'POST', 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: - '{\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', @@ -528,7 +574,7 @@ export const sections = [ params: [ { 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', @@ -550,9 +596,9 @@ export const sections = [ { method: 'POST', path: '/panel/api/nodes/test', - summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.', - body: '{\n "scheme": "https",\n "host": "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}', + 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 "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\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', diff --git a/frontend/src/pages/clients/ClientBulkAddModal.vue b/frontend/src/pages/clients/ClientBulkAddModal.vue new file mode 100644 index 00000000..4c391872 --- /dev/null +++ b/frontend/src/pages/clients/ClientBulkAddModal.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/frontend/src/pages/clients/ClientFormModal.vue b/frontend/src/pages/clients/ClientFormModal.vue new file mode 100644 index 00000000..d8ef80eb --- /dev/null +++ b/frontend/src/pages/clients/ClientFormModal.vue @@ -0,0 +1,402 @@ + + + diff --git a/frontend/src/pages/clients/ClientInfoModal.vue b/frontend/src/pages/clients/ClientInfoModal.vue new file mode 100644 index 00000000..8694e8c2 --- /dev/null +++ b/frontend/src/pages/clients/ClientInfoModal.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/frontend/src/pages/clients/ClientQrModal.vue b/frontend/src/pages/clients/ClientQrModal.vue new file mode 100644 index 00000000..3ae69c3f --- /dev/null +++ b/frontend/src/pages/clients/ClientQrModal.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/pages/clients/ClientsPage.vue b/frontend/src/pages/clients/ClientsPage.vue new file mode 100644 index 00000000..e1a03263 --- /dev/null +++ b/frontend/src/pages/clients/ClientsPage.vue @@ -0,0 +1,1067 @@ + + + + + + + diff --git a/frontend/src/pages/clients/useClients.js b/frontend/src/pages/clients/useClients.js new file mode 100644 index 00000000..f0db1420 --- /dev/null +++ b/frontend/src/pages/clients/useClients.js @@ -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, + }; +} diff --git a/frontend/src/pages/inbounds/ClientBulkModal.vue b/frontend/src/pages/inbounds/ClientBulkModal.vue deleted file mode 100644 index 19804a26..00000000 --- a/frontend/src/pages/inbounds/ClientBulkModal.vue +++ /dev/null @@ -1,280 +0,0 @@ - - - - - diff --git a/frontend/src/pages/inbounds/ClientFormModal.vue b/frontend/src/pages/inbounds/ClientFormModal.vue deleted file mode 100644 index df167ac0..00000000 --- a/frontend/src/pages/inbounds/ClientFormModal.vue +++ /dev/null @@ -1,394 +0,0 @@ - - - - - diff --git a/frontend/src/pages/inbounds/ClientRowTable.vue b/frontend/src/pages/inbounds/ClientRowTable.vue deleted file mode 100644 index 6ed33119..00000000 --- a/frontend/src/pages/inbounds/ClientRowTable.vue +++ /dev/null @@ -1,841 +0,0 @@ - - - - - diff --git a/frontend/src/pages/inbounds/CopyClientsModal.vue b/frontend/src/pages/inbounds/CopyClientsModal.vue deleted file mode 100644 index 685bc9a0..00000000 --- a/frontend/src/pages/inbounds/CopyClientsModal.vue +++ /dev/null @@ -1,185 +0,0 @@ - - - diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index 8d2019de..0c876c7b 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -3,7 +3,15 @@ import { computed, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import dayjs from 'dayjs'; import { message } from 'ant-design-vue'; -import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons-vue'; +import { + SyncOutlined, + PlusOutlined, + MinusOutlined, + DeleteOutlined, + CaretUpOutlined, + CaretDownOutlined, + SettingOutlined, +} from '@ant-design/icons-vue'; import { HttpUtil, @@ -17,8 +25,6 @@ import { Inbound, Protocols, SSMethods, - USERS_SECURITY, - TLS_FLOW_CONTROL, SNIFFING_OPTION, TLS_VERSION_OPTION, TLS_CIPHER_OPTION, @@ -36,34 +42,28 @@ import JsonEditor from '@/components/JsonEditor.vue'; import { useNodeList } from '@/composables/useNodeList.js'; const { t } = useI18n(); - -// Node selector — Phase 1 multi-node deployment. Shows all enabled -// nodes regardless of online state so the form is usable while a node -// is briefly offline; the backend's fail-fast path will surface the -// real error when the user submits. const { nodes: availableNodes } = useNodeList(); const selectableNodes = computed(() => (availableNodes.value || []).filter((n) => n.enable)); - -// Phase 5f-iii-b: structured per-protocol/per-transport forms instead -// of raw JSON textareas. Edits a deeply-reactive Inbound + DBInbound -// pair so the existing model helpers (.toString(), .canEnableTls(), -// genAllLinks(), addPeer(), etc.) keep working unchanged. The -// "Advanced" tab still exposes the full streamSettings JSON for -// transport variants (KCP/XHTTP/sockopt/finalmask) we don't yet have -// dedicated UI for. - +const NODE_ELIGIBLE_PROTOCOLS = new Set([ + Protocols.VLESS, + Protocols.VMESS, + Protocols.TROJAN, + Protocols.SHADOWSOCKS, + Protocols.HYSTERIA, + Protocols.WIREGUARD, +]); +const isNodeEligible = computed(() => NODE_ELIGIBLE_PROTOCOLS.has(inbound.value?.protocol)); const props = defineProps({ open: { type: Boolean, default: false }, mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) }, dbInbound: { type: Object, default: null }, + dbInbounds: { type: Array, default: () => [] }, }); const emit = defineEmits(['update:open', 'saved']); const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly']; const PROTOCOLS = Object.values(Protocols); -const SECURITY_OPTIONS = Object.values(USERS_SECURITY); -const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); // === Reactive state ================================================ // Cloned on every open so cancelling the modal doesn't mutate the row. @@ -75,8 +75,6 @@ const advancedSniffingText = ref(''); const advancedSettingsText = ref(''); const activeTabKey = ref('basic'); const advancedSectionKey = ref('all'); -// Cached default cert/key paths from /panel/setting/defaultSettings — -// powers the "Set default cert" button on the TLS form. const defaultCert = ref(''); const defaultKey = ref(''); @@ -122,58 +120,219 @@ const security = computed({ set: (v) => { if (inbound.value?.stream) inbound.value.stream.security = v; }, }); -const isMultiUser = computed(() => { +const isVlessLike = computed(() => { if (!inbound.value) return false; + return inbound.value.protocol === Protocols.VLESS; +}); + +const FALLBACK_ELIGIBLE_TRANSPORTS = new Set(['tcp', 'ws', 'grpc', 'httpupgrade', 'xhttp']); + +const isFallbackHost = computed(() => { + const ib = inbound.value; + if (!ib) return false; + if (ib.protocol !== Protocols.VLESS && ib.protocol !== Protocols.TROJAN) return false; + if (ib.stream?.network !== 'tcp') return false; + const sec = ib.stream?.security; + return sec === 'tls' || sec === 'reality'; +}); + +const fallbacks = ref([]); +let fallbackRowKey = 0; +const fallbackEditing = ref(new Set()); + +const fallbackChildOptions = computed(() => { + const list = props.dbInbounds || []; + const masterId = props.dbInbound?.id; + return list + .filter((ib) => ib.id !== masterId) + .map((ib) => ({ + label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, + value: ib.id, + })); +}); + +function getChildStream(childDb) { + if (!childDb) return null; + try { return childDb.toInbound()?.stream || null; } catch (_e) { return null; } +} + +function deriveFallbackDefaults(childDb) { + const out = { name: '', alpn: '', path: '', xver: 0 }; + const stream = getChildStream(childDb); + if (!stream) return out; + switch (stream.network) { + case 'tcp': { + const tcp = stream.tcp; + if (tcp?.type === 'http') { + const p = tcp?.request?.path; + if (Array.isArray(p) && p.length) out.path = p[0]; + } + if (tcp?.acceptProxyProtocol) out.xver = 2; + break; + } + case 'ws': { + out.path = stream.ws?.path || ''; + if (stream.ws?.acceptProxyProtocol) out.xver = 2; + break; + } + case 'grpc': { + out.path = stream.grpc?.serviceName || ''; + out.alpn = 'h2'; + break; + } + case 'httpupgrade': { + out.path = stream.httpupgrade?.path || ''; + if (stream.httpupgrade?.acceptProxyProtocol) out.xver = 2; + break; + } + case 'xhttp': { + out.path = stream.xhttp?.path || ''; + break; + } + } + return out; +} + +function addFallback(childId = null) { + const row = { rowKey: `fb-${++fallbackRowKey}`, childId: childId || null, name: '', alpn: '', path: '', xver: 0 }; + if (childId) { + const child = (props.dbInbounds || []).find((ib) => ib.id === childId); + Object.assign(row, deriveFallbackDefaults(child)); + } + fallbacks.value.push(row); +} + +function removeFallback(idx) { + fallbacks.value.splice(idx, 1); +} + +function moveFallback(idx, dir) { + const arr = fallbacks.value; + const j = idx + dir; + if (j < 0 || j >= arr.length) return; + const tmp = arr[idx]; + arr[idx] = arr[j]; + arr[j] = tmp; +} + +function onFallbackChildPicked(record, childId) { + record.childId = childId; + const child = (props.dbInbounds || []).find((ib) => ib.id === childId); + const defaults = deriveFallbackDefaults(child); + record.name = defaults.name; + record.alpn = defaults.alpn; + record.path = defaults.path; + record.xver = defaults.xver; +} + +function rederiveFallback(record) { + if (!record?.childId) return; + const child = (props.dbInbounds || []).find((ib) => ib.id === record.childId); + const defaults = deriveFallbackDefaults(child); + record.name = defaults.name; + record.alpn = defaults.alpn; + record.path = defaults.path; + record.xver = defaults.xver; + message.success(t('pages.inbounds.fallbacks.rederived') || 'Re-filled from child'); +} + +function quickAddAllFallbacks() { + const masterId = props.dbInbound?.id; + const list = props.dbInbounds || []; + const existing = new Set(fallbacks.value.map((r) => r.childId).filter(Boolean)); + let added = 0; + for (const ib of list) { + if (ib.id === masterId) continue; + if (existing.has(ib.id)) continue; + const stream = getChildStream(ib); + if (!stream || !FALLBACK_ELIGIBLE_TRANSPORTS.has(stream.network)) continue; + addFallback(ib.id); + added += 1; + } + if (added > 0) { + message.success(t('pages.inbounds.fallbacks.quickAdded', { n: added }) || `Added ${added} fallback(s)`); + } else { + message.info(t('pages.inbounds.fallbacks.quickAddedNone') || 'No new eligible inbounds to add'); + } +} + +function isFallbackEditing(rowKey) { return fallbackEditing.value.has(rowKey); } +function toggleFallbackEdit(rowKey) { + const next = new Set(fallbackEditing.value); + if (next.has(rowKey)) next.delete(rowKey); else next.add(rowKey); + fallbackEditing.value = next; +} + +function describeFallback(record) { + const parts = []; + if (record.name) parts.push(`SNI=${record.name}`); + if (record.alpn) parts.push(`ALPN=${record.alpn}`); + if (record.path) parts.push(`path=${record.path}`); + const condition = parts.length + ? `${t('pages.inbounds.fallbacks.routesWhen') || 'Routes when'} ${parts.join(' · ')}` + : (t('pages.inbounds.fallbacks.defaultCatchAll') || 'Default — catches anything else'); + const proxyTag = record.xver === 2 ? ' · PROXY v2' : record.xver === 1 ? ' · PROXY v1' : ''; + return { condition, proxyTag }; +} + +async function loadFallbacks(masterId) { + fallbacks.value = []; + if (!masterId) return; + const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`); + if (!msg?.success || !Array.isArray(msg.obj)) return; + fallbacks.value = msg.obj.map((r) => ({ + rowKey: `fb-${++fallbackRowKey}`, + childId: r.childId, + name: r.name || '', + alpn: r.alpn || '', + path: r.path || '', + xver: r.xver || 0, + })); +} + +async function saveFallbacks(masterId) { + if (!masterId) return true; + const payload = { + fallbacks: fallbacks.value + .filter((c) => c.childId) + .map((c, i) => ({ + childId: c.childId, + name: c.name, + alpn: c.alpn, + path: c.path, + xver: Number(c.xver) || 0, + sortOrder: i, + })), + }; + const msg = await HttpUtil.post( + `/panel/api/inbounds/${masterId}/fallbacks`, + payload, + { headers: { 'Content-Type': 'application/json' } }, + ); + return !!msg?.success; +} + +const canEnableStream = computed(() => inbound.value?.canEnableStream?.() === true); +const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true); +const canEnableReality = computed(() => inbound.value?.canEnableReality?.() === true); + +const hasProtocolTabContent = computed(() => { + if (!inbound.value) return false; + if (isVlessLike.value) return true; + if (isFallbackHost.value) return true; switch (inbound.value.protocol) { - case Protocols.VMESS: - case Protocols.VLESS: - case Protocols.TROJAN: - case Protocols.HYSTERIA: - return true; case Protocols.SHADOWSOCKS: - return !!inbound.value.isSSMultiUser; + case Protocols.HTTP: + case Protocols.MIXED: + case Protocols.TUNNEL: + case Protocols.TUN: + case Protocols.WIREGUARD: + return true; default: return false; } }); -const clientsArray = computed(() => { - if (!inbound.value) return []; - switch (inbound.value.protocol) { - case Protocols.VMESS: return inbound.value.settings.vmesses || []; - case Protocols.VLESS: return inbound.value.settings.vlesses || []; - case Protocols.TROJAN: return inbound.value.settings.trojans || []; - case Protocols.SHADOWSOCKS: return inbound.value.settings.shadowsockses || []; - case Protocols.HYSTERIA: return inbound.value.settings.hysterias || []; - default: return []; - } -}); - -const firstClient = computed(() => clientsArray.value[0] || null); -const canEnableStream = computed(() => inbound.value?.canEnableStream?.() === true); -const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true); -const canEnableReality = computed(() => inbound.value?.canEnableReality?.() === true); -const canEnableTlsFlow = computed(() => inbound.value?.canEnableTlsFlow?.() === true); - -// VLESS/Trojan TLS fallbacks — surfaced in the protocol tab when the -// inbound is on TCP and (for VLESS) using no Xray-side encryption. -const showFallbacks = computed(() => { - if (!inbound.value) return false; - if (inbound.value.stream?.network !== 'tcp') return false; - if (inbound.value.protocol === Protocols.VLESS) { - const enc = inbound.value.settings?.encryption; - return !enc || enc === 'none'; - } - return inbound.value.protocol === Protocols.TROJAN; -}); - -function addFallback() { - inbound.value?.settings?.addFallback?.(); -} -function delFallback(idx) { - inbound.value?.settings?.delFallback?.(idx); -} - // Date / GB bridges (legacy used moment via _expiryTime; we go direct). const expiryDate = computed({ get: () => (dbForm.value?.expiryTime > 0 ? dayjs(dbForm.value.expiryTime) : null), @@ -184,16 +343,6 @@ const totalGB = computed({ set: (gb) => { if (dbForm.value) dbForm.value.total = NumberFormatter.toFixed((gb || 0) * SizeFormatter.ONE_GB, 0); }, }); -// Client total/expiry bridges (only relevant in add mode for new clients) -const clientExpiryDate = computed({ - get: () => (firstClient.value?.expiryTime > 0 ? dayjs(firstClient.value.expiryTime) : null), - set: (next) => { if (firstClient.value) firstClient.value.expiryTime = next ? next.valueOf() : 0; }, -}); -const clientTotalGB = computed({ - get: () => firstClient.value?._totalGB ?? 0, - set: (gb) => { if (firstClient.value) firstClient.value._totalGB = gb || 0; }, -}); - // === Open / state management ======================================= function loadFromDbInbound(dbIn) { // Round-trip through Inbound.fromJson so subsequent edits get the @@ -231,12 +380,20 @@ function primeAdvancedJson() { watch(() => props.open, (next) => { if (!next) return; + fallbackEditing.value = new Set(); if (props.mode === 'edit' && props.dbInbound) { loadFromDbInbound(props.dbInbound); + const proto = props.dbInbound.protocol; + if (proto === Protocols.VLESS || proto === Protocols.TROJAN) { + loadFallbacks(props.dbInbound.id); + } else { + fallbacks.value = []; + } } else { inbound.value = makeFreshInbound(Protocols.VLESS); dbForm.value = freshDbForm(); primeAdvancedJson(); + fallbacks.value = []; } activeTabKey.value = 'basic'; advancedSectionKey.value = 'all'; @@ -247,9 +404,9 @@ function applyAdvancedJsonToBasic() { if (!inbound.value) return true; let settings; let streamSettings; let sniffing; try { - settings = parseAdvancedSliceWithLabel(advancedSettingsText.value, settingsFallback(), 'Settings'); - streamSettings = parseAdvancedSliceWithLabel(advancedStreamText.value, streamFallback(), 'Stream'); - sniffing = parseAdvancedSliceWithLabel(advancedSniffingText.value, sniffingFallback(), 'Sniffing'); + settings = parseAdvancedSliceWithLabel(advancedSettingsText.value, settingsFallback(), t('pages.inbounds.advanced.settings')); + streamSettings = parseAdvancedSliceWithLabel(advancedStreamText.value, streamFallback(), t('pages.inbounds.advanced.stream')); + sniffing = parseAdvancedSliceWithLabel(advancedSniffingText.value, sniffingFallback(), t('pages.inbounds.advanced.sniffing')); } catch (_e) { return false; } try { @@ -264,7 +421,7 @@ function applyAdvancedJsonToBasic() { clientStats: inbound.value.clientStats, }); } catch (e) { - message.error(`Advanced JSON: ${e.message}`); + message.error(`${t('pages.inbounds.advanced.jsonErrorPrefix')}: ${e.message}`); return false; } return true; @@ -281,11 +438,20 @@ watch(activeTabKey, (next, prev) => { } }); +watch(hasProtocolTabContent, (next) => { + if (!next && activeTabKey.value === 'protocol') { + activeTabKey.value = 'basic'; + } +}); + // In add mode, switching protocol restamps settings + re-syncs port. function onProtocolChange(next) { if (props.mode === 'edit' || !inbound.value) return; inbound.value.protocol = next; inbound.value.settings = Inbound.Settings.getSettings(next); + if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) { + dbForm.value.nodeId = null; + } primeAdvancedJson(); } @@ -483,25 +649,9 @@ const advancedStreamConfig = makeWrappedAdvancedConfig({ label: 'Stream', }); -// === Random helpers wired to the form's sync icons ================== -function randomEmail(target) { - if (target) target.email = RandomUtil.randomLowerAndNum(9); -} -function randomUuid(target) { - if (target) target.id = RandomUtil.randomUUID(); -} -function randomPasswordSeq(target, len = 10) { - if (target) target.password = RandomUtil.randomSeq(len); -} function randomSSPassword(target) { if (target) target.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method); } -function randomAuth(target) { - if (target) target.auth = RandomUtil.randomSeq(10); -} -function randomSubId(target) { - if (target) target.subId = RandomUtil.randomLowerAndNum(16); -} function regenWgKeypair(target) { const kp = Wireguard.generateKeypair(); target.publicKey = kp.publicKey; @@ -635,18 +785,16 @@ const selectedVlessAuth = computed(() => { const parts = encryption.split('.').filter(Boolean); const authKey = parts[parts.length - 1] || ''; - if (!authKey) return 'Custom'; + if (!authKey) return t('pages.inbounds.vlessAuthCustom'); - return authKey.length > 300 ? 'ML-KEM-768 auth' : 'X25519 auth'; + return authKey.length > 300 + ? t('pages.inbounds.vlessAuthMlkem768') + : t('pages.inbounds.vlessAuthX25519'); }); -// === SS method change tracks legacy semantics ========================= function onSSMethodChange() { inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method); if (inbound.value.isSSMultiUser) { - if (inbound.value.settings.shadowsockses.length === 0) { - inbound.value.settings.shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()]; - } inbound.value.settings.shadowsockses.forEach((c) => { c.method = inbound.value.isSS2022 ? '' : inbound.value.settings.method; c.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method); @@ -668,12 +816,12 @@ async function submit() { let streamSettings; let sniffing; let settings; try { streamSettings = canEnableStream.value - ? compactAdvancedJson(advancedStreamText.value, '', 'Stream') + ? compactAdvancedJson(advancedStreamText.value, '', t('pages.inbounds.advanced.stream')) : (inbound.value.stream?.sockopt ? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() }) : ''); - sniffing = compactAdvancedJson(advancedSniffingText.value, inbound.value.sniffing.toString(), 'Sniffing'); - settings = compactAdvancedJson(advancedSettingsText.value, inbound.value.settings.toString(), 'Settings'); + sniffing = compactAdvancedJson(advancedSniffingText.value, inbound.value.sniffing.toString(), t('pages.inbounds.advanced.sniffing')); + settings = compactAdvancedJson(advancedSettingsText.value, inbound.value.settings.toString(), t('pages.inbounds.advanced.settings')); } catch (_e) { return; } // The structured form mutates `inbound.stream` directly when the @@ -711,6 +859,12 @@ async function submit() { : '/panel/api/inbounds/add'; const msg = await HttpUtil.post(url, payload); if (msg?.success) { + if (isFallbackHost.value) { + const masterId = props.mode === 'edit' + ? props.dbInbound.id + : (msg.obj?.id || msg.obj?.Id); + if (masterId) await saveFallbacks(masterId); + } emit('saved'); close(); } @@ -725,7 +879,7 @@ const title = computed(() => : t('pages.inbounds.addInbound'), ); const okText = computed(() => - props.mode === 'edit' ? t('pages.client.submitEdit') : t('create'), + props.mode === 'edit' ? t('pages.clients.submitEdit') : t('create'), ); // Whenever the structured form mutates stream / sniffing / settings, @@ -754,7 +908,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - + {{ t('pages.inbounds.localPanel') }} @@ -799,144 +953,107 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - - - - + - - + + - + - X25519 auth + {{ t('pages.inbounds.vlessAuthX25519') }} - ML-KEM-768 auth + {{ t('pages.inbounds.vlessAuthMlkem768') }} - Clear + {{ t('clear') }} - Selected: {{ selectedVlessAuth }} + {{ t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth }) }} + + + {{ t('pages.inbounds.fallbacks.help') || 'When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.' }} + + +
+ + + + + + + + + + + + + + + {{ describeFallback(record).condition }}{{ describeFallback(record).proxyTag }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + {{ t('pages.inbounds.fallbacks.add') || 'Add fallback' }} + + + {{ t('pages.inbounds.fallbacks.quickAddAll') || 'Quick add all eligible' }} + + +
+ @@ -1200,96 +1317,10 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - -
- + @@ -1639,205 +1670,6 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - - - - none - tls - reality - - - - - - - @@ -2031,10 +1863,209 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - - + + - + + + none + tls + reality + + + + + + + + + + + + + {{ tag || '(none)' - }} + }}
diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 589a1610..792574e0 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -33,29 +33,31 @@ export class HttpUtil { } static async get(url, params, options = {}) { + const { silent, ...axiosOpts } = options; try { - const resp = await axios.get(url, { params, ...options }); + const resp = await axios.get(url, { params, ...axiosOpts }); const msg = this._respToMsg(resp); - this._handleMsg(msg); + if (!silent) this._handleMsg(msg); return msg; } catch (error) { console.error('GET request failed:', error); const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed'); - this._handleMsg(errorMsg); + if (!silent) this._handleMsg(errorMsg); return errorMsg; } } static async post(url, data, options = {}) { + const { silent, ...axiosOpts } = options; try { - const resp = await axios.post(url, data, options); + const resp = await axios.post(url, data, axiosOpts); const msg = this._respToMsg(resp); - this._handleMsg(msg); + if (!silent) this._handleMsg(msg); return msg; } catch (error) { console.error('POST request failed:', error); const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed'); - this._handleMsg(errorMsg); + if (!silent) this._handleMsg(errorMsg); return errorMsg; } } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 91d42c19..41d68489 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -9,7 +9,14 @@ const BACKEND_TARGET = 'http://localhost:2053'; function resolveDBPath() { 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'); if (fs.existsSync(repoDB)) return repoDB; return '/etc/x-ui/x-ui.db'; @@ -22,6 +29,8 @@ const BASE_MIGRATED_ROUTES = { 'panel/settings/': '/settings.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/nodes': '/nodes.html', @@ -76,19 +85,14 @@ function injectBasePathPlugin() { function bypassMigratedRoute(req) { if (req.method !== 'GET') return undefined; const url = req.url.split('?')[0]; + const basePath = refreshBasePath(); - for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) { - if (url === '/' + key) return value; - } + if (url === basePath) return '/login.html'; - const m = url.match(/^\/[^/]+\/(.+)$/); - if (m) { - const stripped = m[1]; + if (url.startsWith(basePath)) { + const stripped = url.slice(basePath.length); if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped]; } - - if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html'; - return undefined; } @@ -150,6 +154,7 @@ export default defineConfig({ login: path.resolve(__dirname, 'login.html'), settings: path.resolve(__dirname, 'settings.html'), inbounds: path.resolve(__dirname, 'inbounds.html'), + clients: path.resolve(__dirname, 'clients.html'), xray: path.resolve(__dirname, 'xray.html'), nodes: path.resolve(__dirname, 'nodes.html'), apiDocs: path.resolve(__dirname, 'api-docs.html'), diff --git a/go.mod b/go.mod index b3c988d0..886634ae 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 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/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/robfig/cron/v3 v3.0.1 @@ -25,8 +25,9 @@ require ( golang.org/x/crypto v0.51.0 golang.org/x/sys v0.44.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 + gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) @@ -53,6 +54,10 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.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/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index f4dfd091..6bf86420 100644 --- a/go.sum +++ b/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/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/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/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= 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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 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.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= +github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y= +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/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= 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.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 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= 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/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= diff --git a/install.sh b/install.sh index 63d81863..1c9f1ae9 100644 --- a/install.sh +++ b/install.sh @@ -6,13 +6,39 @@ blue='\033[0;34m' yellow='\033[0;33m' plain='\033[0m' -cur_dir=$(pwd) - xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}" xui_service="${XUI_SERVICE:=/etc/systemd/system}" +# Don't edit this config +b_source="${BASH_SOURCE[0]}" +while [ -h "$b_source" ]; do + b_dir="$(cd -P "$(dirname "$b_source")" > /dev/null 2>&1 && pwd || pwd -P)" + b_source="$(readlink "$b_source")" + [[ $b_source != /* ]] && b_source="$b_dir/$b_source" +done +cur_dir="$(cd -P "$(dirname "$b_source")" > /dev/null 2>&1 && pwd || pwd -P)" +script_name=$(basename "$0") + +# Check command exist function +_command_exists() { + type "$1" &> /dev/null +} + +# Fail, log and exit script function +_fail() { + local msg=${1} + echo -e "${red}${msg}${plain}" + exit 2 +} + # check root -[[ $EUID -ne 0 ]] && echo -e "${red}Fatal error: ${plain} Please run this script with root privilege \n " && exit 1 +[[ $EUID -ne 0 ]] && _fail "FATAL ERROR: Please run this script with root privilege." + +if _command_exists curl; then + curl_bin=$(which curl) +else + _fail "ERROR: Command 'curl' not found." +fi # Check OS and set release variable if [[ -f /etc/os-release ]]; then @@ -22,8 +48,7 @@ elif [[ -f /usr/lib/os-release ]]; then source /usr/lib/os-release release=$ID else - echo "Failed to check the system OS, please contact the author!" >&2 - exit 1 + _fail "Failed to check the system OS, please contact the author!" fi echo "The OS release is: $release" @@ -36,7 +61,7 @@ arch() { armv6* | armv6) echo 'armv6' ;; armv5* | armv5) echo 'armv5' ;; s390x) echo 's390x' ;; - *) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;; + *) echo -e "${red}Unsupported CPU architecture!${plain}" && rm -f "${cur_dir}/${script_name}" > /dev/null 2>&1 && exit 2 ;; esac } @@ -73,36 +98,6 @@ is_port_in_use() { return 1 } -install_base() { - case "${release}" in - ubuntu | debian | armbian) - apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl - ;; - fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol) - dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl - ;; - centos) - if [[ "${VERSION_ID}" =~ ^7 ]]; then - yum -y update && yum install -y cronie curl tar tzdata socat ca-certificates openssl - else - dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl - fi - ;; - arch | manjaro | parch) - pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl - ;; - opensuse-tumbleweed | opensuse-leap) - zypper refresh && zypper -q install -y cron curl tar timezone socat ca-certificates openssl - ;; - alpine) - apk update && apk add dcron curl tar tzdata socat ca-certificates openssl - ;; - *) - apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl - ;; - esac -} - gen_random_string() { local length="$1" openssl rand -base64 $((length * 2)) \ @@ -110,6 +105,37 @@ gen_random_string() { | head -c "$length" } +install_base() { + echo -e "${green}Updating and install dependency packages...${plain}" + case "${release}" in + ubuntu | debian | armbian) + apt-get update > /dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1 + ;; + fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol) + dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1 + ;; + centos) + if [[ "${VERSION_ID}" =~ ^7 ]]; then + yum -y update > /dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1 + else + dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1 + fi + ;; + arch | manjaro | parch) + pacman -Syu > /dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl > /dev/null 2>&1 + ;; + opensuse-tumbleweed | opensuse-leap) + zypper refresh > /dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl > /dev/null 2>&1 + ;; + alpine) + apk update > /dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl > /dev/null 2>&1 + ;; + *) + apt-get update > /dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1 + ;; + esac +} + install_acme() { echo -e "${green}Installing acme.sh for SSL certificate management...${plain}" cd ~ || return 1 @@ -172,7 +198,6 @@ setup_ssl_certificate() { # Enable auto-renew ~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1 - # Secure permissions: private key readable only by owner chmod 600 $certPath/privkey.pem 2> /dev/null chmod 644 $certPath/fullchain.pem 2> /dev/null @@ -231,7 +256,7 @@ setup_ip_certificate() { echo -e "${green}Including IPv6 address: ${ipv6}${plain}" fi - # Set reload command for auto-renewal (add || true so it doesn't fail during first install) + # Set reload command for auto-renewal (add || true so it doesn't fail if service stopped) local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null || true" # Choose port for HTTP-01 listener (default 80, prompt override) @@ -250,7 +275,7 @@ setup_ip_certificate() { # Ensure chosen port is available while true; do if is_port_in_use "${WebPort}"; then - echo -e "${yellow}Port ${WebPort} is in use.${plain}" + echo -e "${yellow}Port ${WebPort} is currently in use.${plain}" local alt_port="" read -rp "Enter another port for acme.sh standalone listener (leave empty to abort): " alt_port @@ -319,26 +344,24 @@ setup_ip_certificate() { # Enable auto-upgrade for acme.sh (ensures cron job runs) ~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1 - # Secure permissions: private key readable only by owner chmod 600 ${certDir}/privkey.pem 2> /dev/null chmod 644 ${certDir}/fullchain.pem 2> /dev/null # Configure panel to use the certificate echo -e "${green}Setting certificate paths for the panel...${plain}" ${xui_folder}/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem" - if [ $? -ne 0 ]; then - echo -e "${yellow}Warning: Could not set certificate paths automatically${plain}" - echo -e "${yellow}Certificate files are at:${plain}" - echo -e " Cert: ${certDir}/fullchain.pem" - echo -e " Key: ${certDir}/privkey.pem" + echo -e "${yellow}Warning: Could not set certificate paths automatically.${plain}" + echo -e "${yellow}You may need to set them manually in the panel settings.${plain}" + echo -e "${yellow}Cert path: ${certDir}/fullchain.pem${plain}" + echo -e "${yellow}Key path: ${certDir}/privkey.pem${plain}" else - echo -e "${green}Certificate paths configured successfully${plain}" + echo -e "${green}Certificate paths set successfully!${plain}" fi echo -e "${green}IP certificate installed and configured successfully!${plain}" echo -e "${green}Certificate valid for ~6 days, auto-renews via acme.sh cron job.${plain}" - echo -e "${yellow}acme.sh will automatically renew and reload x-ui before expiry.${plain}" + echo -e "${yellow}Panel will automatically restart after each renewal.${plain}" return 0 } @@ -485,18 +508,16 @@ ssl_cert_issue() { if [ $? -ne 0 ]; then echo -e "${yellow}Auto renew setup had issues, certificate details:${plain}" ls -lah /root/cert/${domain}/ - # Secure permissions: private key readable only by owner - chmod 600 $certPath/privkey.pem 2> /dev/null - chmod 644 $certPath/fullchain.pem 2> /dev/null + chmod 600 $certPath/privkey.pem + chmod 644 $certPath/fullchain.pem else echo -e "${green}Auto renew succeeded, certificate details:${plain}" ls -lah /root/cert/${domain}/ - # Secure permissions: private key readable only by owner - chmod 600 $certPath/privkey.pem 2> /dev/null - chmod 644 $certPath/fullchain.pem 2> /dev/null + chmod 600 $certPath/privkey.pem + chmod 644 $certPath/fullchain.pem fi - # start panel + # Restart panel systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null # Prompt user to set panel paths after successful certificate installation @@ -523,29 +544,25 @@ ssl_cert_issue() { return 0 } - -# Reusable interactive SSL setup (domain or IP) -# Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage +# Unified interactive SSL setup (domain or IP) +# Sets global `SSL_HOST` to the chosen domain/IP prompt_and_setup_ssl() { local panel_port="$1" - local web_base_path="$2" + local web_base_path="$2" # expected without leading slash local server_ip="$3" local ssl_choice="" - SSL_SCHEME="https" echo -e "${yellow}Choose SSL certificate setup method:${plain}" echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)" echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)" echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)" - echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)" echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths." - echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel." read -rp "Choose an option (default 2 for IP): " ssl_choice ssl_choice="${ssl_choice// /}" # Trim whitespace - # Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4) - if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then + # Default to 2 (IP cert) if input is empty or invalid (not 1 or 3) + if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then ssl_choice="2" fi @@ -595,6 +612,14 @@ prompt_and_setup_ssl() { echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}" SSL_HOST="${server_ip}" fi + + # Restart panel after SSL is configured (restart applies new cert settings) + if [[ $release == "alpine" ]]; then + rc-service x-ui restart > /dev/null 2>&1 + else + systemctl restart x-ui > /dev/null 2>&1 + fi + ;; 3) # User chose Custom Paths (User Provided) option @@ -656,41 +681,6 @@ prompt_and_setup_ssl() { systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1 ;; - 4) - echo "" - echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}" - echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}" - echo -e "${yellow}Only safe when:${plain}" - echo -e "${yellow} • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}" - echo -e "${yellow} • You access the panel exclusively via SSH tunnel${plain}" - echo "" - - SSL_SCHEME="http" - SSL_HOST="${server_ip}" - - local bind_local="" - read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local - if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then - ${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1 - SSL_HOST="127.0.0.1" - echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}" - echo "" - echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}" - echo -e " Standard SSH command:" - echo -e " ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}" - echo -e " If using an SSH key:" - echo -e " ${yellow}ssh -i -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}" - echo -e " Then open in your browser:" - echo -e " ${yellow}http://localhost:2222/${web_base_path}${plain}" - echo "" - echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}" - else - echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}" - fi - - systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1 - echo -e "${green}✓ SSL setup skipped.${plain}" - ;; *) echo -e "${red}Invalid option. Skipping SSL setup.${plain}" SSL_HOST="${server_ip}" @@ -698,12 +688,17 @@ prompt_and_setup_ssl() { esac } -config_after_install() { - local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}') - local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') - local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') +config_after_update() { + echo -e "${yellow}x-ui settings:${plain}" + ${xui_folder}/x-ui setting -show true + ${xui_folder}/x-ui migrate + # Properly detect empty cert by checking if cert: line exists and has content after it - local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + local existing_cert=$(${xui_folder}/x-ui setting -getCert true 2> /dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') + + # Get server IP local URL_lists=( "https://api4.ipify.org" "https://ipv4.icanhazip.com" @@ -735,227 +730,183 @@ config_after_install() { done fi + # Handle missing/short webBasePath if [[ ${#existing_webBasePath} -lt 4 ]]; then - if [[ "$existing_hasDefaultCredential" == "true" ]]; then - local config_webBasePath=$(gen_random_string 18) - local config_username=$(gen_random_string 10) - local config_password=$(gen_random_string 10) - - read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/n]: " config_confirm - if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then - read -rp "Please set up the panel port: " config_port - echo -e "${yellow}Your Panel Port is: ${config_port}${plain}" - else - local config_port=$(shuf -i 1024-62000 -n 1) - echo -e "${yellow}Generated random port: ${config_port}${plain}" - fi - - ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}" - - echo "" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${green} SSL Certificate Setup (RECOMMENDED) ${plain}" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${yellow}SSL is strongly recommended. Skip only if a reverse proxy${plain}" - echo -e "${yellow}or SSH tunnel handles TLS for you.${plain}" - echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}" - echo "" - - prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}" - - # Retrieve the API token for display - local config_apiToken=$(${xui_folder}/x-ui setting -getApiToken true | grep -Eo 'apiToken: .+' | awk '{print $2}') - - # Display final credentials and access information - echo "" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${green} Panel Installation Complete! ${plain}" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${green}Username: ${config_username}${plain}" - echo -e "${green}Password: ${config_password}${plain}" - echo -e "${green}Port: ${config_port}${plain}" - echo -e "${green}WebBasePath: ${config_webBasePath}${plain}" - echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}" - echo -e "${green}API Token: ${config_apiToken}${plain}" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}" - if [[ "$SSL_SCHEME" == "https" ]]; then - echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}" - else - echo -e "${yellow}⚠ SSL Certificate: Skipped — panel is HTTP-only. Use a reverse proxy or SSH tunnel.${plain}" - fi - else - local config_webBasePath=$(gen_random_string 18) - echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}" - ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" - echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}" - - # If the panel is already installed but no certificate is configured, prompt for SSL now - if [[ -z "${existing_cert}" ]]; then - echo "" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${green} SSL Certificate Setup (RECOMMENDED) ${plain}" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}" - echo "" - prompt_and_setup_ssl "${existing_port}" "${config_webBasePath}" "${server_ip}" - echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}" - else - # If a cert already exists, just show the access URL - echo -e "${green}Access URL: https://${server_ip}:${existing_port}/${config_webBasePath}${plain}" - fi - fi - else - if [[ "$existing_hasDefaultCredential" == "true" ]]; then - local config_username=$(gen_random_string 10) - local config_password=$(gen_random_string 10) - - echo -e "${yellow}Default credentials detected. Security update required...${plain}" - ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" - echo -e "Generated new random login credentials:" - echo -e "###############################################" - echo -e "${green}Username: ${config_username}${plain}" - echo -e "${green}Password: ${config_password}${plain}" - echo -e "###############################################" - else - echo -e "${green}Username, Password, and WebBasePath are properly set.${plain}" - fi - - # Existing install: if no cert configured, prompt user for SSL setup - # Properly detect empty cert by checking if cert: line exists and has content after it - existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') - if [[ -z "$existing_cert" ]]; then - echo "" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${green} SSL Certificate Setup (RECOMMENDED) ${plain}" - echo -e "${green}═══════════════════════════════════════════${plain}" - echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}" - echo "" - prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}" - echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}" - else - echo -e "${green}SSL certificate already configured. No action needed.${plain}" - fi + echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}" + local config_webBasePath=$(gen_random_string 18) + ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" + existing_webBasePath="${config_webBasePath}" + echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}" fi - ${xui_folder}/x-ui migrate + # Check and prompt for SSL if missing + if [[ -z "$existing_cert" ]]; then + echo "" + echo -e "${red}═══════════════════════════════════════════${plain}" + echo -e "${red} ⚠ NO SSL CERTIFICATE DETECTED ⚠ ${plain}" + echo -e "${red}═══════════════════════════════════════════${plain}" + echo -e "${yellow}For security, SSL certificate is MANDATORY for all panels.${plain}" + echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}" + echo "" + + # Prompt and setup SSL (domain or IP) + prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}" + + echo "" + echo -e "${green}═══════════════════════════════════════════${plain}" + echo -e "${green} Panel Access Information ${plain}" + echo -e "${green}═══════════════════════════════════════════${plain}" + echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}" + echo -e "${green}═══════════════════════════════════════════${plain}" + echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}" + else + echo -e "${green}SSL certificate is already configured${plain}" + # Show access URL with existing certificate + local cert_domain=$(basename "$(dirname "$existing_cert")") + echo "" + echo -e "${green}═══════════════════════════════════════════${plain}" + echo -e "${green} Panel Access Information ${plain}" + echo -e "${green}═══════════════════════════════════════════${plain}" + echo -e "${green}Access URL: https://${cert_domain}:${existing_port}/${existing_webBasePath}${plain}" + echo -e "${green}═══════════════════════════════════════════${plain}" + fi } -install_x-ui() { +update_x-ui() { cd ${xui_folder%/x-ui}/ - # Download resources - if [ $# == 0 ]; then - tag_version=$(curl -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [ -f "${xui_folder}/x-ui" ]; then + current_xui_version=$(${xui_folder}/x-ui -v) + echo -e "${green}Current x-ui version: ${current_xui_version}${plain}" + else + _fail "ERROR: Current x-ui version: unknown" + fi + + echo -e "${green}Downloading new x-ui version...${plain}" + + tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [[ ! -n "$tag_version" ]]; then + echo -e "${yellow}Trying to fetch version with IPv4...${plain}" + tag_version=$(${curl_bin} -4 -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') if [[ ! -n "$tag_version" ]]; then - echo -e "${yellow}Trying to fetch version with IPv4...${plain}" - tag_version=$(curl -4 -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') - if [[ ! -n "$tag_version" ]]; then - echo -e "${red}Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later${plain}" - exit 1 + _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later" + fi + fi + echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..." + ${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null + if [[ $? -ne 0 ]]; then + echo -e "${yellow}Trying to fetch version with IPv4...${plain}" + ${curl_bin} -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null + if [[ $? -ne 0 ]]; then + _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub" + fi + fi + + if [[ -e ${xui_folder}/ ]]; then + echo -e "${green}Stopping x-ui...${plain}" + if [[ $release == "alpine" ]]; then + if [ -f "/etc/init.d/x-ui" ]; then + rc-service x-ui stop > /dev/null 2>&1 + rc-update del x-ui > /dev/null 2>&1 + echo -e "${green}Removing old service unit version...${plain}" + rm -f /etc/init.d/x-ui > /dev/null 2>&1 + else + rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1 + _fail "ERROR: x-ui service unit not installed." + fi + else + if [ -f "${xui_service}/x-ui.service" ]; then + systemctl stop x-ui > /dev/null 2>&1 + systemctl disable x-ui > /dev/null 2>&1 + echo -e "${green}Removing old systemd unit version...${plain}" + rm ${xui_service}/x-ui.service -f > /dev/null 2>&1 + systemctl daemon-reload > /dev/null 2>&1 + else + rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1 + _fail "ERROR: x-ui systemd unit not installed." fi fi - echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..." - curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz - if [[ $? -ne 0 ]]; then - echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}" - exit 1 - fi + echo -e "${green}Removing old x-ui version...${plain}" + rm ${xui_folder} -f > /dev/null 2>&1 + rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1 + rm ${xui_folder}/x-ui.service.debian -f > /dev/null 2>&1 + rm ${xui_folder}/x-ui.service.arch -f > /dev/null 2>&1 + rm ${xui_folder}/x-ui.service.rhel -f > /dev/null 2>&1 + rm ${xui_folder}/x-ui -f > /dev/null 2>&1 + rm ${xui_folder}/x-ui.sh -f > /dev/null 2>&1 + echo -e "${green}Removing old xray version...${plain}" + rm ${xui_folder}/bin/xray-linux-amd64 -f > /dev/null 2>&1 + echo -e "${green}Removing old README and LICENSE file...${plain}" + rm ${xui_folder}/bin/README.md -f > /dev/null 2>&1 + rm ${xui_folder}/bin/LICENSE -f > /dev/null 2>&1 else - tag_version=$1 - tag_version_numeric=${tag_version#v} - min_version="2.3.5" - - if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then - echo -e "${red}Please use a newer version (at least v2.3.5). Exiting installation.${plain}" - exit 1 - fi - - url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz" - echo -e "Beginning to install x-ui $1" - curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz ${url} - if [[ $? -ne 0 ]]; then - echo -e "${red}Download x-ui $1 failed, please check if the version exists ${plain}" - exit 1 - fi - fi - curl -4fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh - if [[ $? -ne 0 ]]; then - echo -e "${red}Failed to download x-ui.sh${plain}" - exit 1 + rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1 + _fail "ERROR: x-ui not installed." fi - # Stop x-ui service and remove old resources - if [[ -e ${xui_folder}/ ]]; then - if [[ $release == "alpine" ]]; then - rc-service x-ui stop - else - systemctl stop x-ui - fi - rm ${xui_folder}/ -rf - fi - - # Extract resources and set permissions - tar zxvf x-ui-linux-$(arch).tar.gz - rm x-ui-linux-$(arch).tar.gz -f - - cd x-ui - chmod +x x-ui - chmod +x x-ui.sh + echo -e "${green}Installing new x-ui version...${plain}" + tar zxvf x-ui-linux-$(arch).tar.gz > /dev/null 2>&1 + rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1 + cd x-ui > /dev/null 2>&1 + chmod +x x-ui > /dev/null 2>&1 # Check the system's architecture and rename the file accordingly if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then - mv bin/xray-linux-$(arch) bin/xray-linux-arm - chmod +x bin/xray-linux-arm + mv bin/xray-linux-$(arch) bin/xray-linux-arm > /dev/null 2>&1 + chmod +x bin/xray-linux-arm > /dev/null 2>&1 fi - chmod +x x-ui bin/xray-linux-$(arch) - # Update x-ui cli and se set permission - mv -f /usr/bin/x-ui-temp /usr/bin/x-ui - chmod +x /usr/bin/x-ui - mkdir -p /var/log/x-ui - config_after_install + chmod +x x-ui bin/xray-linux-$(arch) > /dev/null 2>&1 - # Etckeeper compatibility - if [ -d "/etc/.git" ]; then - if [ -f "/etc/.gitignore" ]; then - if ! grep -q "x-ui/x-ui.db" "/etc/.gitignore"; then - echo "" >> "/etc/.gitignore" - echo "x-ui/x-ui.db" >> "/etc/.gitignore" - echo -e "${green}Added x-ui.db to /etc/.gitignore for etckeeper${plain}" - fi - else - echo "x-ui/x-ui.db" > "/etc/.gitignore" - echo -e "${green}Created /etc/.gitignore and added x-ui.db for etckeeper${plain}" + echo -e "${green}Downloading and installing x-ui.sh script...${plain}" + ${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1 + if [[ $? -ne 0 ]]; then + echo -e "${yellow}Trying to fetch x-ui with IPv4...${plain}" + ${curl_bin} -4fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1 + if [[ $? -ne 0 ]]; then + _fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub" fi fi + chmod +x ${xui_folder}/x-ui.sh > /dev/null 2>&1 + chmod +x /usr/bin/x-ui > /dev/null 2>&1 + mkdir -p /var/log/x-ui > /dev/null 2>&1 + + echo -e "${green}Changing owner...${plain}" + chown -R root:root ${xui_folder} > /dev/null 2>&1 + + if [ -f "${xui_folder}/bin/config.json" ]; then + echo -e "${green}Changing on config file permissions...${plain}" + chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1 + fi + if [[ $release == "alpine" ]]; then - curl -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc + echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}" + ${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1 if [[ $? -ne 0 ]]; then - echo -e "${red}Failed to download x-ui.rc${plain}" - exit 1 - fi - chmod +x /etc/init.d/x-ui - rc-update add x-ui - rc-service x-ui start - else - # Install systemd service file - service_installed=false - - if [ -f "x-ui.service" ]; then - echo -e "${green}Found x-ui.service in extracted files, installing...${plain}" - cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1 - if [[ $? -eq 0 ]]; then - service_installed=true + ${curl_bin} -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1 + if [[ $? -ne 0 ]]; then + _fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub" fi fi - - if [ "$service_installed" = false ]; then + chmod +x /etc/init.d/x-ui > /dev/null 2>&1 + chown root:root /etc/init.d/x-ui > /dev/null 2>&1 + rc-update add x-ui > /dev/null 2>&1 + rc-service x-ui start > /dev/null 2>&1 + else + if [ -f "x-ui.service" ]; then + echo -e "${green}Installing systemd unit...${plain}" + cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1 + if [[ $? -ne 0 ]]; then + echo -e "${red}Failed to copy x-ui.service${plain}" + exit 1 + fi + else + service_installed=false case "${release}" in ubuntu | debian | armbian) if [ -f "x-ui.service.debian" ]; then - echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}" + echo -e "${green}Installing debian-like systemd unit...${plain}" cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1 if [[ $? -eq 0 ]]; then service_installed=true @@ -964,7 +915,7 @@ install_x-ui() { ;; arch | manjaro | parch) if [ -f "x-ui.service.arch" ]; then - echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}" + echo -e "${green}Installing arch-like systemd unit...${plain}" cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1 if [[ $? -eq 0 ]]; then service_installed=true @@ -973,7 +924,7 @@ install_x-ui() { ;; *) if [ -f "x-ui.service.rhel" ]; then - echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}" + echo -e "${green}Installing rhel-like systemd unit...${plain}" cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1 if [[ $? -eq 0 ]]; then service_installed=true @@ -981,44 +932,38 @@ install_x-ui() { fi ;; esac - fi - # If service file not found in tar.gz, download from GitHub - if [ "$service_installed" = false ]; then - echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}" - case "${release}" in - ubuntu | debian | armbian) - curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1 - ;; - arch | manjaro | parch) - curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1 - ;; - *) - curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1 - ;; - esac + # If service file not found in tar.gz, download from GitHub + if [ "$service_installed" = false ]; then + echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}" + case "${release}" in + ubuntu | debian | armbian) + ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1 + ;; + arch | manjaro | parch) + ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1 + ;; + *) + ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1 + ;; + esac - if [[ $? -ne 0 ]]; then - echo -e "${red}Failed to install x-ui.service from GitHub${plain}" - exit 1 + if [[ $? -ne 0 ]]; then + echo -e "${red}Failed to install x-ui.service from GitHub${plain}" + exit 1 + fi fi - service_installed=true - fi - - if [ "$service_installed" = true ]; then - echo -e "${green}Setting up systemd unit...${plain}" - chown root:root ${xui_service}/x-ui.service > /dev/null 2>&1 - chmod 644 ${xui_service}/x-ui.service > /dev/null 2>&1 - systemctl daemon-reload - systemctl enable x-ui - systemctl start x-ui - else - echo -e "${red}Failed to install x-ui.service file${plain}" - exit 1 fi + chown root:root ${xui_service}/x-ui.service > /dev/null 2>&1 + chmod 644 ${xui_service}/x-ui.service > /dev/null 2>&1 + systemctl daemon-reload > /dev/null 2>&1 + systemctl enable x-ui > /dev/null 2>&1 + systemctl start x-ui > /dev/null 2>&1 fi - echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..." + config_after_update + + echo -e "${green}x-ui ${tag_version}${plain} updating finished, it is running now..." echo -e "" echo -e "┌───────────────────────────────────────────────────────┐ │ ${blue}x-ui control menu usages (subcommands):${plain} │ @@ -1042,4 +987,4 @@ install_x-ui() { echo -e "${green}Running...${plain}" install_base -install_x-ui $1 +update_x-ui $1 diff --git a/main.go b/main.go index c5ce40b9..02753f1d 100644 --- a/main.go +++ b/main.go @@ -73,7 +73,13 @@ func runWebServer() { sigCh := make(chan os.Signal, 1) // 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 { sig := <-sigCh @@ -439,6 +445,12 @@ func main() { 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) var port int var username string @@ -482,6 +494,7 @@ func main() { fmt.Println("Commands:") fmt.Println(" run run web panel") 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") } @@ -501,6 +514,23 @@ func main() { runWebServer() case "migrate": 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": err := settingCmd.Parse(os.Args[2:]) if err != nil { diff --git a/sub/links.go b/sub/links.go index 234f8d79..c961ca95 100644 --- a/sub/links.go +++ b/sub/links.go @@ -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 { svc := p.build(host) + svc.projectThroughFallbackMaster(inbound) return splitLinkLines(svc.GetLink(inbound, email)) } diff --git a/sub/links_test.go b/sub/links_test.go new file mode 100644 index 00000000..c600199c --- /dev/null +++ b/sub/links_test.go @@ -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) + } +} diff --git a/sub/sub.go b/sub/sub.go index eb3fece7..534af5ff 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -207,9 +207,9 @@ func (s *Server) initRouter() (*gin.Engine, error) { path := c.Request.URL.Path pathPrefix := strings.TrimRight(LinksPath, "/") + "/" if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") { - assetsIndex := strings.Index(path, "/assets/") - if assetsIndex != -1 { - assetPath := path[assetsIndex+8:] // +8 to skip "/assets/" + _, after, ok := strings.Cut(path, "/assets/") + if ok { + assetPath := after // +8 to skip "/assets/" if assetPath != "" { c.FileFromFS(assetPath, assetsFS) c.Abort() diff --git a/sub/subClashService.go b/sub/subClashService.go index c94ea467..7b638dfe 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -2,6 +2,7 @@ package sub import ( "fmt" + "maps" "strings" "github.com/goccy/go-json" @@ -49,14 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e if clients == nil { continue } - if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { - listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings) - if err == nil { - inbound.Listen = listen - inbound.Port = port - inbound.StreamSettings = streamSettings - } - } + s.SubService.projectThroughFallbackMaster(inbound) for _, client := range clients { if client.SubID == subId { _, 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 } dst := make(map[string]any, len(src)) - for k, v := range src { - dst[k] = v - } + maps.Copy(dst, src) return dst } diff --git a/sub/subController.go b/sub/subController.go index 9c7414c5..2eeeefe7 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -15,6 +15,18 @@ import ( "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. type SUBController struct { subTitle string @@ -105,7 +117,7 @@ func (a *SUBController) subs(c *gin.Context) { scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c) subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host) if err != nil || len(subs) == 0 { - c.String(400, "Error!") + writeSubError(c, err) } else { result := "" for _, sub := range subs { @@ -240,7 +252,7 @@ func (a *SUBController) subJsons(c *gin.Context) { scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) jsonSub, header, err := a.subJsonService.GetJson(subId, host) if err != nil || len(jsonSub) == 0 { - c.String(400, "Error!") + writeSubError(c, err) } else { profileUrl := a.subProfileUrl if profileUrl == "" { @@ -257,7 +269,7 @@ func (a *SUBController) subClashs(c *gin.Context) { scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) clashSub, header, err := a.subClashService.GetClash(subId, host) if err != nil || len(clashSub) == 0 { - c.String(400, "Error!") + writeSubError(c, err) } else { profileUrl := a.subProfileUrl if profileUrl == "" { diff --git a/sub/subJsonService.go b/sub/subJsonService.go index 3b34ed68..bbc0a381 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -110,14 +110,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err if clients == nil { continue } - if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { - listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings) - if err == nil { - inbound.Listen = listen - inbound.Port = port - inbound.StreamSettings = streamSettings - } - } + s.SubService.projectThroughFallbackMaster(inbound) for _, client := range clients { if client.SubID == subId { diff --git a/sub/subService.go b/sub/subService.go index d769bf5a..077ab9a5 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -70,7 +70,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C } 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() @@ -92,14 +92,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C if clients == nil { continue } - if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { - listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings) - if err == nil { - inbound.Listen = listen - inbound.Port = port - inbound.StreamSettings = streamSettings - } - } + s.projectThroughFallbackMaster(inbound) for _, client := range clients { if client.SubID == subId { 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) { db := database.GetDB() 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 ( SELECT DISTINCT inbounds.id - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client + FROM inbounds + JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id + JOIN clients ON clients.id = client_inbounds.client_id WHERE - protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2') - AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ? + inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2') + AND clients.sub_id = ? AND inbounds.enable = ? )`, subId, true).Find(&inbounds).Error if err != nil { return nil, err @@ -193,16 +185,89 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri return "", 0, "", err } - var stream map[string]any - 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, mergeStreamFromMaster(streamSettings, inbound.StreamSettings), nil +} - 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) @@ -536,8 +601,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin return strings.Join(links, "\n") } - // No external proxy configured — fall back to the request host. - link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.address, inbound.Port) + // No external proxy configured — use the inbound's resolved address so + // 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) q := url.Query() for k, v := range params { diff --git a/sub/subService_test.go b/sub/subService_test.go new file mode 100644 index 00000000..f83db7e3 --- /dev/null +++ b/sub/subService_test.go @@ -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") + } +} diff --git a/util/common/format_test.go b/util/common/format_test.go new file mode 100644 index 00000000..d0176e7b --- /dev/null +++ b/util/common/format_test.go @@ -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) + } + }) + } +} diff --git a/util/common/multi_error_test.go b/util/common/multi_error_test.go new file mode 100644 index 00000000..0edb0102 --- /dev/null +++ b/util/common/multi_error_test.go @@ -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()) + } +} diff --git a/util/crypto/crypto_test.go b/util/crypto/crypto_test.go new file mode 100644 index 00000000..00a15bd6 --- /dev/null +++ b/util/crypto/crypto_test.go @@ -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") + } +} diff --git a/util/json_util/json_test.go b/util/json_util/json_test.go new file mode 100644 index 00000000..d4e8afcc --- /dev/null +++ b/util/json_util/json_test.go @@ -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}`) + } +} diff --git a/util/ldap/ldap.go b/util/ldap/ldap.go index 1b9faa53..4d5bdbb7 100644 --- a/util/ldap/ldap.go +++ b/util/ldap/ldap.go @@ -3,6 +3,7 @@ package ldaputil import ( "crypto/tls" "fmt" + "slices" "github.com/go-ldap/ldap/v3" ) @@ -82,13 +83,7 @@ func FetchVlessFlags(cfg Config) (map[string]bool, error) { continue } val := e.GetAttributeValue(cfg.FlagField) - enabled := false - for _, t := range cfg.TruthyVals { - if val == t { - enabled = true - break - } - } + enabled := slices.Contains(cfg.TruthyVals, val) if cfg.Invert { enabled = !enabled } diff --git a/util/netsafe/netsafe_test.go b/util/netsafe/netsafe_test.go new file mode 100644 index 00000000..2fe9bcd5 --- /dev/null +++ b/util/netsafe/netsafe_test.go @@ -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") + } +} diff --git a/util/random/random.go b/util/random/random.go index ddb819c1..a28072c0 100644 --- a/util/random/random.go +++ b/util/random/random.go @@ -3,6 +3,7 @@ package random import ( "crypto/rand" + "encoding/base64" "math/big" ) @@ -59,3 +60,14 @@ func Num(n int) int { } 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) +} diff --git a/util/random/random_test.go b/util/random/random_test.go new file mode 100644 index 00000000..57eb3c59 --- /dev/null +++ b/util/random/random_test.go @@ -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) + } +} diff --git a/util/sys/sys_darwin.go b/util/sys/sys_darwin.go index b44d7689..21822821 100644 --- a/util/sys/sys_darwin.go +++ b/util/sys/sys_darwin.go @@ -1,5 +1,4 @@ //go:build darwin -// +build darwin package sys @@ -33,8 +32,9 @@ func GetUDPCount() (int, error) { // --- CPU Utilization (macOS native) --- -// sysctl kern.cp_time returns an array of 5 longs: user, nice, sys, idle, intr. -// We compute utilization deltas without cgo. +// sysctl kern.cp_time returns 5 longs in the BSD CPUSTATES order: +// user, nice, sys, intr, idle (CP_INTR=3, CP_IDLE=4). gopsutil reads the +// same layout in cpu_darwin_nocgo.go. var ( cpuMu sync.Mutex lastTotals [5]uint64 @@ -61,13 +61,6 @@ func CPUPercentRaw() (float64, error) { 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() defer cpuMu.Unlock() @@ -77,19 +70,19 @@ func CPUPercentRaw() (float64, error) { return 0, nil } - dUser := user - lastTotals[0] - dNice := nice - lastTotals[1] - dSys := sysv - lastTotals[2] - dIdle := idle - lastTotals[3] - dIntr := intr - lastTotals[4] - + var deltas [5]uint64 + var totald uint64 + for i := range 5 { + deltas[i] = out[i] - lastTotals[i] + totald += deltas[i] + } lastTotals = out - totald := dUser + dNice + dSys + dIdle + dIntr if totald == 0 { return 0, nil } - busy := totald - dIdle + idleDelta := deltas[4] + busy := totald - idleDelta pct := float64(busy) / float64(totald) * 100.0 if pct > 100 { pct = 100 diff --git a/util/sys/sys_linux.go b/util/sys/sys_linux.go index 5b1b1127..554f9c98 100644 --- a/util/sys/sys_linux.go +++ b/util/sys/sys_linux.go @@ -1,11 +1,9 @@ //go:build linux -// +build linux package sys import ( "bufio" - "bytes" "fmt" "io" "os" @@ -17,80 +15,63 @@ import ( var SIGUSR1 = syscall.SIGUSR1 -func getLinesNum(filename string) (int, error) { - file, err := os.Open(filename) +// countConnections returns the number of entries in a /proc/net/{tcp,udp}[6] +// 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 { return 0, err } - defer file.Close() + defer f.Close() - sum := 0 - buf := make([]byte, 8192) - for { - n, err := file.Read(buf) - - var buffPosition int - for { - i := bytes.IndexByte(buf[buffPosition:n], '\n') - if i < 0 { - break - } - buffPosition += i + 1 - sum++ - } - - if err == io.EOF { - break - } else if err != nil { - return 0, err - } + sc := bufio.NewScanner(f) + n := 0 + for sc.Scan() { + n++ } - return sum, nil + if err := sc.Err(); err != nil { + return 0, err + } + if n > 0 { + n-- // first line is the column header + } + return n, nil } // GetTCPCount returns the number of active TCP connections by reading // /proc/net/tcp and /proc/net/tcp6 when available. func GetTCPCount() (int, error) { root := HostProc() - - tcp4, err := safeGetLinesNum(fmt.Sprintf("%v/net/tcp", root)) + tcp4, err := countConnections(root + "/net/tcp") if err != nil { return 0, err } - tcp6, err := safeGetLinesNum(fmt.Sprintf("%v/net/tcp6", root)) + tcp6, err := countConnections(root + "/net/tcp6") if err != nil { return 0, err } - 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) { root := HostProc() - - udp4, err := safeGetLinesNum(fmt.Sprintf("%v/net/udp", root)) + udp4, err := countConnections(root + "/net/udp") if err != nil { return 0, err } - udp6, err := safeGetLinesNum(fmt.Sprintf("%v/net/udp6", root)) + udp6, err := countConnections(root + "/net/udp6") if err != nil { return 0, err } - 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) --- var ( @@ -100,10 +81,11 @@ var ( hasLast bool ) -// CPUPercentRaw returns instantaneous total CPU utilization by reading /proc/stat. -// First call initializes and returns 0; subsequent calls return busy/total * 100. +// CPUPercentRaw returns instantaneous total CPU utilization by reading +// /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) { - f, err := os.Open("/proc/stat") + f, err := os.Open(HostProc("stat")) if err != nil { return 0, err } @@ -114,13 +96,13 @@ func CPUPercentRaw() (float64, error) { if err != nil && err != io.EOF { 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) if len(fields) < 5 || fields[0] != "cpu" { 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++ { v, err := strconv.ParseUint(fields[i], 10, 64) if err != nil { @@ -128,34 +110,15 @@ func CPUPercentRaw() (float64, error) { } 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") } + for len(nums) < 8 { + nums = append(nums, 0) + } - // Conform with standard Linux CPU accounting - var user, nice, system, idle, iowait, irq, softirq, steal uint64 - 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] - } + user, nice, system, idle := nums[0], nums[1], nums[2], nums[3] + iowait, irq, softirq, steal := nums[4], nums[5], nums[6], nums[7] idleAll := idle + iowait nonIdle := user + nice + system + irq + softirq + steal diff --git a/util/sys/sys_windows.go b/util/sys/sys_windows.go index 9b6d659f..774f9c02 100644 --- a/util/sys/sys_windows.go +++ b/util/sys/sys_windows.go @@ -1,5 +1,4 @@ //go:build windows -// +build windows package sys @@ -10,6 +9,7 @@ import ( "unsafe" "github.com/shirou/gopsutil/v4/net" + "golang.org/x/sys/windows" ) var SIGUSR1 = syscall.Signal(0) @@ -19,7 +19,6 @@ func GetConnectionCount(proto string) (int, error) { if proto != "tcp" && proto != "udp" { return 0, errors.New("invalid protocol") } - stats, err := net.Connections(proto) if err != nil { return 0, err @@ -40,7 +39,9 @@ func GetUDPCount() (int, error) { // --- CPU Utilization (Windows native) --- 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") cpuMu sync.Mutex @@ -50,32 +51,25 @@ var ( hasLast bool ) -type filetime struct { - 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 { +func ftToUint64(ft windows.Filetime) uint64 { return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime) } -// CPUPercentRaw returns the instantaneous total CPU utilization percentage using -// Windows GetSystemTimes across all logical processors. The first call returns 0 -// as it initializes the baseline. Subsequent calls compute deltas. +// CPUPercentRaw returns instantaneous total CPU utilization across all +// logical processors via Windows GetSystemTimes. The first call returns 0 +// while it initializes the baseline; subsequent calls compute deltas. func CPUPercentRaw() (float64, error) { - var idleFT, kernelFT, userFT filetime + var idleFT, kernelFT, userFT windows.Filetime r1, _, e1 := procGetSystemTimes.Call( uintptr(unsafe.Pointer(&idleFT)), uintptr(unsafe.Pointer(&kernelFT)), uintptr(unsafe.Pointer(&userFT)), ) - if r1 == 0 { // failure - if e1 != nil { - return 0, e1 + if r1 == 0 { + if errno, _ := e1.(syscall.Errno); errno != 0 { + return 0, errno } - return 0, syscall.GetLastError() + return 0, errors.New("GetSystemTimes failed") } idle := ftToUint64(idleFT) @@ -97,7 +91,6 @@ func CPUPercentRaw() (float64, error) { kernelDelta := kernel - lastKernel userDelta := user - lastUser - // Update for next call lastIdle = idle lastKernel = kernel lastUser = user @@ -106,11 +99,10 @@ func CPUPercentRaw() (float64, error) { if total == 0 { 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 pct := float64(busy) / float64(total) * 100.0 - // lower bound not needed; ratios of uint64 are non-negative if pct > 100 { pct = 100 } diff --git a/web/controller/api.go b/web/controller/api.go index e066af77..572410e9 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -32,8 +32,8 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) * func (a *APIController) checkAPIAuth(c *gin.Context) { auth := c.GetHeader("Authorization") - if strings.HasPrefix(auth, "Bearer ") { - tok := strings.TrimPrefix(auth, "Bearer ") + if after, ok := strings.CutPrefix(auth, "Bearer "); ok { + tok := after if a.apiTokenService.Match(tok) { if u, err := a.userService.GetFirstUser(); err == nil { session.SetAPIAuthUser(c, u) @@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom inbounds := api.Group("/inbounds") a.inboundController = NewInboundController(inbounds) + clients := api.Group("/clients") + NewClientController(clients) + // Server API server := api.Group("/server") a.serverController = NewServerController(server) diff --git a/web/controller/api_docs_test.go b/web/controller/api_docs_test.go index d2b1a089..f91e4b7a 100644 --- a/web/controller/api_docs_test.go +++ b/web/controller/api_docs_test.go @@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) { basePath = "/panel/api" case "inbound.go": basePath = "/panel/api/inbounds" + case "client.go": + basePath = "/panel/api/clients" case "server.go": basePath = "/panel/api/server" case "node.go": @@ -127,7 +129,8 @@ func TestAPIRoutesDocumented(t *testing.T) { // Skip SPA page routes (these are UI pages, not API endpoints) spaPages := map[string]bool{ "/": true, "/panel/": true, "/panel/inbounds": true, - "/panel/nodes": true, "/panel/settings": true, + "/panel/clients": true, + "/panel/nodes": true, "/panel/settings": true, "/panel/xray": true, "/panel/api-docs": true, } if spaPages[r.Path] { diff --git a/web/controller/client.go b/web/controller/client.go new file mode 100644 index 00000000..7a4f0d36 --- /dev/null +++ b/web/controller/client.go @@ -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() +} diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 541ae449..8436fd80 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -6,7 +6,6 @@ import ( "net" "strconv" "strings" - "time" "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/web/service" @@ -18,8 +17,9 @@ import ( // InboundController handles HTTP requests related to Xray inbounds management. type InboundController struct { - inboundService service.InboundService - xrayService service.XrayService + inboundService service.InboundService + xrayService service.XrayService + fallbackService service.FallbackService } // 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) { g.GET("/list", a.getInbounds) + g.GET("/options", a.getInboundOptions) g.GET("/get/:id", a.getInbound) - g.GET("/getClientTraffics/:email", a.getClientTraffics) - g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById) - g.GET("/getSubLinks/:subId", a.getSubLinks) - g.GET("/getClientLinks/:id/:email", a.getClientLinks) + g.GET("/:id/fallbacks", a.getFallbacks) g.POST("/add", a.addInbound) g.POST("/del/:id", a.delInbound) g.POST("/update/:id", a.updateInbound) 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/resetClientTraffic/:email", a.resetClientTraffic) g.POST("/resetAllTraffics", a.resetAllTraffics) - g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics) - g.POST("/delDepletedClients/:id", a.delDepletedClients) g.POST("/import", a.importInbound) - g.POST("/onlines", a.onlines) - 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"` + g.POST("/:id/fallbacks", a.setFallbacks) } // 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) } +// 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. func (a *InboundController) getInbound(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) @@ -121,28 +114,6 @@ func (a *InboundController) getInbound(c *gin.Context) { 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. func (a *InboundController) addInbound(c *gin.Context) { inbound := &model.Inbound{} @@ -274,174 +245,6 @@ func (a *InboundController) setInboundEnable(c *gin.Context) { 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. func (a *InboundController) resetInboundTraffic(c *gin.Context) { 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) } -// 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. func (a *InboundController) importInbound(c *gin.Context) { 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 // 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 @@ -621,30 +333,42 @@ func resolveHost(c *gin.Context) string { return c.Request.Host } -// getSubLinks returns every protocol URL produced for the given subscription -// ID — the JSON-array equivalent of /sub/ (no base64 wrap). -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) { +// getFallbacks returns the fallback rules attached to the master inbound. +func (a *InboundController) getFallbacks(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { jsonMsg(c, I18nWeb(c, "get"), err) return } - links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email")) + rows, err := a.fallbackService.GetByMaster(id) if err != nil { - jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + jsonMsg(c, I18nWeb(c, "get"), err) 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) +} + diff --git a/web/controller/node.go b/web/controller/node.go index ab0127d2..d12db5f8 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -178,7 +178,7 @@ func (a *NodeController) history(c *gin.Context) { return } 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")) return } diff --git a/web/controller/server.go b/web/controller/server.go index 4d5aa356..a2326720 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -27,11 +27,6 @@ type ServerController struct { settingService service.SettingService panelService service.PanelService xrayMetricsService service.XrayMetricsService - - lastStatus *service.Status - - lastVersions []string - lastGetVersionsTime int64 // unix seconds } // 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) } -// refreshStatus updates the cached server status and collects time-series -// metrics. CPU/Mem/Net/Online/Load are all written in one call so the -// SystemHistoryModal's tabs share an identical x-axis. -func (a *ServerController) refreshStatus() { - 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. +// startTask registers the @2s ticker that refreshes server status, samples +// xray metrics, and pushes the new snapshot to all websocket subscribers. +// State + sampling live in ServerService; the controller only orchestrates +// the cross-service side effects (xrayMetrics sample + websocket broadcast). func (a *ServerController) startTask() { - webServer := global.GetWebServer() - c := webServer.GetCron() + c := global.GetWebServer().GetCron() c.AddFunc("@every 2s", func() { - // Always refresh to keep CPU history collected continuously. - // Sampling is lightweight and capped to ~6 hours in memory. - a.refreshStatus() + status := a.serverService.RefreshStatus() + if status == nil { + return + } + a.xrayMetricsService.Sample(time.Now()) + websocket.BroadcastStatus(status) }) } // 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 -// /cpuHistory/:bucket and /history/:metric/:bucket. Restricting it -// prevents callers from triggering arbitrary aggregation work and keeps -// the front-end'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 +func parseHistoryBucket(c *gin.Context) (int, bool) { + bucket, err := strconv.Atoi(c.Param("bucket")) + if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) { + jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) + return 0, false + } + return bucket, true } // getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket. // Kept for back-compat; new callers should use /history/cpu/:bucket which // returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}. func (a *ServerController) getCpuHistoryBucket(c *gin.Context) { - bucketStr := c.Param("bucket") - bucket, err := strconv.Atoi(bucketStr) - if err != nil || bucket <= 0 { - jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket")) + bucket, ok := parseHistoryBucket(c) + if !ok { return } - if !allowedHistoryBuckets[bucket] { - jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) - return - } - points := a.serverService.AggregateCpuHistory(bucket, 60) - jsonObj(c, points, nil) + jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil) } // 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")) return } - bucket, err := strconv.Atoi(c.Param("bucket")) - if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] { - jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) + bucket, ok := parseHistoryBucket(c) + if !ok { return } 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")) return } - bucket, err := strconv.Atoi(c.Param("bucket")) - if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] { - jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) + bucket, ok := parseHistoryBucket(c) + if !ok { return } 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")) return } - bucket, err := strconv.Atoi(c.Param("bucket")) - if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] { - jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) + bucket, ok := parseHistoryBucket(c) + if !ok { return } jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil) } func (a *ServerController) getXrayVersion(c *gin.Context) { - const cacheTTLSeconds = 15 * 60 - - now := time.Now().Unix() - if a.lastVersions != nil && now-a.lastGetVersionsTime <= cacheTTLSeconds { - jsonObj(c, a.lastVersions, nil) - return - } - - versions, err := a.serverService.GetXrayVersions() + versions, err := a.serverService.GetXrayVersionsCached() 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) return } - - a.lastVersions = versions - a.lastGetVersionsTime = now - jsonObj(c, versions, nil) } @@ -240,7 +195,6 @@ func (a *ServerController) updatePanel(c *gin.Context) { func (a *ServerController) updateGeofile(c *gin.Context) { fileName := c.Param("fileName") - // Validate the filename for security (prevent path traversal attacks) if fileName != "" && !a.serverService.IsValidGeofileName(fileName) { jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), 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. func (a *ServerController) getLogs(c *gin.Context) { - count := c.Param("count") - level := c.PostForm("level") - syslog := c.PostForm("syslog") - logs := a.serverService.GetLogs(count, level, syslog) + logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog")) jsonObj(c, logs, nil) } // getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic. func (a *ServerController) getXrayLogs(c *gin.Context) { - count := c.Param("count") - filter := c.PostForm("filter") - showDirect := c.PostForm("showDirect") - showBlocked := c.PostForm("showBlocked") - showProxy := c.PostForm("showProxy") - - var freedoms []string - var blackholes []string - - //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) + freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags() + logs := a.serverService.GetXrayLogs( + c.Param("count"), + c.PostForm("filter"), + c.PostForm("showDirect"), + c.PostForm("showBlocked"), + c.PostForm("showProxy"), + freedoms, + blackholes, + ) jsonObj(c, logs, nil) } @@ -358,36 +279,25 @@ func (a *ServerController) getDb(c *gin.Context) { } filename := "x-ui.db" - - if !isValidFilename(filename) { + if !filenameRegex.MatchString(filename) { c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename")) return } - // Set the headers for the response c.Header("Content-Type", "application/octet-stream") c.Header("Content-Disposition", "attachment; filename="+filename) - - // Write the file contents to the response 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. func (a *ServerController) importDB(c *gin.Context) { - // Get the file from the request body file, _, err := c.Request.FormFile("db") if err != nil { jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err) return } defer file.Close() - err = a.serverService.ImportDB(file) - if err != nil { + if err := a.serverService.ImportDB(file); err != nil { jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err) return } @@ -416,8 +326,7 @@ func (a *ServerController) getNewmldsa65(c *gin.Context) { // getNewEchCert generates a new ECH certificate for the given SNI. func (a *ServerController) getNewEchCert(c *gin.Context) { - sni := c.PostForm("sni") - cert, err := a.serverService.GetNewEchCert(sni) + cert, err := a.serverService.GetNewEchCert(c.PostForm("sni")) if err != nil { jsonMsg(c, "get ech certificate", err) return @@ -442,7 +351,6 @@ func (a *ServerController) getNewUUID(c *gin.Context) { jsonMsg(c, "Failed to generate UUID", err) return } - jsonObj(c, uuidResp, nil) } diff --git a/web/controller/util.go b/web/controller/util.go index 94e17513..7d77f580 100644 --- a/web/controller/util.go +++ b/web/controller/util.go @@ -27,7 +27,7 @@ func getRemoteIp(c *gin.Context) string { } 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 { return ip } @@ -50,7 +50,7 @@ func isTrustedProxy(ip string) bool { } trusted := trustedProxyCIDRs() - for _, value := range strings.Split(trusted, ",") { + for value := range strings.SplitSeq(trusted, ",") { value = strings.TrimSpace(value) if value == "" { continue diff --git a/web/controller/xui.go b/web/controller/xui.go index 2fcf346b..7f7f81de 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/", a.index) g.GET("/inbounds", a.inbounds) + g.GET("/clients", a.clients) g.GET("/nodes", a.nodes) g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) @@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) { serveDistPage(c, "inbounds.html") } +func (a *XUIController) clients(c *gin.Context) { + serveDistPage(c, "clients.html") +} + // nodes renders the multi-panel nodes management page. func (a *XUIController) nodes(c *gin.Context) { serveDistPage(c, "nodes.html") diff --git a/web/entity/entity.go b/web/entity/entity.go index bc4ce5a1..82c33d10 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -195,7 +195,7 @@ func (s *AllSetting) CheckValid() error { s.SubClashPath += "/" } - for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") { + for cidr := range strings.SplitSeq(s.TrustedProxyCIDRs, ",") { cidr = strings.TrimSpace(cidr) if cidr == "" { continue diff --git a/web/global/global.go b/web/global/global.go index 5556b486..89bd52c1 100644 --- a/web/global/global.go +++ b/web/global/global.go @@ -3,6 +3,7 @@ package global import ( "context" + "sync" _ "unsafe" "github.com/robfig/cron/v3" @@ -11,6 +12,9 @@ import ( var ( webServer WebServer subServer SubServer + + restartHookMu sync.RWMutex + restartHook func() ) // WebServer interface defines methods for accessing the web server instance. @@ -44,3 +48,24 @@ func SetSubServer(s SubServer) { func GetSubServer() 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 +} diff --git a/web/job/ldap_sync_job.go b/web/job/ldap_sync_job.go index 142e9524..2449495e 100644 --- a/web/job/ldap_sync_job.go +++ b/web/job/ldap_sync_job.go @@ -1,18 +1,15 @@ package job import ( + "strings" "time" - "strings" + "github.com/google/uuid" "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/logger" ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap" "github.com/mhsanaei/3x-ui/v3/web/service" - - "strconv" - - "github.com/google/uuid" ) var DefaultTruthyValues = []string{"true", "1", "yes", "on"} @@ -20,6 +17,7 @@ var DefaultTruthyValues = []string{"true", "1", "yes", "on"} type LdapSyncJob struct { settingService service.SettingService inboundService service.InboundService + clientService service.ClientService xrayService service.XrayService } @@ -135,18 +133,29 @@ func (j *LdapSyncJob) Run() { } } - // --- Execute batch create --- for tag, newClients := range clientsToCreate { if len(newClients) == 0 { continue } - payload := &model.Inbound{Id: inboundMap[tag].Id} - payload.Settings = j.clientsToJSON(newClients) - if _, err := j.inboundService.AddInboundClient(payload); err != nil { - logger.Warningf("Failed to add clients for tag %s: %v", tag, err) - } else { - logger.Infof("LDAP auto-create: %d clients for %s", len(newClients), tag) - j.xrayService.SetToNeedRestart() + ib := inboundMap[tag] + created := 0 + restartNeeded := false + for _, c := range newClients { + nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c) + 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() + } } } @@ -206,34 +215,31 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp return c } -// batchSetEnable enables/disables clients in batch through a single call func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) { if len(emails) == 0 { return } - - // Prepare JSON for mass update - clients := make([]model.Client, 0, len(emails)) + restartNeeded := false + changed := 0 for _, email := range emails { - clients = append(clients, model.Client{ - Email: email, - Enable: enable, - }) + ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable) + if err != nil { + logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err) + continue + } + if ok { + changed++ + } + if needRestart { + restartNeeded = true + } } - - payload := &model.Inbound{ - Id: ib.Id, - Settings: j.clientsToJSON(clients), + if changed > 0 { + logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag) } - - // Use a single AddInboundClient call to update enable - if _, err := j.inboundService.AddInboundClient(payload); err != nil { - logger.Warningf("Batch set enable failed for inbound %s: %v", ib.Tag, err) - return + if restartNeeded { + j.xrayService.SetToNeedRestart() } - - logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, len(emails), ib.Tag) - j.xrayService.SetToNeedRestart() } // 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 } - // Delete in batches for i := 0; i < len(toDelete); i += batchSize { end := min(i+batchSize, len(toDelete)) batch := toDelete[i:end] for _, c := range batch { - var clientKey string - switch ib.Protocol { - 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 { + nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email) + if err != nil { logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v", c.Email, ib.Id, ib.Tag, err) - } else { - logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", - c.Email, ib.Id, ib.Tag) - // do not restart here + continue + } + logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", + c.Email, ib.Id, ib.Tag) + if nr { restartNeeded = true } } } } - // One time after all batches if restartNeeded { j.xrayService.SetToNeedRestart() 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() -} diff --git a/web/job/node_traffic_sync_job.go b/web/job/node_traffic_sync_job.go index c2f5fa6a..63de5018 100644 --- a/web/job/node_traffic_sync_job.go +++ b/web/job/node_traffic_sync_job.go @@ -20,6 +20,8 @@ const ( type NodeTrafficSyncJob struct { nodeService service.NodeService inboundService service.InboundService + settingService service.SettingService + xrayService service.XrayService running sync.Mutex structural atomicBool } @@ -83,6 +85,22 @@ func (j *NodeTrafficSyncJob) Run() { } 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() { return } @@ -123,6 +141,7 @@ func (j *NodeTrafficSyncJob) Run() { if j.structural.takeAndReset() { websocket.BroadcastInvalidate(websocket.MessageTypeInbounds) + websocket.BroadcastInvalidate(websocket.MessageTypeClients) } } diff --git a/web/job/node_traffic_sync_job_test.go b/web/job/node_traffic_sync_job_test.go new file mode 100644 index 00000000..ec04e350 --- /dev/null +++ b/web/job/node_traffic_sync_job_test.go @@ -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) + } +} diff --git a/web/job/periodic_traffic_reset_job.go b/web/job/periodic_traffic_reset_job.go index 50780765..acc0a354 100644 --- a/web/job/periodic_traffic_reset_job.go +++ b/web/job/periodic_traffic_reset_job.go @@ -11,6 +11,7 @@ type Period string // PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period. type PeriodicTrafficResetJob struct { inboundService service.InboundService + clientService service.ClientService period Period } @@ -42,7 +43,7 @@ func (j *PeriodicTrafficResetJob) Run() { 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 { logger.Warning("Failed to reset traffic for all users of inbound", inbound.Id, ":", resetClientErr) } diff --git a/web/runtime/local.go b/web/runtime/local.go index b50cb9de..487e075f 100644 --- a/web/runtime/local.go +++ b/web/runtime/local.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "sync" "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 { if l.deps.SetNeedRestart != nil { l.deps.SetNeedRestart() @@ -89,10 +138,6 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string return nil } -func (l *Local) ResetInboundClientTraffics(_ context.Context, _ *model.Inbound) error { - return nil -} - func (l *Local) ResetAllTraffics(_ context.Context) error { return nil } diff --git a/web/runtime/remote.go b/web/runtime/remote.go index 9cc83f32..44f82a1a 100644 --- a/web/runtime/remote.go +++ b/web/runtime/remote.go @@ -257,31 +257,58 @@ func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) er 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 { _, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil) return err } -func (r *Remote) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error { - id, err := r.resolveRemoteID(ctx, ib.Tag) - if err != 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) +func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error { + _, err := r.do(ctx, http.MethodPost, + "panel/api/clients/resetTraffic/"+url.PathEscape(email), nil) return err } @@ -307,14 +334,14 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er 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 { logger.Warning("remote", r.node.Name, "onlines fetch failed:", err) } else if len(envOnlines.Obj) > 0 { _ = 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 { logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err) } else if len(envLastOnline.Obj) > 0 { diff --git a/web/runtime/remote_test.go b/web/runtime/remote_test.go index dd966792..d00cffb4 100644 --- a/web/runtime/remote_test.go +++ b/web/runtime/remote_test.go @@ -7,8 +7,8 @@ import ( func TestSanitizeStreamSettingsForRemote(t *testing.T) { tests := []struct { - name string - input string + name string + input string // wantCertFile / wantKeyFile: expected presence after sanitize wantCertFile bool wantKeyFile bool @@ -55,7 +55,7 @@ func TestSanitizeStreamSettingsForRemote(t *testing.T) { wantKeyFile: false, }, { - name: "empty stream settings", + name: "empty stream settings", input: "", // empty input returns empty, nothing to check }, diff --git a/web/runtime/runtime.go b/web/runtime/runtime.go index f7f91e83..7c7c60c9 100644 --- a/web/runtime/runtime.go +++ b/web/runtime/runtime.go @@ -16,9 +16,15 @@ type Runtime interface { AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) 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 ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error - ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error ResetAllTraffics(ctx context.Context) error } diff --git a/web/service/client.go b/web/service/client.go new file mode 100644 index 00000000..70e9b516 --- /dev/null +++ b/web/service/client.go @@ -0,0 +1,1959 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/mhsanaei/3x-ui/v3/database" + "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/logger" + "github.com/mhsanaei/3x-ui/v3/util/common" + "github.com/mhsanaei/3x-ui/v3/util/random" + "github.com/mhsanaei/3x-ui/v3/xray" + + "gorm.io/gorm" +) + +type ClientWithAttachments struct { + model.ClientRecord + InboundIds []int `json:"inboundIds"` + Traffic *xray.ClientTraffic `json:"traffic,omitempty"` +} + +// MarshalJSON is required because model.ClientRecord defines its own +// MarshalJSON. Go promotes the embedded method to the outer struct, so without +// this the encoder would call ClientRecord.MarshalJSON for the whole value and +// silently drop InboundIds and Traffic from the API response. +func (c ClientWithAttachments) MarshalJSON() ([]byte, error) { + rec, err := json.Marshal(c.ClientRecord) + if err != nil { + return nil, err + } + extras := struct { + InboundIds []int `json:"inboundIds"` + Traffic *xray.ClientTraffic `json:"traffic,omitempty"` + }{InboundIds: c.InboundIds, Traffic: c.Traffic} + extra, err := json.Marshal(extras) + if err != nil { + return nil, err + } + if len(rec) < 2 || rec[len(rec)-1] != '}' || len(extra) <= 2 { + return rec, nil + } + out := make([]byte, 0, len(rec)+len(extra)) + out = append(out, rec[:len(rec)-1]...) + if len(rec) > 2 { + out = append(out, ',') + } + out = append(out, extra[1:]...) + return out, nil +} + +func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string { + if rec == nil { + return "" + } + switch p { + case model.Trojan: + return rec.Password + case model.Shadowsocks: + return rec.Email + case model.Hysteria, model.Hysteria2: + return rec.Auth + default: + return rec.UUID + } +} + +type ClientService struct{} + +// Short-lived tombstone of just-deleted client emails so that a node snapshot +// arriving between delete and node-side processing doesn't resurrect them. +var ( + recentlyDeletedMu sync.Mutex + recentlyDeleted = map[string]time.Time{} +) + +const deleteTombstoneTTL = 90 * time.Second + +var ( + inboundMutationLocksMu sync.Mutex + inboundMutationLocks = map[int]*sync.Mutex{} +) + +func lockInbound(inboundId int) *sync.Mutex { + inboundMutationLocksMu.Lock() + defer inboundMutationLocksMu.Unlock() + m, ok := inboundMutationLocks[inboundId] + if !ok { + m = &sync.Mutex{} + inboundMutationLocks[inboundId] = m + } + m.Lock() + return m +} + +func compactOrphans(db *gorm.DB, clients []any) []any { + if len(clients) == 0 { + return clients + } + emails := make([]string, 0, len(clients)) + for _, c := range clients { + cm, ok := c.(map[string]any) + if !ok { + continue + } + if e, _ := cm["email"].(string); e != "" { + emails = append(emails, e) + } + } + if len(emails) == 0 { + return clients + } + var existingEmails []string + if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil { + logger.Warning("compactOrphans pluck:", err) + return clients + } + if len(existingEmails) == len(emails) { + return clients + } + existing := make(map[string]struct{}, len(existingEmails)) + for _, e := range existingEmails { + existing[e] = struct{}{} + } + out := make([]any, 0, len(existingEmails)) + for _, c := range clients { + cm, ok := c.(map[string]any) + if !ok { + out = append(out, c) + continue + } + e, _ := cm["email"].(string) + if e == "" { + out = append(out, c) + continue + } + if _, ok := existing[e]; ok { + out = append(out, c) + } + } + return out +} + +func tombstoneClientEmail(email string) { + if email == "" { + return + } + recentlyDeletedMu.Lock() + defer recentlyDeletedMu.Unlock() + recentlyDeleted[email] = time.Now() + cutoff := time.Now().Add(-deleteTombstoneTTL) + for e, ts := range recentlyDeleted { + if ts.Before(cutoff) { + delete(recentlyDeleted, e) + } + } +} + +func isClientEmailTombstoned(email string) bool { + if email == "" { + return false + } + recentlyDeletedMu.Lock() + defer recentlyDeletedMu.Unlock() + ts, ok := recentlyDeleted[email] + if !ok { + return false + } + if time.Since(ts) > deleteTombstoneTTL { + delete(recentlyDeleted, email) + return false + } + return true +} + +func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error { + if tx == nil { + tx = database.GetDB() + } + + if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil { + return err + } + + for i := range clients { + c := clients[i] + email := strings.TrimSpace(c.Email) + if email == "" { + continue + } + + incoming := c.ToRecord() + row := &model.ClientRecord{} + err := tx.Where("email = ?", email).First(row).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if errors.Is(err, gorm.ErrRecordNotFound) { + if isClientEmailTombstoned(email) { + continue + } + if err := tx.Create(incoming).Error; err != nil { + return err + } + row = incoming + } else { + row.UUID = incoming.UUID + row.Password = incoming.Password + row.Auth = incoming.Auth + row.Flow = incoming.Flow + row.Security = incoming.Security + row.Reverse = incoming.Reverse + row.SubID = incoming.SubID + row.LimitIP = incoming.LimitIP + row.TotalGB = incoming.TotalGB + row.ExpiryTime = incoming.ExpiryTime + row.Enable = incoming.Enable + row.TgID = incoming.TgID + row.Comment = incoming.Comment + row.Reset = incoming.Reset + if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) { + row.CreatedAt = incoming.CreatedAt + } + if incoming.UpdatedAt > row.UpdatedAt { + row.UpdatedAt = incoming.UpdatedAt + } + if err := tx.Save(row).Error; err != nil { + return err + } + } + + link := model.ClientInbound{ + ClientId: row.Id, + InboundId: inboundId, + FlowOverride: c.Flow, + } + if err := tx.Create(&link).Error; err != nil { + return err + } + } + return nil +} + +func (s *ClientService) DetachInbound(tx *gorm.DB, inboundId int) error { + if tx == nil { + tx = database.GetDB() + } + return tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error +} + +func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Client, error) { + if tx == nil { + tx = database.GetDB() + } + type joinedRow struct { + model.ClientRecord + FlowOverride string + } + var rows []joinedRow + err := tx.Table("clients"). + Select("clients.*, client_inbounds.flow_override AS flow_override"). + Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). + Where("client_inbounds.inbound_id = ?", inboundId). + Order("clients.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + + out := make([]model.Client, 0, len(rows)) + for i := range rows { + c := rows[i].ToClient() + if rows[i].FlowOverride != "" { + c.Flow = rows[i].FlowOverride + } + out = append(out, *c) + } + return out, nil +} + +func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.ClientRecord, error) { + if tx == nil { + tx = database.GetDB() + } + row := &model.ClientRecord{} + err := tx.Where("email = ?", email).First(row).Error + if err != nil { + return nil, err + } + return row, nil +} + +func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) { + if tx == nil { + tx = database.GetDB() + } + var ids []int + err := tx.Table("client_inbounds"). + Select("client_inbounds.inbound_id"). + Joins("JOIN clients ON clients.id = client_inbounds.client_id"). + Where("clients.email = ?", email). + Scan(&ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + +func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) { + row := &model.ClientRecord{} + if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil { + return nil, err + } + return row, nil +} + +func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) { + var ids []int + err := database.GetDB().Table("client_inbounds"). + Where("client_id = ?", id). + Order("inbound_id ASC"). + Pluck("inbound_id", &ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + +func (s *ClientService) List() ([]ClientWithAttachments, error) { + db := database.GetDB() + var rows []model.ClientRecord + if err := db.Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return []ClientWithAttachments{}, nil + } + + clientIds := make([]int, 0, len(rows)) + emails := make([]string, 0, len(rows)) + for i := range rows { + clientIds = append(clientIds, rows[i].Id) + if rows[i].Email != "" { + emails = append(emails, rows[i].Email) + } + } + + var links []model.ClientInbound + if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil { + return nil, err + } + attachments := make(map[int][]int, len(rows)) + for _, l := range links { + attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId) + } + + trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails)) + if len(emails) > 0 { + var stats []xray.ClientTraffic + if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil { + return nil, err + } + for i := range stats { + trafficByEmail[stats[i].Email] = &stats[i] + } + } + + out := make([]ClientWithAttachments, 0, len(rows)) + for i := range rows { + out = append(out, ClientWithAttachments{ + ClientRecord: rows[i], + InboundIds: attachments[rows[i].Id], + Traffic: trafficByEmail[rows[i].Email], + }) + } + return out, nil +} + +type ClientCreatePayload struct { + Client model.Client `json:"client"` + InboundIds []int `json:"inboundIds"` +} + +func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) { + if payload == nil { + return false, common.NewError("empty payload") + } + client := payload.Client + if strings.TrimSpace(client.Email) == "" { + return false, common.NewError("client email is required") + } + if len(payload.InboundIds) == 0 { + return false, common.NewError("at least one inbound is required") + } + + if client.SubID == "" { + client.SubID = uuid.NewString() + } + if !client.Enable { + client.Enable = true + } + now := time.Now().UnixMilli() + if client.CreatedAt == 0 { + client.CreatedAt = now + } + client.UpdatedAt = now + + existing := &model.ClientRecord{} + err := database.GetDB().Where("email = ?", client.Email).First(existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return false, err + } + emailTaken := !errors.Is(err, gorm.ErrRecordNotFound) + if emailTaken { + if existing.SubID == "" || existing.SubID != client.SubID { + return false, common.NewError("email already in use:", client.Email) + } + } + + needRestart := false + for _, ibId := range payload.InboundIds { + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + if err := s.fillProtocolDefaults(&client, inbound); err != nil { + return needRestart, err + } + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}}) + if mErr != nil { + return needRestart, mErr + } + nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{ + Id: ibId, + Settings: string(settingsPayload), + }) + if addErr != nil { + return needRestart, addErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} + +func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error { + switch ib.Protocol { + case model.VMESS, model.VLESS: + if c.ID == "" { + c.ID = uuid.NewString() + } + case model.Trojan: + if c.Password == "" { + c.Password = strings.ReplaceAll(uuid.NewString(), "-", "") + } + case model.Shadowsocks: + method := shadowsocksMethodFromSettings(ib.Settings) + if c.Password == "" || !validShadowsocksClientKey(method, c.Password) { + c.Password = randomShadowsocksClientKey(method) + } + case model.Hysteria, model.Hysteria2: + if c.Auth == "" { + c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "") + } + } + return nil +} + +// shadowsocksMethodFromSettings pulls the "method" field out of the inbound's +// settings JSON. Returns "" when the field is missing or settings is invalid. +func shadowsocksMethodFromSettings(settings string) string { + if settings == "" { + return "" + } + var m map[string]any + if err := json.Unmarshal([]byte(settings), &m); err != nil { + return "" + } + method, _ := m["method"].(string) + return method +} + +// randomShadowsocksClientKey returns a per-client key sized to the cipher. +// The 2022-blake3 ciphers require a base64-encoded key of an exact byte +// length (16 bytes for aes-128-gcm, 32 bytes for aes-256-gcm and +// chacha20-poly1305) — anything else fails with "bad key" on xray start. +// Older ciphers accept arbitrary passwords, so we keep the uuid-style. +func randomShadowsocksClientKey(method string) string { + if n := shadowsocksKeyBytes(method); n > 0 { + return random.Base64Bytes(n) + } + return strings.ReplaceAll(uuid.NewString(), "-", "") +} + +// validShadowsocksClientKey reports whether key is acceptable for the cipher. +// For 2022-blake3 it must decode to the exact byte length the cipher needs; +// any other method accepts any non-empty string. +func validShadowsocksClientKey(method, key string) bool { + n := shadowsocksKeyBytes(method) + if n == 0 { + return key != "" + } + decoded, err := base64.StdEncoding.DecodeString(key) + if err != nil { + return false + } + return len(decoded) == n +} + +func shadowsocksKeyBytes(method string) int { + switch method { + case "2022-blake3-aes-128-gcm": + return 16 + case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305": + return 32 + } + return 0 +} + +// applyShadowsocksClientMethod ensures each client entry carries a "method" +// field for legacy shadowsocks ciphers. xray's multi-user shadowsocks code +// requires a per-client method; an empty/missing field fails with +// "unsupported cipher method:". 2022-blake3 ciphers use the top-level +// method only, so the per-client field must stay absent. +func applyShadowsocksClientMethod(clients []any, settings map[string]any) { + method, _ := settings["method"].(string) + if method == "" || strings.HasPrefix(method, "2022-blake3-") { + return + } + 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 + } +} + +func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) { + existing, err := s.GetByID(id) + if err != nil { + return false, err + } + inboundIds, err := s.GetInboundIdsForRecord(id) + if err != nil { + return false, err + } + + if strings.TrimSpace(updated.Email) == "" { + return false, common.NewError("client email is required") + } + if updated.SubID == "" { + updated.SubID = existing.SubID + } + if updated.SubID == "" { + updated.SubID = uuid.NewString() + } + updated.UpdatedAt = time.Now().UnixMilli() + if updated.CreatedAt == 0 { + updated.CreatedAt = existing.CreatedAt + } + + needRestart := false + for _, ibId := range inboundIds { + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + oldKey := clientKeyForProtocol(inbound.Protocol, existing) + if oldKey == "" { + continue + } + if err := s.fillProtocolDefaults(&updated, inbound); err != nil { + return needRestart, err + } + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}}) + if mErr != nil { + return needRestart, mErr + } + nr, upErr := s.UpdateInboundClient(inboundSvc, &model.Inbound{ + Id: ibId, + Settings: string(settingsPayload), + }, oldKey) + if upErr != nil { + return needRestart, upErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} + +func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) { + existing, err := s.GetByID(id) + if err != nil { + return false, err + } + tombstoneClientEmail(existing.Email) + + inboundIds, err := s.GetInboundIdsForRecord(id) + if err != nil { + return false, err + } + + needRestart := false + for _, ibId := range inboundIds { + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + key := clientKeyForProtocol(inbound.Protocol, existing) + if key == "" { + continue + } + nr, delErr := s.DelInboundClient(inboundSvc, ibId, key) + if delErr != nil { + return needRestart, delErr + } + if nr { + needRestart = true + } + } + + db := database.GetDB() + if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil { + return needRestart, err + } + if !keepTraffic && existing.Email != "" { + if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil { + return needRestart, err + } + if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil { + return needRestart, err + } + } + if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil { + return needRestart, err + } + return needRestart, nil +} + +func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) { + existing, err := s.GetByID(id) + if err != nil { + return false, err + } + currentIds, err := s.GetInboundIdsForRecord(id) + if err != nil { + return false, err + } + have := make(map[int]struct{}, len(currentIds)) + for _, x := range currentIds { + have[x] = struct{}{} + } + + clientWire := existing.ToClient() + clientWire.UpdatedAt = time.Now().UnixMilli() + + needRestart := false + for _, ibId := range inboundIds { + if _, attached := have[ibId]; attached { + continue + } + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + copyClient := *clientWire + if err := s.fillProtocolDefaults(©Client, inbound); err != nil { + return needRestart, err + } + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}}) + if mErr != nil { + return needRestart, mErr + } + nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{ + Id: ibId, + Settings: string(settingsPayload), + }) + if addErr != nil { + return needRestart, addErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} + +func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) { + return s.Create(inboundSvc, &ClientCreatePayload{ + Client: client, + InboundIds: []int{inboundId}, + }) +} + +func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) { + if email == "" { + return false, common.NewError("client email is required") + } + rec, err := s.GetRecordByEmail(nil, email) + if err != nil { + return false, err + } + return s.Detach(inboundSvc, rec.Id, []int{inboundId}) +} + +func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) { + if email == "" { + return false, common.NewError("client email is required") + } + rec, err := s.GetRecordByEmail(nil, email) + if err != nil { + return false, err + } + return s.Attach(inboundSvc, rec.Id, inboundIds) +} + +func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) { + if email == "" { + return false, common.NewError("client email is required") + } + rec, err := s.GetRecordByEmail(nil, email) + if err != nil { + return false, err + } + return s.Detach(inboundSvc, rec.Id, inboundIds) +} + +func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) { + if email == "" { + return false, common.NewError("client email is required") + } + rec, err := s.GetRecordByEmail(nil, email) + if err != nil { + return false, err + } + return s.Delete(inboundSvc, rec.Id, keepTraffic) +} + +func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) { + if email == "" { + return false, common.NewError("client email is required") + } + rec, err := s.GetRecordByEmail(nil, email) + if err != nil { + return false, err + } + return s.Update(inboundSvc, rec.Id, updated) +} + +func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) { + if email == "" { + return false, common.NewError("client email is required") + } + rec, err := s.GetRecordByEmail(nil, email) + if err != nil { + return false, err + } + inboundIds, err := s.GetInboundIdsForRecord(rec.Id) + if err != nil { + return false, err + } + if len(inboundIds) == 0 { + if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil { + return false, rErr + } + return false, nil + } + needRestart := false + for _, ibId := range inboundIds { + nr, rErr := inboundSvc.ResetClientTraffic(ibId, email) + if rErr != nil { + return needRestart, rErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} + +func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) { + db := database.GetDB() + now := time.Now().UnixMilli() + depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))" + + var rows []xray.ClientTraffic + if err := db.Where(depletedClause, now).Find(&rows).Error; err != nil { + return 0, false, err + } + if len(rows) == 0 { + return 0, false, nil + } + + emails := make(map[string]struct{}, len(rows)) + for _, r := range rows { + if r.Email != "" { + emails[r.Email] = struct{}{} + } + } + + needRestart := false + deleted := 0 + for email := range emails { + var rec model.ClientRecord + if err := db.Where("email = ?", email).First(&rec).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return deleted, needRestart, err + } + nr, err := s.Delete(inboundSvc, rec.Id, false) + if err != nil { + return deleted, needRestart, err + } + if nr { + needRestart = true + } + deleted++ + } + return deleted, needRestart, nil +} + +func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error { + return submitTrafficWrite(func() error { + return s.resetAllClientTrafficsLocked(id) + }) +} + +func (s *ClientService) resetAllClientTrafficsLocked(id int) error { + db := database.GetDB() + now := time.Now().Unix() * 1000 + + if err := db.Transaction(func(tx *gorm.DB) error { + whereText := "inbound_id " + if id == -1 { + whereText += " > ?" + } else { + whereText += " = ?" + } + + result := tx.Model(xray.ClientTraffic{}). + Where(whereText, id). + Updates(map[string]any{"enable": true, "up": 0, "down": 0}) + + if result.Error != nil { + return result.Error + } + + inboundWhereText := "id " + if id == -1 { + inboundWhereText += " > ?" + } else { + inboundWhereText += " = ?" + } + + result = tx.Model(model.Inbound{}). + Where(inboundWhereText, id). + Update("last_traffic_reset_time", now) + + return result.Error + }); err != nil { + return err + } + return nil +} + +func (s *ClientService) ResetAllTraffics() (bool, error) { + res := database.GetDB().Model(&xray.ClientTraffic{}). + Where("1 = 1"). + Updates(map[string]any{"up": 0, "down": 0}) + if res.Error != nil { + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) { + existing, err := s.GetByID(id) + if err != nil { + return false, err + } + currentIds, err := s.GetInboundIdsForRecord(id) + if err != nil { + return false, err + } + have := make(map[int]struct{}, len(currentIds)) + for _, x := range currentIds { + have[x] = struct{}{} + } + + needRestart := false + for _, ibId := range inboundIds { + if _, attached := have[ibId]; !attached { + continue + } + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + key := clientKeyForProtocol(inbound.Protocol, existing) + if key == "" { + continue + } + nr, delErr := s.DelInboundClient(inboundSvc, ibId, key) + if delErr != nil { + return needRestart, delErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} + +func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) { + emailSubIDs, err := inboundSvc.getAllEmailSubIDs() + if err != nil { + return "", err + } + seen := make(map[string]string, len(clients)) + for _, client := range clients { + if client.Email == "" { + continue + } + key := strings.ToLower(client.Email) + if prev, ok := seen[key]; ok { + if prev != client.SubID || client.SubID == "" { + return client.Email, nil + } + continue + } + seen[key] = client.SubID + if existingSub, ok := emailSubIDs[key]; ok { + if client.SubID == "" || existingSub == "" || existingSub != client.SubID { + return client.Email, nil + } + } + } + return "", nil +} + +func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) { + defer lockInbound(data.Id).Unlock() + + clients, err := inboundSvc.GetClients(data) + if err != nil { + return false, err + } + + var settings map[string]any + err = json.Unmarshal([]byte(data.Settings), &settings) + if err != nil { + return false, err + } + + interfaceClients := settings["clients"].([]any) + nowTs := time.Now().Unix() * 1000 + for i := range interfaceClients { + if cm, ok := interfaceClients[i].(map[string]any); ok { + if _, ok2 := cm["created_at"]; !ok2 { + cm["created_at"] = nowTs + } + cm["updated_at"] = nowTs + interfaceClients[i] = cm + } + } + existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients) + if err != nil { + return false, err + } + if existEmail != "" { + return false, common.NewError("Duplicate email:", existEmail) + } + + oldInbound, err := inboundSvc.GetInbound(data.Id) + if err != nil { + return false, err + } + + for _, client := range clients { + if strings.TrimSpace(client.Email) == "" { + return false, common.NewError("client email is required") + } + switch oldInbound.Protocol { + case "trojan": + if client.Password == "" { + return false, common.NewError("empty client ID") + } + case "shadowsocks": + if client.Email == "" { + return false, common.NewError("empty client ID") + } + case "hysteria", "hysteria2": + if client.Auth == "" { + return false, common.NewError("empty client ID") + } + default: + if client.ID == "" { + return false, common.NewError("empty client ID") + } + } + } + + var oldSettings map[string]any + err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings) + if err != nil { + return false, err + } + + if oldInbound.Protocol == model.Shadowsocks { + applyShadowsocksClientMethod(interfaceClients, oldSettings) + } + + oldClients := oldSettings["clients"].([]any) + oldClients = compactOrphans(database.GetDB(), oldClients) + oldClients = append(oldClients, interfaceClients...) + + oldSettings["clients"] = oldClients + + newSettings, err := json.MarshalIndent(oldSettings, "", " ") + if err != nil { + return false, err + } + + oldInbound.Settings = string(newSettings) + + db := database.GetDB() + tx := db.Begin() + + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + needRestart := false + rt, rterr := inboundSvc.runtimeFor(oldInbound) + if rterr != nil { + if oldInbound.NodeID != nil { + err = rterr + return false, err + } + needRestart = true + } else if oldInbound.NodeID == nil { + for _, client := range clients { + if len(client.Email) == 0 { + needRestart = true + continue + } + inboundSvc.AddClientStat(tx, data.Id, &client) + if !client.Enable { + continue + } + cipher := "" + if oldInbound.Protocol == "shadowsocks" { + cipher = oldSettings["method"].(string) + } + err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{ + "email": client.Email, + "id": client.ID, + "auth": client.Auth, + "security": client.Security, + "flow": client.Flow, + "password": client.Password, + "cipher": cipher, + }) + if err1 == nil { + logger.Debug("Client added on", rt.Name(), ":", client.Email) + } else { + logger.Debug("Error in adding client on", rt.Name(), ":", err1) + needRestart = true + } + } + } else { + for _, client := range clients { + if len(client.Email) > 0 { + inboundSvc.AddClientStat(tx, data.Id, &client) + } + if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil { + err = err1 + return false, err + } + } + } + + if err = tx.Save(oldInbound).Error; err != nil { + return false, err + } + finalClients, gcErr := inboundSvc.GetClients(oldInbound) + if gcErr != nil { + err = gcErr + return false, err + } + if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil { + return false, err + } + return needRestart, nil +} + +func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *model.Inbound, clientId string) (bool, error) { + defer lockInbound(data.Id).Unlock() + + clients, err := inboundSvc.GetClients(data) + if err != nil { + return false, err + } + + var settings map[string]any + err = json.Unmarshal([]byte(data.Settings), &settings) + if err != nil { + return false, err + } + + interfaceClients := settings["clients"].([]any) + + oldInbound, err := inboundSvc.GetInbound(data.Id) + if err != nil { + return false, err + } + + oldClients, err := inboundSvc.GetClients(oldInbound) + if err != nil { + return false, err + } + + oldEmail := "" + newClientId := "" + clientIndex := -1 + for index, oldClient := range oldClients { + oldClientId := "" + switch oldInbound.Protocol { + case "trojan": + oldClientId = oldClient.Password + newClientId = clients[0].Password + case "shadowsocks": + oldClientId = oldClient.Email + newClientId = clients[0].Email + case "hysteria", "hysteria2": + oldClientId = oldClient.Auth + newClientId = clients[0].Auth + default: + oldClientId = oldClient.ID + newClientId = clients[0].ID + } + if clientId == oldClientId { + oldEmail = oldClient.Email + clientIndex = index + break + } + } + + if newClientId == "" || clientIndex == -1 { + return false, common.NewError("empty client ID") + } + if strings.TrimSpace(clients[0].Email) == "" { + return false, common.NewError("client email is required") + } + + if clients[0].Email != oldEmail { + existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients) + if err != nil { + return false, err + } + if existEmail != "" { + return false, common.NewError("Duplicate email:", existEmail) + } + } + + var oldSettings map[string]any + err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings) + if err != nil { + return false, err + } + settingsClients := oldSettings["clients"].([]any) + var preservedCreated any + if clientIndex >= 0 && clientIndex < len(settingsClients) { + if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok { + if v, ok2 := oldMap["created_at"]; ok2 { + preservedCreated = v + } + } + } + if len(interfaceClients) > 0 { + if newMap, ok := interfaceClients[0].(map[string]any); ok { + if preservedCreated == nil { + preservedCreated = time.Now().Unix() * 1000 + } + newMap["created_at"] = preservedCreated + newMap["updated_at"] = time.Now().Unix() * 1000 + interfaceClients[0] = newMap + } + } + if oldInbound.Protocol == model.Shadowsocks { + applyShadowsocksClientMethod(interfaceClients, oldSettings) + } + settingsClients[clientIndex] = interfaceClients[0] + oldSettings["clients"] = settingsClients + + if oldInbound.Protocol == model.VLESS { + hasVisionFlow := false + for _, c := range settingsClients { + cm, ok := c.(map[string]any) + if !ok { + continue + } + if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" { + hasVisionFlow = true + break + } + } + if !hasVisionFlow { + delete(oldSettings, "testseed") + } + } + + newSettings, err := json.MarshalIndent(oldSettings, "", " ") + if err != nil { + return false, err + } + + oldInbound.Settings = string(newSettings) + db := database.GetDB() + tx := db.Begin() + + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + if len(clients[0].Email) > 0 { + if len(oldEmail) > 0 { + emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email) + targetExists := int64(0) + if !emailUnchanged { + if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Count(&targetExists).Error; err != nil { + return false, err + } + } + if emailUnchanged || targetExists == 0 { + err = inboundSvc.UpdateClientStat(tx, oldEmail, &clients[0]) + if err != nil { + return false, err + } + err = inboundSvc.UpdateClientIPs(tx, oldEmail, clients[0].Email) + if err != nil { + return false, err + } + } else { + stillUsed, sErr := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id) + if sErr != nil { + return false, sErr + } + if !stillUsed { + if err = inboundSvc.DelClientStat(tx, oldEmail); err != nil { + return false, err + } + if err = inboundSvc.DelClientIPs(tx, oldEmail); err != nil { + return false, err + } + } + if err = inboundSvc.UpdateClientStat(tx, clients[0].Email, &clients[0]); err != nil { + return false, err + } + } + } else { + inboundSvc.AddClientStat(tx, data.Id, &clients[0]) + } + } else { + stillUsed, err := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id) + if err != nil { + return false, err + } + if !stillUsed { + err = inboundSvc.DelClientStat(tx, oldEmail) + if err != nil { + return false, err + } + err = inboundSvc.DelClientIPs(tx, oldEmail) + if err != nil { + return false, err + } + } + } + needRestart := false + if len(oldEmail) > 0 { + rt, rterr := inboundSvc.runtimeFor(oldInbound) + if rterr != nil { + if oldInbound.NodeID != nil { + err = rterr + return false, err + } + needRestart = true + } else if oldInbound.NodeID == nil { + if oldClients[clientIndex].Enable { + err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail) + if err1 == nil { + logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail) + } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) { + logger.Debug("User is already deleted. Nothing to do more...") + } else { + logger.Debug("Error in deleting client on", rt.Name(), ":", err1) + needRestart = true + } + } + if clients[0].Enable { + cipher := "" + if oldInbound.Protocol == "shadowsocks" { + cipher = oldSettings["method"].(string) + } + err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{ + "email": clients[0].Email, + "id": clients[0].ID, + "security": clients[0].Security, + "flow": clients[0].Flow, + "auth": clients[0].Auth, + "password": clients[0].Password, + "cipher": cipher, + }) + if err1 == nil { + logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email) + } else { + logger.Debug("Error in adding client on", rt.Name(), ":", err1) + needRestart = true + } + } + } else { + if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil { + err = err1 + return false, err + } + } + } else { + logger.Debug("Client old email not found") + needRestart = true + } + if err = tx.Save(oldInbound).Error; err != nil { + return false, err + } + finalClients, gcErr := inboundSvc.GetClients(oldInbound) + if gcErr != nil { + err = gcErr + return false, err + } + if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil { + return false, err + } + return needRestart, nil +} + +func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string) (bool, error) { + defer lockInbound(inboundId).Unlock() + + oldInbound, err := inboundSvc.GetInbound(inboundId) + if err != nil { + logger.Error("Load Old Data Error") + return false, err + } + var settings map[string]any + err = json.Unmarshal([]byte(oldInbound.Settings), &settings) + if err != nil { + return false, err + } + + email := "" + client_key := "id" + switch oldInbound.Protocol { + case "trojan": + client_key = "password" + case "shadowsocks": + client_key = "email" + case "hysteria", "hysteria2": + client_key = "auth" + } + + interfaceClients := settings["clients"].([]any) + var newClients []any + needApiDel := false + clientFound := false + for _, client := range interfaceClients { + c := client.(map[string]any) + c_id := c[client_key].(string) + if c_id == clientId { + clientFound = true + email, _ = c["email"].(string) + needApiDel, _ = c["enable"].(bool) + } else { + newClients = append(newClients, client) + } + } + + if !clientFound { + return false, common.NewError("Client Not Found In Inbound For ID:", clientId) + } + + db := database.GetDB() + newClients = compactOrphans(db, newClients) + if newClients == nil { + newClients = []any{} + } + settings["clients"] = newClients + newSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false, err + } + + oldInbound.Settings = string(newSettings) + + emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId) + if err != nil { + return false, err + } + + if !emailShared { + err = inboundSvc.DelClientIPs(db, email) + if err != nil { + logger.Error("Error in delete client IPs") + return false, err + } + } + needRestart := false + + if len(email) > 0 { + var enables []bool + err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error + if err != nil { + logger.Error("Get stats error") + return false, err + } + notDepleted := len(enables) > 0 && enables[0] + if !emailShared { + err = inboundSvc.DelClientStat(db, email) + if err != nil { + logger.Error("Delete stats Data Error") + return false, err + } + } + if needApiDel && notDepleted && oldInbound.NodeID == nil { + rt, rterr := inboundSvc.runtimeFor(oldInbound) + if rterr != nil { + needRestart = true + } else { + err1 := rt.RemoveUser(context.Background(), oldInbound, email) + if err1 == nil { + logger.Debug("Client deleted on", rt.Name(), ":", email) + needRestart = false + } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) { + logger.Debug("User is already deleted. Nothing to do more...") + } else { + logger.Debug("Error in deleting client on", rt.Name(), ":", err1) + needRestart = true + } + } + } + } + if oldInbound.NodeID != nil && len(email) > 0 { + rt, rterr := inboundSvc.runtimeFor(oldInbound) + if rterr != nil { + return false, rterr + } + if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil { + return false, err1 + } + } + if err := db.Save(oldInbound).Error; err != nil { + return false, err + } + finalClients, gcErr := inboundSvc.GetClients(oldInbound) + if gcErr != nil { + return false, gcErr + } + if err := s.SyncInbound(db, inboundId, finalClients); err != nil { + return false, err + } + return needRestart, nil +} + +func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) { + defer lockInbound(inboundId).Unlock() + + oldInbound, err := inboundSvc.GetInbound(inboundId) + if err != nil { + logger.Error("Load Old Data Error") + return false, err + } + + var settings map[string]any + if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil { + return false, err + } + + interfaceClients, ok := settings["clients"].([]any) + if !ok { + return false, common.NewError("invalid clients format in inbound settings") + } + + var newClients []any + needApiDel := false + found := false + + for _, client := range interfaceClients { + c, ok := client.(map[string]any) + if !ok { + continue + } + if cEmail, ok := c["email"].(string); ok && cEmail == email { + found = true + needApiDel, _ = c["enable"].(bool) + } else { + newClients = append(newClients, client) + } + } + + if !found { + return false, common.NewError(fmt.Sprintf("client with email %s not found", email)) + } + db := database.GetDB() + newClients = compactOrphans(db, newClients) + if newClients == nil { + newClients = []any{} + } + settings["clients"] = newClients + newSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false, err + } + + oldInbound.Settings = string(newSettings) + + emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId) + if err != nil { + return false, err + } + + if !emailShared { + if err := inboundSvc.DelClientIPs(db, email); err != nil { + logger.Error("Error in delete client IPs") + return false, err + } + } + + needRestart := false + + if len(email) > 0 && !emailShared { + traffic, err := inboundSvc.GetClientTrafficByEmail(email) + if err != nil { + return false, err + } + if traffic != nil { + if err := inboundSvc.DelClientStat(db, email); err != nil { + logger.Error("Delete stats Data Error") + return false, err + } + } + + if needApiDel { + rt, rterr := inboundSvc.runtimeFor(oldInbound) + if rterr != nil { + if oldInbound.NodeID != nil { + return false, rterr + } + needRestart = true + } else if oldInbound.NodeID == nil { + if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil { + logger.Debug("Client deleted on", rt.Name(), ":", email) + needRestart = false + } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) { + logger.Debug("User is already deleted. Nothing to do more...") + } else { + logger.Debug("Error in deleting client on", rt.Name(), ":", err1) + needRestart = true + } + } else { + if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil { + return false, err1 + } + } + } + } + + if err := db.Save(oldInbound).Error; err != nil { + return false, err + } + finalClients, gcErr := inboundSvc.GetClients(oldInbound) + if gcErr != nil { + return false, gcErr + } + if err := s.SyncInbound(db, inboundId, finalClients); err != nil { + return false, err + } + return needRestart, nil +} + +func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, trafficId int, tgId int64) (bool, error) { + traffic, inbound, err := inboundSvc.GetClientInboundByTrafficID(trafficId) + if err != nil { + return false, err + } + if inbound == nil { + return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId) + } + + clientEmail := traffic.Email + + oldClients, err := inboundSvc.GetClients(inbound) + if err != nil { + return false, err + } + + clientId := "" + + for _, oldClient := range oldClients { + if oldClient.Email == clientEmail { + switch inbound.Protocol { + case "trojan": + clientId = oldClient.Password + case "shadowsocks": + clientId = oldClient.Email + default: + clientId = oldClient.ID + } + break + } + } + + if len(clientId) == 0 { + return false, common.NewError("Client Not Found For Email:", clientEmail) + } + + var settings map[string]any + err = json.Unmarshal([]byte(inbound.Settings), &settings) + if err != nil { + return false, err + } + clients := settings["clients"].([]any) + var newClients []any + for client_index := range clients { + c := clients[client_index].(map[string]any) + if c["email"] == clientEmail { + c["tgId"] = tgId + c["updated_at"] = time.Now().Unix() * 1000 + newClients = append(newClients, any(c)) + } + } + settings["clients"] = newClients + modifiedSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false, err + } + inbound.Settings = string(modifiedSettings) + needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId) + return needRestart, err +} + +func (s *ClientService) checkIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) { + _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) + if err != nil { + return false, err + } + if inbound == nil { + return false, common.NewError("Inbound Not Found For Email:", clientEmail) + } + + clients, err := inboundSvc.GetClients(inbound) + if err != nil { + return false, err + } + + isEnable := false + + for _, client := range clients { + if client.Email == clientEmail { + isEnable = client.Enable + break + } + } + + return isEnable, err +} + +func (s *ClientService) ToggleClientEnableByEmail(inboundSvc *InboundService, clientEmail string) (bool, bool, error) { + _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) + if err != nil { + return false, false, err + } + if inbound == nil { + return false, false, common.NewError("Inbound Not Found For Email:", clientEmail) + } + + oldClients, err := inboundSvc.GetClients(inbound) + if err != nil { + return false, false, err + } + + clientId := "" + clientOldEnabled := false + + for _, oldClient := range oldClients { + if oldClient.Email == clientEmail { + switch inbound.Protocol { + case "trojan": + clientId = oldClient.Password + case "shadowsocks": + clientId = oldClient.Email + default: + clientId = oldClient.ID + } + clientOldEnabled = oldClient.Enable + break + } + } + + if len(clientId) == 0 { + return false, false, common.NewError("Client Not Found For Email:", clientEmail) + } + + var settings map[string]any + err = json.Unmarshal([]byte(inbound.Settings), &settings) + if err != nil { + return false, false, err + } + clients := settings["clients"].([]any) + var newClients []any + for client_index := range clients { + c := clients[client_index].(map[string]any) + if c["email"] == clientEmail { + c["enable"] = !clientOldEnabled + c["updated_at"] = time.Now().Unix() * 1000 + newClients = append(newClients, any(c)) + } + } + settings["clients"] = newClients + modifiedSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false, false, err + } + inbound.Settings = string(modifiedSettings) + + needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId) + if err != nil { + return false, needRestart, err + } + + return !clientOldEnabled, needRestart, nil +} + +func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clientEmail string, enable bool) (bool, bool, error) { + current, err := s.checkIsEnabledByEmail(inboundSvc, clientEmail) + if err != nil { + return false, false, err + } + if current == enable { + return false, false, nil + } + newEnabled, needRestart, err := s.ToggleClientEnableByEmail(inboundSvc, clientEmail) + if err != nil { + return false, needRestart, err + } + return newEnabled == enable, needRestart, nil +} + +func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) { + _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) + if err != nil { + return false, err + } + if inbound == nil { + return false, common.NewError("Inbound Not Found For Email:", clientEmail) + } + + oldClients, err := inboundSvc.GetClients(inbound) + if err != nil { + return false, err + } + + clientId := "" + + for _, oldClient := range oldClients { + if oldClient.Email == clientEmail { + switch inbound.Protocol { + case "trojan": + clientId = oldClient.Password + case "shadowsocks": + clientId = oldClient.Email + default: + clientId = oldClient.ID + } + break + } + } + + if len(clientId) == 0 { + return false, common.NewError("Client Not Found For Email:", clientEmail) + } + + var settings map[string]any + err = json.Unmarshal([]byte(inbound.Settings), &settings) + if err != nil { + return false, err + } + clients := settings["clients"].([]any) + var newClients []any + for client_index := range clients { + c := clients[client_index].(map[string]any) + if c["email"] == clientEmail { + c["limitIp"] = count + c["updated_at"] = time.Now().Unix() * 1000 + newClients = append(newClients, any(c)) + } + } + settings["clients"] = newClients + modifiedSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false, err + } + inbound.Settings = string(modifiedSettings) + needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId) + return needRestart, err +} + +func (s *ClientService) ResetClientExpiryTimeByEmail(inboundSvc *InboundService, clientEmail string, expiry_time int64) (bool, error) { + _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) + if err != nil { + return false, err + } + if inbound == nil { + return false, common.NewError("Inbound Not Found For Email:", clientEmail) + } + + oldClients, err := inboundSvc.GetClients(inbound) + if err != nil { + return false, err + } + + clientId := "" + + for _, oldClient := range oldClients { + if oldClient.Email == clientEmail { + switch inbound.Protocol { + case "trojan": + clientId = oldClient.Password + case "shadowsocks": + clientId = oldClient.Email + default: + clientId = oldClient.ID + } + break + } + } + + if len(clientId) == 0 { + return false, common.NewError("Client Not Found For Email:", clientEmail) + } + + var settings map[string]any + err = json.Unmarshal([]byte(inbound.Settings), &settings) + if err != nil { + return false, err + } + clients := settings["clients"].([]any) + var newClients []any + for client_index := range clients { + c := clients[client_index].(map[string]any) + if c["email"] == clientEmail { + c["expiryTime"] = expiry_time + c["updated_at"] = time.Now().Unix() * 1000 + newClients = append(newClients, any(c)) + } + } + settings["clients"] = newClients + modifiedSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false, err + } + inbound.Settings = string(modifiedSettings) + needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId) + return needRestart, err +} + +func (s *ClientService) ResetClientTrafficLimitByEmail(inboundSvc *InboundService, clientEmail string, totalGB int) (bool, error) { + if totalGB < 0 { + return false, common.NewError("totalGB must be >= 0") + } + _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) + if err != nil { + return false, err + } + if inbound == nil { + return false, common.NewError("Inbound Not Found For Email:", clientEmail) + } + + oldClients, err := inboundSvc.GetClients(inbound) + if err != nil { + return false, err + } + + clientId := "" + + for _, oldClient := range oldClients { + if oldClient.Email == clientEmail { + switch inbound.Protocol { + case "trojan": + clientId = oldClient.Password + case "shadowsocks": + clientId = oldClient.Email + default: + clientId = oldClient.ID + } + break + } + } + + if len(clientId) == 0 { + return false, common.NewError("Client Not Found For Email:", clientEmail) + } + + var settings map[string]any + err = json.Unmarshal([]byte(inbound.Settings), &settings) + if err != nil { + return false, err + } + clients := settings["clients"].([]any) + var newClients []any + for client_index := range clients { + c := clients[client_index].(map[string]any) + if c["email"] == clientEmail { + c["totalGB"] = totalGB * 1024 * 1024 * 1024 + c["updated_at"] = time.Now().Unix() * 1000 + newClients = append(newClients, any(c)) + } + } + settings["clients"] = newClients + modifiedSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false, err + } + inbound.Settings = string(modifiedSettings) + needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId) + return needRestart, err +} diff --git a/web/service/client_test.go b/web/service/client_test.go new file mode 100644 index 00000000..2cf8b219 --- /dev/null +++ b/web/service/client_test.go @@ -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) + } +} diff --git a/web/service/fallback.go b/web/service/fallback.go new file mode 100644 index 00000000..4eb2b6e8 --- /dev/null +++ b/web/service/fallback.go @@ -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 +} diff --git a/web/service/inbound.go b/web/service/inbound.go index 16bb2528..7fab7c48 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -24,7 +24,9 @@ import ( ) type InboundService struct { - xrayApi xray.XrayAPI + xrayApi xray.XrayAPI + clientService ClientService + fallbackService FallbackService } func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) { @@ -129,9 +131,146 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) { return nil, err } s.enrichClientStats(db, inbounds) + s.annotateFallbackParents(db, inbounds) return inbounds, nil } +// annotateFallbackParents fills FallbackParent on each inbound that is +// the child side of a fallback rule. One DB round-trip serves the full +// list — the frontend needs this to rewrite the child's client-share +// link so it points at the master's reachable endpoint. +func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model.Inbound) { + if len(inbounds) == 0 { + return + } + childIds := make([]int, 0, len(inbounds)) + for _, ib := range inbounds { + childIds = append(childIds, ib.Id) + } + var rows []model.InboundFallback + if err := db.Where("child_id IN ?", childIds). + Order("sort_order ASC, id ASC"). + Find(&rows).Error; err != nil { + return + } + first := make(map[int]model.InboundFallback, len(rows)) + for _, r := range rows { + if _, ok := first[r.ChildId]; !ok { + first[r.ChildId] = r + } + } + for _, ib := range inbounds { + if r, ok := first[ib.Id]; ok { + ib.FallbackParent = &model.FallbackParentInfo{ + MasterId: r.MasterId, + Path: r.Path, + } + } + } +} + +// InboundOption is the lightweight projection of an inbound used by client UI +// pickers — only the fields needed to render labels, filter by protocol, and +// decide whether the XTLS Vision flow selector should appear. Keeping this +// payload minimal avoids shipping per-client settings and traffic stats just +// to populate a dropdown. +type InboundOption struct { + Id int `json:"id"` + Remark string `json:"remark"` + Protocol string `json:"protocol"` + Port int `json:"port"` + TlsFlowCapable bool `json:"tlsFlowCapable"` +} + +// GetInboundOptions returns the picker-sized projection of the user's inbounds. +// The TlsFlowCapable flag mirrors Inbound.canEnableTlsFlow() on the frontend +// (VLESS over TCP with tls or reality) so the client modal does not need +// StreamSettings to decide whether to show the Flow field. +func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) { + db := database.GetDB() + var rows []struct { + Id int `gorm:"column:id"` + Remark string `gorm:"column:remark"` + Protocol string `gorm:"column:protocol"` + Port int `gorm:"column:port"` + StreamSettings string `gorm:"column:stream_settings"` + } + err := db.Table("inbounds"). + Select("id, remark, protocol, port, stream_settings"). + Where("user_id = ?", userId). + Order("id ASC"). + Scan(&rows).Error + if err != nil && err != gorm.ErrRecordNotFound { + return nil, err + } + out := make([]InboundOption, 0, len(rows)) + for _, r := range rows { + out = append(out, InboundOption{ + Id: r.Id, + Remark: r.Remark, + Protocol: r.Protocol, + Port: r.Port, + TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings), + }) + } + return out, nil +} + +// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend: +// XTLS Vision is only valid for VLESS on TCP with tls or reality. +func inboundCanEnableTlsFlow(protocol, streamSettings string) bool { + if protocol != string(model.VLESS) { + return false + } + if streamSettings == "" { + return false + } + var stream struct { + Network string `json:"network"` + Security string `json:"security"` + } + if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil { + return false + } + if stream.Network != "tcp" { + return false + } + return stream.Security == "tls" || stream.Security == "reality" +} + +// inboundCanHostFallbacks gates the settings.fallbacks injection. +// Xray only honors fallbacks on VLESS and Trojan inbounds carried over +// TCP transport with TLS or Reality security. +func inboundCanHostFallbacks(ib *model.Inbound) bool { + if ib == nil { + return false + } + if ib.Protocol != model.VLESS && ib.Protocol != model.Trojan { + return false + } + return inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) || + (ib.Protocol == model.Trojan && trojanStreamSupportsFallbacks(ib.StreamSettings)) +} + +// trojanStreamSupportsFallbacks mirrors the Trojan side of the same gate +// (Trojan reuses XTLS-Vision capable streams: tcp + tls or reality). +func trojanStreamSupportsFallbacks(streamSettings string) bool { + if streamSettings == "" { + return false + } + var stream struct { + Network string `json:"network"` + Security string `json:"security"` + } + if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil { + return false + } + if stream.Network != "tcp" { + return false + } + return stream.Security == "tls" || stream.Security == "reality" +} + // GetAllInbounds retrieves all inbounds with client stats. func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) { db := database.GetDB() @@ -171,12 +310,12 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err func (s *InboundService) getAllEmails() ([]string, error) { db := database.GetDB() var emails []string - err := db.Raw(` - SELECT DISTINCT JSON_EXTRACT(client.value, '$.email') - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - `).Scan(&emails).Error - if err != nil { + query := fmt.Sprintf( + "SELECT DISTINCT %s %s", + database.JSONFieldText("client.value", "email"), + database.JSONClientsFromInbound(), + ) + if err := db.Raw(query).Scan(&emails).Error; err != nil { return nil, err } return emails, nil @@ -190,22 +329,22 @@ func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) { Email string SubID string } - err := db.Raw(` - SELECT JSON_EXTRACT(client.value, '$.email') AS email, - JSON_EXTRACT(client.value, '$.subId') AS sub_id - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - `).Scan(&rows).Error - if err != nil { + query := fmt.Sprintf( + "SELECT %s AS email, %s AS sub_id %s", + database.JSONFieldText("client.value", "email"), + database.JSONFieldText("client.value", "subId"), + database.JSONClientsFromInbound(), + ) + if err := db.Raw(query).Scan(&rows).Error; err != nil { return nil, err } result := make(map[string]string, len(rows)) for _, r := range rows { - email := strings.ToLower(strings.Trim(r.Email, "\"")) + email := strings.ToLower(r.Email) if email == "" { continue } - subID := strings.Trim(r.SubID, "\"") + subID := r.SubID if existing, ok := result[email]; ok { if existing != subID { result[email] = "" @@ -217,14 +356,6 @@ func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) { return result, nil } -func lowerAll(in []string) []string { - out := make([]string, len(in)) - for i, s := range in { - out[i] = strings.ToLower(s) - } - return out -} - // emailUsedByOtherInbounds reports whether email lives in any inbound other // than exceptInboundId. Empty email returns false. func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId int) (bool, error) { @@ -233,51 +364,17 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId } db := database.GetDB() var count int64 - err := db.Raw(` - SELECT COUNT(*) - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - WHERE inbounds.id != ? - AND LOWER(JSON_EXTRACT(client.value, '$.email')) = LOWER(?) - `, exceptInboundId, email).Scan(&count).Error - if err != nil { + query := fmt.Sprintf( + "SELECT COUNT(*) %s WHERE inbounds.id != ? AND LOWER(%s) = LOWER(?)", + database.JSONClientsFromInbound(), + database.JSONFieldText("client.value", "email"), + ) + if err := db.Raw(query, exceptInboundId, email).Scan(&count).Error; err != nil { return false, err } return count > 0, nil } -// checkEmailsExistForClients validates a batch of incoming clients. An email -// collides only when the existing holder has a different (or empty) subId — -// matching non-empty subIds let multiple inbounds share one identity. -func (s *InboundService) checkEmailsExistForClients(clients []model.Client) (string, error) { - emailSubIDs, err := s.getAllEmailSubIDs() - if err != nil { - return "", err - } - seen := make(map[string]string, len(clients)) - for _, client := range clients { - if client.Email == "" { - continue - } - key := strings.ToLower(client.Email) - // Within the same payload, the same email must carry the same subId; - // otherwise we would silently merge two distinct identities. - if prev, ok := seen[key]; ok { - if prev != client.SubID || client.SubID == "" { - return client.Email, nil - } - continue - } - seen[key] = client.SubID - if existingSub, ok := emailSubIDs[key]; ok { - if client.SubID == "" || existingSub == "" || existingSub != client.SubID { - return client.Email, nil - } - } - } - return "", nil -} - // normalizeStreamSettings clears StreamSettings for protocols that don't use it. // Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings. func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { @@ -289,7 +386,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { model.Hysteria: true, model.Hysteria2: true, } - + if !protocolsWithStream[inbound.Protocol] { inbound.StreamSettings = "" } @@ -302,7 +399,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { // Normalize streamSettings based on protocol s.normalizeStreamSettings(inbound) - + exist, err := s.checkPortConflict(inbound, 0) if err != nil { return inbound, false, err @@ -320,7 +417,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err != nil { return inbound, false, err } - existEmail, err := s.checkEmailsExistForClients(clients) + existEmail, err := s.clientService.checkEmailsExistForClients(s, clients) if err != nil { return inbound, false, err } @@ -395,6 +492,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return inbound, false, err } + if err = s.clientService.SyncInbound(tx, inbound.Id, clients); err != nil { + return inbound, false, err + } + needRestart := false if inbound.Enable { rt, rterr := s.runtimeFor(inbound) @@ -422,24 +523,29 @@ func (s *InboundService) DelInbound(id int) (bool, error) { needRestart := false var ib model.Inbound - loadErr := db.Model(model.Inbound{}).Where("id = ? and enable = ?", id, true).First(&ib).Error + loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error if loadErr == nil { - rt, rterr := s.runtimeFor(&ib) - if rterr != nil { - logger.Warning("DelInbound: runtime lookup failed, deleting central row anyway:", rterr) - if ib.NodeID == nil { - needRestart = true + shouldPushToRuntime := ib.NodeID != nil || ib.Enable + if shouldPushToRuntime { + rt, rterr := s.runtimeFor(&ib) + if rterr != nil { + logger.Warning("DelInbound: runtime lookup failed, deleting central row anyway:", rterr) + if ib.NodeID == nil { + needRestart = true + } + } else if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil { + logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag) + } else { + logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1) + if ib.NodeID == nil { + needRestart = true + } } - } else if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil { - logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag) } else { - logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1) - if ib.NodeID == nil { - needRestart = true - } + logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id) } } else { - logger.Debug("No enabled inbound found to remove by api, id:", id) + logger.Debug("DelInbound: inbound not found, id:", id) } // Delete client traffics of inbounds @@ -447,6 +553,9 @@ func (s *InboundService) DelInbound(id int) (bool, error) { if err != nil { return false, err } + if err := s.clientService.DetachInbound(db, id); err != nil { + return false, err + } inbound, err := s.GetInbound(id) if err != nil { return false, err @@ -558,7 +667,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) { func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { // Normalize streamSettings based on protocol s.normalizeStreamSettings(inbound) - + exist, err := s.checkPortConflict(inbound, inbound.Id) if err != nil { return inbound, false, err @@ -705,7 +814,18 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, } } - return inbound, needRestart, tx.Save(oldInbound).Error + if err = tx.Save(oldInbound).Error; err != nil { + return inbound, false, err + } + newClients, gcErr := s.GetClients(oldInbound) + if gcErr != nil { + err = gcErr + return inbound, false, err + } + if err = s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil { + return inbound, false, err + } + return inbound, needRestart, nil } func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) { @@ -839,150 +959,6 @@ func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inb return nil } -func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) { - clients, err := s.GetClients(data) - if err != nil { - return false, err - } - - var settings map[string]any - err = json.Unmarshal([]byte(data.Settings), &settings) - if err != nil { - return false, err - } - - interfaceClients := settings["clients"].([]any) - // Add timestamps for new clients being appended - nowTs := time.Now().Unix() * 1000 - for i := range interfaceClients { - if cm, ok := interfaceClients[i].(map[string]any); ok { - if _, ok2 := cm["created_at"]; !ok2 { - cm["created_at"] = nowTs - } - cm["updated_at"] = nowTs - interfaceClients[i] = cm - } - } - existEmail, err := s.checkEmailsExistForClients(clients) - if err != nil { - return false, err - } - if existEmail != "" { - return false, common.NewError("Duplicate email:", existEmail) - } - - oldInbound, err := s.GetInbound(data.Id) - if err != nil { - return false, err - } - - // Secure client ID - for _, client := range clients { - if strings.TrimSpace(client.Email) == "" { - return false, common.NewError("client email is required") - } - switch oldInbound.Protocol { - case "trojan": - if client.Password == "" { - return false, common.NewError("empty client ID") - } - case "shadowsocks": - if client.Email == "" { - return false, common.NewError("empty client ID") - } - case "hysteria", "hysteria2": - if client.Auth == "" { - return false, common.NewError("empty client ID") - } - default: - if client.ID == "" { - return false, common.NewError("empty client ID") - } - } - } - - var oldSettings map[string]any - err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings) - if err != nil { - return false, err - } - - oldClients := oldSettings["clients"].([]any) - oldClients = append(oldClients, interfaceClients...) - - oldSettings["clients"] = oldClients - - newSettings, err := json.MarshalIndent(oldSettings, "", " ") - if err != nil { - return false, err - } - - oldInbound.Settings = string(newSettings) - - db := database.GetDB() - tx := db.Begin() - - defer func() { - if err != nil { - tx.Rollback() - } else { - tx.Commit() - } - }() - - needRestart := false - rt, rterr := s.runtimeFor(oldInbound) - if rterr != nil { - if oldInbound.NodeID != nil { - err = rterr - return false, err - } - needRestart = true - } else if oldInbound.NodeID == nil { - for _, client := range clients { - if len(client.Email) == 0 { - needRestart = true - continue - } - s.AddClientStat(tx, data.Id, &client) - if !client.Enable { - continue - } - cipher := "" - if oldInbound.Protocol == "shadowsocks" { - cipher = oldSettings["method"].(string) - } - err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{ - "email": client.Email, - "id": client.ID, - "auth": client.Auth, - "security": client.Security, - "flow": client.Flow, - "password": client.Password, - "cipher": cipher, - }) - if err1 == nil { - logger.Debug("Client added on", rt.Name(), ":", client.Email) - } else { - logger.Debug("Error in adding client on", rt.Name(), ":", err1) - needRestart = true - } - } - } else { - for _, client := range clients { - if len(client.Email) > 0 { - s.AddClientStat(tx, data.Id, &client) - } - } - if err1 := rt.UpdateInbound(context.Background(), oldInbound, oldInbound); err1 != nil { - err = err1 - return false, err - } - } - - return needRestart, tx.Save(oldInbound).Error -} - func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string { switch protocol { case model.Trojan: @@ -1015,7 +991,7 @@ func (s *InboundService) writeBackClientSubID(sourceInboundID int, sourceProtoco Id: sourceInboundID, Settings: string(settingsBytes), } - return s.UpdateInboundClient(updatePayload, clientID) + return s.clientService.UpdateInboundClient(s, updatePayload, clientID) } func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string { @@ -1165,7 +1141,7 @@ func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID return result, needRestart, err } - addNeedRestart, err := s.AddInboundClient(&model.Inbound{ + addNeedRestart, err := s.clientService.AddInboundClient(s, &model.Inbound{ Id: targetInboundID, Settings: string(settingsPayload), }) @@ -1179,370 +1155,6 @@ func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID return result, needRestart, nil } -func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) { - oldInbound, err := s.GetInbound(inboundId) - if err != nil { - logger.Error("Load Old Data Error") - return false, err - } - var settings map[string]any - err = json.Unmarshal([]byte(oldInbound.Settings), &settings) - if err != nil { - return false, err - } - - email := "" - client_key := "id" - switch oldInbound.Protocol { - case "trojan": - client_key = "password" - case "shadowsocks": - client_key = "email" - case "hysteria", "hysteria2": - client_key = "auth" - } - - interfaceClients := settings["clients"].([]any) - var newClients []any - needApiDel := false - clientFound := false - for _, client := range interfaceClients { - c := client.(map[string]any) - c_id := c[client_key].(string) - if c_id == clientId { - clientFound = true - email, _ = c["email"].(string) - needApiDel, _ = c["enable"].(bool) - } else { - newClients = append(newClients, client) - } - } - - if !clientFound { - return false, common.NewError("Client Not Found In Inbound For ID:", clientId) - } - - if len(newClients) == 0 { - return false, common.NewError("no client remained in Inbound") - } - - settings["clients"] = newClients - newSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, err - } - - oldInbound.Settings = string(newSettings) - - db := database.GetDB() - - // Keep the client_traffics row and IPs alive when another inbound still - // references this email — siblings depend on the shared accounting state. - emailShared, err := s.emailUsedByOtherInbounds(email, inboundId) - if err != nil { - return false, err - } - - if !emailShared { - err = s.DelClientIPs(db, email) - if err != nil { - logger.Error("Error in delete client IPs") - return false, err - } - } - needRestart := false - - if len(email) > 0 { - notDepleted := true - err = db.Model(xray.ClientTraffic{}).Select("enable").Where("email = ?", email).First(¬Depleted).Error - if err != nil { - logger.Error("Get stats error") - return false, err - } - if !emailShared { - err = s.DelClientStat(db, email) - if err != nil { - logger.Error("Delete stats Data Error") - return false, err - } - } - if needApiDel && notDepleted { - rt, rterr := s.runtimeFor(oldInbound) - if rterr != nil { - if oldInbound.NodeID != nil { - return false, rterr - } - needRestart = true - } else if oldInbound.NodeID == nil { - err1 := rt.RemoveUser(context.Background(), oldInbound, email) - if err1 == nil { - logger.Debug("Client deleted on", rt.Name(), ":", email) - needRestart = false - } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) { - logger.Debug("User is already deleted. Nothing to do more...") - } else { - logger.Debug("Error in deleting client on", rt.Name(), ":", err1) - needRestart = true - } - } else { - if err1 := rt.UpdateInbound(context.Background(), oldInbound, oldInbound); err1 != nil { - return false, err1 - } - } - } - } - return needRestart, db.Save(oldInbound).Error -} - -func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) { - // TODO: check if TrafficReset field is updating - clients, err := s.GetClients(data) - if err != nil { - return false, err - } - - var settings map[string]any - err = json.Unmarshal([]byte(data.Settings), &settings) - if err != nil { - return false, err - } - - interfaceClients := settings["clients"].([]any) - - oldInbound, err := s.GetInbound(data.Id) - if err != nil { - return false, err - } - - oldClients, err := s.GetClients(oldInbound) - if err != nil { - return false, err - } - - oldEmail := "" - newClientId := "" - clientIndex := -1 - for index, oldClient := range oldClients { - oldClientId := "" - switch oldInbound.Protocol { - case "trojan": - oldClientId = oldClient.Password - newClientId = clients[0].Password - case "shadowsocks": - oldClientId = oldClient.Email - newClientId = clients[0].Email - case "hysteria", "hysteria2": - oldClientId = oldClient.Auth - newClientId = clients[0].Auth - default: - oldClientId = oldClient.ID - newClientId = clients[0].ID - } - if clientId == oldClientId { - oldEmail = oldClient.Email - clientIndex = index - break - } - } - - // Validate new client ID - if newClientId == "" || clientIndex == -1 { - return false, common.NewError("empty client ID") - } - if strings.TrimSpace(clients[0].Email) == "" { - return false, common.NewError("client email is required") - } - - if clients[0].Email != oldEmail { - existEmail, err := s.checkEmailsExistForClients(clients) - if err != nil { - return false, err - } - if existEmail != "" { - return false, common.NewError("Duplicate email:", existEmail) - } - } - - var oldSettings map[string]any - err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings) - if err != nil { - return false, err - } - settingsClients := oldSettings["clients"].([]any) - // Preserve created_at and set updated_at for the replacing client - var preservedCreated any - if clientIndex >= 0 && clientIndex < len(settingsClients) { - if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok { - if v, ok2 := oldMap["created_at"]; ok2 { - preservedCreated = v - } - } - } - if len(interfaceClients) > 0 { - if newMap, ok := interfaceClients[0].(map[string]any); ok { - if preservedCreated == nil { - preservedCreated = time.Now().Unix() * 1000 - } - newMap["created_at"] = preservedCreated - newMap["updated_at"] = time.Now().Unix() * 1000 - interfaceClients[0] = newMap - } - } - settingsClients[clientIndex] = interfaceClients[0] - oldSettings["clients"] = settingsClients - - // testseed is only meaningful when at least one VLESS client uses the exact - // xtls-rprx-vision flow. The client-edit path only rewrites a single client, - // so re-check the flow set here and strip a stale testseed when nothing in the - // inbound still warrants it. The full-inbound update path already handles this - // on the JS side via VLESSSettings.toJson(). - if oldInbound.Protocol == model.VLESS { - hasVisionFlow := false - for _, c := range settingsClients { - cm, ok := c.(map[string]any) - if !ok { - continue - } - if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" { - hasVisionFlow = true - break - } - } - if !hasVisionFlow { - delete(oldSettings, "testseed") - } - } - - newSettings, err := json.MarshalIndent(oldSettings, "", " ") - if err != nil { - return false, err - } - - oldInbound.Settings = string(newSettings) - db := database.GetDB() - tx := db.Begin() - - defer func() { - if err != nil { - tx.Rollback() - } else { - tx.Commit() - } - }() - - if len(clients[0].Email) > 0 { - if len(oldEmail) > 0 { - // Repointing onto an email that already has a row would collide on - // the unique constraint, so retire the donor and let the surviving - // row carry the merged identity. - emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email) - targetExists := int64(0) - if !emailUnchanged { - if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Count(&targetExists).Error; err != nil { - return false, err - } - } - if emailUnchanged || targetExists == 0 { - err = s.UpdateClientStat(tx, oldEmail, &clients[0]) - if err != nil { - return false, err - } - err = s.UpdateClientIPs(tx, oldEmail, clients[0].Email) - if err != nil { - return false, err - } - } else { - stillUsed, sErr := s.emailUsedByOtherInbounds(oldEmail, data.Id) - if sErr != nil { - return false, sErr - } - if !stillUsed { - if err = s.DelClientStat(tx, oldEmail); err != nil { - return false, err - } - if err = s.DelClientIPs(tx, oldEmail); err != nil { - return false, err - } - } - // Refresh the surviving row with the new client's limits/expiry. - if err = s.UpdateClientStat(tx, clients[0].Email, &clients[0]); err != nil { - return false, err - } - } - } else { - s.AddClientStat(tx, data.Id, &clients[0]) - } - } else { - stillUsed, err := s.emailUsedByOtherInbounds(oldEmail, data.Id) - if err != nil { - return false, err - } - if !stillUsed { - err = s.DelClientStat(tx, oldEmail) - if err != nil { - return false, err - } - err = s.DelClientIPs(tx, oldEmail) - if err != nil { - return false, err - } - } - } - needRestart := false - if len(oldEmail) > 0 { - rt, rterr := s.runtimeFor(oldInbound) - if rterr != nil { - if oldInbound.NodeID != nil { - err = rterr - return false, err - } - needRestart = true - } else if oldInbound.NodeID == nil { - if oldClients[clientIndex].Enable { - err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail) - if err1 == nil { - logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail) - } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) { - logger.Debug("User is already deleted. Nothing to do more...") - } else { - logger.Debug("Error in deleting client on", rt.Name(), ":", err1) - needRestart = true - } - } - if clients[0].Enable { - cipher := "" - if oldInbound.Protocol == "shadowsocks" { - cipher = oldSettings["method"].(string) - } - err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{ - "email": clients[0].Email, - "id": clients[0].ID, - "security": clients[0].Security, - "flow": clients[0].Flow, - "auth": clients[0].Auth, - "password": clients[0].Password, - "cipher": cipher, - }) - if err1 == nil { - logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email) - } else { - logger.Debug("Error in adding client on", rt.Name(), ":", err1) - needRestart = true - } - } - } else { - if err1 := rt.UpdateInbound(context.Background(), oldInbound, oldInbound); err1 != nil { - err = err1 - return false, err - } - } - } else { - logger.Debug("Client old email not found") - needRestart = true - } - return needRestart, tx.Save(oldInbound).Error -} - const resetGracePeriodMs int64 = 30000 // onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval — @@ -1597,8 +1209,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi email string } centralCS := make(map[csKey]*xray.ClientTraffic, len(centralClientStats)) + centralCSByEmail := make(map[string]*xray.ClientTraffic, len(centralClientStats)) for i := range centralClientStats { centralCS[csKey{centralClientStats[i].InboundId, centralClientStats[i].Email}] = ¢ralClientStats[i] + centralCSByEmail[centralClientStats[i].Email] = ¢ralClientStats[i] } var defaultUserId int @@ -1649,7 +1263,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi ExpiryTime: snapIb.ExpiryTime, Up: snapIb.Up, Down: snapIb.Down, - AllTime: snapIb.AllTime, } if err := tx.Create(&newIb).Error; err != nil { logger.Warning("setRemoteTraffic: create central inbound for tag", snapIb.Tag, "failed:", err) @@ -1679,9 +1292,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi updates["up"] = snapIb.Up updates["down"] = snapIb.Down } - if snapIb.AllTime > c.AllTime { - updates["all_time"] = snapIb.AllTime - } if c.Settings != snapIb.Settings || c.Remark != snapIb.Remark || @@ -1732,7 +1342,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi existing := centralCS[csKey{c.Id, cs.Email}] if existing == nil { - if err := tx.Create(&xray.ClientTraffic{ + existing = centralCSByEmail[cs.Email] + } + if existing == nil { + row := &xray.ClientTraffic{ InboundId: c.Id, Email: cs.Email, Enable: cs.Enable, @@ -1741,11 +1354,14 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi Reset: cs.Reset, Up: cs.Up, Down: cs.Down, - AllTime: cs.AllTime, LastOnline: cs.LastOnline, - }).Error; err != nil { + } + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}). + Create(row).Error; err != nil { return false, err } + centralCS[csKey{c.Id, cs.Email}] = row + centralCSByEmail[cs.Email] = row structuralChange = true continue } @@ -1757,17 +1373,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi structuralChange = true } - allTime := existing.AllTime - if cs.AllTime > allTime { - allTime = cs.AllTime - } - if inGrace && cs.Up+cs.Down > 0 { if err := tx.Exec( `UPDATE client_traffics - SET enable = ?, total = ?, expiry_time = ?, reset = ?, all_time = ? - WHERE inbound_id = ? AND email = ?`, - cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, allTime, c.Id, cs.Email, + SET enable = ?, total = ?, expiry_time = ?, reset = ? + WHERE email = ?`, + cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, cs.Email, ).Error; err != nil { return false, err } @@ -1777,10 +1388,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if err := tx.Exec( `UPDATE client_traffics SET up = ?, down = ?, enable = ?, total = ?, expiry_time = ?, reset = ?, - all_time = ?, last_online = MAX(last_online, ?) - WHERE inbound_id = ? AND email = ?`, - cs.Up, cs.Down, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, allTime, - cs.LastOnline, c.Id, cs.Email, + last_online = MAX(last_online, ?) + WHERE email = ?`, + cs.Up, cs.Down, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, + cs.LastOnline, cs.Email, ).Error; err != nil { return false, err } @@ -1801,6 +1412,93 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } + type oldSet struct { + inboundID int + emails map[string]struct{} + } + var perInboundOld []oldSet + for _, snapIb := range snap.Inbounds { + if snapIb == nil { + continue + } + c, ok := tagToCentral[snapIb.Tag] + if !ok { + continue + } + var oldEmailsRows []string + if err := tx.Table("clients"). + Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). + Where("client_inbounds.inbound_id = ?", c.Id). + Pluck("email", &oldEmailsRows).Error; err == nil { + oldEmails := make(map[string]struct{}, len(oldEmailsRows)) + for _, e := range oldEmailsRows { + if e != "" { + oldEmails[e] = struct{}{} + } + } + perInboundOld = append(perInboundOld, oldSet{inboundID: c.Id, emails: oldEmails}) + } + + clients, gcErr := s.GetClients(snapIb) + if gcErr != nil { + logger.Warning("setRemoteTraffic: parse clients for tag", snapIb.Tag, "failed:", gcErr) + continue + } + csEnableByEmail := make(map[string]bool, len(snapIb.ClientStats)) + for _, cs := range snapIb.ClientStats { + csEnableByEmail[cs.Email] = cs.Enable + } + filtered := clients[:0] + for i := range clients { + if isClientEmailTombstoned(clients[i].Email) { + continue + } + if cse, hit := csEnableByEmail[clients[i].Email]; hit && !cse { + clients[i].Enable = false + } + filtered = append(filtered, clients[i]) + } + if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil { + logger.Warning("setRemoteTraffic: sync clients for tag", snapIb.Tag, "failed:", err) + } + } + + for _, old := range perInboundOld { + var stillAttached []string + if err := tx.Table("clients"). + Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). + Where("client_inbounds.inbound_id = ?", old.inboundID). + Pluck("email", &stillAttached).Error; err != nil { + continue + } + stillSet := make(map[string]struct{}, len(stillAttached)) + for _, e := range stillAttached { + stillSet[e] = struct{}{} + } + for email := range old.emails { + if _, kept := stillSet[email]; kept { + continue + } + var attachmentCount int64 + if err := tx.Table("client_inbounds"). + Joins("JOIN clients ON clients.id = client_inbounds.client_id"). + Where("clients.email = ?", email). + Count(&attachmentCount).Error; err != nil { + continue + } + if attachmentCount > 0 { + continue + } + if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil { + logger.Warning("setRemoteTraffic: delete ClientRecord", email, "failed:", err) + } + if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil { + logger.Warning("setRemoteTraffic: delete ClientTraffic", email, "failed:", err) + } + structuralChange = true + } + } + if err := tx.Commit().Error; err != nil { return false, err } @@ -1879,9 +1577,8 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic if traffic.IsInbound { err = tx.Model(&model.Inbound{}).Where("tag = ? AND node_id IS NULL", traffic.Tag). Updates(map[string]any{ - "up": gorm.Expr("up + ?", traffic.Up), - "down": gorm.Expr("down + ?", traffic.Down), - "all_time": gorm.Expr("COALESCE(all_time, 0) + ?", traffic.Up+traffic.Down), + "up": gorm.Expr("up + ?", traffic.Up), + "down": gorm.Expr("down + ?", traffic.Down), }).Error if err != nil { return err @@ -1936,7 +1633,6 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr } dbClientTraffics[dbTraffic_index].Up += t.Up dbClientTraffics[dbTraffic_index].Down += t.Down - dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down if t.Up+t.Down > 0 { dbClientTraffics[dbTraffic_index].LastOnline = now } @@ -2002,6 +1698,20 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl if err != nil { logger.Warning("AddClientTraffic update inbounds ", err) logger.Error(inbounds) + } else { + for _, ib := range inbounds { + if ib == nil { + continue + } + cs, gcErr := s.GetClients(ib) + if gcErr != nil { + logger.Warning("AddClientTraffic sync clients: GetClients failed", gcErr) + continue + } + if syncErr := s.clientService.SyncInbound(tx, ib.Id, cs); syncErr != nil { + logger.Warning("AddClientTraffic sync clients: SyncInbound failed", syncErr) + } + } } } @@ -2096,6 +1806,19 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) { if err != nil { return false, 0, err } + for _, ib := range inbounds { + if ib == nil { + continue + } + cs, gcErr := s.GetClients(ib) + if gcErr != nil { + logger.Warning("autoRenewClients sync clients: GetClients failed", gcErr) + continue + } + if syncErr := s.clientService.SyncInbound(tx, ib.Id, cs); syncErr != nil { + logger.Warning("autoRenewClients sync clients: SyncInbound failed", syncErr) + } + } err = tx.Save(traffics).Error if err != nil { return false, 0, err @@ -2157,7 +1880,6 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) var depletedRows []xray.ClientTraffic err := tx.Model(xray.ClientTraffic{}). Where("((total > 0 AND up + down >= total) OR (expiry_time > 0 AND expiry_time <= ?)) AND enable = ?", now, true). - Where("inbound_id IN (?)", tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NULL")). Find(&depletedRows).Error if err != nil { return false, 0, err @@ -2166,85 +1888,53 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) return false, 0, nil } - rowByEmail := make(map[string]*xray.ClientTraffic, len(depletedRows)) depletedEmails := make([]string, 0, len(depletedRows)) for i := range depletedRows { if depletedRows[i].Email == "" { continue } - rowByEmail[strings.ToLower(depletedRows[i].Email)] = &depletedRows[i] depletedEmails = append(depletedEmails, depletedRows[i].Email) } - // Resolve inbound membership only for the depleted emails — pushing the - // filter into SQLite avoids dragging every panel client through Go for - // the common case where most clients are healthy. - var memberships []struct { - InboundId int + type target struct { + InboundID int `gorm:"column:inbound_id"` + NodeID *int `gorm:"column:node_id"` Tag string Email string - SubID string `gorm:"column:sub_id"` } + var targets []target if len(depletedEmails) > 0 { err = tx.Raw(` - SELECT inbounds.id AS inbound_id, - inbounds.tag AS tag, - JSON_EXTRACT(client.value, '$.email') AS email, - JSON_EXTRACT(client.value, '$.subId') AS sub_id - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - WHERE LOWER(JSON_EXTRACT(client.value, '$.email')) IN ? - `, lowerAll(depletedEmails)).Scan(&memberships).Error + SELECT inbounds.id AS inbound_id, inbounds.node_id AS node_id, + inbounds.tag AS tag, clients.email AS email + FROM clients + JOIN client_inbounds ON client_inbounds.client_id = clients.id + JOIN inbounds ON inbounds.id = client_inbounds.inbound_id + WHERE clients.email IN ? + `, depletedEmails).Scan(&targets).Error if err != nil { return false, 0, err } } - // Discover the row holder's subId per email. Only siblings sharing it - // get cascaded; legacy data where two identities reuse the same email - // stays isolated to the row owner. - holderSub := make(map[string]string, len(rowByEmail)) - for _, m := range memberships { - email := strings.ToLower(strings.Trim(m.Email, "\"")) - row, ok := rowByEmail[email] - if !ok || m.InboundId != row.InboundId { - continue - } - holderSub[email] = strings.Trim(m.SubID, "\"") - } - - type target struct { - InboundId int - Tag string - Email string - } - var targets []target - for _, m := range memberships { - email := strings.ToLower(strings.Trim(m.Email, "\"")) - row, ok := rowByEmail[email] - if !ok { - continue - } - expected, hasSub := holderSub[email] - mSub := strings.Trim(m.SubID, "\"") - switch { - case !hasSub || expected == "": - if m.InboundId != row.InboundId { - continue + var localTargets []target + localByInbound := make(map[int]map[string]struct{}) + remoteByInbound := make(map[int][]target) + for _, t := range targets { + if t.NodeID == nil { + localTargets = append(localTargets, t) + if localByInbound[t.InboundID] == nil { + localByInbound[t.InboundID] = make(map[string]struct{}) } - case mSub != expected: - continue + localByInbound[t.InboundID][t.Email] = struct{}{} + } else { + remoteByInbound[t.InboundID] = append(remoteByInbound[t.InboundID], t) } - targets = append(targets, target{ - InboundId: m.InboundId, - Tag: m.Tag, - Email: strings.Trim(m.Email, "\""), - }) } - if p != nil && len(targets) > 0 { + if p != nil && len(localTargets) > 0 { s.xrayApi.Init(p.GetAPIPort()) - for _, t := range targets { + for _, t := range localTargets { err1 := s.xrayApi.RemoveUser(t.Tag, t.Email) if err1 == nil { logger.Debug("Client disabled by api:", t.Email) @@ -2258,9 +1948,14 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) s.xrayApi.Close() } + for inboundID, emails := range localByInbound { + if _, _, mErr := s.markClientsDisabledInSettings(tx, inboundID, emails); mErr != nil { + logger.Warning("disableInvalidClients: settings.JSON sync failed for inbound", inboundID, ":", mErr) + } + } + result := tx.Model(xray.ClientTraffic{}). Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true). - Where("inbound_id IN (?)", tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NULL")). Update("enable", false) err = result.Error count := result.RowsAffected @@ -2268,76 +1963,94 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) return needRestart, count, err } - if len(targets) == 0 { - return needRestart, count, nil + if len(depletedEmails) > 0 { + if err := tx.Model(&model.ClientRecord{}). + Where("email IN ?", depletedEmails). + Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil { + logger.Warning("disableInvalidClients update clients.enable:", err) + } } - inboundEmailMap := make(map[int]map[string]struct{}) - for _, t := range targets { - if inboundEmailMap[t.InboundId] == nil { - inboundEmailMap[t.InboundId] = make(map[string]struct{}) + for inboundID, group := range remoteByInbound { + emails := make(map[string]struct{}, len(group)) + for _, t := range group { + emails[t.Email] = struct{}{} } - inboundEmailMap[t.InboundId][t.Email] = struct{}{} - } - inboundIds := make([]int, 0, len(inboundEmailMap)) - for id := range inboundEmailMap { - inboundIds = append(inboundIds, id) - } - var inbounds []*model.Inbound - if err = tx.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error; err != nil { - logger.Warning("disableInvalidClients fetch inbounds:", err) - return needRestart, count, nil - } - dirty := make([]*model.Inbound, 0, len(inbounds)) - for _, inbound := range inbounds { - settings := map[string]any{} - if jsonErr := json.Unmarshal([]byte(inbound.Settings), &settings); jsonErr != nil { - continue - } - clientsRaw, ok := settings["clients"].([]any) - if !ok { - continue - } - emailSet := inboundEmailMap[inbound.Id] - changed := false - for i := range clientsRaw { - c, ok := clientsRaw[i].(map[string]any) - if !ok { - continue - } - email, _ := c["email"].(string) - if _, shouldDisable := emailSet[email]; !shouldDisable { - continue - } - c["enable"] = false - if row, ok := rowByEmail[strings.ToLower(email)]; ok { - c["totalGB"] = row.Total - c["expiryTime"] = row.ExpiryTime - } - c["updated_at"] = now - clientsRaw[i] = c - changed = true - } - if !changed { - continue - } - settings["clients"] = clientsRaw - modifiedSettings, jsonErr := json.MarshalIndent(settings, "", " ") - if jsonErr != nil { - continue - } - inbound.Settings = string(modifiedSettings) - dirty = append(dirty, inbound) - } - if len(dirty) > 0 { - if err = tx.Save(dirty).Error; err != nil { - logger.Warning("disableInvalidClients update inbound settings:", err) + if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil { + logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr) + needRestart = true } } return needRestart, count, nil } +// markClientsDisabledInSettings flips client.enable=false in the inbound's +// stored settings JSON for the given emails and returns both the pre and +// post snapshots so a caller pushing to a remote node has the diff to hand. +func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID int, emails map[string]struct{}) (oldIb, newIb *model.Inbound, err error) { + var ib model.Inbound + if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID).First(&ib).Error; err != nil { + return nil, nil, err + } + snapshot := ib + + settings := map[string]any{} + if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil { + return nil, nil, err + } + clients, _ := settings["clients"].([]any) + now := time.Now().Unix() * 1000 + mutated := false + for i := range clients { + entry, ok := clients[i].(map[string]any) + if !ok { + continue + } + email, _ := entry["email"].(string) + if _, hit := emails[email]; !hit { + continue + } + if cur, _ := entry["enable"].(bool); cur == false { + continue + } + entry["enable"] = false + entry["updated_at"] = now + clients[i] = entry + mutated = true + } + if !mutated { + return &snapshot, &ib, nil + } + settings["clients"] = clients + bs, marshalErr := json.MarshalIndent(settings, "", " ") + if marshalErr != nil { + return nil, nil, marshalErr + } + ib.Settings = string(bs) + if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID). + Update("settings", ib.Settings).Error; err != nil { + return nil, nil, err + } + return &snapshot, &ib, nil +} + +func (s *InboundService) disableRemoteClients(tx *gorm.DB, inboundID int, emails map[string]struct{}) error { + oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails) + if err != nil { + return err + } + + rt, err := s.runtimeFor(ib) + if err != nil { + return err + } + if err := rt.UpdateInbound(context.Background(), oldSnapshot, ib); err != nil { + return err + } + return nil +} + func (s *InboundService) GetInboundTags() (string, error) { db := database.GetDB() var inboundTags []string @@ -2396,14 +2109,12 @@ func (s *InboundService) GetClientReverseTags() (string, error) { func (s *InboundService) MigrationRemoveOrphanedTraffics() { db := database.GetDB() - db.Exec(` - DELETE FROM client_traffics - WHERE email NOT IN ( - SELECT JSON_EXTRACT(client.value, '$.email') - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - ) - `) + query := fmt.Sprintf( + "DELETE FROM client_traffics WHERE email NOT IN (SELECT %s %s)", + database.JSONFieldText("client.value", "email"), + database.JSONClientsFromInbound(), + ) + db.Exec(query) } // AddClientStat inserts a per-client accounting row, no-op on email @@ -2501,355 +2212,6 @@ func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraff return nil, nil, common.NewError("Client Not Found In Inbound For Email:", clientEmail) } -func (s *InboundService) SetClientTelegramUserID(trafficId int, tgId int64) (bool, error) { - traffic, inbound, err := s.GetClientInboundByTrafficID(trafficId) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId) - } - - clientEmail := traffic.Email - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - clientId := "" - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - break - } - } - - if len(clientId) == 0 { - return false, common.NewError("Client Not Found For Email:", clientEmail) - } - - var settings map[string]any - err = json.Unmarshal([]byte(inbound.Settings), &settings) - if err != nil { - return false, err - } - clients := settings["clients"].([]any) - var newClients []any - for client_index := range clients { - c := clients[client_index].(map[string]any) - if c["email"] == clientEmail { - c["tgId"] = tgId - c["updated_at"] = time.Now().Unix() * 1000 - newClients = append(newClients, any(c)) - } - } - settings["clients"] = newClients - modifiedSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, err - } - inbound.Settings = string(modifiedSettings) - needRestart, err := s.UpdateInboundClient(inbound, clientId) - return needRestart, err -} - -func (s *InboundService) checkIsEnabledByEmail(clientEmail string) (bool, error) { - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - clients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - isEnable := false - - for _, client := range clients { - if client.Email == clientEmail { - isEnable = client.Enable - break - } - } - - return isEnable, err -} - -func (s *InboundService) ToggleClientEnableByEmail(clientEmail string) (bool, bool, error) { - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, false, err - } - if inbound == nil { - return false, false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, false, err - } - - clientId := "" - clientOldEnabled := false - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - clientOldEnabled = oldClient.Enable - break - } - } - - if len(clientId) == 0 { - return false, false, common.NewError("Client Not Found For Email:", clientEmail) - } - - var settings map[string]any - err = json.Unmarshal([]byte(inbound.Settings), &settings) - if err != nil { - return false, false, err - } - clients := settings["clients"].([]any) - var newClients []any - for client_index := range clients { - c := clients[client_index].(map[string]any) - if c["email"] == clientEmail { - c["enable"] = !clientOldEnabled - c["updated_at"] = time.Now().Unix() * 1000 - newClients = append(newClients, any(c)) - } - } - settings["clients"] = newClients - modifiedSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, false, err - } - inbound.Settings = string(modifiedSettings) - - needRestart, err := s.UpdateInboundClient(inbound, clientId) - if err != nil { - return false, needRestart, err - } - - return !clientOldEnabled, needRestart, nil -} - -// SetClientEnableByEmail sets client enable state to desired value; returns (changed, needRestart, error) -func (s *InboundService) SetClientEnableByEmail(clientEmail string, enable bool) (bool, bool, error) { - current, err := s.checkIsEnabledByEmail(clientEmail) - if err != nil { - return false, false, err - } - if current == enable { - return false, false, nil - } - newEnabled, needRestart, err := s.ToggleClientEnableByEmail(clientEmail) - if err != nil { - return false, needRestart, err - } - return newEnabled == enable, needRestart, nil -} - -func (s *InboundService) ResetClientIpLimitByEmail(clientEmail string, count int) (bool, error) { - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - clientId := "" - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - break - } - } - - if len(clientId) == 0 { - return false, common.NewError("Client Not Found For Email:", clientEmail) - } - - var settings map[string]any - err = json.Unmarshal([]byte(inbound.Settings), &settings) - if err != nil { - return false, err - } - clients := settings["clients"].([]any) - var newClients []any - for client_index := range clients { - c := clients[client_index].(map[string]any) - if c["email"] == clientEmail { - c["limitIp"] = count - c["updated_at"] = time.Now().Unix() * 1000 - newClients = append(newClients, any(c)) - } - } - settings["clients"] = newClients - modifiedSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, err - } - inbound.Settings = string(modifiedSettings) - needRestart, err := s.UpdateInboundClient(inbound, clientId) - return needRestart, err -} - -func (s *InboundService) ResetClientExpiryTimeByEmail(clientEmail string, expiry_time int64) (bool, error) { - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - clientId := "" - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - break - } - } - - if len(clientId) == 0 { - return false, common.NewError("Client Not Found For Email:", clientEmail) - } - - var settings map[string]any - err = json.Unmarshal([]byte(inbound.Settings), &settings) - if err != nil { - return false, err - } - clients := settings["clients"].([]any) - var newClients []any - for client_index := range clients { - c := clients[client_index].(map[string]any) - if c["email"] == clientEmail { - c["expiryTime"] = expiry_time - c["updated_at"] = time.Now().Unix() * 1000 - newClients = append(newClients, any(c)) - } - } - settings["clients"] = newClients - modifiedSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, err - } - inbound.Settings = string(modifiedSettings) - needRestart, err := s.UpdateInboundClient(inbound, clientId) - return needRestart, err -} - -func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, totalGB int) (bool, error) { - if totalGB < 0 { - return false, common.NewError("totalGB must be >= 0") - } - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - clientId := "" - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - break - } - } - - if len(clientId) == 0 { - return false, common.NewError("Client Not Found For Email:", clientEmail) - } - - var settings map[string]any - err = json.Unmarshal([]byte(inbound.Settings), &settings) - if err != nil { - return false, err - } - clients := settings["clients"].([]any) - var newClients []any - for client_index := range clients { - c := clients[client_index].(map[string]any) - if c["email"] == clientEmail { - c["totalGB"] = totalGB * 1024 * 1024 * 1024 - c["updated_at"] = time.Now().Unix() * 1000 - newClients = append(newClients, any(c)) - } - } - settings["clients"] = newClients - modifiedSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, err - } - inbound.Settings = string(modifiedSettings) - needRestart, err := s.UpdateInboundClient(inbound, clientId) - return needRestart, err -} - func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { return submitTrafficWrite(func() error { db := database.GetDB() @@ -2953,73 +2315,6 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b return needRestart, nil } -func (s *InboundService) ResetAllClientTraffics(id int) error { - return submitTrafficWrite(func() error { - return s.resetAllClientTrafficsLocked(id) - }) -} - -func (s *InboundService) resetAllClientTrafficsLocked(id int) error { - db := database.GetDB() - now := time.Now().Unix() * 1000 - - if err := db.Transaction(func(tx *gorm.DB) error { - whereText := "inbound_id " - if id == -1 { - whereText += " > ?" - } else { - whereText += " = ?" - } - - // Reset client traffics - result := tx.Model(xray.ClientTraffic{}). - Where(whereText, id). - Updates(map[string]any{"enable": true, "up": 0, "down": 0}) - - if result.Error != nil { - return result.Error - } - - // Update lastTrafficResetTime for the inbound(s) - inboundWhereText := "id " - if id == -1 { - inboundWhereText += " > ?" - } else { - inboundWhereText += " = ?" - } - - result = tx.Model(model.Inbound{}). - Where(inboundWhereText, id). - Update("last_traffic_reset_time", now) - - return result.Error - }); err != nil { - return err - } - - var inbounds []model.Inbound - q := db.Model(model.Inbound{}).Where("node_id IS NOT NULL") - if id != -1 { - q = q.Where("id = ?", id) - } - if err := q.Find(&inbounds).Error; err != nil { - logger.Warning("ResetAllClientTraffics: discover node inbounds failed:", err) - return nil - } - for i := range inbounds { - ib := &inbounds[i] - rt, rterr := s.runtimeFor(ib) - if rterr != nil { - logger.Warning("ResetAllClientTraffics: runtime lookup for inbound", ib.Id, "failed:", rterr) - continue - } - if e := rt.ResetInboundClientTraffics(context.Background(), ib); e != nil { - logger.Warning("ResetAllClientTraffics: remote propagation to", rt.Name(), "failed:", e) - } - } - return nil -} - func (s *InboundService) ResetAllTraffics() error { return submitTrafficWrite(func() error { return s.resetAllTrafficsLocked() @@ -3040,24 +2335,6 @@ func (s *InboundService) resetAllTrafficsLocked() error { return err } - var inbounds []model.Inbound - if err := db.Model(model.Inbound{}). - Where("node_id IS NOT NULL"). - Find(&inbounds).Error; err != nil { - logger.Warning("ResetAllTraffics: discover node inbounds failed:", err) - return nil - } - for i := range inbounds { - ib := &inbounds[i] - rt, rterr := s.runtimeFor(ib) - if rterr != nil { - logger.Warning("ResetAllTraffics: runtime lookup for inbound", ib.Id, "failed:", rterr) - continue - } - if e := rt.ResetInboundClientTraffics(context.Background(), ib); e != nil { - logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e) - } - } return nil } @@ -3156,6 +2433,14 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { if err = tx.Save(inbound).Error; err != nil { return err } + survivingClients, gcErr := s.GetClients(inbound) + if gcErr != nil { + err = gcErr + return err + } + if err = s.clientService.SyncInbound(tx, inbound.Id, survivingClients); err != nil { + return err + } } // Drop now-orphaned rows. With id >= 0, a row is safe to drop only when @@ -3169,12 +2454,14 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { emails = append(emails, e) } var stillReferenced []string - if err = tx.Raw(` - SELECT DISTINCT LOWER(JSON_EXTRACT(client.value, '$.email')) - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - WHERE LOWER(JSON_EXTRACT(client.value, '$.email')) IN ? - `, emails).Scan(&stillReferenced).Error; err != nil { + emailExpr := database.JSONFieldText("client.value", "email") + stillQuery := fmt.Sprintf( + "SELECT DISTINCT LOWER(%s) %s WHERE LOWER(%s) IN ?", + emailExpr, + database.JSONClientsFromInbound(), + emailExpr, + ) + if err = tx.Raw(stillQuery, emails).Scan(&stillReferenced).Error; err != nil { return err } stillSet := make(map[string]struct{}, len(stillReferenced)) @@ -3305,10 +2592,7 @@ func chunkStrings(s []string, size int) [][]string { } out := make([][]string, 0, (len(s)+size-1)/size) for i := 0; i < len(s); i += size { - end := i + size - if end > len(s) { - end = len(s) - } + end := min(i+size, len(s)) out = append(out, s[i:end]) } return out @@ -3322,10 +2606,7 @@ func chunkInts(s []int, size int) [][]int { } out := make([][]int, 0, (len(s)+size-1)/size) for i := 0; i < len(s); i += size { - end := i + size - if end > len(s) { - end = len(s) - } + end := min(i+size, len(s)) out = append(out, s[i:end]) } return out @@ -3363,19 +2644,18 @@ func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) { } type InboundTrafficSummary struct { - Id int `json:"id"` - Up int64 `json:"up"` - Down int64 `json:"down"` - Total int64 `json:"total"` - AllTime int64 `json:"allTime"` - Enable bool `json:"enable"` + Id int `json:"id"` + Up int64 `json:"up"` + Down int64 `json:"down"` + Total int64 `json:"total"` + Enable bool `json:"enable"` } func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, error) { db := database.GetDB() var summaries []InboundTrafficSummary if err := db.Model(&model.Inbound{}). - Select("id, up, down, total, all_time, enable"). + Select("id, up, down, total, enable"). Find(&summaries).Error; err != nil { return nil, err } @@ -3403,9 +2683,8 @@ func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64, err := db.Model(xray.ClientTraffic{}). Where("email = ?", email). Updates(map[string]any{ - "up": upload, - "down": download, - "all_time": gorm.Expr("CASE WHEN COALESCE(all_time, 0) < ? THEN ? ELSE all_time END", upload+download, upload+download), + "up": upload, + "down": download, }).Error if err != nil { logger.Warningf("Error updating ClientTraffic with email %s: %v", email, err) @@ -3414,33 +2693,6 @@ func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64, }) } -func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) { - db := database.GetDB() - var traffics []xray.ClientTraffic - - err := db.Model(xray.ClientTraffic{}).Where(`email IN( - SELECT JSON_EXTRACT(client.value, '$.email') as email - FROM inbounds, - JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client - WHERE - JSON_EXTRACT(client.value, '$.id') in (?) - )`, id).Find(&traffics).Error - - if err != nil { - logger.Debug(err) - return nil, err - } - // Reconcile enable flag with client settings per email to avoid stale DB value - for i := range traffics { - if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { - traffics[i].Enable = client.Enable - traffics[i].UUID = client.ID - traffics[i].SubId = client.SubID - } - } - return traffics, err -} - func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.ClientTraffic, err error) { db := database.GetDB() inbound := &model.Inbound{} @@ -3570,31 +2822,25 @@ func (s *InboundService) MigrationRequirements() { defer func() { if err == nil { tx.Commit() - if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil { - logger.Warningf("VACUUM failed: %v", dbErr) + if !database.IsPostgres() { + if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil { + logger.Warningf("VACUUM failed: %v", dbErr) + } } } else { tx.Rollback() } }() - // Calculate and backfill all_time from up+down for inbounds and clients - err = tx.Exec(` - UPDATE inbounds - SET all_time = IFNULL(up, 0) + IFNULL(down, 0) - WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0 - `).Error - if err != nil { - return + if tx.Migrator().HasColumn(&model.Inbound{}, "all_time") { + if err = tx.Migrator().DropColumn(&model.Inbound{}, "all_time"); err != nil { + return + } } - err = tx.Exec(` - UPDATE client_traffics - SET all_time = IFNULL(up, 0) + IFNULL(down, 0) - WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0 - `).Error - - if err != nil { - return + if tx.Migrator().HasColumn(&xray.ClientTraffic{}, "all_time") { + if err = tx.Migrator().DropColumn(&xray.ClientTraffic{}, "all_time"); err != nil { + return + } } // Fix inbounds based problems @@ -3818,114 +3064,6 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [ return validEmails, extraEmails, nil } -func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) { - oldInbound, err := s.GetInbound(inboundId) - if err != nil { - logger.Error("Load Old Data Error") - return false, err - } - - var settings map[string]any - if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil { - return false, err - } - - interfaceClients, ok := settings["clients"].([]any) - if !ok { - return false, common.NewError("invalid clients format in inbound settings") - } - - var newClients []any - needApiDel := false - found := false - - for _, client := range interfaceClients { - c, ok := client.(map[string]any) - if !ok { - continue - } - if cEmail, ok := c["email"].(string); ok && cEmail == email { - // matched client, drop it - found = true - needApiDel, _ = c["enable"].(bool) - } else { - newClients = append(newClients, client) - } - } - - if !found { - return false, common.NewError(fmt.Sprintf("client with email %s not found", email)) - } - if len(newClients) == 0 { - return false, common.NewError("no client remained in Inbound") - } - - settings["clients"] = newClients - newSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, err - } - - oldInbound.Settings = string(newSettings) - - db := database.GetDB() - - // Drop the row and IPs only when this was the last inbound referencing - // the email — siblings still need the shared accounting state. - emailShared, err := s.emailUsedByOtherInbounds(email, inboundId) - if err != nil { - return false, err - } - - if !emailShared { - if err := s.DelClientIPs(db, email); err != nil { - logger.Error("Error in delete client IPs") - return false, err - } - } - - needRestart := false - - // remove stats too - if len(email) > 0 && !emailShared { - traffic, err := s.GetClientTrafficByEmail(email) - if err != nil { - return false, err - } - if traffic != nil { - if err := s.DelClientStat(db, email); err != nil { - logger.Error("Delete stats Data Error") - return false, err - } - } - - if needApiDel { - rt, rterr := s.runtimeFor(oldInbound) - if rterr != nil { - if oldInbound.NodeID != nil { - return false, rterr - } - needRestart = true - } else if oldInbound.NodeID == nil { - if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil { - logger.Debug("Client deleted on", rt.Name(), ":", email) - needRestart = false - } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) { - logger.Debug("User is already deleted. Nothing to do more...") - } else { - logger.Debug("Error in deleting client on", rt.Name(), ":", err1) - needRestart = true - } - } else { - if err1 := rt.UpdateInbound(context.Background(), oldInbound, oldInbound); err1 != nil { - return false, err1 - } - } - } - } - - return needRestart, db.Save(oldInbound).Error -} type SubLinkProvider interface { SubLinksForSubId(host, subId string) ([]string, error) @@ -3944,13 +3082,28 @@ func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) { } return registeredSubLinkProvider.SubLinksForSubId(host, subId) } -func (s *InboundService) GetClientLinks(host string, id int, email string) ([]string, error) { - inbound, err := s.GetInbound(id) - if err != nil { - return nil, err +func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) { + if email == "" { + return nil, common.NewError("client email is required") } if registeredSubLinkProvider == nil { return nil, common.NewError("sub link provider not registered") } - return registeredSubLinkProvider.LinksForClient(host, inbound, email), nil + rec, err := s.clientService.GetRecordByEmail(nil, email) + if err != nil { + return nil, err + } + inboundIds, err := s.clientService.GetInboundIdsForRecord(rec.Id) + if err != nil { + return nil, err + } + var links []string + for _, ibId := range inboundIds { + inbound, getErr := s.GetInbound(ibId) + if getErr != nil { + return nil, getErr + } + links = append(links, registeredSubLinkProvider.LinksForClient(host, inbound, email)...) + } + return links, nil } diff --git a/web/service/metric_history.go b/web/service/metric_history.go index 5905b678..42d2cb82 100644 --- a/web/service/metric_history.go +++ b/web/service/metric_history.go @@ -124,7 +124,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in } // 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 // process-local — survival across panel restart is not required. var ( diff --git a/web/service/node.go b/web/service/node.go index 1c834f78..29cf5f10 100644 --- a/web/service/node.go +++ b/web/service/node.go @@ -24,6 +24,7 @@ type HeartbeatPatch struct { LastHeartbeat int64 LatencyMs int XrayVersion string + PanelVersion string CpuPct float64 MemPct float64 UptimeSecs uint64 @@ -45,7 +46,105 @@ func (s *NodeService) GetAll() ([]*model.Node, error) { db := database.GetDB() var nodes []*model.Node err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error - return nodes, err + if err != nil || len(nodes) == 0 { + 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) { @@ -154,6 +253,7 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error { "last_heartbeat": p.LastHeartbeat, "latency_ms": p.LatencyMs, "xray_version": p.XrayVersion, + "panel_version": p.PanelVersion, "cpu_pct": p.CpuPct, "mem_pct": p.MemPct, "uptime_secs": p.UptimeSecs, @@ -238,7 +338,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, Xray struct { Version string `json:"version"` } `json:"xray"` - Uptime uint64 `json:"uptime"` + PanelVersion string `json:"panelVersion"` + Uptime uint64 `json:"uptime"` } `json:"obj"` } if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { @@ -255,28 +356,31 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total) } patch.XrayVersion = o.Xray.Version + patch.PanelVersion = o.PanelVersion patch.UptimeSecs = o.Uptime return patch, nil } type ProbeResultUI struct { - Status string `json:"status"` - LatencyMs int `json:"latencyMs"` - XrayVersion string `json:"xrayVersion"` - CpuPct float64 `json:"cpuPct"` - MemPct float64 `json:"memPct"` - UptimeSecs uint64 `json:"uptimeSecs"` - Error string `json:"error"` + Status string `json:"status"` + LatencyMs int `json:"latencyMs"` + XrayVersion string `json:"xrayVersion"` + PanelVersion string `json:"panelVersion"` + CpuPct float64 `json:"cpuPct"` + MemPct float64 `json:"memPct"` + UptimeSecs uint64 `json:"uptimeSecs"` + Error string `json:"error"` } func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI { r := ProbeResultUI{ - LatencyMs: p.LatencyMs, - XrayVersion: p.XrayVersion, - CpuPct: p.CpuPct, - MemPct: p.MemPct, - UptimeSecs: p.UptimeSecs, - Error: p.LastError, + LatencyMs: p.LatencyMs, + XrayVersion: p.XrayVersion, + PanelVersion: p.PanelVersion, + CpuPct: p.CpuPct, + MemPct: p.MemPct, + UptimeSecs: p.UptimeSecs, + Error: p.LastError, } if ok { r.Status = "online" diff --git a/web/service/node_test.go b/web/service/node_test.go new file mode 100644 index 00000000..83243fbf --- /dev/null +++ b/web/service/node_test.go @@ -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) + } +} diff --git a/web/service/panel.go b/web/service/panel.go index 3ab51ab3..b776a214 100644 --- a/web/service/panel.go +++ b/web/service/panel.go @@ -16,6 +16,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/config" "github.com/mhsanaei/3x-ui/v3/logger" + "github.com/mhsanaei/3x-ui/v3/web/global" ) // PanelService provides business logic for panel management operations. @@ -35,14 +36,21 @@ const ( ) func (s *PanelService) RestartPanel(delay time.Duration) error { - p, err := os.FindProcess(syscall.Getpid()) - if err != nil { - return err - } go func() { 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 { + logger.Error("panel restart: FindProcess failed:", err) + return + } + if err := p.Signal(syscall.SIGHUP); err != nil { logger.Error("failed to send SIGHUP signal:", err) } }() @@ -213,7 +221,7 @@ func compareVersionStrings(a string, b string) (int, bool) { if !okA || !okB { return 0, false } - for i := 0; i < len(aParts); i++ { + for i := range len(aParts) { if aParts[i] > bParts[i] { return 1, true } diff --git a/web/service/port_conflict.go b/web/service/port_conflict.go index a2dd2183..8d71082b 100644 --- a/web/service/port_conflict.go +++ b/web/service/port_conflict.go @@ -72,7 +72,7 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string) // "udp", or "tcp,udp". if it's set, it wins outright. if n, ok := st["network"].(string); ok && n != "" { bits = 0 - for _, part := range strings.Split(n, ",") { + for part := range strings.SplitSeq(n, ",") { switch strings.TrimSpace(part) { case "tcp": bits |= transportTCP diff --git a/web/service/port_conflict_test.go b/web/service/port_conflict_test.go index 1a7f0c1e..70f637d9 100644 --- a/web/service/port_conflict_test.go +++ b/web/service/port_conflict_test.go @@ -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) { cases := []struct { @@ -360,7 +361,7 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) { func TestCheckPortConflict_NodeScope(t *testing.T) { setupConflictDB(t) 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{} @@ -370,8 +371,8 @@ func TestCheckPortConflict_NodeScope(t *testing.T) { want bool }{ {"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 existing node 1 clashes", intPtr(1), true}, + {"new remote on different node from local is fine", new(2), false}, + {"new remote on existing node 1 clashes", new(1), true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/web/service/server.go b/web/service/server.go index 54aef910..e6e7bccb 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -71,11 +71,12 @@ type Status struct { ErrorMsg string `json:"errorMsg"` Version string `json:"version"` } `json:"xray"` - Uptime uint64 `json:"uptime"` - Loads []float64 `json:"loads"` - TcpCount int `json:"tcpCount"` - UdpCount int `json:"udpCount"` - NetIO struct { + PanelVersion string `json:"panelVersion"` + Uptime uint64 `json:"uptime"` + Loads []float64 `json:"loads"` + TcpCount int `json:"tcpCount"` + UdpCount int `json:"udpCount"` + NetIO struct { Up uint64 `json:"up"` Down uint64 `json:"down"` } `json:"netIO"` @@ -104,6 +105,7 @@ type Release struct { type ServerService struct { xrayService XrayService inboundService InboundService + settingService SettingService cachedIPv4 string cachedIPv6 string noIPv6 bool @@ -114,6 +116,128 @@ type ServerService struct { emaCPU float64 cachedCpuSpeedMhz float64 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. @@ -360,6 +484,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { status.Xray.ErrorMsg = s.xrayService.GetXrayResult() } status.Xray.Version = s.xrayService.GetXrayVersion() + status.PanelVersion = config.GetVersion() // Application stats 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 // percent, network throughput (bytes/s), online client count, and the three -// load averages. Called by ServerController.refreshStatus on the same @2s -// cadence as AppendCpuSample, so all series stay aligned. +// load averages. Called by RefreshStatus on the same @2s cadence as +// AppendCpuSample, so all series stay aligned. func (s *ServerService) AppendStatusSample(t time.Time, status *Status) { if status == nil { return diff --git a/web/service/tgbot.go b/web/service/tgbot.go index 179c082f..d62f23a7 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -31,7 +31,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/web/locale" "github.com/mhsanaei/3x-ui/v3/xray" - "github.com/google/uuid" "github.com/mymmrac/telego" th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" @@ -73,23 +72,23 @@ var ( mutex sync.RWMutex } - // clients data to adding new client - receiver_inbound_ID int - client_Id string - client_Flow string - client_Email string - client_LimitIP int - client_TotalGB int64 - client_ExpiryTime int64 - client_Enable bool - client_TgID string - client_SubID string - client_Comment string - client_Reset int - client_Security string - client_ShPassword string - client_TrPassword string - client_Method string + // clients data to adding new client. receiver_inbound_IDs is the set of + // inbounds the new client will be attached to; receiver_inbound_ID mirrors + // the primary pick for the legacy attach-picker entry point. Per-protocol + // secrets (UUID, password, flow, method) are filled per-inbound on submit + // by ClientService.fillProtocolDefaults, so the bot only tracks universal + // client fields here. + receiver_inbound_ID int + receiver_inbound_IDs []int + client_Email string + client_LimitIP int + client_TotalGB int64 + client_ExpiryTime int64 + client_Enable bool + client_TgID string + client_SubID string + client_Comment string + client_Reset int ) var userStates = make(map[int64]string) @@ -118,6 +117,7 @@ type LoginAttempt struct { // It handles bot commands, user interactions, and status reporting via Telegram. type Tgbot struct { inboundService InboundService + clientService ClientService settingService SettingService serverService ServerService xrayService XrayService @@ -227,7 +227,7 @@ func (t *Tgbot) Start(i18nFS embed.FS) error { parsedAdminIds := make([]int64, 0) // Parse admin IDs from comma-separated string if tgBotID != "" { - for _, adminID := range strings.Split(tgBotID, ",") { + for adminID := range strings.SplitSeq(tgBotID, ",") { id, err := strconv.ParseInt(adminID, 10, 64) if err != nil { logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err) @@ -505,84 +505,6 @@ func (t *Tgbot) OnReceive() { h.HandleMessage(func(ctx *th.Context, message telego.Message) error { if userState, exists := userStates[message.Chat.ID]; exists { switch userState { - case "awaiting_id": - if client_Id == strings.TrimSpace(message.Text) { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) - delete(userStates, message.Chat.ID) - inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID) - message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - t.addClient(message.Chat.ID, message_text) - return nil - } - - client_Id = strings.TrimSpace(message.Text) - if t.isSingleWord(client_Id) { - userStates[message.Chat.ID] = "awaiting_id" - - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) - } else { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_id"), 3, tu.ReplyKeyboardRemove()) - delete(userStates, message.Chat.ID) - inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID) - message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - t.addClient(message.Chat.ID, message_text) - } - case "awaiting_password_tr": - if client_TrPassword == strings.TrimSpace(message.Text) { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) - delete(userStates, message.Chat.ID) - return nil - } - - client_TrPassword = strings.TrimSpace(message.Text) - if t.isSingleWord(client_TrPassword) { - userStates[message.Chat.ID] = "awaiting_password_tr" - - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) - } else { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove()) - delete(userStates, message.Chat.ID) - inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID) - message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - t.addClient(message.Chat.ID, message_text) - } - case "awaiting_password_sh": - if client_ShPassword == strings.TrimSpace(message.Text) { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) - delete(userStates, message.Chat.ID) - return nil - } - - client_ShPassword = strings.TrimSpace(message.Text) - if t.isSingleWord(client_ShPassword) { - userStates[message.Chat.ID] = "awaiting_password_sh" - - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) - } else { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove()) - delete(userStates, message.Chat.ID) - inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID) - message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - t.addClient(message.Chat.ID, message_text) - } case "awaiting_email": if client_Email == strings.TrimSpace(message.Text) { t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) @@ -604,9 +526,7 @@ func (t *Tgbot) OnReceive() { } else { t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove()) delete(userStates, message.Chat.ID) - inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID) - message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - t.addClient(message.Chat.ID, message_text) + t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) } case "awaiting_comment": if client_Comment == strings.TrimSpace(message.Text) { @@ -618,9 +538,29 @@ func (t *Tgbot) OnReceive() { client_Comment = strings.TrimSpace(message.Text) t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove()) delete(userStates, message.Chat.ID) - inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID) - message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - t.addClient(message.Chat.ID, message_text) + t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + case "awaiting_tg_id": + input := strings.TrimSpace(message.Text) + if input == "" || input == "-" || strings.EqualFold(input, "none") { + client_TgID = "" + t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) + delete(userStates, message.Chat.ID) + t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + return nil + } + if _, err := strconv.ParseInt(input, 10, 64); err != nil { + cancel_btn_markup := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), + ), + ) + t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) + return nil + } + client_TgID = input + t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove()) + delete(userStates, message.Chat.ID) + t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) } } else { @@ -628,7 +568,7 @@ func (t *Tgbot) OnReceive() { if checkAdmin(message.From.ID) { for _, sharedUser := range message.UsersShared.Users { userID := sharedUser.UserID - needRestart, err := t.inboundService.SetClientTelegramUserID(message.UsersShared.RequestID, userID) + needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, message.UsersShared.RequestID, userID) if needRestart { t.xrayService.SetToNeedRestart() } @@ -748,16 +688,6 @@ func (t *Tgbot) randomLowerAndNum(length int) string { return string(bytes) } -// randomShadowSocksPassword generates a random password for Shadowsocks. -func (t *Tgbot) randomShadowSocksPassword() string { - array := make([]byte, 32) - _, err := rand.Read(array) - if err != nil { - return t.randomLowerAndNum(32) - } - return base64.StdEncoding.EncodeToString(array) -} - // answerCallback processes callback queries from inline keyboards. func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool) { chatId := callbackQuery.Message.GetChat().ID @@ -899,7 +829,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool if len(dataArray) == 3 { limitTraffic, err := strconv.Atoi(dataArray[2]) if err == nil { - needRestart, err := t.inboundService.ResetClientTrafficLimitByEmail(email, limitTraffic) + needRestart, err := t.clientService.ResetClientTrafficLimitByEmail(&t.inboundService, email, limitTraffic) if needRestart { t.xrayService.SetToNeedRestart() } @@ -978,16 +908,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool limitTraffic, _ := strconv.ParseInt(dataArray[1], 10, 64) client_TotalGB = limitTraffic * 1024 * 1024 * 1024 messageId := callbackQuery.Message.GetMessageID() - inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } + message_text := t.BuildClientDraftMessage() t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) @@ -1108,7 +1029,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } } - needRestart, err := t.inboundService.ResetClientExpiryTimeByEmail(email, date) + needRestart, err := t.clientService.ResetClientExpiryTimeByEmail(&t.inboundService, email, date) if needRestart { t.xrayService.SetToNeedRestart() } @@ -1199,16 +1120,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool client_ExpiryTime = date messageId := callbackQuery.Message.GetMessageID() - inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } + message_text := t.BuildClientDraftMessage() t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) @@ -1305,7 +1217,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool if len(dataArray) == 3 { count, err := strconv.Atoi(dataArray[2]) if err == nil { - needRestart, err := t.inboundService.ResetClientIpLimitByEmail(email, count) + needRestart, err := t.clientService.ResetClientIpLimitByEmail(&t.inboundService, email, count) if needRestart { t.xrayService.SetToNeedRestart() } @@ -1387,36 +1299,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } messageId := callbackQuery.Message.GetMessageID() - inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } + message_text := t.BuildClientDraftMessage() - t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - case "add_client_set_flow": - if dataArray[1] == "none" { - client_Flow = "" - } else { - client_Flow = dataArray[1] - } - messageId := callbackQuery.Message.GetMessageID() - inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) case "add_client_ip_limit_in": @@ -1519,7 +1403,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) return } - needRestart, err := t.inboundService.SetClientTelegramUserID(traffic.Id, EmptyTelegramUserID) + needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, traffic.Id, EmptyTelegramUserID) if needRestart { t.xrayService.SetToNeedRestart() } @@ -1540,7 +1424,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool ) t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) case "toggle_enable_c": - enabled, needRestart, err := t.inboundService.ToggleClientEnableByEmail(email) + enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email) if needRestart { t.xrayService.SetToNeedRestart() } @@ -1573,9 +1457,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clients) case "add_client_to": - // assign default values to clients variables - client_Id = uuid.New().String() - client_Flow = "" client_Email = t.randomLowerAndNum(8) client_LimitIP = 0 client_TotalGB = 0 @@ -1585,10 +1466,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool client_SubID = t.randomLowerAndNum(16) client_Comment = "" client_Reset = 0 - client_Security = "auto" - client_ShPassword = t.randomShadowSocksPassword() - client_TrPassword = t.randomLowerAndNum(10) - client_Method = "" inboundId := dataArray[1] inboundIdInt, err := strconv.Atoi(inboundId) @@ -1597,19 +1474,33 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool return } receiver_inbound_ID = inboundIdInt - inbound, err := t.inboundService.GetInbound(inboundIdInt) + receiver_inbound_IDs = []int{inboundIdInt} + t.addClient(callbackQuery.Message.GetChat().ID, t.BuildClientDraftMessage()) + case "add_client_toggle_attach": + inboundIdStr := dataArray[1] + inboundIdInt, err := strconv.Atoi(inboundIdStr) if err != nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) return } - - message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) + found := -1 + for i, id := range receiver_inbound_IDs { + if id == inboundIdInt { + found = i + break + } + } + if found >= 0 { + receiver_inbound_IDs = append(receiver_inbound_IDs[:found], receiver_inbound_IDs[found+1:]...) + } else { + receiver_inbound_IDs = append(receiver_inbound_IDs, inboundIdInt) + } + picker, err := t.getInboundsAttachPicker() if err != nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) return } - - t.addClient(callbackQuery.Message.GetChat().ID, message_text) + t.editMessageCallbackTgBot(callbackQuery.Message.GetChat().ID, callbackQuery.Message.GetMessageID(), picker) } return } else { @@ -1752,9 +1643,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands")) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpAdminCommands")) case "add_client": - // assign default values to clients variables - client_Id = uuid.New().String() - client_Flow = "" client_Email = t.randomLowerAndNum(8) client_LimitIP = 0 client_TotalGB = 0 @@ -1764,10 +1652,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool client_SubID = t.randomLowerAndNum(16) client_Comment = "" client_Reset = 0 - client_Security = "auto" - client_ShPassword = t.randomShadowSocksPassword() - client_TrPassword = t.randomLowerAndNum(10) - client_Method = "" inbounds, err := t.getInboundsAddClient() if err != nil { @@ -1786,36 +1670,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool ) prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+client_Email) t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) - case "add_client_ch_default_id": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - userStates[chatId] = "awaiting_id" - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - prompt_message := t.I18nBot("tgbot.messages.id_prompt", "ClientId=="+client_Id) - t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) - case "add_client_ch_default_pass_tr": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - userStates[chatId] = "awaiting_password_tr" - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - prompt_message := t.I18nBot("tgbot.messages.pass_prompt", "ClientPassword=="+client_TrPassword) - t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) - case "add_client_ch_default_pass_sh": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - userStates[chatId] = "awaiting_password_sh" - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - prompt_message := t.I18nBot("tgbot.messages.pass_prompt", "ClientPassword=="+client_ShPassword) - t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) case "add_client_ch_default_comment": t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) userStates[chatId] = "awaiting_comment" @@ -1826,6 +1680,19 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool ) prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment) t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) + case "add_client_ch_default_tg_id": + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + userStates[chatId] = "awaiting_tg_id" + cancel_btn_markup := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), + ), + ) + current := client_TgID + if current == "" { + current = "—" + } + t.SendMsgToTgbot(chatId, fmt.Sprintf("Send the Telegram user id (numeric) to attach to this client, or send `-` / `none` to clear.\nCurrent: `%s`", current), cancel_btn_markup) case "add_client_ch_default_traffic": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -1884,22 +1751,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool ), ) t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "add_client_ch_default_flow": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("None").WithCallbackData(t.encodeQuery("add_client_set_flow none")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("xtls-rprx-vision").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("xtls-rprx-vision-udp443").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision-udp443")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) case "add_client_ch_default_ip_limit": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -1933,41 +1784,41 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) delete(userStates, chatId) - inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID) - message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - t.addClient(chatId, message_text) + t.addClient(chatId, t.BuildClientDraftMessage()) case "add_client_cancel": delete(userStates, chatId) + receiver_inbound_ID = 0 + receiver_inbound_IDs = nil t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.cancel"), 3, tu.ReplyKeyboardRemove()) case "add_client_default_traffic_exp": messageId := callbackQuery.Message.GetMessageID() - inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } + message_text := t.BuildClientDraftMessage() t.addClient(chatId, message_text, messageId) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) case "add_client_default_ip_limit": messageId := callbackQuery.Message.GetMessageID() - inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } + message_text := t.BuildClientDraftMessage() t.addClient(chatId, message_text, messageId) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) + case "add_client_attach_more": + picker, err := t.getInboundsAttachPicker() + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + t.SendMsgToTgbot(chatId, "Pick inbound(s) to attach:", picker) + case "add_client_attach_done": + if receiver_inbound_ID == 0 && len(receiver_inbound_IDs) > 0 { + receiver_inbound_ID = receiver_inbound_IDs[0] + } + if receiver_inbound_ID == 0 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed")) + return + } + message_text := t.BuildClientDraftMessage() + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + t.addClient(chatId, message_text) case "add_client_submit_disable": client_Enable = false _, err := t.SubmitAddClient() @@ -1979,6 +1830,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) t.sendClientIndividualLinks(chatId, client_Email) t.sendClientQRLinks(chatId, client_Email) + receiver_inbound_ID = 0 + receiver_inbound_IDs = nil } case "add_client_submit_enable": client_Enable = true @@ -1991,6 +1844,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) t.sendClientIndividualLinks(chatId, client_Email) t.sendClientQRLinks(chatId, client_Email) + receiver_inbound_ID = 0 + receiver_inbound_IDs = nil } case "reset_all_traffics_cancel": t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) @@ -2080,166 +1935,123 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } } -// BuildInboundClientDataMessage builds a message with client data for the given inbound and protocol. -func (t *Tgbot) BuildInboundClientDataMessage(inbound_remark string, protocol model.Protocol) (string, error) { - var message string - - currentTime := time.Now() - timestampMillis := currentTime.UnixNano() / int64(time.Millisecond) - - expiryTime := "" - diff := client_ExpiryTime/1000 - timestampMillis - if client_ExpiryTime == 0 { - expiryTime = t.I18nBot("tgbot.unlimited") - } else if diff > 172800 { - expiryTime = time.Unix((client_ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05") - } else if client_ExpiryTime < 0 { - expiryTime = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days")) - } else { - expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours")) - } - - traffic_value := "" - if client_TotalGB == 0 { - traffic_value = "♾️ Unlimited(Reset)" - } else { - traffic_value = common.FormatTraffic(client_TotalGB) - } - - ip_limit := "" - if client_LimitIP == 0 { - ip_limit = "♾️ Unlimited(Reset)" - } else { - ip_limit = fmt.Sprint(client_LimitIP) - } - - switch protocol { - case model.VMESS, model.VLESS: - message = t.I18nBot("tgbot.messages.inbound_client_data_id", "InboundRemark=="+inbound_remark, "ClientId=="+client_Id, "ClientEmail=="+client_Email, "ClientTraffic=="+traffic_value, "ClientExp=="+expiryTime, "IpLimit=="+ip_limit, "ClientComment=="+client_Comment) - - case model.Trojan: - message = t.I18nBot("tgbot.messages.inbound_client_data_pass", "InboundRemark=="+inbound_remark, "ClientPass=="+client_TrPassword, "ClientEmail=="+client_Email, "ClientTraffic=="+traffic_value, "ClientExp=="+expiryTime, "IpLimit=="+ip_limit, "ClientComment=="+client_Comment) - - case model.Shadowsocks: - message = t.I18nBot("tgbot.messages.inbound_client_data_pass", "InboundRemark=="+inbound_remark, "ClientPass=="+client_ShPassword, "ClientEmail=="+client_Email, "ClientTraffic=="+traffic_value, "ClientExp=="+expiryTime, "IpLimit=="+ip_limit, "ClientComment=="+client_Comment) +// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress +// client (email, attached inbounds, traffic limit, expiry, ip limit, comment) +// shown in the multi-inbound add flow. Per-protocol secrets (UUID, password, +// flow, method) are generated by fillProtocolDefaults on submit, so the bot +// never has to track them per inbound itself. +func (t *Tgbot) BuildClientDraftMessage() string { + now := time.Now().UnixMilli() + expiry := "" + switch { + case client_ExpiryTime == 0: + expiry = t.I18nBot("tgbot.unlimited") + case client_ExpiryTime < 0: + expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days")) default: - return "", errors.New("unknown protocol") + diff := client_ExpiryTime - now + if diff > 172800000 { + expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05") + } else { + expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours")) + } } - return message, nil + traffic := "♾️ Unlimited(Reset)" + if client_TotalGB > 0 { + traffic = common.FormatTraffic(client_TotalGB) + } + + ipLimit := "♾️ Unlimited(Reset)" + if client_LimitIP > 0 { + ipLimit = fmt.Sprint(client_LimitIP) + } + + attached := t.describeAttachedInbounds(receiver_inbound_IDs) + if attached == "" { + attached = "—" + } + + comment := client_Comment + if comment == "" { + comment = "—" + } + + tgID := client_TgID + if tgID == "" { + tgID = "—" + } + + var b strings.Builder + b.WriteString("📝 *New client draft*\r\n") + b.WriteString(fmt.Sprintf("📧 Email: `%s`\r\n", client_Email)) + b.WriteString(fmt.Sprintf("🔗 Attached: %s\r\n", attached)) + b.WriteString(fmt.Sprintf("📊 Traffic: %s\r\n", traffic)) + b.WriteString(fmt.Sprintf("📅 Expire: %s\r\n", expiry)) + b.WriteString(fmt.Sprintf("🔢 IP limit: %s\r\n", ipLimit)) + b.WriteString(fmt.Sprintf("👤 TG user: %s\r\n", tgID)) + b.WriteString(fmt.Sprintf("💬 Comment: %s\r\n", comment)) + return b.String() } -// BuildJSONForProtocol builds a JSON string for the given protocol with client data. -func (t *Tgbot) BuildJSONForProtocol(protocol model.Protocol) (string, error) { - var jsonString string - - switch protocol { - case model.VMESS: - jsonString = fmt.Sprintf(`{ - "clients": [{ - "id": "%s", - "security": "%s", - "email": "%s", - "limitIp": %d, - "totalGB": %d, - "expiryTime": %d, - "enable": %t, - "tgId": "%s", - "subId": "%s", - "comment": "%s", - "reset": %d - }] - }`, client_Id, client_Security, client_Email, client_LimitIP, client_TotalGB, client_ExpiryTime, client_Enable, client_TgID, client_SubID, client_Comment, client_Reset) - - case model.VLESS: - jsonString = fmt.Sprintf(`{ - "clients": [{ - "id": "%s", - "flow": "%s", - "email": "%s", - "limitIp": %d, - "totalGB": %d, - "expiryTime": %d, - "enable": %t, - "tgId": "%s", - "subId": "%s", - "comment": "%s", - "reset": %d - }] - }`, client_Id, client_Flow, client_Email, client_LimitIP, client_TotalGB, client_ExpiryTime, client_Enable, client_TgID, client_SubID, client_Comment, client_Reset) - - case model.Trojan: - jsonString = fmt.Sprintf(`{ - "clients": [{ - "password": "%s", - "email": "%s", - "limitIp": %d, - "totalGB": %d, - "expiryTime": %d, - "enable": %t, - "tgId": "%s", - "subId": "%s", - "comment": "%s", - "reset": %d - }] - }`, client_TrPassword, client_Email, client_LimitIP, client_TotalGB, client_ExpiryTime, client_Enable, client_TgID, client_SubID, client_Comment, client_Reset) - - case model.Shadowsocks: - jsonString = fmt.Sprintf(`{ - "clients": [{ - "method": "%s", - "password": "%s", - "email": "%s", - "limitIp": %d, - "totalGB": %d, - "expiryTime": %d, - "enable": %t, - "tgId": "%s", - "subId": "%s", - "comment": "%s", - "reset": %d - }] - }`, client_Method, client_ShPassword, client_Email, client_LimitIP, client_TotalGB, client_ExpiryTime, client_Enable, client_TgID, client_SubID, client_Comment, client_Reset) - - default: - return "", errors.New("unknown protocol") +// describeAttachedInbounds returns a short "remark1, remark2" list for the given +// inbound ids, falling back to "#id" when an inbound can't be loaded. +func (t *Tgbot) describeAttachedInbounds(ids []int) string { + if len(ids) == 0 { + return "" } - - return jsonString, nil + parts := make([]string, 0, len(ids)) + for _, id := range ids { + ib, err := t.inboundService.GetInbound(id) + if err != nil || ib == nil { + parts = append(parts, fmt.Sprintf("#%d", id)) + continue + } + label := ib.Remark + if label == "" { + label = fmt.Sprintf("#%d", id) + } + parts = append(parts, label) + } + return strings.Join(parts, ", ") } -// SubmitAddClient submits the client addition request to the inbound service. +// SubmitAddClient sends the in-progress client to ClientService.Create with +// the full set of attached inbound ids. Per-inbound fillProtocolDefaults on +// the panel generates UUID/password/auth per protocol, so the bot only +// supplies the universal fields it actually collected. func (t *Tgbot) SubmitAddClient() (bool, error) { - - inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) - if err != nil { - logger.Warning("getIboundClients run failed:", err) + inboundIDs := receiver_inbound_IDs + if len(inboundIDs) == 0 && receiver_inbound_ID > 0 { + inboundIDs = []int{receiver_inbound_ID} + } + if len(inboundIDs) == 0 { return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) } - jsonString, err := t.BuildJSONForProtocol(inbound.Protocol) - if err != nil { - logger.Warning("BuildJSONForProtocol run failed:", err) - return false, errors.New("failed to build JSON for protocol") + tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64) + client := model.Client{ + Email: client_Email, + Enable: client_Enable, + LimitIP: client_LimitIP, + TotalGB: client_TotalGB, + ExpiryTime: client_ExpiryTime, + SubID: client_SubID, + Comment: client_Comment, + Reset: client_Reset, + TgID: tgIDInt, } - newInbound := &model.Inbound{ - Id: receiver_inbound_ID, - Settings: jsonString, - } - - return t.inboundService.AddInboundClient(newInbound) + return t.clientService.Create(&t.inboundService, &ClientCreatePayload{ + Client: client, + InboundIds: inboundIDs, + }) } // checkAdmin checks if the given Telegram ID is an admin. func checkAdmin(tgId int64) bool { - for _, adminId := range adminIds { - if adminId == tgId { - return true - } - } - return false + return slices.Contains(adminIds, tgId) } // SendAnswer sends a response message with an inline keyboard to the specified chat. @@ -2556,17 +2368,18 @@ func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) { // Send in chunks to respect message length; use monospace formatting const maxPerMessage = 50 for i := 0; i < len(cleaned); i += maxPerMessage { - j := i + maxPerMessage - if j > len(cleaned) { - j = len(cleaned) - } + j := min(i+maxPerMessage, len(cleaned)) chunk := cleaned[i:j] - msg := t.I18nBot("subscription.individualLinks") + ":\r\n" + var msg strings.Builder + msg.WriteString(t.I18nBot("subscription.individualLinks")) + msg.WriteString(":\r\n") for _, link := range chunk { // wrap each link in - msg += "" + link + "\r\n" + msg.WriteString("") + msg.WriteString(link) + msg.WriteString("\r\n") } - t.SendMsgToTgbot(chatId, msg) + t.SendMsgToTgbot(chatId, msg.String()) } } @@ -2847,26 +2660,28 @@ func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) { // getInboundUsages retrieves and formats inbound usage information. func (t *Tgbot) getInboundUsages() string { var info strings.Builder - // get traffic inbounds, err := t.inboundService.GetAllInbounds() if err != nil { logger.Warning("GetAllInbounds run failed:", err) info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed")) - } else { - // NOTE:If there no any sessions here,need to notify here - // TODO:Sub-node push, automatic conversion format - for _, inbound := range inbounds { - info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)) - info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))) - info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))) + return info.String() + } + for _, inbound := range inbounds { + info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)) + info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))) + info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))) - if inbound.ExpiryTime == 0 { - info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))) - } else { - info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))) - } - info.WriteString("\r\n") + clients, listErr := t.clientService.ListForInbound(nil, inbound.Id) + if listErr == nil { + info.WriteString(fmt.Sprintf("👥 Clients: %d\r\n", len(clients))) } + + if inbound.ExpiryTime == 0 { + info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))) + } else { + info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))) + } + info.WriteString("\r\n") } return info.String() } @@ -3013,6 +2828,54 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { return keyboard, nil } +// getInboundsAttachPicker builds a toggle picker over multi-client inbounds +// for the "attach more inbounds to the new client" step. Each row shows the +// current selection state for the inbound; tapping fires +// add_client_toggle_attach which flips it and re-renders. A final +// "Done" button (add_client_attach_done) returns to the field-edit screen. +func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("GetAllInbounds run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + if len(inbounds) == 0 { + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + excludedProtocols := map[model.Protocol]bool{ + model.Tunnel: true, + model.Mixed: true, + model.WireGuard: true, + model.HTTP: true, + } + selected := make(map[int]bool, len(receiver_inbound_IDs)) + for _, id := range receiver_inbound_IDs { + selected[id] = true + } + var buttons []telego.InlineKeyboardButton + for _, ib := range inbounds { + if excludedProtocols[ib.Protocol] { + continue + } + mark := "☐" + if selected[ib.Id] { + mark = "✅" + } + label := fmt.Sprintf("%s %s (%s)", mark, ib.Remark, ib.Protocol) + callback := t.encodeQuery(fmt.Sprintf("add_client_toggle_attach %d", ib.Id)) + buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(callback)) + } + cols := 1 + if len(buttons) >= 6 { + cols = 2 + } + rows := tu.InlineKeyboardCols(cols, buttons...) + rows = append(rows, tu.InlineKeyboardRow( + tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")), + )) + return tu.InlineKeyboardGrid(rows), nil +} + // getInboundClients creates an inline keyboard with clients of a specific inbound. func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) { inbound, err := t.inboundService.GetInbound(id) @@ -3098,7 +2961,7 @@ func (t *Tgbot) clientInfoMsg( } enabled := "" - isEnabled, err := t.inboundService.checkIsEnabledByEmail(traffic.Email) + isEnabled, err := t.clientService.checkIsEnabledByEmail(&t.inboundService, traffic.Email) if err != nil { logger.Warning(err) enabled = t.I18nBot("tgbot.wentWrong") @@ -3126,6 +2989,9 @@ func (t *Tgbot) clientInfoMsg( output := "" output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email) + if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 { + output += fmt.Sprintf("🔗 Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds)) + } if printEnabled { output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled) } @@ -3359,16 +3225,27 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { } } -// getCommonClientButtons returns the shared inline keyboard rows for client configuration +// getCommonClientButtons returns the shared inline keyboard rows for the +// client-first multi-inbound add flow. Per-protocol secrets (UUID, password, +// flow, method) are generated by fillProtocolDefaults on submit, so the bot +// only exposes the universal client fields here. func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton { + attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(receiver_inbound_IDs)) return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), + ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"), ), tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData("add_client_ch_default_tg_id"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(attachLabel).WithCallbackData("add_client_attach_more"), ), tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"), @@ -3380,87 +3257,14 @@ func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton { } } -// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend -// model: xtls-rprx-vision is only valid on VLESS-over-TCP with TLS or Reality. -func inboundCanEnableTlsFlow(ib *model.Inbound) bool { - if ib == nil || ib.Protocol != model.VLESS { - return false - } - var stream struct { - Network string `json:"network"` - Security string `json:"security"` - } - if err := json.Unmarshal([]byte(ib.StreamSettings), &stream); err != nil { - return false - } - if stream.Network != "tcp" { - return false - } - return stream.Security == "tls" || stream.Security == "reality" -} - -// addClient handles the process of adding a new client to an inbound. +// addClient renders the draft message + shared client-first keyboard. func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { - inbound, err := t.inboundService.GetInbound(receiver_inbound_ID) - if err != nil { - t.SendMsgToTgbot(chatId, err.Error()) - return - } - - protocol := inbound.Protocol - - var protocolRows [][]telego.InlineKeyboardButton - switch protocol { - case model.VMESS: - protocolRows = [][]telego.InlineKeyboardButton{ - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"), - ), - } - case model.VLESS: - protocolRows = [][]telego.InlineKeyboardButton{ - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"), - ), - } - if inboundCanEnableTlsFlow(inbound) { - flowLabel := t.I18nBot("tgbot.buttons.change_flow") - if client_Flow != "" { - flowLabel = flowLabel + ": " + client_Flow - } - protocolRows = append(protocolRows, tu.InlineKeyboardRow( - tu.InlineKeyboardButton(flowLabel).WithCallbackData("add_client_ch_default_flow"), - )) - } else if client_Flow != "" { - client_Flow = "" - } - case model.Trojan: - protocolRows = [][]telego.InlineKeyboardButton{ - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"), - ), - } - case model.Shadowsocks: - protocolRows = [][]telego.InlineKeyboardButton{ - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"), - ), - } - } - - commonRows := t.getCommonClientButtons() - inlineKeyboard := tu.InlineKeyboard(append(protocolRows, commonRows...)...) - + inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...) if len(messageID) > 0 { t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) } else { t.SendMsgToTgbot(chatId, msg, inlineKeyboard) } - } // searchInbound searches for inbounds by remark and sends the results. @@ -3631,7 +3435,8 @@ func (t *Tgbot) notifyExhausted() { var exhaustedClients []xray.ClientTraffic traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID) if err == nil && len(traffics) > 0 { - output := t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")) + var output strings.Builder + output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))) for _, traffic := range traffics { if traffic.Enable { if (traffic.ExpiryTime > 0 && (traffic.ExpiryTime-now < exDiff)) || @@ -3643,21 +3448,23 @@ func (t *Tgbot) notifyExhausted() { } } if len(exhaustedClients) > 0 { - output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients))) + output.WriteString(t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))) if len(disabledClients) > 0 { - output += t.I18nBot("tgbot.clients") + ":\r\n" + output.WriteString(t.I18nBot("tgbot.clients")) + output.WriteString(":\r\n") for _, traffic := range disabledClients { - output += " " + traffic.Email + output.WriteString(" ") + output.WriteString(traffic.Email) } - output += "\r\n" + output.WriteString("\r\n") } - output += "\r\n" - output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedClients))) + output.WriteString("\r\n") + output.WriteString(t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedClients)))) for _, traffic := range exhaustedClients { - output += t.clientInfoMsg(&traffic, true, false, false, true, true, false) - output += "\r\n" + output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false)) + output.WriteString("\r\n") } - t.SendMsgToTgbot(chatID, output) + t.SendMsgToTgbot(chatID, output.String()) } chatIDsDone = append(chatIDsDone, chatID) } @@ -3672,12 +3479,7 @@ func (t *Tgbot) notifyExhausted() { // int64Contains checks if an int64 slice contains a specific item. func int64Contains(slice []int64, item int64) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false + return slices.Contains(slice, item) } // onlineClients retrieves and sends information about online clients. diff --git a/web/service/tgbot_test.go b/web/service/tgbot_test.go index 39173563..70411122 100644 --- a/web/service/tgbot_test.go +++ b/web/service/tgbot_test.go @@ -6,7 +6,7 @@ import ( ) func TestLoginAttemptDoesNotCarryPassword(t *testing.T) { - typ := reflect.TypeOf(LoginAttempt{}) + typ := reflect.TypeFor[LoginAttempt]() if _, ok := typ.FieldByName("Password"); ok { t.Fatal("LoginAttempt must not carry attempted passwords") } diff --git a/web/service/xray.go b/web/service/xray.go index 09908aab..d86da4c0 100644 --- a/web/service/xray.go +++ b/web/service/xray.go @@ -4,8 +4,10 @@ import ( "encoding/json" "errors" "runtime" + "strings" "sync" + "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/xray" @@ -116,57 +118,101 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { if inbound.NodeID != nil { continue } - // get settings clients settings := map[string]any{} json.Unmarshal([]byte(inbound.Settings), &settings) - clients, ok := settings["clients"].([]any) - if ok { - // Fast O(N) lookup map for client traffic enablement - clientStats := inbound.ClientStats - enableMap := make(map[string]bool, len(clientStats)) - for _, clientTraffic := range clientStats { - enableMap[clientTraffic.Email] = clientTraffic.Enable + + dbClients, listErr := s.inboundService.clientService.ListForInbound(nil, inbound.Id) + if listErr != nil { + return nil, listErr + } + + clientStats := inbound.ClientStats + enableMap := make(map[string]bool, len(clientStats)) + for _, clientTraffic := range clientStats { + enableMap[clientTraffic.Email] = clientTraffic.Enable + } + + var finalClients []any + for i := range dbClients { + c := dbClients[i] + if enable, exists := enableMap[c.Email]; exists && !enable { + logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email) + continue } - - // filter and clean clients - var final_clients []any - for _, client := range clients { - c, ok := client.(map[string]any) - if !ok { - continue - } - - 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 - } - - // check manual disabled flag - if manualEnable, ok := c["enable"].(bool); ok && !manualEnable { - continue - } - - // clear client config for additional parameters - for key := range c { - if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" && key != "reverse" { - delete(c, key) - } - if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" { - c["flow"] = "xtls-rprx-vision" - } - } - final_clients = append(final_clients, any(c)) + if !c.Enable { + continue } + flow := c.Flow + if flow == "xtls-rprx-vision-udp443" { + flow = "xtls-rprx-vision" + } + 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) + } - settings["clients"] = final_clients + _, hadClients := settings["clients"] + mutated := hadClients || len(finalClients) > 0 + if mutated { + settings["clients"] = finalClients + } + + 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, "", " ") if err != nil { return nil, err } - inbound.Settings = string(modifiedSettings) } @@ -195,12 +241,62 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { inbound.StreamSettings = string(newStream) } + if inbound.Protocol == model.Shadowsocks { + if healed, ok := healShadowsocksClientMethods(inbound.Settings); ok { + inbound.Settings = healed + } + } + inboundConfig := inbound.GenXrayInboundConfig() xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig) } 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. func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) { if !s.IsXrayRunning() { diff --git a/web/service/xray_setting.go b/web/service/xray_setting.go index 4249f018..1fda04aa 100644 --- a/web/service/xray_setting.go +++ b/web/service/xray_setting.go @@ -3,6 +3,7 @@ package service import ( _ "embed" "encoding/json" + "slices" "github.com/mhsanaei/3x-ui/v3/util/common" "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. func UnwrapXrayTemplateConfig(raw string) string { 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 if err := json.Unmarshal([]byte(raw), &top); err != nil { return raw @@ -190,10 +191,8 @@ func findApiRule(rules []map[string]any) int { } } case []string: - for _, s := range tags { - if s == "api" { - return i - } + if slices.Contains(tags, "api") { + return i } case string: if tags == "api" { diff --git a/web/service/xray_setting_test.go b/web/service/xray_setting_test.go index 22b00ce3..5fe54df7 100644 --- a/web/service/xray_setting_test.go +++ b/web/service/xray_setting_test.go @@ -65,7 +65,7 @@ func TestUnwrapXrayTemplateConfig(t *testing.T) { // non-wrapped, and confirm we end up at some valid JSON (we // don't loop forever and we don't blow the stack). s := real - for i := 0; i < 16; i++ { + for range 16 { s = `{"xraySetting":` + s + `}` } got := UnwrapXrayTemplateConfig(s) diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index ecf1c19d..e1ed1aed 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -18,6 +18,8 @@ "search": "بحث", "filter": "فلترة", "loading": "جاري التحميل...", + "refresh": "تحديث", + "clear": "مسح", "second": "ثانية", "minute": "دقيقة", "hour": "ساعة", @@ -94,6 +96,7 @@ "ultraDark": "داكن جدًا", "dashboard": "نظرة عامة", "inbounds": "الإدخالات", + "clients": "العملاء", "nodes": "النودز", "settings": "إعدادات البانل", "xray": "إعدادات Xray", @@ -127,9 +130,9 @@ "stopXray": "إيقاف", "restartXray": "إعادة تشغيل", "xraySwitch": "النسخة", + "xrayUpdates": "تحديثات Xray", "xraySwitchClick": "اختار النسخة اللي عايز تتحول لها.", "xraySwitchClickDesk": "اختار بحذر، النسخ القديمة ممكن ما تتوافقش مع الإعدادات الحالية.", - "xrayUpdates": "تحديثات Xray", "updatePanel": "تحديث البانل", "panelUpdateDesc": "ده هيحدث 3X-UI لآخر إصدار وهيعيد تشغيل خدمة البانل.", "currentPanelVersion": "إصدار البانل الحالي", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "سيؤدي هذا إلى تحديث كافة الملفات.", "geofilesUpdateAll": "تحديث الكل", "geofileUpdatePopover": "تم تحديث ملف الجغرافيا بنجاح", - "dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة", - "logs": "السجلات", - "config": "الإعدادات", - "backup": "نسخة احتياطية", - "backupTitle": "نسخ احتياطي واستعادة", - "exportDatabase": "اخزن نسخة", - "exportDatabaseDesc": "اضغط عشان تحمل ملف .db يحتوي على نسخة احتياطية لقاعدة البيانات الحالية على جهازك.", - "importDatabase": "استرجاع", - "importDatabaseDesc": "اضغط عشان تختار وتحمل ملف .db من جهازك لاسترجاع قاعدة البيانات من نسخة احتياطية.", - "importDatabaseSuccess": "تم استيراد قاعدة البيانات بنجاح", - "importDatabaseError": "حدث خطأ أثناء استيراد قاعدة البيانات", - "readDatabaseError": "حدث خطأ أثناء قراءة قاعدة البيانات", - "getDatabaseError": "حدث خطأ أثناء استرجاع قاعدة البيانات", - "getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات", "customGeoTitle": "GeoSite / GeoIP مخصص", "customGeoAdd": "إضافة", "customGeoType": "النوع", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "مصدر geo المخصص غير موجود", "customGeoErrDownload": "فشل التنزيل", "customGeoErrUpdateAllIncomplete": "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة", - "customGeoEmpty": "لا توجد مصادر geo مخصصة بعد — انقر على «إضافة» لإنشاء واحد" + "customGeoEmpty": "لا توجد مصادر geo مخصصة بعد — انقر على «إضافة» لإنشاء واحد", + "dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة", + "logs": "السجلات", + "config": "الإعدادات", + "backup": "نسخة احتياطية", + "backupTitle": "نسخ احتياطي واستعادة", + "exportDatabase": "اخزن نسخة", + "exportDatabaseDesc": "اضغط عشان تحمل ملف .db يحتوي على نسخة احتياطية لقاعدة البيانات الحالية على جهازك.", + "importDatabase": "استرجاع", + "importDatabaseDesc": "اضغط عشان تختار وتحمل ملف .db من جهازك لاسترجاع قاعدة البيانات من نسخة احتياطية.", + "importDatabaseSuccess": "تم استيراد قاعدة البيانات بنجاح", + "importDatabaseError": "حدث خطأ أثناء استيراد قاعدة البيانات", + "readDatabaseError": "حدث خطأ أثناء قراءة قاعدة البيانات", + "getDatabaseError": "حدث خطأ أثناء استرجاع قاعدة البيانات", + "getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات" }, "inbounds": { - "allTimeTraffic": "إجمالي حركة المرور", - "allTimeTrafficUsage": "إجمالي الاستخدام طوال الوقت", "title": "الإدخالات", "totalDownUp": "إجمالي المرسل/المستقبل", "totalUsage": "إجمالي الاستخدام", @@ -249,6 +250,23 @@ "node": "نود", "deployTo": "نشر على", "localPanel": "بانل محلي", + "fallbacks": { + "title": "الـ Fallbacks", + "help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى inbound آخر. اختر فرعًا أدناه وسيتم ملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقل الفرع — في الغالب لا تحتاج إلى أي تعديل إضافي. يجب أن يستمع كل فرع على 127.0.0.1 مع security=none.", + "empty": "لا توجد fallbacks بعد", + "add": "إضافة fallback", + "pickInbound": "اختر inbound", + "matchAny": "أي", + "rederive": "إعادة الملء من الفرع", + "rederived": "تم إعادة الملء من الفرع", + "editAdvanced": "تحرير حقول التوجيه", + "hideAdvanced": "إخفاء المتقدم", + "quickAddAll": "إضافة سريعة لكل الـ inbounds المؤهلة", + "quickAdded": "تمت إضافة {n} fallback", + "quickAddedNone": "لا توجد inbounds جديدة مؤهلة للإضافة", + "routesWhen": "يوجَّه عندما", + "defaultCatchAll": "افتراضي — يلتقط أي شيء آخر" + }, "protocol": "بروتوكول", "port": "بورت", "portMap": "خريطة البورت", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "سجل تاريخ الـ IPs. (عشان تفعل الإدخال بعد التعطيل، امسح السجل)", "IPLimitlogclear": "امسح السجل", "setDefaultCert": "استخدم شهادة البانل", + "streamTab": "الدفق", + "securityTab": "الأمان", + "sniffingTab": "الاستشعار", + "sniffingMetadataOnly": "البيانات الوصفية فقط", + "sniffingRouteOnly": "التوجيه فقط", + "sniffingIpsExcluded": "IP المستثناة", + "sniffingDomainsExcluded": "النطاقات المستثناة", + "decryption": "فك التشفير", + "encryption": "التشفير", + "vlessAuthX25519": "مصادقة X25519", + "vlessAuthMlkem768": "مصادقة ML-KEM-768", + "vlessAuthCustom": "مخصص", + "vlessAuthSelected": "المحدد: {auth}", + "advanced": { + "title": "أقسام JSON للاتصال الوارد", + "subtitle": "JSON الكامل للاتصال الوارد ومحررات مخصصة لـ settings و sniffing و streamSettings.", + "all": "الكل", + "allHelp": "كائن الاتصال الوارد الكامل بكل الحقول في محرر واحد.", + "settings": "الإعدادات", + "settingsHelp": "غلاف كتلة settings في Xray:", + "sniffing": "الاستشعار", + "sniffingHelp": "غلاف كتلة sniffing في Xray:", + "stream": "الدفق", + "streamHelp": "غلاف كتلة stream في Xray:", + "jsonErrorPrefix": "JSON متقدم" + }, "telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)", "subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.", "info": "معلومات", @@ -365,37 +409,101 @@ } } }, - "client": { - "add": "أضف عميل", - "edit": "تعديل عميل", - "submitAdd": "أضف العميل", - "submitEdit": "احفظ التعديلات", + "clients": { + "add": "إضافة عميل", + "edit": "تعديل العميل", + "submitAdd": "إضافة عميل", + "submitEdit": "حفظ التغييرات", "clientCount": "عدد العملاء", - "bulk": "إضافة بالجملة", - "copyFromInbound": "نسخ العملاء من الـ Inbound", + "bulk": "إضافة مجمعة", + "copyFromInbound": "نسخ العملاء من الاتصال الوارد", "copyToInbound": "نسخ العملاء إلى", "copySelected": "نسخ المحدد", "copySource": "المصدر", - "copyEmailPreview": "معاينة البريد الإلكتروني الناتج", - "copySelectSourceFirst": "الرجاء اختيار الـ Inbound المصدر أولاً.", + "copyEmailPreview": "معاينة البريد الناتج", + "copySelectSourceFirst": "يرجى تحديد اتصال وارد مصدر أولاً.", "copyResult": "نتيجة النسخ", "copyResultSuccess": "تم النسخ بنجاح", - "copyResultNone": "لا يوجد شيء للنسخ: لم يتم اختيار أي عميل أو أن المصدر فارغ", + "copyResultNone": "لا شيء للنسخ: لم يتم تحديد عملاء أو أن المصدر فارغ", "copyResultErrors": "أخطاء النسخ", "copyFlowLabel": "Flow للعملاء الجدد (VLESS)", - "copyFlowHint": "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغاً لتخطيه.", + "copyFlowHint": "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغًا للتخطي.", "selectAll": "تحديد الكل", "clearAll": "مسح الكل", - "method": "طريقة", - "first": "أول واحد", - "last": "آخر واحد", + "method": "الطريقة", + "first": "أول", + "last": "آخر", + "ipLog": "سجل IP", "prefix": "بادئة", "postfix": "لاحقة", - "delayedStart": "ابدأ بعد أول استخدام", + "delayedStart": "البدء بعد أول استخدام", "expireDays": "المدة", - "days": "يوم/أيام", + "days": "يوم", "renew": "تجديد تلقائي", - "renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)" + "renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل) (الوحدة: يوم)", + "title": "العملاء", + "actions": "الإجراءات", + "totalGB": "مجموع المرسل/المستقبل (جيجابايت)", + "expiryTime": "انتهاء الصلاحية", + "addClients": "إضافة عملاء", + "limitIp": "حد عناوين IP", + "password": "كلمة المرور", + "subId": "معرّف الاشتراك", + "online": "متصل", + "email": "البريد الإلكتروني", + "comment": "ملاحظة", + "traffic": "حركة المرور", + "offline": "غير متصل", + "addTitle": "إضافة عميل", + "qrCode": "رمز QR", + "moreInformation": "مزيد من المعلومات", + "delete": "حذف", + "reset": "إعادة ضبط حركة المرور", + "editTitle": "تعديل العميل", + "client": "العميل", + "enabled": "مفعّل", + "remaining": "المتبقي", + "duration": "المدة", + "attachedInbounds": "الاتصالات الواردة المرتبطة", + "selectInbound": "حدد اتصالاً واردًا واحدًا أو أكثر", + "noSubId": "هذا العميل ليس لديه subId، لا يوجد رابط قابل للمشاركة.", + "noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.", + "link": "رابط", + "resetNotPossible": "قم بإرفاق هذا العميل بأحد الاتصالات الواردة أولاً.", + "general": "عام", + "resetAllTraffics": "إعادة ضبط حركة مرور كل العملاء", + "resetAllTrafficsTitle": "إعادة ضبط حركة مرور كل العملاء؟", + "resetAllTrafficsContent": "يُعاد ضبط عدّاد الإرسال/الاستقبال لكل عميل إلى الصفر. لا تتأثر الحصص ومواعيد الانتهاء. لا يمكن التراجع.", + "empty": "لا يوجد عملاء بعد — أضف واحدًا للبدء.", + "deleteConfirmTitle": "حذف العميل {email}؟", + "deleteConfirmContent": "سيؤدي هذا إلى إزالة العميل من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.", + "deleteSelected": "حذف ({count})", + "bulkDeleteConfirmTitle": "حذف {count} عميل؟", + "bulkDeleteConfirmContent": "سيتم إزالة كل عميل محدد من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.", + "delDepleted": "حذف المنتهية", + "delDepletedConfirmTitle": "حذف العملاء المنتهية حصصهم؟", + "delDepletedConfirmContent": "يُحذف كل عميل استُنفِدت حصة حركة مروره أو انتهت صلاحيته. لا يمكن التراجع.", + "auth": "Auth", + "hysteriaAuth": "Auth (Hysteria)", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Reverse tag اختياري", + "telegramId": "معرّف مستخدم تلغرام", + "telegramIdPlaceholder": "معرّف مستخدم تلغرام رقمي (0 = لا شيء)", + "created": "تاريخ الإنشاء", + "updated": "تاريخ التحديث", + "ipLimit": "حد IP", + "toasts": { + "deleted": "تم حذف العميل", + "trafficReset": "تمت إعادة ضبط حركة المرور", + "allTrafficsReset": "تمت إعادة ضبط حركة مرور كل العملاء", + "bulkDeleted": "تم حذف {count} عميل", + "bulkDeletedMixed": "تم حذف {ok}, وفشل {failed}", + "bulkCreated": "تم إنشاء {count} عميل", + "bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}", + "delDepleted": "تم حذف {count} عميل منتهٍ" + } }, "nodes": { "title": "النودز", @@ -428,6 +536,7 @@ "latency": "الكمون", "lastHeartbeat": "آخر نبضة", "xrayVersion": "إصدار Xray", + "panelVersion": "إصدار اللوحة", "actions": "العمليات", "probe": "فحص فوري", "testConnection": "اختبار الاتصال", @@ -777,9 +886,6 @@ "unexpectIPs": "عناوين IP غير متوقعة", "useSystemHosts": "استخدام ملف Hosts الخاص بالنظام", "useSystemHostsDesc": "استخدام ملف hosts من نظام مثبت", - "usePreset": "استخدام النموذج", - "dnsPresetTitle": "قوالب DNS", - "dnsPresetFamily": "العائلي", "serveStale": "تقديم النتائج المنتهية", "serveStaleDesc": "إرجاع نتائج الكاش المنتهية الصلاحية أثناء التحديث في الخلفية", "serveExpiredTTL": "مدة صلاحية النتائج المنتهية", @@ -792,6 +898,9 @@ "hostsEmpty": "لم يتم تعريف أي Host", "hostsDomain": "النطاق (مثل domain:example.com)", "hostsValues": "عنوان IP أو نطاق — اكتب واضغط Enter", + "usePreset": "استخدام النموذج", + "dnsPresetTitle": "قوالب DNS", + "dnsPresetFamily": "العائلي", "clearAll": "حذف الكل", "clearAllTitle": "حذف جميع خوادم DNS؟", "clearAllConfirm": "سيؤدي هذا إلى إزالة جميع خوادم DNS من القائمة. لا يمكن التراجع عن هذا الإجراء." @@ -980,4 +1089,4 @@ "chooseInbound": "اختار الإدخال" } } -} +} \ No newline at end of file diff --git a/web/translation/en-US.json b/web/translation/en-US.json index 8231b7e8..89a2a9f8 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -18,6 +18,8 @@ "search": "Search", "filter": "Filter", "loading": "Loading...", + "refresh": "Refresh", + "clear": "Clear", "second": "Second", "minute": "Minute", "hour": "Hour", @@ -94,6 +96,7 @@ "ultraDark": "Ultra Dark", "dashboard": "Overview", "inbounds": "Inbounds", + "clients": "Clients", "nodes": "Nodes", "settings": "Panel Settings", "xray": "Xray Configs", @@ -111,7 +114,7 @@ "emptyUsername": "Username is required", "emptyPassword": "Password is required", "wrongUsernameOrPassword": "Invalid username or password or two-factor code.", - "successLogin": " You have successfully logged into your account." + "successLogin": "You have successfully logged into your account." } }, "index": { @@ -237,8 +240,6 @@ "getConfigError": "An error occurred while retrieving the config file." }, "inbounds": { - "allTimeTraffic": "All-time Traffic", - "allTimeTrafficUsage": "All-Time Total Usage", "title": "Inbounds", "totalDownUp": "Total Sent/Received", "totalUsage": "Total Usage", @@ -249,6 +250,23 @@ "node": "Node", "deployTo": "Deploy to", "localPanel": "Local panel", + "fallbacks": { + "title": "Fallbacks", + "help": "When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.", + "empty": "No fallbacks yet", + "add": "Add fallback", + "pickInbound": "Pick an inbound", + "matchAny": "any", + "rederive": "Re-fill from child", + "rederived": "Re-filled from child", + "editAdvanced": "Edit routing fields", + "hideAdvanced": "Hide advanced", + "quickAddAll": "Quick add all eligible", + "quickAdded": "Added {n} fallback(s)", + "quickAddedNone": "No new eligible inbounds to add", + "routesWhen": "Routes when", + "defaultCatchAll": "Default — catches anything else" + }, "protocol": "Protocol", "port": "Port", "portMap": "Port Mapping", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "The IP history log. (to re-enable the inbound after disabling, clear the log)", "IPLimitlogclear": "Clear the Log", "setDefaultCert": "Set Cert from Panel", + "streamTab": "Stream", + "securityTab": "Security", + "sniffingTab": "Sniffing", + "sniffingMetadataOnly": "Metadata only", + "sniffingRouteOnly": "Route only", + "sniffingIpsExcluded": "IPs excluded", + "sniffingDomainsExcluded": "Domains excluded", + "decryption": "Decryption", + "encryption": "Encryption", + "vlessAuthX25519": "X25519 auth", + "vlessAuthMlkem768": "ML-KEM-768 auth", + "vlessAuthCustom": "Custom", + "vlessAuthSelected": "Selected: {auth}", + "advanced": { + "title": "Inbound JSON sections", + "subtitle": "Full inbound JSON and focused editors for settings, sniffing, and streamSettings.", + "all": "All", + "allHelp": "Full inbound object with all fields in one editor.", + "settings": "Settings", + "settingsHelp": "Xray settings block wrapper:", + "sniffing": "Sniffing", + "sniffingHelp": "Xray sniffing block wrapper:", + "stream": "Stream", + "streamHelp": "Xray stream block wrapper:", + "jsonErrorPrefix": "Advanced JSON" + }, "telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)", "subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.", "info": "Info", @@ -365,7 +409,7 @@ } } }, - "client": { + "clients": { "add": "Add Client", "edit": "Edit Client", "submitAdd": "Add Client", @@ -389,13 +433,77 @@ "method": "Method", "first": "First", "last": "Last", + "ipLog": "IP Log", "prefix": "Prefix", "postfix": "Postfix", "delayedStart": "Start After First Use", "expireDays": "Duration", "days": "Day(s)", "renew": "Auto Renew", - "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)" + "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)", + "title": "Clients", + "actions": "Actions", + "totalGB": "Total Sent/Received (GB)", + "expiryTime": "Expiry", + "addClients": "Add Clients", + "limitIp": "IP Limit", + "password": "Password", + "subId": "Subscription ID", + "online": "Online", + "email": "Email", + "comment": "Comment", + "traffic": "Traffic", + "offline": "Offline", + "addTitle": "Add Client", + "qrCode": "QR Code", + "moreInformation": "More Information", + "delete": "Delete", + "reset": "Reset Traffic", + "editTitle": "Edit Client", + "client": "Client", + "enabled": "Enabled", + "remaining": "Remaining", + "duration": "Duration", + "attachedInbounds": "Attached inbounds", + "selectInbound": "Select one or more inbounds", + "noSubId": "This client has no subId, no shareable link.", + "noLinks": "No shareable links — attach this client to a protocol-capable inbound first.", + "link": "Link", + "resetNotPossible": "Attach this client to an inbound first.", + "general": "General", + "resetAllTraffics": "Reset all client traffic", + "resetAllTrafficsTitle": "Reset all client traffic?", + "resetAllTrafficsContent": "Every client's up/down counter drops to zero. Quotas and expiry are not affected. This cannot be undone.", + "empty": "No clients yet — add one to get started.", + "deleteConfirmTitle": "Delete client {email}?", + "deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.", + "deleteSelected": "Delete ({count})", + "bulkDeleteConfirmTitle": "Delete {count} clients?", + "bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.", + "delDepleted": "Delete depleted", + "delDepletedConfirmTitle": "Delete depleted clients?", + "delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.", + "auth": "Auth", + "hysteriaAuth": "Hysteria Auth", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Optional reverse tag", + "telegramId": "Telegram user ID", + "telegramIdPlaceholder": "Numeric Telegram user ID (0 = none)", + "created": "Created", + "updated": "Updated", + "ipLimit": "IP limit", + "toasts": { + "deleted": "Client deleted", + "trafficReset": "Traffic reset", + "allTrafficsReset": "All client traffic reset", + "bulkDeleted": "{count} clients deleted", + "bulkDeletedMixed": "{ok} deleted, {failed} failed", + "bulkCreated": "{count} clients created", + "bulkCreatedMixed": "{ok} created, {failed} failed", + "delDepleted": "{count} depleted clients deleted" + } }, "nodes": { "title": "Nodes", @@ -428,6 +536,7 @@ "latency": "Latency", "lastHeartbeat": "Last Heartbeat", "xrayVersion": "Xray Version", + "panelVersion": "Panel Version", "actions": "Actions", "probe": "Probe Now", "testConnection": "Test Connection", @@ -627,7 +736,7 @@ "generalConfigs": "General", "generalConfigsDesc": "These options will determine general adjustments.", "logConfigs": "Log", - "logConfigsDesc": "Logs may affect your server's efficiency. It is recommended to enable it wisely only in case of your needs", + "logConfigsDesc": "Logs may affect your server's efficiency. It is recommended to enable them wisely only when needed.", "blockConfigsDesc": "These options will block traffic based on specific requested protocols and websites.", "basicRouting": "Basic Routing", "blockConnectionsConfigsDesc": "These options will block traffic based on the specific requested country.", @@ -776,7 +885,7 @@ "expectIPs": "Expect IPs", "unexpectIPs": "Unexpected IPs", "useSystemHosts": "Use System Hosts", - "useSystemHostsDesc": "Use the hosts file from an installed system", + "useSystemHostsDesc": "Use the operating system's hosts file", "serveStale": "Serve Stale", "serveStaleDesc": "Return expired cached results while refreshing in the background", "serveExpiredTTL": "Serve Expired TTL", @@ -980,4 +1089,4 @@ "chooseInbound": "Choose an Inbound" } } -} +} \ No newline at end of file diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index 34af89ca..4415d864 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -18,6 +18,8 @@ "search": "Buscar", "filter": "Filtrar", "loading": "Cargando...", + "refresh": "Actualizar", + "clear": "Borrar", "second": "Segundo", "minute": "Minuto", "hour": "Hora", @@ -94,6 +96,7 @@ "ultraDark": "Ultra Oscuro", "dashboard": "Estado del Sistema", "inbounds": "Entradas", + "clients": "Clientes", "nodes": "Nodos", "settings": "Configuraciones", "xray": "Ajustes Xray", @@ -127,9 +130,9 @@ "stopXray": "Detener", "restartXray": "Reiniciar", "xraySwitch": "Versión", + "xrayUpdates": "Actualizaciones de Xray", "xraySwitchClick": "Elige la versión a la que deseas cambiar.", "xraySwitchClickDesk": "Elige sabiamente, ya que las versiones anteriores pueden no ser compatibles con las configuraciones actuales.", - "xrayUpdates": "Actualizaciones de Xray", "updatePanel": "Actualizar panel", "panelUpdateDesc": "Esto actualizará 3X-UI a la última versión y reiniciará el servicio del panel.", "currentPanelVersion": "Versión actual del panel", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "Esto actualizará todos los archivos.", "geofilesUpdateAll": "Actualizar todo", "geofileUpdatePopover": "Geofichero actualizado correctamente", - "dontRefresh": "La instalación está en progreso, por favor no actualices esta página.", - "logs": "Registros", - "config": "Configuración", - "backup": "Сopia de Seguridad", - "backupTitle": "Copia & Restauración", - "exportDatabase": "Copia de seguridad", - "exportDatabaseDesc": "Haz clic para descargar un archivo .db que contiene una copia de seguridad de tu base de datos actual en tu dispositivo.", - "importDatabase": "Restaurar", - "importDatabaseDesc": "Haz clic para seleccionar y cargar un archivo .db desde tu dispositivo para restaurar tu base de datos desde una copia de seguridad.", - "importDatabaseSuccess": "La base de datos se ha importado correctamente", - "importDatabaseError": "Ocurrió un error al importar la base de datos", - "readDatabaseError": "Ocurrió un error al leer la base de datos", - "getDatabaseError": "Ocurrió un error al obtener la base de datos", - "getConfigError": "Ocurrió un error al obtener el archivo de configuración", "customGeoTitle": "GeoSite / GeoIP personalizados", "customGeoAdd": "Añadir", "customGeoType": "Tipo", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "Fuente geo personalizada no encontrada", "customGeoErrDownload": "Error de descarga", "customGeoErrUpdateAllIncomplete": "No se pudieron actualizar una o más fuentes geo personalizadas", - "customGeoEmpty": "Aún no hay fuentes geo personalizadas — haz clic en Añadir para crear una" + "customGeoEmpty": "Aún no hay fuentes geo personalizadas — haz clic en Añadir para crear una", + "dontRefresh": "La instalación está en progreso, por favor no actualices esta página.", + "logs": "Registros", + "config": "Configuración", + "backup": "Сopia de Seguridad", + "backupTitle": "Copia & Restauración", + "exportDatabase": "Copia de seguridad", + "exportDatabaseDesc": "Haz clic para descargar un archivo .db que contiene una copia de seguridad de tu base de datos actual en tu dispositivo.", + "importDatabase": "Restaurar", + "importDatabaseDesc": "Haz clic para seleccionar y cargar un archivo .db desde tu dispositivo para restaurar tu base de datos desde una copia de seguridad.", + "importDatabaseSuccess": "La base de datos se ha importado correctamente", + "importDatabaseError": "Ocurrió un error al importar la base de datos", + "readDatabaseError": "Ocurrió un error al leer la base de datos", + "getDatabaseError": "Ocurrió un error al obtener la base de datos", + "getConfigError": "Ocurrió un error al obtener el archivo de configuración" }, "inbounds": { - "allTimeTraffic": "Tráfico Total", - "allTimeTrafficUsage": "Uso de datos histórico", "title": "Entradas", "totalDownUp": "Subidas/Descargas Totales", "totalUsage": "Uso Total", @@ -249,6 +250,23 @@ "node": "Nodo", "deployTo": "Desplegar en", "localPanel": "Panel local", + "fallbacks": { + "title": "Fallbacks", + "help": "Cuando una conexión en este inbound no coincide con ningún cliente, redirígela a otro inbound. Elige un hijo abajo y los campos de enrutamiento (SNI / ALPN / Path / xver) se rellenan automáticamente desde su transporte; la mayoría de las configuraciones no necesitan más ajustes. Cada hijo debe escuchar en 127.0.0.1 con security=none.", + "empty": "Aún no hay fallbacks", + "add": "Añadir fallback", + "pickInbound": "Selecciona un inbound", + "matchAny": "cualquiera", + "rederive": "Rellenar desde el hijo", + "rederived": "Rellenado desde el hijo", + "editAdvanced": "Editar campos de enrutamiento", + "hideAdvanced": "Ocultar avanzado", + "quickAddAll": "Añadir todos los elegibles", + "quickAdded": "Se añadieron {n} fallback(s)", + "quickAddedNone": "No hay nuevos inbounds elegibles", + "routesWhen": "Enruta cuando", + "defaultCatchAll": "Por defecto — captura cualquier otra cosa" + }, "protocol": "Protocolo", "port": "Puerto", "portMap": "Puertos de Destino", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "Registro de historial de IPs (antes de habilitar la entrada después de que haya sido desactivada por el límite de IP, debes borrar el registro).", "IPLimitlogclear": "Limpiar el Registro", "setDefaultCert": "Establecer certificado desde el panel", + "streamTab": "Stream", + "securityTab": "Seguridad", + "sniffingTab": "Sniffing", + "sniffingMetadataOnly": "Solo metadatos", + "sniffingRouteOnly": "Solo enrutamiento", + "sniffingIpsExcluded": "IPs excluidas", + "sniffingDomainsExcluded": "Dominios excluidos", + "decryption": "Descifrado", + "encryption": "Cifrado", + "vlessAuthX25519": "Autenticación X25519", + "vlessAuthMlkem768": "Autenticación ML-KEM-768", + "vlessAuthCustom": "Personalizado", + "vlessAuthSelected": "Seleccionado: {auth}", + "advanced": { + "title": "Secciones JSON del inbound", + "subtitle": "JSON completo del inbound y editores específicos para settings, sniffing y streamSettings.", + "all": "Todo", + "allHelp": "Objeto inbound completo con todos los campos en un solo editor.", + "settings": "Ajustes", + "settingsHelp": "Envoltorio del bloque settings de Xray:", + "sniffing": "Sniffing", + "sniffingHelp": "Envoltorio del bloque sniffing de Xray:", + "stream": "Stream", + "streamHelp": "Envoltorio del bloque stream de Xray:", + "jsonErrorPrefix": "JSON avanzado" + }, "telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)", "subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.", "info": "Info", @@ -365,37 +409,101 @@ } } }, - "client": { - "add": "Agregar Cliente", - "edit": "Editar Cliente", - "submitAdd": "Agregar Cliente", - "submitEdit": "Guardar Cambios", - "clientCount": "Número de Clientes", - "bulk": "Agregar en Lote", - "copyFromInbound": "Copiar clientes desde entrada", + "clients": { + "add": "Añadir cliente", + "edit": "Editar cliente", + "submitAdd": "Añadir cliente", + "submitEdit": "Guardar cambios", + "clientCount": "Número de clientes", + "bulk": "Añadir en lote", + "copyFromInbound": "Copiar clientes desde inbound", "copyToInbound": "Copiar clientes a", - "copySelected": "Copiar seleccionados", + "copySelected": "Copiar selección", "copySource": "Origen", - "copyEmailPreview": "Vista previa del email resultante", - "copySelectSourceFirst": "Seleccione primero una entrada de origen.", + "copyEmailPreview": "Vista previa del correo resultante", + "copySelectSourceFirst": "Selecciona primero un inbound de origen.", "copyResult": "Resultado de la copia", "copyResultSuccess": "Copiado correctamente", - "copyResultNone": "Nada que copiar: ningún cliente seleccionado o el origen está vacío", - "copyResultErrors": "Errores al copiar", - "copyFlowLabel": "Flow para nuevos clientes (VLESS)", - "copyFlowHint": "Se aplica a todos los clientes copiados. Déjelo vacío para omitir.", + "copyResultNone": "Nada que copiar: no hay clientes seleccionados o el origen está vacío", + "copyResultErrors": "Errores de copia", + "copyFlowLabel": "Flow para clientes nuevos (VLESS)", + "copyFlowHint": "Se aplica a todos los clientes copiados. Déjalo vacío para omitir.", "selectAll": "Seleccionar todo", "clearAll": "Limpiar todo", "method": "Método", "first": "Primero", "last": "Último", + "ipLog": "Registro de IP", "prefix": "Prefijo", "postfix": "Sufijo", - "delayedStart": "Iniciar después del primer uso", + "delayedStart": "Iniciar tras el primer uso", "expireDays": "Duración", "days": "Día(s)", "renew": "Renovación automática", - "renewDesc": "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)" + "renewDesc": "Renovación automática tras la expiración. (0 = desactivado) (unidad: día)", + "title": "Clientes", + "actions": "Acciones", + "totalGB": "Total enviado/recibido (GB)", + "expiryTime": "Expiración", + "addClients": "Añadir clientes", + "limitIp": "Límite de IP", + "password": "Contraseña", + "subId": "ID de suscripción", + "online": "En línea", + "email": "Correo", + "comment": "Comentario", + "traffic": "Tráfico", + "offline": "Desconectado", + "addTitle": "Añadir cliente", + "qrCode": "Código QR", + "moreInformation": "Más información", + "delete": "Eliminar", + "reset": "Restablecer tráfico", + "editTitle": "Editar cliente", + "client": "Cliente", + "enabled": "Habilitado", + "remaining": "Restante", + "duration": "Duración", + "attachedInbounds": "Inbounds asociados", + "selectInbound": "Selecciona uno o más inbounds", + "noSubId": "Este cliente no tiene subId, no hay enlace compartible.", + "noLinks": "No hay enlaces compartibles — asocia primero este cliente a un inbound con protocolo válido.", + "link": "Enlace", + "resetNotPossible": "Asocia primero este cliente a un inbound.", + "general": "General", + "resetAllTraffics": "Restablecer tráfico de todos los clientes", + "resetAllTrafficsTitle": "¿Restablecer tráfico de todos los clientes?", + "resetAllTrafficsContent": "El contador de subida/bajada de cada cliente vuelve a cero. Las cuotas y la expiración no se modifican. Esta acción no se puede deshacer.", + "empty": "Aún no hay clientes — añade uno para empezar.", + "deleteConfirmTitle": "¿Eliminar al cliente {email}?", + "deleteConfirmContent": "Esto elimina al cliente de cada inbound asociado y descarta su registro de tráfico. No se puede deshacer.", + "deleteSelected": "Eliminar ({count})", + "bulkDeleteConfirmTitle": "¿Eliminar {count} clientes?", + "bulkDeleteConfirmContent": "Cada cliente seleccionado se elimina de los inbounds asociados y se descarta su registro de tráfico. No se puede deshacer.", + "delDepleted": "Eliminar agotados", + "delDepletedConfirmTitle": "¿Eliminar clientes agotados?", + "delDepletedConfirmContent": "Elimina todos los clientes con cuota agotada o expirados. No se puede deshacer.", + "auth": "Auth", + "hysteriaAuth": "Auth de Hysteria", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Reverse tag opcional", + "telegramId": "ID de usuario de Telegram", + "telegramIdPlaceholder": "ID numérico de usuario de Telegram (0 = ninguno)", + "created": "Creado", + "updated": "Actualizado", + "ipLimit": "Límite de IP", + "toasts": { + "deleted": "Cliente eliminado", + "trafficReset": "Tráfico restablecido", + "allTrafficsReset": "Tráfico de todos los clientes restablecido", + "bulkDeleted": "{count} clientes eliminados", + "bulkDeletedMixed": "{ok} eliminados, {failed} fallidos", + "bulkCreated": "{count} clientes creados", + "bulkCreatedMixed": "{ok} creados, {failed} fallidos", + "delDepleted": "{count} clientes agotados eliminados" + } }, "nodes": { "title": "Nodos", @@ -428,6 +536,7 @@ "latency": "Latencia", "lastHeartbeat": "Último latido", "xrayVersion": "Versión de Xray", + "panelVersion": "Versión del panel", "actions": "Acciones", "probe": "Sondear ahora", "testConnection": "Probar conexión", @@ -552,13 +661,13 @@ "subEmailInRemark": "Incluir Email en el nombre", "subEmailInRemarkDesc": "Incluir el correo del cliente en el nombre del perfil de suscripción.", "subURI": "URI de proxy inverso", + "subURIDesc": "Cambiar el URI base de la URL de suscripción para usar detrás de los servidores proxy", "externalTrafficInformEnable": "Informe de tráfico externo", "externalTrafficInformEnableDesc": "Informar a la API externa sobre cada actualización de tráfico.", "externalTrafficInformURI": "URI de información de tráfico externo", "externalTrafficInformURIDesc": "Las actualizaciones de tráfico se envían a este URI.", "restartXrayOnClientDisable": "Reiniciar Xray tras desactivación automática", "restartXrayOnClientDisableDesc": "Cuando un cliente se desactive automáticamente por vencimiento o límite de tráfico, reiniciar Xray.", - "subURIDesc": "Cambiar el URI base de la URL de suscripción para usar detrás de los servidores proxy", "fragment": "Fragmentación", "fragmentDesc": "Habilitar la fragmentación para el paquete de saludo de TLS", "fragmentSett": "Configuración de Fragmentación", @@ -777,9 +886,6 @@ "unexpectIPs": "IPs inesperadas", "useSystemHosts": "Usar Hosts del sistema", "useSystemHostsDesc": "Usar el archivo hosts de un sistema instalado", - "usePreset": "Usar plantilla", - "dnsPresetTitle": "Plantillas DNS", - "dnsPresetFamily": "Familiar", "serveStale": "Servir caducados", "serveStaleDesc": "Devolver resultados caducados de la caché mientras se actualiza en segundo plano", "serveExpiredTTL": "TTL de caducados", @@ -792,6 +898,9 @@ "hostsEmpty": "No hay Hosts definidos", "hostsDomain": "Dominio (ej. domain:example.com)", "hostsValues": "IP o dominio — escribe y presiona Enter", + "usePreset": "Usar plantilla", + "dnsPresetTitle": "Plantillas DNS", + "dnsPresetFamily": "Familiar", "clearAll": "Eliminar todos", "clearAllTitle": "¿Eliminar todos los servidores DNS?", "clearAllConfirm": "Esto eliminará todos los servidores DNS de la lista. No se puede deshacer." @@ -980,4 +1089,4 @@ "chooseInbound": "Elige un Inbound" } } -} +} \ No newline at end of file diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index fd948788..71dc3d68 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -18,6 +18,8 @@ "search": "جستجو", "filter": "فیلتر", "loading": "...در حال بارگذاری", + "refresh": "تازه‌سازی", + "clear": "پاک کردن", "second": "ثانیه", "minute": "دقیقه", "hour": "ساعت", @@ -94,6 +96,7 @@ "ultraDark": "فوق تیره", "dashboard": "نمای کلی", "inbounds": "ورودی‌ها", + "clients": "کلاینت‌ها", "nodes": "نودها", "settings": "تنظیمات پنل", "xray": "پیکربندی ایکس‌ری", @@ -127,9 +130,9 @@ "stopXray": "توقف", "restartXray": "شروع‌مجدد", "xraySwitch": "‌نسخه", + "xrayUpdates": "به‌روزرسانی‌های Xray", "xraySwitchClick": "نسخه مورد نظر را انتخاب کنید", "xraySwitchClickDesk": "لطفا بادقت انتخاب کنید. درصورت انتخاب نسخه قدیمی‌تر، امکان ناهماهنگی با پیکربندی فعلی وجود دارد", - "xrayUpdates": "به‌روزرسانی‌های Xray", "updatePanel": "به‌روزرسانی پنل", "panelUpdateDesc": "این عملیات 3X-UI را به آخرین نسخه به‌روزرسانی می‌کند و سرویس پنل را مجدداً راه‌اندازی می‌کند.", "currentPanelVersion": "نسخه فعلی پنل", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "با این کار همه فایل‌ها به‌روزرسانی می‌شوند.", "geofilesUpdateAll": "همه را به‌روزرسانی کنید", "geofileUpdatePopover": "فایل جغرافیایی با موفقیت به‌روز شد", - "dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید", - "logs": "گزارش‌ها", - "config": "پیکربندی", - "backup": "پشتیبان‌گیری", - "backupTitle": "پشتیبان‌گیری و بازیابی", - "exportDatabase": "پشتیبان‌گیری", - "exportDatabaseDesc": "برای دانلود یک فایل .db حاوی پشتیبان از پایگاه داده فعلی خود به دستگاهتان کلیک کنید.", - "importDatabase": "بازیابی", - "importDatabaseDesc": "برای انتخاب و آپلود یک فایل .db از دستگاهتان و بازیابی پایگاه داده از یک پشتیبان کلیک کنید.", - "importDatabaseSuccess": "پایگاه داده با موفقیت وارد شد", - "importDatabaseError": "خطا در وارد کردن پایگاه داده", - "readDatabaseError": "خطا در خواندن پایگاه داده", - "getDatabaseError": "خطا در دریافت پایگاه داده", - "getConfigError": "خطا در دریافت فایل پیکربندی", "customGeoTitle": "GeoSite / GeoIP سفارشی", "customGeoAdd": "افزودن", "customGeoType": "نوع", @@ -234,14 +223,23 @@ "customGeoErrNotFound": "منبع geo سفارشی یافت نشد", "customGeoErrDownload": "بارگیری ناموفق بود", "customGeoErrUpdateAllIncomplete": "به‌روزرسانی یک یا چند منبع geo سفارشی ناموفق بود", - "customGeoEmpty": "هنوز منبع geo سفارشی‌ای ثبت نشده — برای ایجاد روی «افزودن» کلیک کنید" + "customGeoEmpty": "هنوز منبع geo سفارشی‌ای ثبت نشده — برای ایجاد روی «افزودن» کلیک کنید", + "dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید", + "logs": "گزارش‌ها", + "config": "پیکربندی", + "backup": "پشتیبان‌گیری", + "backupTitle": "پشتیبان‌گیری و بازیابی", + "exportDatabase": "پشتیبان‌گیری", + "exportDatabaseDesc": "برای دانلود یک فایل .db حاوی پشتیبان از پایگاه داده فعلی خود به دستگاهتان کلیک کنید.", + "importDatabase": "بازیابی", + "importDatabaseDesc": "برای انتخاب و آپلود یک فایل .db از دستگاهتان و بازیابی پایگاه داده از یک پشتیبان کلیک کنید.", + "importDatabaseSuccess": "پایگاه داده با موفقیت وارد شد", + "importDatabaseError": "خطا در وارد کردن پایگاه داده", + "readDatabaseError": "خطا در خواندن پایگاه داده", + "getDatabaseError": "خطا در دریافت پایگاه داده", + "getConfigError": "خطا در دریافت فایل پیکربندی" }, "inbounds": { - "node": "نود", - "deployTo": "استقرار روی", - "localPanel": "پنل لوکال", - "allTimeTraffic": "کل ترافیک", - "allTimeTrafficUsage": "کل استفاده در تمام مدت", "title": "کاربران", "totalDownUp": "دریافت/ارسال کل", "totalUsage": "‌‌‌مصرف کل", @@ -249,6 +247,26 @@ "operate": "عملیات", "enable": "فعال", "remark": "نام", + "node": "نود", + "deployTo": "استقرار روی", + "localPanel": "پنل لوکال", + "fallbacks": { + "title": "فال‌بک‌ها", + "help": "وقتی اتصالی روی این اینباند با هیچ کلاینتی تطبیق پیدا نمی‌کند، به یک اینباند دیگر ارجاع داده می‌شود. یک فرزند انتخاب کنید، فیلدهای مسیریابی (SNI / ALPN / Path / xver) خودکار از روی transport آن پر می‌شود — برای بیشتر تنظیمات نیازی به ویرایش نیست. هر فرزند باید روی 127.0.0.1 با security=none گوش بدهد.", + "empty": "هنوز فال‌بکی اضافه نشده", + "add": "افزودن فال‌بک", + "pickInbound": "یک اینباند انتخاب کنید", + "matchAny": "همه", + "rederive": "پر کردن مجدد از فرزند", + "rederived": "از فرزند پر شد", + "editAdvanced": "ویرایش فیلدهای مسیریابی", + "hideAdvanced": "بستن پیشرفته", + "quickAddAll": "افزودن سریع همه‌ی موارد واجد شرایط", + "quickAdded": "{n} فال‌بک افزوده شد", + "quickAddedNone": "اینباند جدیدی برای افزودن وجود ندارد", + "routesWhen": "هدایت می‌شود وقتی", + "defaultCatchAll": "پیش‌فرض — همه‌ی موارد دیگر را می‌گیرد" + }, "protocol": "پروتکل", "port": "پورت", "portMap": "پورت‌های نظیر", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "گزارش تاریخچه آی‌پی. برای فعال کردن ورودی پس از غیرفعال شدن، گزارش را پاک کنید", "IPLimitlogclear": "پاک کردن گزارش‌ها", "setDefaultCert": "استفاده از گواهی پنل", + "streamTab": "استریم", + "securityTab": "امنیت", + "sniffingTab": "اسنیفینگ", + "sniffingMetadataOnly": "فقط متادیتا", + "sniffingRouteOnly": "فقط مسیریابی", + "sniffingIpsExcluded": "IPهای مستثنا", + "sniffingDomainsExcluded": "دامنه‌های مستثنا", + "decryption": "رمزگشایی", + "encryption": "رمزنگاری", + "vlessAuthX25519": "احراز X25519", + "vlessAuthMlkem768": "احراز ML-KEM-768", + "vlessAuthCustom": "سفارشی", + "vlessAuthSelected": "انتخاب‌شده: {auth}", + "advanced": { + "title": "بخش‌های JSON اینباند", + "subtitle": "JSON کامل اینباند و ویرایشگرهای جداگانه برای settings، sniffing و streamSettings.", + "all": "همه", + "allHelp": "شیء کامل اینباند با همه فیلدها در یک ویرایشگر.", + "settings": "تنظیمات", + "settingsHelp": "ساختار بلوک settings در Xray:", + "sniffing": "اسنیفینگ", + "sniffingHelp": "ساختار بلوک sniffing در Xray:", + "stream": "استریم", + "streamHelp": "ساختار بلوک stream در Xray:", + "jsonErrorPrefix": "JSON پیشرفته" + }, "telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)", "subscriptionDesc": "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید", "info": "اطلاعات", @@ -365,37 +409,101 @@ } } }, - "client": { - "add": "کاربر جدید", - "edit": "ویرایش کاربر", - "submitAdd": "اضافه کردن", + "clients": { + "add": "افزودن کلاینت", + "edit": "ویرایش کلاینت", + "submitAdd": "افزودن کلاینت", "submitEdit": "ذخیره تغییرات", - "clientCount": "تعداد کاربران", - "bulk": "انبوه‌سازی", - "copyFromInbound": "کپی کاربران از اینباند", - "copyToInbound": "کپی کاربران به", + "clientCount": "تعداد کلاینت‌ها", + "bulk": "افزودن گروهی", + "copyFromInbound": "کپی کلاینت‌ها از اینباند", + "copyToInbound": "کپی کلاینت‌ها به", "copySelected": "کپی انتخاب‌شده‌ها", "copySource": "منبع", - "copyEmailPreview": "پیش‌نمایش ایمیل نهایی", - "copySelectSourceFirst": "ابتدا یک اینباند منبع انتخاب کنید.", + "copyEmailPreview": "پیش‌نمایش ایمیل خروجی", + "copySelectSourceFirst": "ابتدا یک اینباند مبدأ انتخاب کنید.", "copyResult": "نتیجه کپی", "copyResultSuccess": "با موفقیت کپی شد", - "copyResultNone": "چیزی برای کپی نیست: هیچ کاربری انتخاب نشده یا منبع خالی است", + "copyResultNone": "چیزی برای کپی نیست: کلاینتی انتخاب نشده یا منبع خالی است", "copyResultErrors": "خطاهای کپی", - "copyFlowLabel": "Flow برای کاربران جدید (VLESS)", - "copyFlowHint": "برای همه کاربران کپی‌شده اعمال می‌شود. برای نادیده گرفتن، خالی بگذارید.", + "copyFlowLabel": "Flow برای کلاینت‌های جدید (VLESS)", + "copyFlowHint": "روی همه کلاینت‌های کپی‌شده اعمال می‌شود. خالی بگذارید تا رد شود.", "selectAll": "انتخاب همه", "clearAll": "پاک کردن همه", "method": "روش", - "first": "از", - "last": "تا", + "first": "اول", + "last": "آخر", + "ipLog": "گزارش IP", "prefix": "پیشوند", "postfix": "پسوند", - "delayedStart": "شروع‌پس‌از‌اولین‌استفاده", - "expireDays": "مدت زمان", - "days": "(روز)", + "delayedStart": "شروع پس از اولین استفاده", + "expireDays": "مدت", + "days": "روز", "renew": "تمدید خودکار", - "renewDesc": "تمدید خودکار پس‌از ‌انقضا. (0 = غیرفعال)(واحد: روز)" + "renewDesc": "تمدید خودکار پس از انقضا. (۰ = غیرفعال) (واحد: روز)", + "title": "کلاینت‌ها", + "actions": "عملیات", + "totalGB": "مجموع ارسال/دریافت (گیگابایت)", + "expiryTime": "انقضا", + "addClients": "افزودن کلاینت‌ها", + "limitIp": "محدودیت IP", + "password": "رمز عبور", + "subId": "شناسه اشتراک", + "online": "آنلاین", + "email": "ایمیل", + "comment": "توضیحات", + "traffic": "ترافیک", + "offline": "آفلاین", + "addTitle": "افزودن کلاینت", + "qrCode": "کد QR", + "moreInformation": "اطلاعات بیشتر", + "delete": "حذف", + "reset": "بازنشانی ترافیک", + "editTitle": "ویرایش کلاینت", + "client": "کلاینت", + "enabled": "فعال", + "remaining": "باقی‌مانده", + "duration": "مدت", + "attachedInbounds": "اینباندهای متصل", + "selectInbound": "یک یا چند اینباند انتخاب کنید", + "noSubId": "این کلاینت subId ندارد، لینک اشتراک‌گذاری وجود ندارد.", + "noLinks": "لینکی برای اشتراک‌گذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.", + "link": "لینک", + "resetNotPossible": "ابتدا این کلاینت را به یک اینباند متصل کنید.", + "general": "عمومی", + "resetAllTraffics": "بازنشانی ترافیک همه کلاینت‌ها", + "resetAllTrafficsTitle": "بازنشانی ترافیک همه کلاینت‌ها؟", + "resetAllTrafficsContent": "شمارنده ارسال/دریافت همه کلاینت‌ها به صفر می‌رسد. سهمیه و تاریخ انقضا تغییری نمی‌کند. این عمل غیرقابل بازگشت است.", + "empty": "هنوز کلاینتی نیست — برای شروع یکی اضافه کنید.", + "deleteConfirmTitle": "حذف کلاینت {email}؟", + "deleteConfirmContent": "این کلاینت از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.", + "deleteSelected": "حذف ({count})", + "bulkDeleteConfirmTitle": "حذف {count} کلاینت؟", + "bulkDeleteConfirmContent": "هر کلاینت انتخاب‌شده از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.", + "delDepleted": "حذف اتمام‌یافته‌ها", + "delDepletedConfirmTitle": "حذف کلاینت‌های اتمام‌یافته؟", + "delDepletedConfirmContent": "هر کلاینتی که سهمیه ترافیک‌اش تمام شده یا تاریخ انقضایش گذشته است حذف می‌شود. این عمل غیرقابل بازگشت است.", + "auth": "Auth", + "hysteriaAuth": "Auth (هیستریا)", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Reverse tag اختیاری", + "telegramId": "شناسه کاربر تلگرام", + "telegramIdPlaceholder": "شناسه عددی کاربر تلگرام (۰ = هیچ)", + "created": "ساخته‌شده", + "updated": "به‌روزشده", + "ipLimit": "محدودیت IP", + "toasts": { + "deleted": "کلاینت حذف شد", + "trafficReset": "ترافیک بازنشانی شد", + "allTrafficsReset": "ترافیک همه کلاینت‌ها بازنشانی شد", + "bulkDeleted": "{count} کلاینت حذف شد", + "bulkDeletedMixed": "{ok} حذف، {failed} ناموفق", + "bulkCreated": "{count} کلاینت ساخته شد", + "bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق", + "delDepleted": "{count} کلاینت اتمام‌یافته حذف شد" + } }, "nodes": { "title": "نودها", @@ -428,6 +536,7 @@ "latency": "تاخیر", "lastHeartbeat": "آخرین ضربان", "xrayVersion": "نسخه Xray", + "panelVersion": "نسخه پنل", "actions": "عملیات", "probe": "بررسی فوری", "testConnection": "تست اتصال", @@ -725,9 +834,9 @@ "accessToken": "توکن دسترسی", "country": "کشور", "server": "سرور", - "privateKey": "کلید خصوصی", "city": "شهر", "allCities": "همه شهرها", + "privateKey": "کلید خصوصی", "load": "فشار سرور" }, "balancer": { @@ -980,4 +1089,4 @@ "chooseInbound": "یک ورودی انتخاب کنید" } } -} +} \ No newline at end of file diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index b8f44ac6..ce740d50 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -18,6 +18,8 @@ "search": "Cari", "filter": "Filter", "loading": "Memuat...", + "refresh": "Segarkan", + "clear": "Bersihkan", "second": "Detik", "minute": "Menit", "hour": "Jam", @@ -94,6 +96,7 @@ "ultraDark": "Sangat Gelap", "dashboard": "Ikhtisar", "inbounds": "Masuk", + "clients": "Klien", "nodes": "Node", "settings": "Pengaturan Panel", "xray": "Konfigurasi Xray", @@ -127,9 +130,9 @@ "stopXray": "Stop", "restartXray": "Restart", "xraySwitch": "Versi", + "xrayUpdates": "Pembaruan Xray", "xraySwitchClick": "Pilih versi yang ingin Anda pindah.", "xraySwitchClickDesk": "Pilih dengan hati-hati, karena versi yang lebih lama mungkin tidak kompatibel dengan konfigurasi saat ini.", - "xrayUpdates": "Pembaruan Xray", "updatePanel": "Perbarui Panel", "panelUpdateDesc": "Ini akan memperbarui 3X-UI ke rilis terbaru dan me-restart layanan panel.", "currentPanelVersion": "Versi panel saat ini", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "Ini akan memperbarui semua berkas.", "geofilesUpdateAll": "Perbarui semua", "geofileUpdatePopover": "Geofile berhasil diperbarui", - "dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini", - "logs": "Log", - "config": "Konfigurasi", - "backup": "Cadangan", - "backupTitle": "Cadangan & Pulihkan", - "exportDatabase": "Cadangkan", - "exportDatabaseDesc": "Klik untuk mengunduh file .db yang berisi cadangan dari database Anda saat ini ke perangkat Anda.", - "importDatabase": "Pulihkan", - "importDatabaseDesc": "Klik untuk memilih dan mengunggah file .db dari perangkat Anda untuk memulihkan database dari cadangan.", - "importDatabaseSuccess": "Database berhasil diimpor", - "importDatabaseError": "Terjadi kesalahan saat mengimpor database", - "readDatabaseError": "Terjadi kesalahan saat membaca database", - "getDatabaseError": "Terjadi kesalahan saat mengambil database", - "getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi", "customGeoTitle": "GeoSite / GeoIP kustom", "customGeoAdd": "Tambah", "customGeoType": "Jenis", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "Sumber geo kustom tidak ditemukan", "customGeoErrDownload": "Unduh gagal", "customGeoErrUpdateAllIncomplete": "Satu atau lebih sumber geo kustom gagal diperbarui", - "customGeoEmpty": "Belum ada sumber geo kustom — klik Tambah untuk membuatnya" + "customGeoEmpty": "Belum ada sumber geo kustom — klik Tambah untuk membuatnya", + "dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini", + "logs": "Log", + "config": "Konfigurasi", + "backup": "Cadangan", + "backupTitle": "Cadangan & Pulihkan", + "exportDatabase": "Cadangkan", + "exportDatabaseDesc": "Klik untuk mengunduh file .db yang berisi cadangan dari database Anda saat ini ke perangkat Anda.", + "importDatabase": "Pulihkan", + "importDatabaseDesc": "Klik untuk memilih dan mengunggah file .db dari perangkat Anda untuk memulihkan database dari cadangan.", + "importDatabaseSuccess": "Database berhasil diimpor", + "importDatabaseError": "Terjadi kesalahan saat mengimpor database", + "readDatabaseError": "Terjadi kesalahan saat membaca database", + "getDatabaseError": "Terjadi kesalahan saat mengambil database", + "getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi" }, "inbounds": { - "allTimeTraffic": "Total Lalu Lintas", - "allTimeTrafficUsage": "Total Penggunaan Sepanjang Waktu", "title": "Masuk", "totalDownUp": "Total Terkirim/Diterima", "totalUsage": "Penggunaan Total", @@ -249,6 +250,23 @@ "node": "Node", "deployTo": "Terapkan ke", "localPanel": "Panel lokal", + "fallbacks": { + "title": "Fallback", + "help": "Saat koneksi pada inbound ini tidak cocok dengan client mana pun, arahkan ke inbound lain. Pilih child di bawah dan field routing (SNI / ALPN / Path / xver) terisi otomatis dari transport-nya — sebagian besar konfigurasi tidak perlu disesuaikan lagi. Setiap child harus listen di 127.0.0.1 dengan security=none.", + "empty": "Belum ada fallback", + "add": "Tambah fallback", + "pickInbound": "Pilih inbound", + "matchAny": "apa pun", + "rederive": "Isi ulang dari child", + "rederived": "Diisi ulang dari child", + "editAdvanced": "Edit field routing", + "hideAdvanced": "Sembunyikan lanjutan", + "quickAddAll": "Tambah cepat semua yang memenuhi syarat", + "quickAdded": "Menambahkan {n} fallback", + "quickAddedNone": "Tidak ada inbound baru yang memenuhi syarat", + "routesWhen": "Diarahkan ketika", + "defaultCatchAll": "Default — menangkap apa pun lainnya" + }, "protocol": "Protokol", "port": "Port", "portMap": "Port Mapping", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "Log histori IP. (untuk mengaktifkan masuk setelah menonaktifkan, hapus log)", "IPLimitlogclear": "Hapus Log", "setDefaultCert": "Atur Sertifikat dari Panel", + "streamTab": "Stream", + "securityTab": "Keamanan", + "sniffingTab": "Sniffing", + "sniffingMetadataOnly": "Hanya metadata", + "sniffingRouteOnly": "Hanya routing", + "sniffingIpsExcluded": "IP yang dikecualikan", + "sniffingDomainsExcluded": "Domain yang dikecualikan", + "decryption": "Dekripsi", + "encryption": "Enkripsi", + "vlessAuthX25519": "Auth X25519", + "vlessAuthMlkem768": "Auth ML-KEM-768", + "vlessAuthCustom": "Khusus", + "vlessAuthSelected": "Dipilih: {auth}", + "advanced": { + "title": "Bagian JSON inbound", + "subtitle": "JSON inbound lengkap dan editor fokus untuk settings, sniffing, dan streamSettings.", + "all": "Semua", + "allHelp": "Objek inbound lengkap dengan semua bidang dalam satu editor.", + "settings": "Pengaturan", + "settingsHelp": "Pembungkus blok settings Xray:", + "sniffing": "Sniffing", + "sniffingHelp": "Pembungkus blok sniffing Xray:", + "stream": "Stream", + "streamHelp": "Pembungkus blok stream Xray:", + "jsonErrorPrefix": "JSON lanjutan" + }, "telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)", "subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.", "info": "Info", @@ -365,37 +409,101 @@ } } }, - "client": { - "add": "Tambah Klien", - "edit": "Edit Klien", - "submitAdd": "Tambah Klien", - "submitEdit": "Simpan Perubahan", - "clientCount": "Jumlah Klien", - "bulk": "Tambahkan Massal", + "clients": { + "add": "Tambah klien", + "edit": "Ubah klien", + "submitAdd": "Tambah klien", + "submitEdit": "Simpan perubahan", + "clientCount": "Jumlah klien", + "bulk": "Tambah massal", "copyFromInbound": "Salin klien dari inbound", "copyToInbound": "Salin klien ke", - "copySelected": "Salin yang dipilih", + "copySelected": "Salin terpilih", "copySource": "Sumber", "copyEmailPreview": "Pratinjau email hasil", - "copySelectSourceFirst": "Silakan pilih inbound sumber terlebih dahulu.", - "copyResult": "Hasil penyalinan", + "copySelectSourceFirst": "Pilih inbound sumber terlebih dahulu.", + "copyResult": "Hasil salinan", "copyResultSuccess": "Berhasil disalin", - "copyResultNone": "Tidak ada yang disalin: tidak ada klien yang dipilih atau sumber kosong", - "copyResultErrors": "Kesalahan penyalinan", + "copyResultNone": "Tidak ada yang disalin: tidak ada klien terpilih atau sumber kosong", + "copyResultErrors": "Kesalahan salin", "copyFlowLabel": "Flow untuk klien baru (VLESS)", - "copyFlowHint": "Diterapkan ke semua klien yang disalin. Biarkan kosong untuk melewati.", + "copyFlowHint": "Diterapkan ke semua klien yang disalin. Kosongkan untuk dilewati.", "selectAll": "Pilih semua", "clearAll": "Hapus semua", "method": "Metode", "first": "Pertama", "last": "Terakhir", + "ipLog": "Log IP", "prefix": "Awalan", "postfix": "Akhiran", - "delayedStart": "Mulai Awal", + "delayedStart": "Mulai setelah penggunaan pertama", "expireDays": "Durasi", "days": "Hari", - "renew": "Perpanjang Otomatis", - "renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)" + "renew": "Perpanjangan otomatis", + "renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif) (satuan: hari)", + "title": "Klien", + "actions": "Aksi", + "totalGB": "Total Kirim/Terima (GB)", + "expiryTime": "Kedaluwarsa", + "addClients": "Tambah klien", + "limitIp": "Batas IP", + "password": "Kata sandi", + "subId": "ID Langganan", + "online": "Online", + "email": "Email", + "comment": "Komentar", + "traffic": "Lalu lintas", + "offline": "Offline", + "addTitle": "Tambah klien", + "qrCode": "Kode QR", + "moreInformation": "Informasi lebih lanjut", + "delete": "Hapus", + "reset": "Reset lalu lintas", + "editTitle": "Ubah klien", + "client": "Klien", + "enabled": "Aktif", + "remaining": "Sisa", + "duration": "Durasi", + "attachedInbounds": "Inbound terlampir", + "selectInbound": "Pilih satu atau lebih inbound", + "noSubId": "Klien ini tidak punya subId, tidak ada tautan yang bisa dibagikan.", + "noLinks": "Tidak ada tautan yang bisa dibagikan — lampirkan klien ini ke inbound yang mendukung protokol terlebih dahulu.", + "link": "Tautan", + "resetNotPossible": "Lampirkan klien ini ke inbound terlebih dahulu.", + "general": "Umum", + "resetAllTraffics": "Reset lalu lintas semua klien", + "resetAllTrafficsTitle": "Reset lalu lintas semua klien?", + "resetAllTrafficsContent": "Penghitung kirim/terima setiap klien turun ke nol. Kuota dan kedaluwarsa tidak terpengaruh. Tidak dapat dibatalkan.", + "empty": "Belum ada klien — tambahkan satu untuk memulai.", + "deleteConfirmTitle": "Hapus klien {email}?", + "deleteConfirmContent": "Tindakan ini menghapus klien dari setiap inbound terlampir dan menghapus catatan lalu lintasnya. Tidak dapat dibatalkan.", + "deleteSelected": "Hapus ({count})", + "bulkDeleteConfirmTitle": "Hapus {count} klien?", + "bulkDeleteConfirmContent": "Setiap klien yang dipilih dihapus dari semua inbound terlampir dan catatan lalu lintasnya dihapus. Tidak dapat dibatalkan.", + "delDepleted": "Hapus yang habis", + "delDepletedConfirmTitle": "Hapus klien yang habis?", + "delDepletedConfirmContent": "Hapus setiap klien yang kuota lalu lintasnya habis atau yang masa berlakunya telah berakhir. Tidak dapat dibatalkan.", + "auth": "Auth", + "hysteriaAuth": "Auth Hysteria", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Reverse tag opsional", + "telegramId": "ID pengguna Telegram", + "telegramIdPlaceholder": "ID numerik pengguna Telegram (0 = tidak ada)", + "created": "Dibuat", + "updated": "Diperbarui", + "ipLimit": "Batas IP", + "toasts": { + "deleted": "Klien dihapus", + "trafficReset": "Lalu lintas direset", + "allTrafficsReset": "Lalu lintas semua klien direset", + "bulkDeleted": "{count} klien dihapus", + "bulkDeletedMixed": "{ok} dihapus, {failed} gagal", + "bulkCreated": "{count} klien dibuat", + "bulkCreatedMixed": "{ok} dibuat, {failed} gagal", + "delDepleted": "{count} klien habis dihapus" + } }, "nodes": { "title": "Node", @@ -428,6 +536,7 @@ "latency": "Latensi", "lastHeartbeat": "Heartbeat Terakhir", "xrayVersion": "Versi Xray", + "panelVersion": "Versi panel", "actions": "Aksi", "probe": "Probe Sekarang", "testConnection": "Tes Koneksi", @@ -777,9 +886,6 @@ "unexpectIPs": "IP tak terduga", "useSystemHosts": "Gunakan Hosts Sistem", "useSystemHostsDesc": "Gunakan file hosts dari sistem yang terinstal", - "usePreset": "Gunakan templat", - "dnsPresetTitle": "Templat DNS", - "dnsPresetFamily": "Keluarga", "serveStale": "Sajikan Kedaluwarsa", "serveStaleDesc": "Mengembalikan hasil cache yang kedaluwarsa saat memperbarui di latar belakang", "serveExpiredTTL": "TTL Kedaluwarsa", @@ -792,6 +898,9 @@ "hostsEmpty": "Tidak ada Host yang ditentukan", "hostsDomain": "Domain (mis. domain:example.com)", "hostsValues": "IP atau domain — ketik dan tekan Enter", + "usePreset": "Gunakan templat", + "dnsPresetTitle": "Templat DNS", + "dnsPresetFamily": "Keluarga", "clearAll": "Hapus Semua", "clearAllTitle": "Hapus semua server DNS?", "clearAllConfirm": "Ini akan menghapus semua server DNS dari daftar. Tidak dapat dibatalkan." @@ -980,4 +1089,4 @@ "chooseInbound": "Pilih Inbound" } } -} +} \ No newline at end of file diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 630f5623..7cba08ed 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -18,6 +18,8 @@ "search": "検索", "filter": "フィルター", "loading": "読み込み中...", + "refresh": "更新", + "clear": "クリア", "second": "秒", "minute": "分", "hour": "時間", @@ -94,6 +96,7 @@ "ultraDark": "ウルトラダーク", "dashboard": "ダッシュボード", "inbounds": "インバウンド一覧", + "clients": "クライアント", "nodes": "ノード", "settings": "パネル設定", "xray": "Xray設定", @@ -127,9 +130,9 @@ "stopXray": "停止", "restartXray": "再起動", "xraySwitch": "バージョン", + "xrayUpdates": "Xrayの更新", "xraySwitchClick": "切り替えるバージョンを選択してください", "xraySwitchClickDesk": "慎重に選択してください。古いバージョンは現在の設定と互換性がない可能性があります。", - "xrayUpdates": "Xrayの更新", "updatePanel": "パネルを更新", "panelUpdateDesc": "これにより3X-UIが最新リリースに更新され、パネルサービスが再起動されます。", "currentPanelVersion": "現在のパネルバージョン", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "これにより、すべてのファイルが更新されます。", "geofilesUpdateAll": "すべて更新", "geofileUpdatePopover": "ジオファイルの更新が成功しました", - "dontRefresh": "インストール中、このページをリロードしないでください", - "logs": "ログ", - "config": "設定", - "backup": "バックアップ", - "backupTitle": "バックアップと復元", - "exportDatabase": "バックアップ", - "exportDatabaseDesc": "クリックして、現在のデータベースのバックアップを含む .db ファイルをデバイスにダウンロードします。", - "importDatabase": "復元", - "importDatabaseDesc": "クリックして、デバイスから .db ファイルを選択し、アップロードしてバックアップからデータベースを復元します。", - "importDatabaseSuccess": "データベースのインポートに成功しました", - "importDatabaseError": "データベースのインポート中にエラーが発生しました", - "readDatabaseError": "データベースの読み取り中にエラーが発生しました", - "getDatabaseError": "データベースの取得中にエラーが発生しました", - "getConfigError": "設定ファイルの取得中にエラーが発生しました", "customGeoTitle": "カスタム GeoSite / GeoIP", "customGeoAdd": "追加", "customGeoType": "種類", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "カスタム geo ソースが見つかりません", "customGeoErrDownload": "ダウンロードに失敗しました", "customGeoErrUpdateAllIncomplete": "カスタム geo ソースの 1 件以上を更新できませんでした", - "customGeoEmpty": "カスタム geo ソースはまだありません — 「追加」をクリックして作成してください" + "customGeoEmpty": "カスタム geo ソースはまだありません — 「追加」をクリックして作成してください", + "dontRefresh": "インストール中、このページをリロードしないでください", + "logs": "ログ", + "config": "設定", + "backup": "バックアップ", + "backupTitle": "バックアップと復元", + "exportDatabase": "バックアップ", + "exportDatabaseDesc": "クリックして、現在のデータベースのバックアップを含む .db ファイルをデバイスにダウンロードします。", + "importDatabase": "復元", + "importDatabaseDesc": "クリックして、デバイスから .db ファイルを選択し、アップロードしてバックアップからデータベースを復元します。", + "importDatabaseSuccess": "データベースのインポートに成功しました", + "importDatabaseError": "データベースのインポート中にエラーが発生しました", + "readDatabaseError": "データベースの読み取り中にエラーが発生しました", + "getDatabaseError": "データベースの取得中にエラーが発生しました", + "getConfigError": "設定ファイルの取得中にエラーが発生しました" }, "inbounds": { - "allTimeTraffic": "総トラフィック", - "allTimeTrafficUsage": "これまでの総使用量", "title": "インバウンド一覧", "totalDownUp": "総アップロード / ダウンロード", "totalUsage": "総使用量", @@ -249,6 +250,23 @@ "node": "ノード", "deployTo": "デプロイ先", "localPanel": "ローカルパネル", + "fallbacks": { + "title": "フォールバック", + "help": "このインバウンドへの接続がどのクライアントにも一致しない場合、別のインバウンドへルーティングします。下から子インバウンドを選ぶと、ルーティング項目(SNI / ALPN / Path / xver)はその子のトランスポートから自動的に埋められます — ほとんどの構成で追加の調整は不要です。各子インバウンドは 127.0.0.1 で security=none をリッスンする必要があります。", + "empty": "フォールバックはまだありません", + "add": "フォールバックを追加", + "pickInbound": "インバウンドを選択", + "matchAny": "任意", + "rederive": "子から再取得", + "rederived": "子から再取得しました", + "editAdvanced": "ルーティング項目を編集", + "hideAdvanced": "詳細を隠す", + "quickAddAll": "対象のインバウンドをすべて一括追加", + "quickAdded": "{n} 件のフォールバックを追加しました", + "quickAddedNone": "追加可能な新規インバウンドはありません", + "routesWhen": "次の条件でルーティング", + "defaultCatchAll": "デフォルト — その他すべてを捕捉" + }, "protocol": "プロトコル", "port": "ポート", "portMap": "ポートマッピング", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "IP履歴ログ(無効なインバウンドトラフィックを有効にするには、ログをクリアしてください)", "IPLimitlogclear": "ログをクリア", "setDefaultCert": "パネル設定から証明書を設定", + "streamTab": "ストリーム", + "securityTab": "セキュリティ", + "sniffingTab": "スニッフィング", + "sniffingMetadataOnly": "メタデータのみ", + "sniffingRouteOnly": "ルーティングのみ", + "sniffingIpsExcluded": "除外する IP", + "sniffingDomainsExcluded": "除外するドメイン", + "decryption": "復号", + "encryption": "暗号化", + "vlessAuthX25519": "X25519 認証", + "vlessAuthMlkem768": "ML-KEM-768 認証", + "vlessAuthCustom": "カスタム", + "vlessAuthSelected": "選択中: {auth}", + "advanced": { + "title": "インバウンド JSON セクション", + "subtitle": "インバウンド全体の JSON と、settings、sniffing、streamSettings 用の専用エディター。", + "all": "すべて", + "allHelp": "すべてのフィールドを含むインバウンドオブジェクト全体を 1 つのエディターで編集します。", + "settings": "設定", + "settingsHelp": "Xray settings ブロックのラッパー:", + "sniffing": "スニッフィング", + "sniffingHelp": "Xray sniffing ブロックのラッパー:", + "stream": "ストリーム", + "streamHelp": "Xray stream ブロックのラッパー:", + "jsonErrorPrefix": "高度な JSON" + }, "telegramDesc": "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または({'@'}userinfobot)", "subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。", "info": "情報", @@ -365,37 +409,101 @@ } } }, - "client": { - "add": "クライアント追加", - "edit": "クライアント編集", - "submitAdd": "クライアント追加", + "clients": { + "add": "クライアントを追加", + "edit": "クライアントを編集", + "submitAdd": "クライアントを追加", "submitEdit": "変更を保存", "clientCount": "クライアント数", - "bulk": "一括作成", + "bulk": "一括追加", "copyFromInbound": "インバウンドからクライアントをコピー", - "copyToInbound": "クライアントのコピー先", - "copySelected": "選択項目をコピー", - "copySource": "ソース", - "copyEmailPreview": "結果メールのプレビュー", - "copySelectSourceFirst": "先にソースインバウンドを選択してください。", + "copyToInbound": "コピー先", + "copySelected": "選択をコピー", + "copySource": "コピー元", + "copyEmailPreview": "生成されるメールのプレビュー", + "copySelectSourceFirst": "まずコピー元のインバウンドを選択してください。", "copyResult": "コピー結果", - "copyResultSuccess": "正常にコピーされました", - "copyResultNone": "コピーする項目がありません: クライアントが選択されていないかソースが空です", + "copyResultSuccess": "コピーに成功しました", + "copyResultNone": "コピーする対象がありません。クライアントが選択されていないか、コピー元が空です", "copyResultErrors": "コピーエラー", "copyFlowLabel": "新規クライアントの Flow (VLESS)", - "copyFlowHint": "すべてのコピー対象クライアントに適用されます。空のままにするとスキップします。", + "copyFlowHint": "コピーされる全クライアントに適用されます。空欄でスキップします。", "selectAll": "すべて選択", - "clearAll": "すべて解除", - "method": "方法", + "clearAll": "すべてクリア", + "method": "メソッド", "first": "最初", "last": "最後", + "ipLog": "IP ログ", "prefix": "プレフィックス", "postfix": "サフィックス", - "delayedStart": "初回使用後に開始", + "delayedStart": "初回使用から開始", "expireDays": "期間", "days": "日", "renew": "自動更新", - "renewDesc": "期限が切れた後に自動更新。(0 = 無効)(単位:日)" + "renewDesc": "有効期限切れ後に自動更新します。(0 = 無効) (単位: 日)", + "title": "クライアント", + "actions": "操作", + "totalGB": "送受信合計 (GB)", + "expiryTime": "有効期限", + "addClients": "クライアントを追加", + "limitIp": "IP 制限", + "password": "パスワード", + "subId": "サブスクリプション ID", + "online": "オンライン", + "email": "メール", + "comment": "コメント", + "traffic": "トラフィック", + "offline": "オフライン", + "addTitle": "クライアントを追加", + "qrCode": "QR コード", + "moreInformation": "詳細情報", + "delete": "削除", + "reset": "トラフィックをリセット", + "editTitle": "クライアントを編集", + "client": "クライアント", + "enabled": "有効", + "remaining": "残量", + "duration": "期間", + "attachedInbounds": "関連付けされたインバウンド", + "selectInbound": "1 つ以上のインバウンドを選択", + "noSubId": "このクライアントには subId がなく、共有可能なリンクはありません。", + "noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。", + "link": "リンク", + "resetNotPossible": "まずこのクライアントをインバウンドに関連付けてください。", + "general": "一般", + "resetAllTraffics": "すべてのクライアントのトラフィックをリセット", + "resetAllTrafficsTitle": "すべてのクライアントのトラフィックをリセットしますか?", + "resetAllTrafficsContent": "すべてのクライアントの送受信カウンターがゼロにリセットされます。クォータと有効期限には影響しません。元に戻せません。", + "empty": "クライアントはまだいません — 1 つ追加して始めましょう。", + "deleteConfirmTitle": "クライアント {email} を削除しますか?", + "deleteConfirmContent": "クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。", + "deleteSelected": "削除 ({count})", + "bulkDeleteConfirmTitle": "{count} 件のクライアントを削除しますか?", + "bulkDeleteConfirmContent": "選択された各クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。", + "delDepleted": "使い切ったクライアントを削除", + "delDepletedConfirmTitle": "使い切ったクライアントを削除しますか?", + "delDepletedConfirmContent": "トラフィック上限に達したか有効期限が切れたクライアントをすべて削除します。元に戻せません。", + "auth": "Auth", + "hysteriaAuth": "Auth (Hysteria)", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "任意の Reverse tag", + "telegramId": "Telegram ユーザー ID", + "telegramIdPlaceholder": "数値の Telegram ユーザー ID (0 = なし)", + "created": "作成日", + "updated": "更新日", + "ipLimit": "IP 制限", + "toasts": { + "deleted": "クライアントを削除しました", + "trafficReset": "トラフィックをリセットしました", + "allTrafficsReset": "すべてのクライアントのトラフィックをリセットしました", + "bulkDeleted": "{count} 件のクライアントを削除しました", + "bulkDeletedMixed": "{ok} 件削除、{failed} 件失敗", + "bulkCreated": "{count} 件のクライアントを作成しました", + "bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗", + "delDepleted": "使い切った {count} 件のクライアントを削除しました" + } }, "nodes": { "title": "ノード", @@ -428,6 +536,7 @@ "latency": "レイテンシ", "lastHeartbeat": "最後のハートビート", "xrayVersion": "Xrayバージョン", + "panelVersion": "パネルのバージョン", "actions": "操作", "probe": "今すぐプローブ", "testConnection": "接続テスト", @@ -777,9 +886,6 @@ "unexpectIPs": "予期しないIP", "useSystemHosts": "システムのHostsを使用", "useSystemHostsDesc": "インストール済みシステムのhostsファイルを使用する", - "usePreset": "テンプレートを使用", - "dnsPresetTitle": "DNSテンプレート", - "dnsPresetFamily": "ファミリー", "serveStale": "期限切れキャッシュを使用", "serveStaleDesc": "バックグラウンドで更新中に期限切れキャッシュ結果を返す", "serveExpiredTTL": "期限切れTTL", @@ -792,6 +898,9 @@ "hostsEmpty": "Host が定義されていません", "hostsDomain": "ドメイン (例: domain:example.com)", "hostsValues": "IP またはドメイン — 入力して Enter", + "usePreset": "テンプレートを使用", + "dnsPresetTitle": "DNSテンプレート", + "dnsPresetFamily": "ファミリー", "clearAll": "すべて削除", "clearAllTitle": "すべての DNS サーバを削除しますか?", "clearAllConfirm": "リストからすべての DNS サーバが削除されます。この操作は元に戻せません。" @@ -980,4 +1089,4 @@ "chooseInbound": "インバウンドを選択" } } -} +} \ No newline at end of file diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 9f1b67d7..711021a9 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -18,6 +18,8 @@ "search": "Pesquisar", "filter": "Filtrar", "loading": "Carregando...", + "refresh": "Atualizar", + "clear": "Limpar", "second": "Segundo", "minute": "Minuto", "hour": "Hora", @@ -94,6 +96,7 @@ "ultraDark": "Ultra Escuro", "dashboard": "Visão Geral", "inbounds": "Inbounds", + "clients": "Clientes", "nodes": "Nós", "settings": "Panel Settings", "xray": "Xray Configs", @@ -127,9 +130,9 @@ "stopXray": "Parar", "restartXray": "Reiniciar", "xraySwitch": "Versão", + "xrayUpdates": "Atualizações do Xray", "xraySwitchClick": "Escolha a versão para a qual deseja alternar.", "xraySwitchClickDesk": "Escolha com cuidado, pois versões mais antigas podem não ser compatíveis com as configurações atuais.", - "xrayUpdates": "Atualizações do Xray", "updatePanel": "Atualizar painel", "panelUpdateDesc": "Isso atualizará o 3X-UI para a versão mais recente e reiniciará o serviço do painel.", "currentPanelVersion": "Versão atual do painel", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "Isso atualizará todos os arquivos.", "geofilesUpdateAll": "Atualizar tudo", "geofileUpdatePopover": "Geofile atualizado com sucesso", - "dontRefresh": "Instalação em andamento, por favor não atualize a página", - "logs": "Logs", - "config": "Configuração", - "backup": "Backup", - "backupTitle": "Backup & Restauração", - "exportDatabase": "Backup", - "exportDatabaseDesc": "Clique para baixar um arquivo .db contendo um backup do seu banco de dados atual para o seu dispositivo.", - "importDatabase": "Restaurar", - "importDatabaseDesc": "Clique para selecionar e enviar um arquivo .db do seu dispositivo para restaurar seu banco de dados a partir de um backup.", - "importDatabaseSuccess": "O banco de dados foi importado com sucesso", - "importDatabaseError": "Ocorreu um erro ao importar o banco de dados", - "readDatabaseError": "Ocorreu um erro ao ler o banco de dados", - "getDatabaseError": "Ocorreu um erro ao recuperar o banco de dados", - "getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração", "customGeoTitle": "GeoSite / GeoIP personalizados", "customGeoAdd": "Adicionar", "customGeoType": "Tipo", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "Fonte geo personalizada não encontrada", "customGeoErrDownload": "Falha no download", "customGeoErrUpdateAllIncomplete": "Falha ao atualizar uma ou mais fontes geo personalizadas", - "customGeoEmpty": "Ainda não há fontes geo personalizadas — clique em Adicionar para criar uma" + "customGeoEmpty": "Ainda não há fontes geo personalizadas — clique em Adicionar para criar uma", + "dontRefresh": "Instalação em andamento, por favor não atualize a página", + "logs": "Logs", + "config": "Configuração", + "backup": "Backup", + "backupTitle": "Backup & Restauração", + "exportDatabase": "Backup", + "exportDatabaseDesc": "Clique para baixar um arquivo .db contendo um backup do seu banco de dados atual para o seu dispositivo.", + "importDatabase": "Restaurar", + "importDatabaseDesc": "Clique para selecionar e enviar um arquivo .db do seu dispositivo para restaurar seu banco de dados a partir de um backup.", + "importDatabaseSuccess": "O banco de dados foi importado com sucesso", + "importDatabaseError": "Ocorreu um erro ao importar o banco de dados", + "readDatabaseError": "Ocorreu um erro ao ler o banco de dados", + "getDatabaseError": "Ocorreu um erro ao recuperar o banco de dados", + "getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração" }, "inbounds": { - "allTimeTraffic": "Tráfego Total", - "allTimeTrafficUsage": "Uso total de todos os tempos", "title": "Inbounds", "totalDownUp": "Total Enviado/Recebido", "totalUsage": "Uso Total", @@ -249,6 +250,23 @@ "node": "Nó", "deployTo": "Implantar em", "localPanel": "Painel local", + "fallbacks": { + "title": "Fallbacks", + "help": "Quando uma conexão neste inbound não corresponde a nenhum cliente, redirecione-a para outro inbound. Escolha um filho abaixo e os campos de roteamento (SNI / ALPN / Path / xver) são preenchidos automaticamente a partir do transporte dele — a maioria das configurações não precisa de mais ajustes. Cada filho deve escutar em 127.0.0.1 com security=none.", + "empty": "Ainda sem fallbacks", + "add": "Adicionar fallback", + "pickInbound": "Escolha um inbound", + "matchAny": "qualquer", + "rederive": "Preencher a partir do filho", + "rederived": "Preenchido a partir do filho", + "editAdvanced": "Editar campos de roteamento", + "hideAdvanced": "Ocultar avançado", + "quickAddAll": "Adicionar todos os elegíveis", + "quickAdded": "{n} fallback(s) adicionado(s)", + "quickAddedNone": "Nenhum inbound novo elegível para adicionar", + "routesWhen": "Roteia quando", + "defaultCatchAll": "Padrão — captura qualquer outra coisa" + }, "protocol": "Protocolo", "port": "Porta", "portMap": "Porta Mapeada", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "O histórico de IPs. (para ativar o inbound após a desativação, limpe o log)", "IPLimitlogclear": "Limpar o Log", "setDefaultCert": "Definir Certificado pelo Painel", + "streamTab": "Stream", + "securityTab": "Segurança", + "sniffingTab": "Sniffing", + "sniffingMetadataOnly": "Apenas metadados", + "sniffingRouteOnly": "Apenas roteamento", + "sniffingIpsExcluded": "IPs excluídos", + "sniffingDomainsExcluded": "Domínios excluídos", + "decryption": "Descriptografia", + "encryption": "Criptografia", + "vlessAuthX25519": "Autenticação X25519", + "vlessAuthMlkem768": "Autenticação ML-KEM-768", + "vlessAuthCustom": "Personalizado", + "vlessAuthSelected": "Selecionado: {auth}", + "advanced": { + "title": "Seções JSON do inbound", + "subtitle": "JSON completo do inbound e editores específicos para settings, sniffing e streamSettings.", + "all": "Tudo", + "allHelp": "Objeto inbound completo com todos os campos em um único editor.", + "settings": "Configurações", + "settingsHelp": "Wrapper do bloco settings do Xray:", + "sniffing": "Sniffing", + "sniffingHelp": "Wrapper do bloco sniffing do Xray:", + "stream": "Stream", + "streamHelp": "Wrapper do bloco stream do Xray:", + "jsonErrorPrefix": "JSON avançado" + }, "telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)", "subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.", "info": "Informações", @@ -365,37 +409,101 @@ } } }, - "client": { - "add": "Adicionar Cliente", - "edit": "Editar Cliente", - "submitAdd": "Adicionar Cliente", - "submitEdit": "Salvar Alterações", - "clientCount": "Número de Clientes", - "bulk": "Adicionar Vários", - "copyFromInbound": "Copiar clientes da entrada", + "clients": { + "add": "Adicionar cliente", + "edit": "Editar cliente", + "submitAdd": "Adicionar cliente", + "submitEdit": "Salvar alterações", + "clientCount": "Número de clientes", + "bulk": "Adicionar em lote", + "copyFromInbound": "Copiar clientes do inbound", "copyToInbound": "Copiar clientes para", "copySelected": "Copiar selecionados", "copySource": "Origem", - "copyEmailPreview": "Prévia do email resultante", - "copySelectSourceFirst": "Selecione primeiro uma entrada de origem.", + "copyEmailPreview": "Prévia do e-mail resultante", + "copySelectSourceFirst": "Selecione primeiro um inbound de origem.", "copyResult": "Resultado da cópia", "copyResultSuccess": "Copiado com sucesso", - "copyResultNone": "Nada a copiar: nenhum cliente selecionado ou origem vazia", - "copyResultErrors": "Erros ao copiar", - "copyFlowLabel": "Flow para novos clientes (VLESS)", + "copyResultNone": "Nada a copiar: nenhum cliente selecionado ou a origem está vazia", + "copyResultErrors": "Erros de cópia", + "copyFlowLabel": "Flow para os novos clientes (VLESS)", "copyFlowHint": "Aplicado a todos os clientes copiados. Deixe em branco para ignorar.", "selectAll": "Selecionar tudo", "clearAll": "Limpar tudo", "method": "Método", "first": "Primeiro", "last": "Último", + "ipLog": "Registro de IP", "prefix": "Prefixo", "postfix": "Sufixo", - "delayedStart": "Iniciar Após Primeiro Uso", + "delayedStart": "Iniciar após o primeiro uso", "expireDays": "Duração", "days": "Dia(s)", - "renew": "Renovação Automática", - "renewDesc": "Renovação automática após expiração. (0 = desativado)(unidade: dia)" + "renew": "Renovação automática", + "renewDesc": "Renovação automática após a expiração. (0 = desativar) (unidade: dia)", + "title": "Clientes", + "actions": "Ações", + "totalGB": "Total enviado/recebido (GB)", + "expiryTime": "Expiração", + "addClients": "Adicionar clientes", + "limitIp": "Limite de IP", + "password": "Senha", + "subId": "ID da assinatura", + "online": "Online", + "email": "E-mail", + "comment": "Comentário", + "traffic": "Tráfego", + "offline": "Offline", + "addTitle": "Adicionar cliente", + "qrCode": "Código QR", + "moreInformation": "Mais informações", + "delete": "Excluir", + "reset": "Redefinir tráfego", + "editTitle": "Editar cliente", + "client": "Cliente", + "enabled": "Habilitado", + "remaining": "Restante", + "duration": "Duração", + "attachedInbounds": "Inbounds associados", + "selectInbound": "Selecione um ou mais inbounds", + "noSubId": "Este cliente não tem subId, sem link compartilhável.", + "noLinks": "Sem links compartilháveis — associe primeiro este cliente a um inbound compatível com o protocolo.", + "link": "Link", + "resetNotPossible": "Associe primeiro este cliente a um inbound.", + "general": "Geral", + "resetAllTraffics": "Redefinir o tráfego de todos os clientes", + "resetAllTrafficsTitle": "Redefinir o tráfego de todos os clientes?", + "resetAllTrafficsContent": "Os contadores de envio/recebimento de cada cliente vão a zero. Cota e expiração não são afetadas. Não é possível desfazer.", + "empty": "Ainda não há clientes — adicione um para começar.", + "deleteConfirmTitle": "Excluir o cliente {email}?", + "deleteConfirmContent": "Isto remove o cliente de cada inbound associado e descarta o registro de tráfego. Não é possível desfazer.", + "deleteSelected": "Excluir ({count})", + "bulkDeleteConfirmTitle": "Excluir {count} clientes?", + "bulkDeleteConfirmContent": "Cada cliente selecionado é removido dos inbounds associados e o registro de tráfego é descartado. Não é possível desfazer.", + "delDepleted": "Excluir esgotados", + "delDepletedConfirmTitle": "Excluir clientes esgotados?", + "delDepletedConfirmContent": "Remove todos os clientes cuja cota de tráfego foi esgotada ou cuja expiração já passou. Não é possível desfazer.", + "auth": "Auth", + "hysteriaAuth": "Auth do Hysteria", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Reverse tag opcional", + "telegramId": "ID de usuário do Telegram", + "telegramIdPlaceholder": "ID numérico de usuário do Telegram (0 = nenhum)", + "created": "Criado", + "updated": "Atualizado", + "ipLimit": "Limite de IP", + "toasts": { + "deleted": "Cliente excluído", + "trafficReset": "Tráfego redefinido", + "allTrafficsReset": "Tráfego de todos os clientes redefinido", + "bulkDeleted": "{count} clientes excluídos", + "bulkDeletedMixed": "{ok} excluídos, {failed} com falha", + "bulkCreated": "{count} clientes criados", + "bulkCreatedMixed": "{ok} criados, {failed} com falha", + "delDepleted": "{count} clientes esgotados excluídos" + } }, "nodes": { "title": "Nós", @@ -428,6 +536,7 @@ "latency": "Latência", "lastHeartbeat": "Último heartbeat", "xrayVersion": "Versão do Xray", + "panelVersion": "Versão do painel", "actions": "Ações", "probe": "Sondar agora", "testConnection": "Testar conexão", @@ -777,9 +886,6 @@ "unexpectIPs": "IPs inesperados", "useSystemHosts": "Usar Hosts do sistema", "useSystemHostsDesc": "Usar o arquivo hosts de um sistema instalado", - "usePreset": "Usar modelo", - "dnsPresetTitle": "Modelos DNS", - "dnsPresetFamily": "Familiar", "serveStale": "Servir Expirados", "serveStaleDesc": "Retornar resultados expirados do cache enquanto atualiza em segundo plano", "serveExpiredTTL": "TTL de Expirados", @@ -792,6 +898,9 @@ "hostsEmpty": "Nenhum Host definido", "hostsDomain": "Domínio (ex. domain:example.com)", "hostsValues": "IP ou domínio — digite e pressione Enter", + "usePreset": "Usar modelo", + "dnsPresetTitle": "Modelos DNS", + "dnsPresetFamily": "Familiar", "clearAll": "Remover Todos", "clearAllTitle": "Remover todos os servidores DNS?", "clearAllConfirm": "Isso remove todos os servidores DNS da lista. Não pode ser desfeito." @@ -980,4 +1089,4 @@ "chooseInbound": "Escolha um Inbound" } } -} +} \ No newline at end of file diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index f7ddafa6..27d7c129 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -18,6 +18,8 @@ "search": "Поиск", "filter": "Фильтр", "loading": "Загрузка...", + "refresh": "Обновить", + "clear": "Очистить", "second": "Секунда", "minute": "Минута", "hour": "Час", @@ -94,6 +96,7 @@ "ultraDark": "Очень темная", "dashboard": "Дашборд", "inbounds": "Подключения", + "clients": "Клиенты", "nodes": "Узлы", "settings": "Настройки", "xray": "Настройки Xray", @@ -127,9 +130,9 @@ "stopXray": "Остановить", "restartXray": "Перезапустить", "xraySwitch": "Выбор версии", + "xrayUpdates": "Обновления Xray", "xraySwitchClick": "Выберите нужную версию", "xraySwitchClickDesk": "Важно: старые версии могут не поддерживать текущие настройки", - "xrayUpdates": "Обновления Xray", "updatePanel": "Обновить панель", "panelUpdateDesc": "Это обновит 3X-UI до последнего релиза и перезапустит сервис панели.", "currentPanelVersion": "Текущая версия панели", @@ -237,8 +240,6 @@ "getConfigError": "Произошла ошибка при получении конфигурационного файла" }, "inbounds": { - "allTimeTraffic": "Общий трафик", - "allTimeTrafficUsage": "Общее использование за все время", "title": "Подключения", "totalDownUp": "Отправлено/получено", "totalUsage": "Всего трафика", @@ -249,6 +250,23 @@ "node": "Узел", "deployTo": "Развернуть на", "localPanel": "Локальная панель", + "fallbacks": { + "title": "Фолбэки", + "help": "Когда соединение на этом инбаунде не совпадает ни с одним клиентом, оно перенаправляется на другой инбаунд. Выберите дочерний инбаунд ниже — поля маршрутизации (SNI / ALPN / Path / xver) заполнятся автоматически из его транспорта, для большинства конфигураций больше ничего менять не нужно. Каждый дочерний должен слушать на 127.0.0.1 с security=none.", + "empty": "Фолбэков пока нет", + "add": "Добавить фолбэк", + "pickInbound": "Выберите инбаунд", + "matchAny": "любой", + "rederive": "Заполнить из дочернего", + "rederived": "Заполнено из дочернего", + "editAdvanced": "Изменить поля маршрутизации", + "hideAdvanced": "Скрыть расширенные", + "quickAddAll": "Быстро добавить все подходящие", + "quickAdded": "Добавлено {n} фолбэк(ов)", + "quickAddedNone": "Нет новых подходящих инбаундов", + "routesWhen": "Маршрутизирует, когда", + "defaultCatchAll": "По умолчанию — ловит всё остальное" + }, "protocol": "Протокол", "port": "Порт", "portMap": "Порт-маппинг", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "Лог IP-адресов (перед включением лога IP-адресов, вы должны очистить лог)", "IPLimitlogclear": "Очистить лог", "setDefaultCert": "Установить сертификат панели", + "streamTab": "Поток", + "securityTab": "Безопасность", + "sniffingTab": "Сниффинг", + "sniffingMetadataOnly": "Только метаданные", + "sniffingRouteOnly": "Только маршрутизация", + "sniffingIpsExcluded": "Исключённые IP", + "sniffingDomainsExcluded": "Исключённые домены", + "decryption": "Расшифрование", + "encryption": "Шифрование", + "vlessAuthX25519": "Аутентификация X25519", + "vlessAuthMlkem768": "Аутентификация ML-KEM-768", + "vlessAuthCustom": "Свой", + "vlessAuthSelected": "Выбрано: {auth}", + "advanced": { + "title": "Разделы JSON входящего", + "subtitle": "Полный JSON входящего и отдельные редакторы для settings, sniffing и streamSettings.", + "all": "Всё", + "allHelp": "Полный объект входящего со всеми полями в одном редакторе.", + "settings": "Настройки", + "settingsHelp": "Обёртка блока settings Xray:", + "sniffing": "Сниффинг", + "sniffingHelp": "Обёртка блока sniffing Xray:", + "stream": "Поток", + "streamHelp": "Обёртка блока stream Xray:", + "jsonErrorPrefix": "Расширенный JSON" + }, "telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)", "subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'", "info": "Информация", @@ -365,37 +409,101 @@ } } }, - "client": { + "clients": { "add": "Добавить клиента", - "edit": "Редактировать клиента", - "submitAdd": "Добавить", + "edit": "Изменить клиента", + "submitAdd": "Добавить клиента", "submitEdit": "Сохранить изменения", "clientCount": "Количество клиентов", - "bulk": "Добавить несколько", - "copyFromInbound": "Скопировать клиентов из инбаунда", + "bulk": "Массовое добавление", + "copyFromInbound": "Скопировать клиентов из входящего", "copyToInbound": "Скопировать клиентов в", - "copySelected": "Скопировать выбранных", + "copySelected": "Скопировать выбранное", "copySource": "Источник", - "copyEmailPreview": "Предпросмотр итоговых email", - "copySelectSourceFirst": "Сначала выберите источник.", + "copyEmailPreview": "Предпросмотр результирующего email", + "copySelectSourceFirst": "Сначала выберите исходный входящий.", "copyResult": "Результат копирования", - "copyResultSuccess": "Успешно скопировано", - "copyResultNone": "Нечего копировать: ни одного клиента не выбрано или список источника пуст", - "copyResultErrors": "Ошибки при копировании", + "copyResultSuccess": "Скопировано успешно", + "copyResultNone": "Нечего копировать: клиенты не выбраны или источник пуст", + "copyResultErrors": "Ошибки копирования", "copyFlowLabel": "Flow для новых клиентов (VLESS)", - "copyFlowHint": "Применится ко всем копируемым клиентам. Оставьте пустым, чтобы не задавать.", - "selectAll": "Выбрать всех", - "clearAll": "Снять всё", + "copyFlowHint": "Применяется ко всем скопированным клиентам. Оставьте пустым, чтобы пропустить.", + "selectAll": "Выбрать всё", + "clearAll": "Очистить всё", "method": "Метод", "first": "Первый", "last": "Последний", + "ipLog": "Журнал IP", "prefix": "Префикс", "postfix": "Постфикс", - "delayedStart": "Начало использования", + "delayedStart": "Старт после первого использования", "expireDays": "Длительность", - "days": "дней", + "days": "Дни", "renew": "Автопродление", - "renewDesc": "Автопродление после истечения срока действия. (0 = отключить)(единица: день)" + "renewDesc": "Автоматическое продление после окончания. (0 = отключено) (единица: день)", + "title": "Клиенты", + "actions": "Действия", + "totalGB": "Всего отправлено/получено (ГБ)", + "expiryTime": "Срок действия", + "addClients": "Добавить клиентов", + "limitIp": "Лимит IP", + "password": "Пароль", + "subId": "ID подписки", + "online": "В сети", + "email": "Email", + "comment": "Комментарий", + "traffic": "Трафик", + "offline": "Не в сети", + "addTitle": "Добавить клиента", + "qrCode": "QR-код", + "moreInformation": "Подробнее", + "delete": "Удалить", + "reset": "Сбросить трафик", + "editTitle": "Изменить клиента", + "client": "Клиент", + "enabled": "Включён", + "remaining": "Остаток", + "duration": "Длительность", + "attachedInbounds": "Привязанные входящие", + "selectInbound": "Выберите один или несколько входящих", + "noSubId": "У этого клиента нет subId, ссылка для общего доступа недоступна.", + "noLinks": "Нет ссылок для общего доступа — сначала привяжите клиента к входящему с поддерживаемым протоколом.", + "link": "Ссылка", + "resetNotPossible": "Сначала привяжите этого клиента к входящему.", + "general": "Общее", + "resetAllTraffics": "Сбросить трафик всех клиентов", + "resetAllTrafficsTitle": "Сбросить трафик всех клиентов?", + "resetAllTrafficsContent": "Счётчики отправки/приёма всех клиентов сбрасываются в ноль. Квоты и срок действия не затрагиваются. Это действие нельзя отменить.", + "empty": "Клиентов пока нет — добавьте первого, чтобы начать.", + "deleteConfirmTitle": "Удалить клиента {email}?", + "deleteConfirmContent": "Клиент будет удалён из всех привязанных входящих, а его запись трафика будет уничтожена. Это действие нельзя отменить.", + "deleteSelected": "Удалить ({count})", + "bulkDeleteConfirmTitle": "Удалить {count} клиентов?", + "bulkDeleteConfirmContent": "Каждый выбранный клиент удаляется из всех привязанных входящих, его запись трафика уничтожается. Это действие нельзя отменить.", + "delDepleted": "Удалить исчерпанных", + "delDepletedConfirmTitle": "Удалить исчерпанных клиентов?", + "delDepletedConfirmContent": "Удаляются все клиенты, у которых исчерпана квота трафика или истёк срок. Это действие нельзя отменить.", + "auth": "Auth", + "hysteriaAuth": "Auth для Hysteria", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Необязательный Reverse tag", + "telegramId": "ID пользователя Telegram", + "telegramIdPlaceholder": "Числовой ID пользователя Telegram (0 = нет)", + "created": "Создан", + "updated": "Обновлён", + "ipLimit": "Лимит IP", + "toasts": { + "deleted": "Клиент удалён", + "trafficReset": "Трафик сброшен", + "allTrafficsReset": "Трафик всех клиентов сброшен", + "bulkDeleted": "Удалено клиентов: {count}", + "bulkDeletedMixed": "Удалено: {ok}, не удалось: {failed}", + "bulkCreated": "Создано клиентов: {count}", + "bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}", + "delDepleted": "Удалено исчерпанных клиентов: {count}" + } }, "nodes": { "title": "Узлы", @@ -428,6 +536,7 @@ "latency": "Задержка", "lastHeartbeat": "Последний пинг", "xrayVersion": "Версия Xray", + "panelVersion": "Версия панели", "actions": "Действия", "probe": "Проверить сейчас", "testConnection": "Проверить соединение", @@ -777,9 +886,6 @@ "unexpectIPs": "Неожидаемые IP", "useSystemHosts": "Использовать системные Hosts", "useSystemHostsDesc": "Использовать файл hosts из установленной системы", - "usePreset": "Использовать шаблон", - "dnsPresetTitle": "Шаблоны DNS", - "dnsPresetFamily": "Семейный", "serveStale": "Использовать устаревшие", "serveStaleDesc": "Возвращать устаревшие результаты из кэша во время обновления в фоне", "serveExpiredTTL": "TTL устаревших", @@ -792,6 +898,9 @@ "hostsEmpty": "Host не определены", "hostsDomain": "Домен (напр. domain:example.com)", "hostsValues": "IP или домен — введите и нажмите Enter", + "usePreset": "Использовать шаблон", + "dnsPresetTitle": "Шаблоны DNS", + "dnsPresetFamily": "Семейный", "clearAll": "Удалить все", "clearAllTitle": "Удалить все DNS-серверы?", "clearAllConfirm": "Все DNS-серверы будут удалены из списка. Это действие нельзя отменить." @@ -980,4 +1089,4 @@ "chooseInbound": "Выберите входящее подключение" } } -} +} \ No newline at end of file diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index a8dc2c3c..0999658b 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -18,6 +18,8 @@ "search": "Ara", "filter": "Filtrele", "loading": "Yükleniyor...", + "refresh": "Yenile", + "clear": "Temizle", "second": "Saniye", "minute": "Dakika", "hour": "Saat", @@ -94,6 +96,7 @@ "ultraDark": "Ultra Koyu", "dashboard": "Genel Bakış", "inbounds": "Gelenler", + "clients": "İstemciler", "nodes": "Düğümler", "settings": "Panel Ayarları", "xray": "Xray Yapılandırmaları", @@ -127,9 +130,9 @@ "stopXray": "Durdur", "restartXray": "Yeniden Başlat", "xraySwitch": "Sürüm", + "xrayUpdates": "Xray Güncellemeleri", "xraySwitchClick": "Geçiş yapmak istediğiniz sürümü seçin.", "xraySwitchClickDesk": "Dikkatli seçin, eski sürümler mevcut yapılandırmalarla uyumlu olmayabilir.", - "xrayUpdates": "Xray Güncellemeleri", "updatePanel": "Paneli Güncelle", "panelUpdateDesc": "Bu, 3X-UI'yi en son sürüme güncelleyecek ve panel servisini yeniden başlatacaktır.", "currentPanelVersion": "Mevcut panel sürümü", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "Bu, tüm dosyaları güncelleyecektir.", "geofilesUpdateAll": "Tümünü güncelle", "geofileUpdatePopover": "Geofile başarıyla güncellendi", - "dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin", - "logs": "Günlükler", - "config": "Yapılandırma", - "backup": "Yedek", - "backupTitle": "Yedekleme & Geri Yükleme", - "exportDatabase": "Yedekle", - "exportDatabaseDesc": "Mevcut veritabanınızın yedeğini içeren bir .db dosyasını cihazınıza indirmek için tıklayın.", - "importDatabase": "Geri Yükle", - "importDatabaseDesc": "Cihazınızdan bir .db dosyası seçip yükleyerek veritabanınızı yedekten geri yüklemek için tıklayın.", - "importDatabaseSuccess": "Veritabanı başarıyla içe aktarıldı", - "importDatabaseError": "Veritabanı içe aktarılırken bir hata oluştu", - "readDatabaseError": "Veritabanı okunurken bir hata oluştu", - "getDatabaseError": "Veritabanı alınırken bir hata oluştu", - "getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu", "customGeoTitle": "Özel GeoSite / GeoIP", "customGeoAdd": "Ekle", "customGeoType": "Tür", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "Özel geo kaynağı bulunamadı", "customGeoErrDownload": "İndirme başarısız", "customGeoErrUpdateAllIncomplete": "Bir veya daha fazla özel geo kaynağı güncellenemedi", - "customGeoEmpty": "Henüz özel geo kaynağı yok — oluşturmak için Ekle'ye tıklayın" + "customGeoEmpty": "Henüz özel geo kaynağı yok — oluşturmak için Ekle'ye tıklayın", + "dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin", + "logs": "Günlükler", + "config": "Yapılandırma", + "backup": "Yedek", + "backupTitle": "Yedekleme & Geri Yükleme", + "exportDatabase": "Yedekle", + "exportDatabaseDesc": "Mevcut veritabanınızın yedeğini içeren bir .db dosyasını cihazınıza indirmek için tıklayın.", + "importDatabase": "Geri Yükle", + "importDatabaseDesc": "Cihazınızdan bir .db dosyası seçip yükleyerek veritabanınızı yedekten geri yüklemek için tıklayın.", + "importDatabaseSuccess": "Veritabanı başarıyla içe aktarıldı", + "importDatabaseError": "Veritabanı içe aktarılırken bir hata oluştu", + "readDatabaseError": "Veritabanı okunurken bir hata oluştu", + "getDatabaseError": "Veritabanı alınırken bir hata oluştu", + "getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu" }, "inbounds": { - "allTimeTraffic": "Toplam Trafik", - "allTimeTrafficUsage": "Tüm Zamanların Toplam Kullanımı", "title": "Gelenler", "totalDownUp": "Toplam Gönderilen/Alınan", "totalUsage": "Toplam Kullanım", @@ -249,6 +250,23 @@ "node": "Düğüm", "deployTo": "Şuraya dağıt", "localPanel": "Yerel panel", + "fallbacks": { + "title": "Fallback'ler", + "help": "Bu inbound üzerindeki bir bağlantı hiçbir client ile eşleşmediğinde, başka bir inbound'a yönlendirilir. Aşağıdan bir child seçin; yönlendirme alanları (SNI / ALPN / Path / xver) onun transport'undan otomatik dolar — çoğu kurulum için ek ayar gerekmez. Her child 127.0.0.1 üzerinde security=none ile dinlemelidir.", + "empty": "Henüz fallback yok", + "add": "Fallback ekle", + "pickInbound": "Bir inbound seç", + "matchAny": "herhangi", + "rederive": "Child'dan yeniden doldur", + "rederived": "Child'dan yeniden dolduruldu", + "editAdvanced": "Yönlendirme alanlarını düzenle", + "hideAdvanced": "Gelişmişi gizle", + "quickAddAll": "Uygun olan tümünü hızlı ekle", + "quickAdded": "{n} fallback eklendi", + "quickAddedNone": "Eklenecek yeni uygun inbound yok", + "routesWhen": "Şu durumda yönlendirir", + "defaultCatchAll": "Varsayılan — başka her şeyi yakalar" + }, "protocol": "Protokol", "port": "Port", "portMap": "Port Atama", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "IP geçmiş günlüğü. (devre dışı bırakıldıktan sonra gelini etkinleştirmek için günlüğü temizleyin)", "IPLimitlogclear": "Günlüğü Temizle", "setDefaultCert": "Panelden Sertifikayı Ayarla", + "streamTab": "Akış", + "securityTab": "Güvenlik", + "sniffingTab": "Sniffing", + "sniffingMetadataOnly": "Yalnızca üst veri", + "sniffingRouteOnly": "Yalnızca yönlendirme", + "sniffingIpsExcluded": "Hariç tutulan IP'ler", + "sniffingDomainsExcluded": "Hariç tutulan alan adları", + "decryption": "Şifre çözme", + "encryption": "Şifreleme", + "vlessAuthX25519": "X25519 kimlik doğrulama", + "vlessAuthMlkem768": "ML-KEM-768 kimlik doğrulama", + "vlessAuthCustom": "Özel", + "vlessAuthSelected": "Seçili: {auth}", + "advanced": { + "title": "Inbound JSON bölümleri", + "subtitle": "Tam inbound JSON'u ve settings, sniffing, streamSettings için odaklanmış düzenleyiciler.", + "all": "Tümü", + "allHelp": "Tüm alanları tek bir düzenleyicide içeren tam inbound nesnesi.", + "settings": "Ayarlar", + "settingsHelp": "Xray settings bloğunun sarmalayıcısı:", + "sniffing": "Sniffing", + "sniffingHelp": "Xray sniffing bloğunun sarmalayıcısı:", + "stream": "Akış", + "streamHelp": "Xray stream bloğunun sarmalayıcısı:", + "jsonErrorPrefix": "Gelişmiş JSON" + }, "telegramDesc": "Lütfen Telegram Sohbet Kimliği sağlayın. (botta '/id' komutunu kullanın) veya ({'@'}userinfobot)", "subscriptionDesc": "Abonelik URL'inizi bulmak için 'Detaylar'a gidin. Ayrıca, aynı adı birden fazla müşteri için kullanabilirsiniz.", "info": "Bilgi", @@ -365,37 +409,101 @@ } } }, - "client": { - "add": "Müşteri Ekle", - "edit": "Müşteriyi Düzenle", - "submitAdd": "Müşteri Ekle", - "submitEdit": "Değişiklikleri Kaydet", - "clientCount": "Müşteri Sayısı", - "bulk": "Toplu Ekle", - "copyFromInbound": "Gelen bağlantıdan istemcileri kopyala", - "copyToInbound": "İstemcileri şuraya kopyala", - "copySelected": "Seçilenleri kopyala", + "clients": { + "add": "İstemci ekle", + "edit": "İstemciyi düzenle", + "submitAdd": "İstemci ekle", + "submitEdit": "Değişiklikleri kaydet", + "clientCount": "İstemci sayısı", + "bulk": "Toplu ekle", + "copyFromInbound": "Inbound'dan istemcileri kopyala", + "copyToInbound": "İstemcileri kopyalanacak yer", + "copySelected": "Seçileni kopyala", "copySource": "Kaynak", - "copyEmailPreview": "Sonuç e-posta önizlemesi", - "copySelectSourceFirst": "Önce bir kaynak gelen bağlantı seçin.", - "copyResult": "Kopyalama sonucu", + "copyEmailPreview": "Oluşacak e-posta önizlemesi", + "copySelectSourceFirst": "Önce bir kaynak inbound seçin.", + "copyResult": "Kopya sonucu", "copyResultSuccess": "Başarıyla kopyalandı", - "copyResultNone": "Kopyalanacak bir şey yok: istemci seçilmedi veya kaynak boş", + "copyResultNone": "Kopyalanacak bir şey yok: istemci seçilmemiş veya kaynak boş", "copyResultErrors": "Kopyalama hataları", "copyFlowLabel": "Yeni istemciler için Flow (VLESS)", - "copyFlowHint": "Kopyalanan tüm istemcilere uygulanır. Boş bırakırsanız atlanır.", + "copyFlowHint": "Kopyalanan tüm istemcilere uygulanır. Atlamak için boş bırakın.", "selectAll": "Tümünü seç", "clearAll": "Tümünü temizle", "method": "Yöntem", "first": "İlk", "last": "Son", + "ipLog": "IP günlüğü", "prefix": "Önek", "postfix": "Sonek", - "delayedStart": "İlk Kullanımdan Sonra Başlat", + "delayedStart": "İlk kullanımdan sonra başla", "expireDays": "Süre", "days": "Gün", - "renew": "Otomatik Yenile", - "renewDesc": "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)" + "renew": "Otomatik yenileme", + "renewDesc": "Süre dolduktan sonra otomatik yenileme. (0 = devre dışı) (birim: gün)", + "title": "İstemciler", + "actions": "Eylemler", + "totalGB": "Toplam Gönderilen/Alınan (GB)", + "expiryTime": "Son kullanma", + "addClients": "İstemci ekle", + "limitIp": "IP limiti", + "password": "Şifre", + "subId": "Abonelik ID'si", + "online": "Çevrimiçi", + "email": "E-posta", + "comment": "Yorum", + "traffic": "Trafik", + "offline": "Çevrimdışı", + "addTitle": "İstemci ekle", + "qrCode": "QR kodu", + "moreInformation": "Daha fazla bilgi", + "delete": "Sil", + "reset": "Trafiği sıfırla", + "editTitle": "İstemciyi düzenle", + "client": "İstemci", + "enabled": "Etkin", + "remaining": "Kalan", + "duration": "Süre", + "attachedInbounds": "Bağlı inbound'lar", + "selectInbound": "Bir veya daha fazla inbound seçin", + "noSubId": "Bu istemcinin subId'si yok, paylaşılabilir bağlantı yok.", + "noLinks": "Paylaşılabilir bağlantı yok — önce bu istemciyi protokol destekli bir inbound'a bağlayın.", + "link": "Bağlantı", + "resetNotPossible": "Önce bu istemciyi bir inbound'a bağlayın.", + "general": "Genel", + "resetAllTraffics": "Tüm istemcilerin trafiğini sıfırla", + "resetAllTrafficsTitle": "Tüm istemcilerin trafiği sıfırlansın mı?", + "resetAllTrafficsContent": "Her istemcinin yükleme/indirme sayaçları sıfırlanır. Kotalar ve son kullanma tarihleri etkilenmez. Geri alınamaz.", + "empty": "Henüz istemci yok — başlamak için bir tane ekleyin.", + "deleteConfirmTitle": "{email} istemcisi silinsin mi?", + "deleteConfirmContent": "Bu işlem istemciyi bağlı tüm inbound'lardan kaldırır ve trafik kaydını siler. Geri alınamaz.", + "deleteSelected": "Sil ({count})", + "bulkDeleteConfirmTitle": "{count} istemci silinsin mi?", + "bulkDeleteConfirmContent": "Seçili her istemci bağlı tüm inbound'lardan kaldırılır ve trafik kaydı silinir. Geri alınamaz.", + "delDepleted": "Tükenmişleri sil", + "delDepletedConfirmTitle": "Tükenmiş istemciler silinsin mi?", + "delDepletedConfirmContent": "Trafik kotası dolan veya süresi geçen tüm istemciler silinir. Geri alınamaz.", + "auth": "Auth", + "hysteriaAuth": "Hysteria Auth", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "İsteğe bağlı Reverse tag", + "telegramId": "Telegram kullanıcı ID'si", + "telegramIdPlaceholder": "Sayısal Telegram kullanıcı ID'si (0 = yok)", + "created": "Oluşturuldu", + "updated": "Güncellendi", + "ipLimit": "IP limiti", + "toasts": { + "deleted": "İstemci silindi", + "trafficReset": "Trafik sıfırlandı", + "allTrafficsReset": "Tüm istemcilerin trafiği sıfırlandı", + "bulkDeleted": "{count} istemci silindi", + "bulkDeletedMixed": "{ok} silindi, {failed} başarısız", + "bulkCreated": "{count} istemci oluşturuldu", + "bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız", + "delDepleted": "{count} tükenmiş istemci silindi" + } }, "nodes": { "title": "Düğümler", @@ -428,6 +536,7 @@ "latency": "Gecikme", "lastHeartbeat": "Son Sinyal", "xrayVersion": "Xray Sürümü", + "panelVersion": "Panel sürümü", "actions": "İşlemler", "probe": "Şimdi Test Et", "testConnection": "Bağlantıyı Test Et", @@ -777,9 +886,6 @@ "unexpectIPs": "Beklenmeyen IP'ler", "useSystemHosts": "Sistem Hosts'larını Kullan", "useSystemHostsDesc": "Yüklü bir sistemden hosts dosyasını kullan", - "usePreset": "Şablon kullan", - "dnsPresetTitle": "DNS Şablonları", - "dnsPresetFamily": "Aile", "serveStale": "Süresi Dolmuş Sonuçları Sun", "serveStaleDesc": "Arka planda yenilenirken süresi dolmuş önbellek sonuçlarını döndür", "serveExpiredTTL": "Süresi Dolmuş TTL", @@ -792,6 +898,9 @@ "hostsEmpty": "Tanımlı Host yok", "hostsDomain": "Alan adı (ör. domain:example.com)", "hostsValues": "IP veya alan adı — yazıp Enter'a basın", + "usePreset": "Şablon kullan", + "dnsPresetTitle": "DNS Şablonları", + "dnsPresetFamily": "Aile", "clearAll": "Tümünü Sil", "clearAllTitle": "Tüm DNS sunucularını sil?", "clearAllConfirm": "Bu, tüm DNS sunucularını listeden kaldırır. Geri alınamaz." @@ -980,4 +1089,4 @@ "chooseInbound": "Bir Gelen Seçin" } } -} +} \ No newline at end of file diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index 417866d1..cb9f789b 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -18,6 +18,8 @@ "search": "Пошук", "filter": "Фільтр", "loading": "Завантаження...", + "refresh": "Оновити", + "clear": "Очистити", "second": "Секунда", "minute": "Хвилина", "hour": "Година", @@ -94,6 +96,7 @@ "ultraDark": "Ультра темна", "dashboard": "Огляд", "inbounds": "Вхідні", + "clients": "Клієнти", "nodes": "Вузли", "settings": "Параметри панелі", "xray": "Конфігурації Xray", @@ -127,9 +130,9 @@ "stopXray": "Зупинити", "restartXray": "Перезапустити", "xraySwitch": "Версія", + "xrayUpdates": "Оновлення Xray", "xraySwitchClick": "Виберіть версію, на яку ви хочете перейти.", "xraySwitchClickDesk": "Вибирайте уважно, оскільки старіші версії можуть бути несумісними з поточними конфігураціями.", - "xrayUpdates": "Оновлення Xray", "updatePanel": "Оновити панель", "panelUpdateDesc": "Це оновить 3X-UI до останнього релізу та перезапустить сервіс панелі.", "currentPanelVersion": "Поточна версія панелі", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "Це оновить усі геофайли.", "geofilesUpdateAll": "Оновити все", "geofileUpdatePopover": "Геофайл успішно оновлено", - "dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку", - "logs": "Журнали", - "config": "Конфігурація", - "backup": "Резервна копія", - "backupTitle": "Резервне копіювання та відновлення", - "exportDatabase": "Резервна копія", - "exportDatabaseDesc": "Натисніть, щоб завантажити файл .db, що містить резервну копію вашої поточної бази даних на ваш пристрій.", - "importDatabase": "Відновити", - "importDatabaseDesc": "Натисніть, щоб вибрати та завантажити файл .db з вашого пристрою для відновлення бази даних з резервної копії.", - "importDatabaseSuccess": "Базу даних успішно імпортовано", - "importDatabaseError": "Виникла помилка під час імпорту бази даних", - "readDatabaseError": "Виникла помилка під час читання бази даних", - "getDatabaseError": "Виникла помилка під час отримання бази даних", - "getConfigError": "Виникла помилка під час отримання файлу конфігурації", "customGeoTitle": "Користувацькі GeoSite / GeoIP", "customGeoAdd": "Додати", "customGeoType": "Тип", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "Джерело geo не знайдено", "customGeoErrDownload": "Помилка завантаження", "customGeoErrUpdateAllIncomplete": "Не вдалося оновити один або кілька користувацьких джерел", - "customGeoEmpty": "Користувацьких джерел geo поки немає — натисніть «Додати», щоб створити" + "customGeoEmpty": "Користувацьких джерел geo поки немає — натисніть «Додати», щоб створити", + "dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку", + "logs": "Журнали", + "config": "Конфігурація", + "backup": "Резервна копія", + "backupTitle": "Резервне копіювання та відновлення", + "exportDatabase": "Резервна копія", + "exportDatabaseDesc": "Натисніть, щоб завантажити файл .db, що містить резервну копію вашої поточної бази даних на ваш пристрій.", + "importDatabase": "Відновити", + "importDatabaseDesc": "Натисніть, щоб вибрати та завантажити файл .db з вашого пристрою для відновлення бази даних з резервної копії.", + "importDatabaseSuccess": "Базу даних успішно імпортовано", + "importDatabaseError": "Виникла помилка під час імпорту бази даних", + "readDatabaseError": "Виникла помилка під час читання бази даних", + "getDatabaseError": "Виникла помилка під час отримання бази даних", + "getConfigError": "Виникла помилка під час отримання файлу конфігурації" }, "inbounds": { - "allTimeTraffic": "Загальний трафік", - "allTimeTrafficUsage": "Загальне використання за весь час", "title": "Вхідні", "totalDownUp": "Всього надісланих/отриманих", "totalUsage": "Всього використанно", @@ -249,6 +250,23 @@ "node": "Вузол", "deployTo": "Розгорнути на", "localPanel": "Локальна панель", + "fallbacks": { + "title": "Фолбеки", + "help": "Коли з'єднання на цьому інбаунді не збігається з жодним клієнтом, воно перенаправляється на інший інбаунд. Оберіть дочірній інбаунд нижче — поля маршрутизації (SNI / ALPN / Path / xver) заповняться автоматично з його транспорту; для більшості налаштувань більше нічого змінювати не треба. Кожен дочірній має слухати на 127.0.0.1 з security=none.", + "empty": "Фолбеків поки немає", + "add": "Додати фолбек", + "pickInbound": "Оберіть інбаунд", + "matchAny": "будь-який", + "rederive": "Заповнити з дочірнього", + "rederived": "Заповнено з дочірнього", + "editAdvanced": "Редагувати поля маршрутизації", + "hideAdvanced": "Сховати розширені", + "quickAddAll": "Швидко додати всі придатні", + "quickAdded": "Додано {n} фолбек(ів)", + "quickAddedNone": "Немає нових придатних інбаундів", + "routesWhen": "Маршрутизує, коли", + "defaultCatchAll": "За замовчуванням — ловить усе інше" + }, "protocol": "Протокол", "port": "Порт", "portMap": "Порт-перехід", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "Журнал історії IP-адрес. (щоб увімкнути вхідну після вимкнення, очистіть журнал)", "IPLimitlogclear": "Очистити журнал", "setDefaultCert": "Установити сертифікат з панелі", + "streamTab": "Потік", + "securityTab": "Безпека", + "sniffingTab": "Сніфінг", + "sniffingMetadataOnly": "Лише метадані", + "sniffingRouteOnly": "Лише маршрутизація", + "sniffingIpsExcluded": "Виключені IP", + "sniffingDomainsExcluded": "Виключені домени", + "decryption": "Розшифрування", + "encryption": "Шифрування", + "vlessAuthX25519": "Автентифікація X25519", + "vlessAuthMlkem768": "Автентифікація ML-KEM-768", + "vlessAuthCustom": "Користувацький", + "vlessAuthSelected": "Вибрано: {auth}", + "advanced": { + "title": "Розділи JSON вхідного", + "subtitle": "Повний JSON вхідного та окремі редактори для settings, sniffing і streamSettings.", + "all": "Усе", + "allHelp": "Повний об'єкт вхідного з усіма полями в одному редакторі.", + "settings": "Налаштування", + "settingsHelp": "Обгортка блоку settings Xray:", + "sniffing": "Сніфінг", + "sniffingHelp": "Обгортка блоку sniffing Xray:", + "stream": "Потік", + "streamHelp": "Обгортка блоку stream Xray:", + "jsonErrorPrefix": "Розширений JSON" + }, "telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)", "subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.", "info": "Інформація", @@ -365,37 +409,101 @@ } } }, - "client": { + "clients": { "add": "Додати клієнта", "edit": "Редагувати клієнта", "submitAdd": "Додати клієнта", "submitEdit": "Зберегти зміни", "clientCount": "Кількість клієнтів", - "bulk": "Додати групу", - "copyFromInbound": "Скопіювати клієнтів з інбаунда", + "bulk": "Масове додавання", + "copyFromInbound": "Скопіювати клієнтів із вхідного", "copyToInbound": "Скопіювати клієнтів у", - "copySelected": "Скопіювати вибраних", + "copySelected": "Скопіювати вибране", "copySource": "Джерело", - "copyEmailPreview": "Попередній перегляд підсумкових email", - "copySelectSourceFirst": "Спочатку виберіть джерело.", + "copyEmailPreview": "Перегляд email, що буде створено", + "copySelectSourceFirst": "Спочатку виберіть вхідний-джерело.", "copyResult": "Результат копіювання", - "copyResultSuccess": "Успішно скопійовано", - "copyResultNone": "Нічого копіювати: жодного клієнта не вибрано або список джерела порожній", - "copyResultErrors": "Помилки під час копіювання", + "copyResultSuccess": "Скопійовано успішно", + "copyResultNone": "Нічого копіювати: не вибрано клієнтів або джерело порожнє", + "copyResultErrors": "Помилки копіювання", "copyFlowLabel": "Flow для нових клієнтів (VLESS)", - "copyFlowHint": "Застосується до всіх скопійованих клієнтів. Залиште порожнім, щоб не задавати.", - "selectAll": "Вибрати всіх", - "clearAll": "Зняти все", + "copyFlowHint": "Застосовується до всіх скопійованих клієнтів. Залишіть порожнім, щоб пропустити.", + "selectAll": "Вибрати все", + "clearAll": "Очистити все", "method": "Метод", "first": "Перший", "last": "Останній", + "ipLog": "Журнал IP", "prefix": "Префікс", "postfix": "Постфікс", - "delayedStart": "Початок використання", + "delayedStart": "Запуск після першого використання", "expireDays": "Тривалість", - "days": "Дні(в)", - "renew": "Автоматичне оновлення", - "renewDesc": "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)" + "days": "Дні", + "renew": "Авто-продовження", + "renewDesc": "Автоматичне продовження після закінчення. (0 = вимкнено) (одиниця: день)", + "title": "Клієнти", + "actions": "Дії", + "totalGB": "Усього надіслано/отримано (ГБ)", + "expiryTime": "Термін дії", + "addClients": "Додати клієнтів", + "limitIp": "Ліміт IP", + "password": "Пароль", + "subId": "ID підписки", + "online": "У мережі", + "email": "Email", + "comment": "Коментар", + "traffic": "Трафік", + "offline": "Не в мережі", + "addTitle": "Додати клієнта", + "qrCode": "QR-код", + "moreInformation": "Докладніше", + "delete": "Видалити", + "reset": "Скинути трафік", + "editTitle": "Редагувати клієнта", + "client": "Клієнт", + "enabled": "Увімкнено", + "remaining": "Залишок", + "duration": "Тривалість", + "attachedInbounds": "Прив'язані вхідні", + "selectInbound": "Виберіть один або кілька вхідних", + "noSubId": "У цього клієнта немає subId, посилання для спільного доступу відсутнє.", + "noLinks": "Немає посилань для спільного доступу — спочатку прив'яжіть цього клієнта до вхідного з підтримкою протоколу.", + "link": "Посилання", + "resetNotPossible": "Спочатку прив'яжіть цього клієнта до вхідного.", + "general": "Загальне", + "resetAllTraffics": "Скинути трафік усіх клієнтів", + "resetAllTrafficsTitle": "Скинути трафік усіх клієнтів?", + "resetAllTrafficsContent": "Лічильники відправлення/отримання кожного клієнта обнулюються. Квоти й термін дії не змінюються. Цю дію неможливо скасувати.", + "empty": "Клієнтів ще немає — додайте першого, щоб почати.", + "deleteConfirmTitle": "Видалити клієнта {email}?", + "deleteConfirmContent": "Клієнт буде вилучений з усіх прив'язаних вхідних, його запис трафіку буде знищено. Цю дію неможливо скасувати.", + "deleteSelected": "Видалити ({count})", + "bulkDeleteConfirmTitle": "Видалити {count} клієнтів?", + "bulkDeleteConfirmContent": "Кожен вибраний клієнт вилучається з усіх прив'язаних вхідних, його запис трафіку знищується. Цю дію неможливо скасувати.", + "delDepleted": "Видалити вичерпаних", + "delDepletedConfirmTitle": "Видалити вичерпаних клієнтів?", + "delDepletedConfirmContent": "Видаляються всі клієнти, у яких вичерпана квота трафіку або сплив термін. Цю дію неможливо скасувати.", + "auth": "Auth", + "hysteriaAuth": "Auth для Hysteria", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Необов'язковий Reverse tag", + "telegramId": "ID користувача Telegram", + "telegramIdPlaceholder": "Числовий ID користувача Telegram (0 = немає)", + "created": "Створено", + "updated": "Оновлено", + "ipLimit": "Ліміт IP", + "toasts": { + "deleted": "Клієнта видалено", + "trafficReset": "Трафік скинуто", + "allTrafficsReset": "Трафік усіх клієнтів скинуто", + "bulkDeleted": "Видалено клієнтів: {count}", + "bulkDeletedMixed": "Видалено: {ok}, не вдалось: {failed}", + "bulkCreated": "Створено клієнтів: {count}", + "bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}", + "delDepleted": "Видалено вичерпаних клієнтів: {count}" + } }, "nodes": { "title": "Вузли", @@ -428,6 +536,7 @@ "latency": "Затримка", "lastHeartbeat": "Останній пінг", "xrayVersion": "Версія Xray", + "panelVersion": "Версія панелі", "actions": "Дії", "probe": "Перевірити зараз", "testConnection": "Перевірити з'єднання", @@ -777,9 +886,6 @@ "unexpectIPs": "Неочікувані IP", "useSystemHosts": "Використовувати системні Hosts", "useSystemHostsDesc": "Використовувати файл hosts з встановленої системи", - "usePreset": "Використати шаблон", - "dnsPresetTitle": "Шаблони DNS", - "dnsPresetFamily": "Сімейний", "serveStale": "Видавати застарілі", "serveStaleDesc": "Повертати застарілі результати з кешу під час фонового оновлення", "serveExpiredTTL": "TTL застарілих", @@ -792,6 +898,9 @@ "hostsEmpty": "Host не визначено", "hostsDomain": "Домен (напр. domain:example.com)", "hostsValues": "IP або домен — введіть і натисніть Enter", + "usePreset": "Використати шаблон", + "dnsPresetTitle": "Шаблони DNS", + "dnsPresetFamily": "Сімейний", "clearAll": "Видалити всі", "clearAllTitle": "Видалити всі DNS-сервери?", "clearAllConfirm": "Усі DNS-сервери буде видалено зі списку. Дію не можна скасувати." @@ -980,4 +1089,4 @@ "chooseInbound": "Виберіть Вхідний" } } -} +} \ No newline at end of file diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index b292c058..b2ffa275 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -18,6 +18,8 @@ "search": "Tìm kiếm", "filter": "Bộ lọc", "loading": "Đang tải", + "refresh": "Làm mới", + "clear": "Xóa", "second": "Giây", "minute": "Phút", "hour": "Giờ", @@ -94,11 +96,12 @@ "ultraDark": "Siêu tối", "dashboard": "Trạng thái hệ thống", "inbounds": "Đầu vào khách hàng", + "clients": "Khách hàng", "nodes": "Nút", "settings": "Cài đặt bảng điều khiển", - "logout": "Đăng xuất", "xray": "Cài đặt Xray", "apiDocs": "Tài liệu API", + "logout": "Đăng xuất", "link": "Quản lý" }, "pages": { @@ -127,9 +130,9 @@ "stopXray": "Dừng lại", "restartXray": "Khởi động lại", "xraySwitch": "Phiên bản", + "xrayUpdates": "Cập nhật Xray", "xraySwitchClick": "Chọn phiên bản mà bạn muốn chuyển đổi sang.", "xraySwitchClickDesk": "Hãy lựa chọn thận trọng, vì các phiên bản cũ có thể không tương thích với các cấu hình hiện tại.", - "xrayUpdates": "Cập nhật Xray", "updatePanel": "Cập nhật Panel", "panelUpdateDesc": "Điều này sẽ cập nhật 3X-UI lên bản phát hành mới nhất và khởi động lại dịch vụ panel.", "currentPanelVersion": "Phiên bản panel hiện tại", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "Thao tác này sẽ cập nhật tất cả các tập tin.", "geofilesUpdateAll": "Cập nhật tất cả", "geofileUpdatePopover": "Geofile đã được cập nhật thành công", - "dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.", - "logs": "Nhật ký", - "config": "Cấu hình", - "backup": "Sao lưu", - "backupTitle": "Sao lưu & Khôi phục", - "exportDatabase": "Sao lưu", - "exportDatabaseDesc": "Nhấp để tải xuống tệp .db chứa bản sao lưu cơ sở dữ liệu hiện tại của bạn vào thiết bị.", - "importDatabase": "Khôi phục", - "importDatabaseDesc": "Nhấp để chọn và tải lên tệp .db từ thiết bị của bạn để khôi phục cơ sở dữ liệu từ bản sao lưu.", - "importDatabaseSuccess": "Đã nhập cơ sở dữ liệu thành công", - "importDatabaseError": "Lỗi xảy ra khi nhập cơ sở dữ liệu", - "readDatabaseError": "Lỗi xảy ra khi đọc cơ sở dữ liệu", - "getDatabaseError": "Lỗi xảy ra khi truy xuất cơ sở dữ liệu", - "getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình", "customGeoTitle": "GeoSite / GeoIP tùy chỉnh", "customGeoAdd": "Thêm", "customGeoType": "Loại", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "Không tìm thấy nguồn geo tùy chỉnh", "customGeoErrDownload": "Tải xuống thất bại", "customGeoErrUpdateAllIncomplete": "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được", - "customGeoEmpty": "Chưa có nguồn geo tùy chỉnh nào — nhấp Thêm để tạo" + "customGeoEmpty": "Chưa có nguồn geo tùy chỉnh nào — nhấp Thêm để tạo", + "dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.", + "logs": "Nhật ký", + "config": "Cấu hình", + "backup": "Sao lưu", + "backupTitle": "Sao lưu & Khôi phục", + "exportDatabase": "Sao lưu", + "exportDatabaseDesc": "Nhấp để tải xuống tệp .db chứa bản sao lưu cơ sở dữ liệu hiện tại của bạn vào thiết bị.", + "importDatabase": "Khôi phục", + "importDatabaseDesc": "Nhấp để chọn và tải lên tệp .db từ thiết bị của bạn để khôi phục cơ sở dữ liệu từ bản sao lưu.", + "importDatabaseSuccess": "Đã nhập cơ sở dữ liệu thành công", + "importDatabaseError": "Lỗi xảy ra khi nhập cơ sở dữ liệu", + "readDatabaseError": "Lỗi xảy ra khi đọc cơ sở dữ liệu", + "getDatabaseError": "Lỗi xảy ra khi truy xuất cơ sở dữ liệu", + "getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình" }, "inbounds": { - "allTimeTraffic": "Tổng Lưu Lượng", - "allTimeTrafficUsage": "Tổng mức sử dụng mọi lúc", "title": "Điểm vào (Inbounds)", "totalDownUp": "Tổng tải lên/tải xuống", "totalUsage": "Tổng sử dụng", @@ -249,6 +250,23 @@ "node": "Nút", "deployTo": "Triển khai tới", "localPanel": "Panel cục bộ", + "fallbacks": { + "title": "Fallback", + "help": "Khi một kết nối trên inbound này không khớp với client nào, nó sẽ được chuyển hướng tới inbound khác. Chọn một child bên dưới và các trường định tuyến (SNI / ALPN / Path / xver) sẽ được tự động điền từ transport của child — hầu hết cấu hình không cần chỉnh thêm. Mỗi child nên lắng nghe trên 127.0.0.1 với security=none.", + "empty": "Chưa có fallback nào", + "add": "Thêm fallback", + "pickInbound": "Chọn một inbound", + "matchAny": "bất kỳ", + "rederive": "Điền lại từ child", + "rederived": "Đã điền lại từ child", + "editAdvanced": "Sửa trường định tuyến", + "hideAdvanced": "Ẩn nâng cao", + "quickAddAll": "Thêm nhanh tất cả các inbound đủ điều kiện", + "quickAdded": "Đã thêm {n} fallback", + "quickAddedNone": "Không có inbound mới nào đủ điều kiện", + "routesWhen": "Định tuyến khi", + "defaultCatchAll": "Mặc định — bắt mọi thứ khác" + }, "protocol": "Giao thức", "port": "Cổng", "portMap": "Cổng tạo", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "Lịch sử đăng nhập IP (trước khi kích hoạt điểm vào sau khi bị vô hiệu hóa bởi giới hạn IP, bạn nên xóa lịch sử).", "IPLimitlogclear": "Xóa Lịch sử", "setDefaultCert": "Đặt chứng chỉ từ bảng điều khiển", + "streamTab": "Stream", + "securityTab": "Bảo mật", + "sniffingTab": "Sniffing", + "sniffingMetadataOnly": "Chỉ siêu dữ liệu", + "sniffingRouteOnly": "Chỉ định tuyến", + "sniffingIpsExcluded": "IP bị loại trừ", + "sniffingDomainsExcluded": "Tên miền bị loại trừ", + "decryption": "Giải mã", + "encryption": "Mã hóa", + "vlessAuthX25519": "Xác thực X25519", + "vlessAuthMlkem768": "Xác thực ML-KEM-768", + "vlessAuthCustom": "Tùy chỉnh", + "vlessAuthSelected": "Đã chọn: {auth}", + "advanced": { + "title": "Các phần JSON của inbound", + "subtitle": "JSON inbound đầy đủ và các trình chỉnh sửa riêng cho settings, sniffing và streamSettings.", + "all": "Tất cả", + "allHelp": "Đối tượng inbound đầy đủ với mọi trường trong một trình chỉnh sửa.", + "settings": "Cài đặt", + "settingsHelp": "Bao đóng khối settings của Xray:", + "sniffing": "Sniffing", + "sniffingHelp": "Bao đóng khối sniffing của Xray:", + "stream": "Stream", + "streamHelp": "Bao đóng khối stream của Xray:", + "jsonErrorPrefix": "JSON nâng cao" + }, "telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc ({'@'}userinfobot)", "subscriptionDesc": "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau", "info": "Thông tin", @@ -365,37 +409,101 @@ } } }, - "client": { - "add": "Thêm người dùng", - "edit": "Chỉnh sửa người dùng", - "submitAdd": "Thêm", + "clients": { + "add": "Thêm khách hàng", + "edit": "Chỉnh sửa khách hàng", + "submitAdd": "Thêm khách hàng", "submitEdit": "Lưu thay đổi", - "clientCount": "Số lượng người dùng", + "clientCount": "Số lượng khách hàng", "bulk": "Thêm hàng loạt", - "copyFromInbound": "Sao chép người dùng từ Inbound", - "copyToInbound": "Sao chép người dùng đến", + "copyFromInbound": "Sao chép khách hàng từ inbound", + "copyToInbound": "Sao chép khách hàng đến", "copySelected": "Sao chép đã chọn", "copySource": "Nguồn", "copyEmailPreview": "Xem trước email kết quả", - "copySelectSourceFirst": "Vui lòng chọn Inbound nguồn trước.", + "copySelectSourceFirst": "Hãy chọn inbound nguồn trước.", "copyResult": "Kết quả sao chép", "copyResultSuccess": "Đã sao chép thành công", - "copyResultNone": "Không có gì để sao chép: chưa chọn người dùng hoặc nguồn trống", + "copyResultNone": "Không có gì để sao chép: chưa chọn khách hàng hoặc nguồn rỗng", "copyResultErrors": "Lỗi sao chép", - "copyFlowLabel": "Flow cho người dùng mới (VLESS)", - "copyFlowHint": "Áp dụng cho tất cả người dùng được sao chép. Để trống để bỏ qua.", + "copyFlowLabel": "Flow cho khách hàng mới (VLESS)", + "copyFlowHint": "Áp dụng cho tất cả khách hàng được sao chép. Để trống để bỏ qua.", "selectAll": "Chọn tất cả", - "clearAll": "Bỏ chọn tất cả", - "method": "Phương pháp", - "first": "Đầu tiên", - "last": "Cuối cùng", + "clearAll": "Xóa tất cả", + "method": "Phương thức", + "first": "Đầu", + "last": "Cuối", + "ipLog": "Nhật ký IP", "prefix": "Tiền tố", "postfix": "Hậu tố", - "delayedStart": "Bắt đầu ở Lần Đầu", - "expireDays": "Khoảng thời gian", - "days": "ngày", + "delayedStart": "Bắt đầu sau lần dùng đầu", + "expireDays": "Thời hạn", + "days": "Ngày", "renew": "Tự động gia hạn", - "renewDesc": "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)" + "renewDesc": "Tự động gia hạn sau khi hết hạn. (0 = tắt) (đơn vị: ngày)", + "title": "Khách hàng", + "actions": "Hành động", + "totalGB": "Tổng gửi/nhận (GB)", + "expiryTime": "Hết hạn", + "addClients": "Thêm khách hàng", + "limitIp": "Giới hạn IP", + "password": "Mật khẩu", + "subId": "ID đăng ký", + "online": "Trực tuyến", + "email": "Email", + "comment": "Ghi chú", + "traffic": "Lưu lượng", + "offline": "Ngoại tuyến", + "addTitle": "Thêm khách hàng", + "qrCode": "Mã QR", + "moreInformation": "Thông tin thêm", + "delete": "Xóa", + "reset": "Đặt lại lưu lượng", + "editTitle": "Chỉnh sửa khách hàng", + "client": "Khách hàng", + "enabled": "Đã bật", + "remaining": "Còn lại", + "duration": "Thời hạn", + "attachedInbounds": "Inbound đã gắn", + "selectInbound": "Chọn một hoặc nhiều inbound", + "noSubId": "Khách hàng này không có subId, không có liên kết chia sẻ.", + "noLinks": "Không có liên kết chia sẻ — hãy gắn khách hàng này vào một inbound có giao thức tương thích trước.", + "link": "Liên kết", + "resetNotPossible": "Hãy gắn khách hàng này vào một inbound trước.", + "general": "Chung", + "resetAllTraffics": "Đặt lại lưu lượng của tất cả khách hàng", + "resetAllTrafficsTitle": "Đặt lại lưu lượng của tất cả khách hàng?", + "resetAllTrafficsContent": "Bộ đếm gửi/nhận của mỗi khách hàng về 0. Hạn mức và thời hạn không bị ảnh hưởng. Không thể hoàn tác.", + "empty": "Chưa có khách hàng nào — thêm một để bắt đầu.", + "deleteConfirmTitle": "Xóa khách hàng {email}?", + "deleteConfirmContent": "Hành động này gỡ khách hàng khỏi mọi inbound đã gắn và xóa bản ghi lưu lượng. Không thể hoàn tác.", + "deleteSelected": "Xóa ({count})", + "bulkDeleteConfirmTitle": "Xóa {count} khách hàng?", + "bulkDeleteConfirmContent": "Mỗi khách hàng được chọn sẽ bị gỡ khỏi tất cả inbound đã gắn và bản ghi lưu lượng cũng bị xóa. Không thể hoàn tác.", + "delDepleted": "Xóa hết hạn mức", + "delDepletedConfirmTitle": "Xóa khách hàng hết hạn mức?", + "delDepletedConfirmContent": "Gỡ tất cả khách hàng đã dùng hết hạn mức lưu lượng hoặc đã quá hạn. Không thể hoàn tác.", + "auth": "Auth", + "hysteriaAuth": "Auth Hysteria", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "Reverse tag tùy chọn", + "telegramId": "ID người dùng Telegram", + "telegramIdPlaceholder": "ID người dùng Telegram dạng số (0 = không có)", + "created": "Tạo", + "updated": "Cập nhật", + "ipLimit": "Giới hạn IP", + "toasts": { + "deleted": "Đã xóa khách hàng", + "trafficReset": "Đã đặt lại lưu lượng", + "allTrafficsReset": "Đã đặt lại lưu lượng của tất cả khách hàng", + "bulkDeleted": "Đã xóa {count} khách hàng", + "bulkDeletedMixed": "Đã xóa {ok}, thất bại {failed}", + "bulkCreated": "Đã tạo {count} khách hàng", + "bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}", + "delDepleted": "Đã xóa {count} khách hàng hết hạn mức" + } }, "nodes": { "title": "Nút", @@ -428,6 +536,7 @@ "latency": "Độ trễ", "lastHeartbeat": "Heartbeat gần nhất", "xrayVersion": "Phiên bản Xray", + "panelVersion": "Phiên bản panel", "actions": "Hành động", "probe": "Kiểm tra ngay", "testConnection": "Kiểm tra kết nối", @@ -777,9 +886,6 @@ "unexpectIPs": "IP không mong muốn", "useSystemHosts": "Sử dụng Hosts hệ thống", "useSystemHostsDesc": "Sử dụng file hosts từ hệ thống đã cài đặt", - "usePreset": "Dùng mẫu", - "dnsPresetTitle": "Mẫu DNS", - "dnsPresetFamily": "Gia đình", "serveStale": "Phục vụ kết quả hết hạn", "serveStaleDesc": "Trả về kết quả cache đã hết hạn trong khi làm mới ở chế độ nền", "serveExpiredTTL": "TTL hết hạn", @@ -792,6 +898,9 @@ "hostsEmpty": "Chưa có Host nào", "hostsDomain": "Tên miền (vd. domain:example.com)", "hostsValues": "IP hoặc tên miền — nhập và nhấn Enter", + "usePreset": "Dùng mẫu", + "dnsPresetTitle": "Mẫu DNS", + "dnsPresetFamily": "Gia đình", "clearAll": "Xóa tất cả", "clearAllTitle": "Xóa tất cả máy chủ DNS?", "clearAllConfirm": "Thao tác này sẽ xóa toàn bộ máy chủ DNS khỏi danh sách. Không thể hoàn tác." @@ -980,4 +1089,4 @@ "chooseInbound": "Chọn một Inbound" } } -} +} \ No newline at end of file diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 3f648cb2..3ff99e4d 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -18,6 +18,8 @@ "search": "搜索", "filter": "筛选", "loading": "加载中...", + "refresh": "刷新", + "clear": "清除", "second": "秒", "minute": "分钟", "hour": "小时", @@ -94,6 +96,7 @@ "ultraDark": "超暗色", "dashboard": "系统状态", "inbounds": "入站列表", + "clients": "客户端", "nodes": "节点", "settings": "面板设置", "xray": "Xray 设置", @@ -127,9 +130,9 @@ "stopXray": "停止", "restartXray": "重启", "xraySwitch": "版本", + "xrayUpdates": "Xray 更新", "xraySwitchClick": "选择你要切换到的版本", "xraySwitchClickDesk": "请谨慎选择,因为较旧版本可能与当前配置不兼容", - "xrayUpdates": "Xray 更新", "updatePanel": "更新面板", "panelUpdateDesc": "这将把 3X-UI 更新到最新版本并重启面板服务。", "currentPanelVersion": "当前面板版本", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "这将更新所有文件。", "geofilesUpdateAll": "全部更新", "geofileUpdatePopover": "地理文件更新成功", - "dontRefresh": "安装中,请勿刷新此页面", - "logs": "日志", - "config": "配置", - "backup": "备份", - "backupTitle": "备份和恢复", - "exportDatabase": "备份", - "exportDatabaseDesc": "点击下载包含当前数据库备份的 .db 文件到您的设备。", - "importDatabase": "恢复", - "importDatabaseDesc": "点击选择并上传设备中的 .db 文件以从备份恢复数据库。", - "importDatabaseSuccess": "数据库导入成功", - "importDatabaseError": "导入数据库时出错", - "readDatabaseError": "读取数据库时出错", - "getDatabaseError": "检索数据库时出错", - "getConfigError": "检索配置文件时出错", "customGeoTitle": "自定义 GeoSite / GeoIP", "customGeoAdd": "添加", "customGeoType": "类型", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "未找到自定义 geo 源", "customGeoErrDownload": "下载失败", "customGeoErrUpdateAllIncomplete": "有一个或多个自定义 geo 源更新失败", - "customGeoEmpty": "暂无自定义 geo 源 — 点击「添加」以创建" + "customGeoEmpty": "暂无自定义 geo 源 — 点击「添加」以创建", + "dontRefresh": "安装中,请勿刷新此页面", + "logs": "日志", + "config": "配置", + "backup": "备份", + "backupTitle": "备份和恢复", + "exportDatabase": "备份", + "exportDatabaseDesc": "点击下载包含当前数据库备份的 .db 文件到您的设备。", + "importDatabase": "恢复", + "importDatabaseDesc": "点击选择并上传设备中的 .db 文件以从备份恢复数据库。", + "importDatabaseSuccess": "数据库导入成功", + "importDatabaseError": "导入数据库时出错", + "readDatabaseError": "读取数据库时出错", + "getDatabaseError": "检索数据库时出错", + "getConfigError": "检索配置文件时出错" }, "inbounds": { - "allTimeTraffic": "累计总流量", - "allTimeTrafficUsage": "所有时间总使用量", "title": "入站列表", "totalDownUp": "总上传 / 下载", "totalUsage": "总用量", @@ -249,6 +250,23 @@ "node": "节点", "deployTo": "部署到", "localPanel": "本地面板", + "fallbacks": { + "title": "回落", + "help": "当此入站的连接未匹配任何客户端时,将其路由到另一个入站。在下方选择一个子入站,路由字段(SNI / ALPN / Path / xver)会从子入站的传输方式中自动填充——大多数场景无需再调整。每个子入站应监听 127.0.0.1,security=none。", + "empty": "暂无回落", + "add": "添加回落", + "pickInbound": "选择一个入站", + "matchAny": "任意", + "rederive": "从子入站重新填充", + "rederived": "已从子入站重新填充", + "editAdvanced": "编辑路由字段", + "hideAdvanced": "隐藏高级", + "quickAddAll": "一键添加所有可用入站", + "quickAdded": "已添加 {n} 条回落", + "quickAddedNone": "没有可添加的新入站", + "routesWhen": "当满足条件时路由", + "defaultCatchAll": "默认 — 兜底匹配其他所有" + }, "protocol": "协议", "port": "端口", "portMap": "端口映射", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "IP 历史日志(要启用被禁用的入站流量,请清除日志)", "IPLimitlogclear": "清除日志", "setDefaultCert": "从面板设置证书", + "streamTab": "流", + "securityTab": "安全", + "sniffingTab": "嗅探", + "sniffingMetadataOnly": "仅元数据", + "sniffingRouteOnly": "仅路由", + "sniffingIpsExcluded": "排除的 IP", + "sniffingDomainsExcluded": "排除的域名", + "decryption": "解密", + "encryption": "加密", + "vlessAuthX25519": "X25519 认证", + "vlessAuthMlkem768": "ML-KEM-768 认证", + "vlessAuthCustom": "自定义", + "vlessAuthSelected": "已选择:{auth}", + "advanced": { + "title": "入站 JSON 部分", + "subtitle": "完整入站 JSON 以及针对 settings、sniffing 和 streamSettings 的专用编辑器。", + "all": "全部", + "allHelp": "在单个编辑器中编辑包含所有字段的完整入站对象。", + "settings": "设置", + "settingsHelp": "Xray settings 块包装:", + "sniffing": "嗅探", + "sniffingHelp": "Xray sniffing 块包装:", + "stream": "流", + "streamHelp": "Xray stream 块包装:", + "jsonErrorPrefix": "高级 JSON" + }, "telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot", "subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。", "info": "信息", @@ -365,37 +409,101 @@ } } }, - "client": { + "clients": { "add": "添加客户端", "edit": "编辑客户端", "submitAdd": "添加客户端", - "submitEdit": "保存修改", + "submitEdit": "保存更改", "clientCount": "客户端数量", - "bulk": "批量创建", + "bulk": "批量添加", "copyFromInbound": "从入站复制客户端", "copyToInbound": "复制客户端到", "copySelected": "复制所选", "copySource": "来源", - "copyEmailPreview": "最终邮箱预览", - "copySelectSourceFirst": "请先选择来源入站。", + "copyEmailPreview": "生成的邮箱预览", + "copySelectSourceFirst": "请先选择一个来源入站。", "copyResult": "复制结果", "copyResultSuccess": "复制成功", - "copyResultNone": "没有可复制的内容:未选择客户端或来源为空", + "copyResultNone": "没有内容可复制:未选中客户端或来源为空", "copyResultErrors": "复制错误", "copyFlowLabel": "新客户端的 Flow (VLESS)", - "copyFlowHint": "应用于所有复制的客户端。留空则跳过。", + "copyFlowHint": "应用于所有被复制的客户端。留空则跳过。", "selectAll": "全选", - "clearAll": "全不选", - "method": "方法", - "first": "置顶", - "last": "置底", + "clearAll": "全部清除", + "method": "方式", + "first": "首个", + "last": "末位", + "ipLog": "IP 日志", "prefix": "前缀", "postfix": "后缀", "delayedStart": "首次使用后开始", - "expireDays": "期间", + "expireDays": "时长", "days": "天", - "renew": "自动续订", - "renewDesc": "到期后自动续订。(0 = 禁用)(单位: 天)" + "renew": "自动续期", + "renewDesc": "到期后自动续期。(0 = 禁用) (单位: 天)", + "title": "客户端", + "actions": "操作", + "totalGB": "总上传/下载 (GB)", + "expiryTime": "过期时间", + "addClients": "添加客户端", + "limitIp": "IP 限制", + "password": "密码", + "subId": "订阅 ID", + "online": "在线", + "email": "邮箱", + "comment": "备注", + "traffic": "流量", + "offline": "离线", + "addTitle": "添加客户端", + "qrCode": "二维码", + "moreInformation": "更多信息", + "delete": "删除", + "reset": "重置流量", + "editTitle": "编辑客户端", + "client": "客户端", + "enabled": "已启用", + "remaining": "剩余", + "duration": "时长", + "attachedInbounds": "关联入站", + "selectInbound": "选择一个或多个入站", + "noSubId": "该客户端没有 subId,无法生成共享链接。", + "noLinks": "没有可共享的链接 — 请先将此客户端关联到支持协议的入站。", + "link": "链接", + "resetNotPossible": "请先将此客户端关联到入站。", + "general": "通用", + "resetAllTraffics": "重置所有客户端流量", + "resetAllTrafficsTitle": "重置所有客户端流量?", + "resetAllTrafficsContent": "所有客户端的上下行计数器将归零。配额与过期时间不受影响。该操作不可撤销。", + "empty": "尚无客户端 — 添加一个开始使用。", + "deleteConfirmTitle": "删除客户端 {email}?", + "deleteConfirmContent": "将从所有关联入站中移除该客户端并删除其流量记录。该操作不可撤销。", + "deleteSelected": "删除 ({count})", + "bulkDeleteConfirmTitle": "删除 {count} 个客户端?", + "bulkDeleteConfirmContent": "每个所选客户端都会从关联的入站中被移除,其流量记录也会被删除。该操作不可撤销。", + "delDepleted": "删除已耗尽", + "delDepletedConfirmTitle": "删除已耗尽的客户端?", + "delDepletedConfirmContent": "删除所有流量配额已用尽或已过期的客户端。该操作不可撤销。", + "auth": "Auth", + "hysteriaAuth": "Hysteria Auth", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "可选 Reverse tag", + "telegramId": "Telegram 用户 ID", + "telegramIdPlaceholder": "数字形式的 Telegram 用户 ID (0 = 无)", + "created": "创建时间", + "updated": "更新时间", + "ipLimit": "IP 限制", + "toasts": { + "deleted": "客户端已删除", + "trafficReset": "流量已重置", + "allTrafficsReset": "所有客户端流量已重置", + "bulkDeleted": "已删除 {count} 个客户端", + "bulkDeletedMixed": "已删除 {ok} 个,失败 {failed} 个", + "bulkCreated": "已创建 {count} 个客户端", + "bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个", + "delDepleted": "已删除 {count} 个已耗尽的客户端" + } }, "nodes": { "title": "节点", @@ -428,6 +536,7 @@ "latency": "延迟", "lastHeartbeat": "上次心跳", "xrayVersion": "Xray 版本", + "panelVersion": "面板版本", "actions": "操作", "probe": "立即探测", "testConnection": "测试连接", @@ -777,9 +886,6 @@ "unexpectIPs": "意外IP", "useSystemHosts": "使用系统Hosts", "useSystemHostsDesc": "使用已安装系统的hosts文件", - "usePreset": "使用模板", - "dnsPresetTitle": "DNS模板", - "dnsPresetFamily": "家庭", "serveStale": "提供过期结果", "serveStaleDesc": "在后台刷新时返回过期的缓存结果", "serveExpiredTTL": "过期TTL", @@ -792,6 +898,9 @@ "hostsEmpty": "未定义任何 Host", "hostsDomain": "域名 (例如 domain:example.com)", "hostsValues": "IP 或域名 — 输入后按 Enter", + "usePreset": "使用模板", + "dnsPresetTitle": "DNS模板", + "dnsPresetFamily": "家庭", "clearAll": "删除全部", "clearAllTitle": "删除所有 DNS 服务器?", "clearAllConfirm": "此操作将从列表中删除所有 DNS 服务器,且无法撤销。" @@ -980,4 +1089,4 @@ "chooseInbound": "选择一个入站" } } -} +} \ No newline at end of file diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index a28c0e0f..6965c9b1 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -18,6 +18,8 @@ "search": "搜尋", "filter": "篩選", "loading": "載入中...", + "refresh": "重新整理", + "clear": "清除", "second": "秒", "minute": "分鐘", "hour": "小時", @@ -94,6 +96,7 @@ "ultraDark": "超深色", "dashboard": "系統狀態", "inbounds": "入站列表", + "clients": "客戶端", "nodes": "節點", "settings": "面板設定", "xray": "Xray 設定", @@ -127,9 +130,9 @@ "stopXray": "停止", "restartXray": "重啟", "xraySwitch": "版本", + "xrayUpdates": "Xray 更新", "xraySwitchClick": "選擇你要切換到的版本", "xraySwitchClickDesk": "請謹慎選擇,因為較舊版本可能與當前配置不相容", - "xrayUpdates": "Xray 更新", "updatePanel": "更新面板", "panelUpdateDesc": "這將把 3X-UI 更新到最新版本並重新啟動面板服務。", "currentPanelVersion": "目前面板版本", @@ -179,20 +182,6 @@ "geofilesUpdateDialogDesc": "這將更新所有文件。", "geofilesUpdateAll": "全部更新", "geofileUpdatePopover": "地理檔案更新成功", - "dontRefresh": "安裝中,請勿重新整理此頁面", - "logs": "日誌", - "config": "配置", - "backup": "備份和恢復", - "backupTitle": "備份和恢復", - "exportDatabase": "備份", - "exportDatabaseDesc": "點擊下載包含當前資料庫備份的 .db 文件到您的設備。", - "importDatabase": "恢復", - "importDatabaseDesc": "點擊選擇並上傳設備中的 .db 文件以從備份恢復資料庫。", - "importDatabaseSuccess": "資料庫匯入成功", - "importDatabaseError": "匯入資料庫時發生錯誤", - "readDatabaseError": "讀取資料庫時發生錯誤", - "getDatabaseError": "檢索資料庫時發生錯誤", - "getConfigError": "檢索設定檔時發生錯誤", "customGeoTitle": "自訂 GeoSite / GeoIP", "customGeoAdd": "新增", "customGeoType": "類型", @@ -234,11 +223,23 @@ "customGeoErrNotFound": "找不到自訂 geo 來源", "customGeoErrDownload": "下載失敗", "customGeoErrUpdateAllIncomplete": "有一個或多個自訂 geo 來源更新失敗", - "customGeoEmpty": "尚無自訂 geo 來源 — 點擊「新增」以建立" + "customGeoEmpty": "尚無自訂 geo 來源 — 點擊「新增」以建立", + "dontRefresh": "安裝中,請勿重新整理此頁面", + "logs": "日誌", + "config": "配置", + "backup": "備份和恢復", + "backupTitle": "備份和恢復", + "exportDatabase": "備份", + "exportDatabaseDesc": "點擊下載包含當前資料庫備份的 .db 文件到您的設備。", + "importDatabase": "恢復", + "importDatabaseDesc": "點擊選擇並上傳設備中的 .db 文件以從備份恢復資料庫。", + "importDatabaseSuccess": "資料庫匯入成功", + "importDatabaseError": "匯入資料庫時發生錯誤", + "readDatabaseError": "讀取資料庫時發生錯誤", + "getDatabaseError": "檢索資料庫時發生錯誤", + "getConfigError": "檢索設定檔時發生錯誤" }, "inbounds": { - "allTimeTraffic": "累計總流量", - "allTimeTrafficUsage": "所有时间总使用量", "title": "入站列表", "totalDownUp": "總上傳 / 下載", "totalUsage": "總用量", @@ -249,6 +250,23 @@ "node": "節點", "deployTo": "部署到", "localPanel": "本機面板", + "fallbacks": { + "title": "回落", + "help": "當此入站的連線未匹配任何用戶時,將其路由到另一個入站。在下方選擇一個子入站,路由欄位(SNI / ALPN / Path / xver)會自動從子入站的傳輸方式填入——大多數情境不需要再調整。每個子入站應監聽 127.0.0.1,security=none。", + "empty": "尚未新增回落", + "add": "新增回落", + "pickInbound": "選擇一個入站", + "matchAny": "任何", + "rederive": "從子入站重新填入", + "rederived": "已從子入站重新填入", + "editAdvanced": "編輯路由欄位", + "hideAdvanced": "隱藏進階", + "quickAddAll": "一鍵新增所有符合的入站", + "quickAdded": "已新增 {n} 個回落", + "quickAddedNone": "沒有可新增的新入站", + "routesWhen": "當條件成立時路由", + "defaultCatchAll": "預設 — 兜底匹配其餘" + }, "protocol": "協議", "port": "埠", "portMap": "埠映射", @@ -308,6 +326,32 @@ "IPLimitlogDesc": "IP 歷史日誌(要啟用被禁用的入站流量,請清除日誌)", "IPLimitlogclear": "清除日誌", "setDefaultCert": "從面板設定證書", + "streamTab": "串流", + "securityTab": "安全", + "sniffingTab": "嗅探", + "sniffingMetadataOnly": "僅中繼資料", + "sniffingRouteOnly": "僅路由", + "sniffingIpsExcluded": "排除的 IP", + "sniffingDomainsExcluded": "排除的網域", + "decryption": "解密", + "encryption": "加密", + "vlessAuthX25519": "X25519 認證", + "vlessAuthMlkem768": "ML-KEM-768 認證", + "vlessAuthCustom": "自訂", + "vlessAuthSelected": "已選擇:{auth}", + "advanced": { + "title": "入站 JSON 部分", + "subtitle": "完整入站 JSON 以及針對 settings、sniffing 和 streamSettings 的專用編輯器。", + "all": "全部", + "allHelp": "在單一編輯器中編輯包含所有欄位的完整入站物件。", + "settings": "設定", + "settingsHelp": "Xray settings 區塊包裝:", + "sniffing": "嗅探", + "sniffingHelp": "Xray sniffing 區塊包裝:", + "stream": "串流", + "streamHelp": "Xray stream 區塊包裝:", + "jsonErrorPrefix": "進階 JSON" + }, "telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot", "subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。", "info": "資訊", @@ -365,37 +409,101 @@ } } }, - "client": { + "clients": { "add": "新增客戶端", "edit": "編輯客戶端", "submitAdd": "新增客戶端", - "submitEdit": "儲存修改", + "submitEdit": "儲存變更", "clientCount": "客戶端數量", - "bulk": "批量建立", - "copyFromInbound": "從入站複製用戶端", - "copyToInbound": "複製用戶端到", + "bulk": "批次新增", + "copyFromInbound": "從入站複製客戶端", + "copyToInbound": "複製客戶端至", "copySelected": "複製所選", "copySource": "來源", - "copyEmailPreview": "最終郵箱預覽", - "copySelectSourceFirst": "請先選擇來源入站。", + "copyEmailPreview": "產生的信箱預覽", + "copySelectSourceFirst": "請先選擇一個來源入站。", "copyResult": "複製結果", "copyResultSuccess": "複製成功", - "copyResultNone": "沒有可複製的內容:未選擇用戶端或來源為空", + "copyResultNone": "沒有內容可複製:未選取客戶端或來源為空", "copyResultErrors": "複製錯誤", - "copyFlowLabel": "新用戶端的 Flow (VLESS)", - "copyFlowHint": "套用於所有複製的用戶端。留空則略過。", + "copyFlowLabel": "新客戶端的 Flow (VLESS)", + "copyFlowHint": "套用至所有被複製的客戶端。留空則略過。", "selectAll": "全選", - "clearAll": "全不選", + "clearAll": "全部清除", "method": "方法", - "first": "置頂", - "last": "置底", - "prefix": "字首", - "postfix": "字尾", + "first": "首個", + "last": "末位", + "ipLog": "IP 日誌", + "prefix": "前綴", + "postfix": "後綴", "delayedStart": "首次使用後開始", - "expireDays": "期間", + "expireDays": "時長", "days": "天", - "renew": "自動續訂", - "renewDesc": "到期後自動續訂。(0 = 禁用)(單位: 天)" + "renew": "自動續期", + "renewDesc": "到期後自動續期。(0 = 停用) (單位: 天)", + "title": "客戶端", + "actions": "操作", + "totalGB": "總上傳/下載 (GB)", + "expiryTime": "到期時間", + "addClients": "新增客戶端", + "limitIp": "IP 限制", + "password": "密碼", + "subId": "訂閱 ID", + "online": "上線", + "email": "信箱", + "comment": "備註", + "traffic": "流量", + "offline": "離線", + "addTitle": "新增客戶端", + "qrCode": "QR 碼", + "moreInformation": "更多資訊", + "delete": "刪除", + "reset": "重設流量", + "editTitle": "編輯客戶端", + "client": "客戶端", + "enabled": "已啟用", + "remaining": "剩餘", + "duration": "時長", + "attachedInbounds": "關聯入站", + "selectInbound": "選擇一個或多個入站", + "noSubId": "此客戶端沒有 subId,無法產生共享連結。", + "noLinks": "沒有可共享的連結 — 請先將此客戶端關聯至支援協定的入站。", + "link": "連結", + "resetNotPossible": "請先將此客戶端關聯至入站。", + "general": "通用", + "resetAllTraffics": "重設所有客戶端流量", + "resetAllTrafficsTitle": "重設所有客戶端流量?", + "resetAllTrafficsContent": "所有客戶端的上下行計數器將歸零。配額與到期時間不受影響。此操作無法復原。", + "empty": "尚無客戶端 — 新增一個開始使用。", + "deleteConfirmTitle": "刪除客戶端 {email}?", + "deleteConfirmContent": "將從所有關聯入站中移除該客戶端並刪除其流量紀錄。此操作無法復原。", + "deleteSelected": "刪除 ({count})", + "bulkDeleteConfirmTitle": "刪除 {count} 個客戶端?", + "bulkDeleteConfirmContent": "每個所選客戶端都會從關聯的入站中被移除,其流量紀錄也會被刪除。此操作無法復原。", + "delDepleted": "刪除已耗盡", + "delDepletedConfirmTitle": "刪除已耗盡的客戶端?", + "delDepletedConfirmContent": "刪除所有流量配額已用盡或已過期的客戶端。此操作無法復原。", + "auth": "Auth", + "hysteriaAuth": "Hysteria Auth", + "uuid": "UUID", + "flow": "Flow", + "reverseTag": "Reverse tag", + "reverseTagPlaceholder": "選用 Reverse tag", + "telegramId": "Telegram 使用者 ID", + "telegramIdPlaceholder": "數字形式的 Telegram 使用者 ID (0 = 無)", + "created": "建立時間", + "updated": "更新時間", + "ipLimit": "IP 限制", + "toasts": { + "deleted": "客戶端已刪除", + "trafficReset": "流量已重設", + "allTrafficsReset": "所有客戶端流量已重設", + "bulkDeleted": "已刪除 {count} 個客戶端", + "bulkDeletedMixed": "已刪除 {ok} 個,失敗 {failed} 個", + "bulkCreated": "已建立 {count} 個客戶端", + "bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個", + "delDepleted": "已刪除 {count} 個已耗盡的客戶端" + } }, "nodes": { "title": "節點", @@ -428,6 +536,7 @@ "latency": "延遲", "lastHeartbeat": "上次心跳", "xrayVersion": "Xray 版本", + "panelVersion": "面板版本", "actions": "操作", "probe": "立即探測", "testConnection": "測試連線", @@ -777,9 +886,6 @@ "unexpectIPs": "意外IP", "useSystemHosts": "使用系統Hosts", "useSystemHostsDesc": "使用已安裝系統的hosts檔案", - "usePreset": "使用範本", - "dnsPresetTitle": "DNS範本", - "dnsPresetFamily": "家庭", "serveStale": "提供過期結果", "serveStaleDesc": "在背景重新整理時傳回過期的快取結果", "serveExpiredTTL": "過期TTL", @@ -792,6 +898,9 @@ "hostsEmpty": "未定義任何 Host", "hostsDomain": "網域 (例如 domain:example.com)", "hostsValues": "IP 或網域 — 輸入後按 Enter", + "usePreset": "使用範本", + "dnsPresetTitle": "DNS範本", + "dnsPresetFamily": "家庭", "clearAll": "全部刪除", "clearAllTitle": "刪除所有 DNS 伺服器?", "clearAllConfirm": "此操作將從清單中刪除所有 DNS 伺服器,無法復原。" @@ -980,4 +1089,4 @@ "chooseInbound": "選擇一個入站" } } -} +} \ No newline at end of file diff --git a/web/websocket/hub.go b/web/websocket/hub.go index 5eeb80da..2b8773cf 100644 --- a/web/websocket/hub.go +++ b/web/websocket/hub.go @@ -21,36 +21,17 @@ const ( MessageTypeNodes MessageType = "nodes" MessageTypeNotification MessageType = "notification" MessageTypeXrayState MessageType = "xray_state" - // MessageTypeClientStats carries absolute traffic counters for the clients - // that had activity in the latest collection window. Frontend applies these - // in-place — far smaller than re-broadcasting the full inbound list and - // scales to 10k+ clients without falling back to REST. - MessageTypeClientStats MessageType = "client_stats" - MessageTypeInvalidate MessageType = "invalidate" // Tells frontend to re-fetch via REST (last-resort). + MessageTypeClientStats MessageType = "client_stats" + MessageTypeClients MessageType = "clients" + MessageTypeInvalidate MessageType = "invalidate" + maxMessageSize = 10 * 1024 * 1024 // 10MB - // maxMessageSize caps the WebSocket payload. Beyond this the hub sends a - // lightweight invalidate signal and the frontend re-fetches via REST. - // 10MB lets typical 2k–8k-client deployments push directly via WS (low - // latency); larger installs fall back to invalidate. - maxMessageSize = 10 * 1024 * 1024 // 10MB - - enqueueTimeout = 100 * time.Millisecond - clientSendQueue = 512 // ~50s of buffering for a momentarily slow browser. - hubBroadcastQueue = 2048 // Headroom for cron-storm + admin-mutation bursts. - hubControlQueue = 64 // Backlog for register/unregister bursts (page reloads, disconnect storms). - - // minBroadcastInterval throttles per-type broadcasts so cron storms or - // rapid mutations cannot drown the hub. Bursts within the interval are - // dropped (not coalesced); the next broadcast outside the window delivers - // the latest state. Only message types in throttledMessageTypes are gated — - // heartbeat and one-shot signals (status, notification, xray_state, - // invalidate) bypass this so they are never delayed. + enqueueTimeout = 100 * time.Millisecond + clientSendQueue = 512 // ~50s of buffering for a momentarily slow browser. + hubBroadcastQueue = 2048 // Headroom for cron-storm + admin-mutation bursts. + hubControlQueue = 64 // Backlog for register/unregister bursts (page reloads, disconnect storms). minBroadcastInterval = 250 * time.Millisecond - - // hubRestartAttempts caps panic-recovery restarts. After this many - // consecutive failures we stop trying and log; the panel keeps running - // (frontend falls back to REST polling) and the operator can investigate. - hubRestartAttempts = 3 + hubRestartAttempts = 3 ) // NewClient builds a Client ready for hub registration. @@ -129,7 +110,7 @@ func (h *Hub) shouldThrottle(msgType MessageType) bool { // panic doesn't permanently kill real-time updates for commercial deployments. // After the cap, the hub stays down and the frontend falls back to REST polling. func (h *Hub) Run() { - for attempt := 0; attempt < hubRestartAttempts; attempt++ { + for attempt := range hubRestartAttempts { stopped := h.runOnce() if stopped { return diff --git a/web/websocket/hub_test.go b/web/websocket/hub_test.go new file mode 100644 index 00000000..18998789 --- /dev/null +++ b/web/websocket/hub_test.go @@ -0,0 +1,257 @@ +package websocket + +import ( + "encoding/json" + "os" + "sync" + "testing" + "time" + + xuilogger "github.com/mhsanaei/3x-ui/v3/logger" + "github.com/op/go-logging" +) + +func TestMain(m *testing.M) { + _ = os.Setenv("XUI_LOG_FOLDER", os.TempDir()) + xuilogger.InitLogger(logging.ERROR) + os.Exit(m.Run()) +} + +func TestNewClient_HasBufferedSendChannel(t *testing.T) { + c := NewClient("client-1") + if c.ID != "client-1" { + t.Fatalf("ID = %q, want client-1", c.ID) + } + if cap(c.Send) != clientSendQueue { + t.Fatalf("Send cap = %d, want %d", cap(c.Send), clientSendQueue) + } +} + +func TestHub_NilReceiver_DoesNotPanic(t *testing.T) { + var h *Hub + if h.GetClientCount() != 0 { + t.Fatal("nil hub GetClientCount should return 0") + } + h.Broadcast(MessageTypeStatus, "anything") + h.Register(NewClient("x")) + h.Unregister(NewClient("x")) + h.Stop() +} + +func TestHub_BroadcastDropsWhenNoClients(t *testing.T) { + h := NewHub() + defer h.Stop() + go h.Run() + + h.Broadcast(MessageTypeStatus, "payload") + + select { + case <-h.broadcast: + t.Fatal("Broadcast should drop when client count is zero") + case <-time.After(50 * time.Millisecond): + } +} + +func TestHub_BroadcastDropsNilPayload(t *testing.T) { + h := NewHub() + defer h.Stop() + go h.Run() + + c := NewClient("c1") + h.Register(c) + waitClientCount(t, h, 1) + + h.Broadcast(MessageTypeStatus, nil) + + select { + case <-c.Send: + t.Fatal("nil payload should be dropped, not delivered") + case <-time.After(50 * time.Millisecond): + } +} + +func TestHub_BroadcastDeliversToClient(t *testing.T) { + h := NewHub() + defer h.Stop() + go h.Run() + + c := NewClient("c1") + h.Register(c) + waitClientCount(t, h, 1) + + h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"}) + + select { + case raw := <-c.Send: + var m Message + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("payload is not valid JSON: %v\n%s", err, raw) + } + if m.Type != MessageTypeStatus { + t.Fatalf("Type = %q, want %q", m.Type, MessageTypeStatus) + } + if m.Time == 0 { + t.Fatal("Time should be set to a non-zero unix-millis value") + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for broadcast to reach client") + } +} + +func TestHub_UnregisterClosesSendAndDecrementsCount(t *testing.T) { + h := NewHub() + defer h.Stop() + go h.Run() + + c := NewClient("c1") + h.Register(c) + waitClientCount(t, h, 1) + + h.Unregister(c) + waitClientCount(t, h, 0) + + select { + case _, ok := <-c.Send: + if ok { + t.Fatal("expected Send channel to be closed after Unregister") + } + case <-time.After(500 * time.Millisecond): + t.Fatal("Send channel was not closed after Unregister") + } +} + +func TestHub_StopClosesAllClients(t *testing.T) { + h := NewHub() + go h.Run() + + c1 := NewClient("c1") + c2 := NewClient("c2") + h.Register(c1) + h.Register(c2) + waitClientCount(t, h, 2) + + h.Stop() + + for _, c := range []*Client{c1, c2} { + select { + case _, ok := <-c.Send: + if ok { + t.Fatalf("client %s Send should be closed after Stop", c.ID) + } + case <-time.After(500 * time.Millisecond): + t.Fatalf("client %s Send not closed after Stop", c.ID) + } + } +} + +func TestHub_ShouldThrottle(t *testing.T) { + h := NewHub() + defer h.Stop() + + if h.shouldThrottle(MessageTypeStatus) { + t.Fatal("non-gated message type should never throttle") + } + if h.shouldThrottle(MessageTypeStatus) { + t.Fatal("non-gated message type should never throttle on second call") + } + + if h.shouldThrottle(MessageTypeTraffic) { + t.Fatal("first call for gated type should not throttle") + } + if !h.shouldThrottle(MessageTypeTraffic) { + t.Fatal("immediate second call for gated type should throttle") + } +} + +func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) { + h := NewHub() + defer h.Stop() + + if h.shouldThrottle(MessageTypeTraffic) { + t.Fatal("first Traffic call should not throttle") + } + if h.shouldThrottle(MessageTypeInbounds) { + t.Fatal("first Inbounds call should not throttle even after Traffic") + } +} + +func TestTrySend_SucceedsWithRoom(t *testing.T) { + c := &Client{ID: "c", Send: make(chan []byte, 1)} + if !trySend(c, []byte("hi")) { + t.Fatal("trySend should succeed when buffer has room") + } +} + +func TestTrySend_FailsWhenFull(t *testing.T) { + c := &Client{ID: "c", Send: make(chan []byte, 1)} + c.Send <- []byte("first") + if trySend(c, []byte("second")) { + t.Fatal("trySend should fail when buffer is full") + } +} + +func TestTrySend_FailsOnClosedChannel(t *testing.T) { + c := &Client{ID: "c", Send: make(chan []byte, 1)} + close(c.Send) + if trySend(c, []byte("after-close")) { + t.Fatal("trySend should fail (not panic) when channel is closed") + } +} + +func TestHub_FanoutEvictsSlowClient(t *testing.T) { + h := NewHub() + defer h.Stop() + go h.Run() + + slow := &Client{ID: "slow", Send: make(chan []byte, 1)} + slow.Send <- []byte("buffer-already-full") + h.Register(slow) + waitClientCount(t, h, 1) + + h.Broadcast(MessageTypeStatus, "payload") + waitClientCount(t, h, 0) + + select { + case _, ok := <-slow.Send: + if ok { + _, ok = <-slow.Send + if ok { + t.Fatal("slow client Send should eventually be closed by fanout eviction") + } + } + case <-time.After(500 * time.Millisecond): + t.Fatal("slow client Send channel was not closed") + } +} + +func TestHub_ConcurrentRegisterUnregister(t *testing.T) { + h := NewHub() + defer h.Stop() + go h.Run() + + const n = 50 + var wg sync.WaitGroup + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + c := NewClient("c") + h.Register(c) + h.Unregister(c) + }(i) + } + wg.Wait() + waitClientCount(t, h, 0) +} + +func waitClientCount(t *testing.T, h *Hub, want int) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if h.GetClientCount() == want { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("client count never reached %d (last seen %d)", want, h.GetClientCount()) +} diff --git a/xray/api.go b/xray/api.go index 2bc9c61f..77964db2 100644 --- a/xray/api.go +++ b/xray/api.go @@ -212,6 +212,8 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an var ssCipherType shadowsocks.CipherType switch cipher { + case "aes-256-gcm": + ssCipherType = shadowsocks.CipherType_AES_256_GCM case "chacha20-poly1305", "chacha20-ietf-poly1305": ssCipherType = shadowsocks.CipherType_CHACHA20_POLY1305 case "xchacha20-poly1305", "xchacha20-ietf-poly1305": diff --git a/xray/api_test.go b/xray/api_test.go new file mode 100644 index 00000000..3f018f52 --- /dev/null +++ b/xray/api_test.go @@ -0,0 +1,82 @@ +package xray + +import ( + "strings" + "testing" +) + +func TestGetRequiredUserString_Present(t *testing.T) { + user := map[string]any{"email": "alice@example.com"} + got, err := getRequiredUserString(user, "email") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "alice@example.com" { + t.Fatalf("got %q, want %q", got, "alice@example.com") + } +} + +func TestGetRequiredUserString_Missing(t *testing.T) { + user := map[string]any{} + if _, err := getRequiredUserString(user, "email"); err == nil { + t.Fatal("expected error for missing key") + } +} + +func TestGetRequiredUserString_NilValue(t *testing.T) { + user := map[string]any{"email": nil} + if _, err := getRequiredUserString(user, "email"); err == nil { + t.Fatal("expected error for nil value") + } +} + +func TestGetRequiredUserString_WrongType(t *testing.T) { + user := map[string]any{"email": 42} + _, err := getRequiredUserString(user, "email") + if err == nil { + t.Fatal("expected error for non-string value") + } + if !strings.Contains(err.Error(), "invalid type") { + t.Fatalf("expected %q in error, got: %v", "invalid type", err) + } +} + +func TestGetOptionalUserString_Present(t *testing.T) { + user := map[string]any{"flow": "xtls-rprx-vision"} + got, err := getOptionalUserString(user, "flow") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "xtls-rprx-vision" { + t.Fatalf("got %q, want %q", got, "xtls-rprx-vision") + } +} + +func TestGetOptionalUserString_MissingReturnsEmptyNoError(t *testing.T) { + user := map[string]any{} + got, err := getOptionalUserString(user, "flow") + if err != nil { + t.Fatalf("unexpected error for missing optional field: %v", err) + } + if got != "" { + t.Fatalf("got %q, want empty string", got) + } +} + +func TestGetOptionalUserString_NilReturnsEmptyNoError(t *testing.T) { + user := map[string]any{"flow": nil} + got, err := getOptionalUserString(user, "flow") + if err != nil { + t.Fatalf("unexpected error for nil optional field: %v", err) + } + if got != "" { + t.Fatalf("got %q, want empty string", got) + } +} + +func TestGetOptionalUserString_WrongTypeErrors(t *testing.T) { + user := map[string]any{"flow": []string{"a", "b"}} + if _, err := getOptionalUserString(user, "flow"); err == nil { + t.Fatal("expected error for non-string optional value") + } +} diff --git a/xray/client_traffic.go b/xray/client_traffic.go index fcb2585e..c936c366 100644 --- a/xray/client_traffic.go +++ b/xray/client_traffic.go @@ -11,7 +11,6 @@ type ClientTraffic struct { SubId string `json:"subId" form:"subId" gorm:"-"` Up int64 `json:"up" form:"up"` Down int64 `json:"down" form:"down"` - AllTime int64 `json:"allTime" form:"allTime"` ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` Total int64 `json:"total" form:"total"` Reset int `json:"reset" form:"reset" gorm:"default:0"` diff --git a/xray/config_test.go b/xray/config_test.go new file mode 100644 index 00000000..bcd97d59 --- /dev/null +++ b/xray/config_test.go @@ -0,0 +1,91 @@ +package xray + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/util/json_util" +) + +func makeConfig() *Config { + return &Config{ + LogConfig: json_util.RawMessage(`{"loglevel":"warning"}`), + RouterConfig: json_util.RawMessage(`{}`), + OutboundConfigs: json_util.RawMessage(`[]`), + Policy: json_util.RawMessage(`{}`), + API: json_util.RawMessage(`{}`), + Stats: json_util.RawMessage(`{}`), + Metrics: json_util.RawMessage(`{}`), + InboundConfigs: []InboundConfig{ + { + Port: 1080, + Protocol: "vless", + Tag: "inbound-1080", + Listen: json_util.RawMessage(`"0.0.0.0"`), + Settings: json_util.RawMessage(`{"clients":[]}`), + }, + }, + } +} + +func TestConfigEquals_IdenticalConfigs(t *testing.T) { + a := makeConfig() + b := makeConfig() + if !a.Equals(b) { + t.Fatal("two identical configs should be Equals") + } +} + +func TestConfigEquals_DifferentInboundCount(t *testing.T) { + a := makeConfig() + b := makeConfig() + b.InboundConfigs = append(b.InboundConfigs, InboundConfig{Port: 2080, Protocol: "vmess", Tag: "inbound-2080"}) + if a.Equals(b) { + t.Fatal("configs with different inbound counts should not be Equals") + } +} + +func TestConfigEquals_DifferentInboundContent(t *testing.T) { + a := makeConfig() + b := makeConfig() + b.InboundConfigs[0].Port = 9999 + if a.Equals(b) { + t.Fatal("config with changed inbound port should not be Equals") + } +} + +func TestConfigEquals_DifferentLogConfig(t *testing.T) { + a := makeConfig() + b := makeConfig() + b.LogConfig = json_util.RawMessage(`{"loglevel":"debug"}`) + if a.Equals(b) { + t.Fatal("config with changed log section should not be Equals") + } +} + +func TestConfigEquals_RawSectionsCompared(t *testing.T) { + fields := []struct { + name string + mutator func(c *Config) + }{ + {"RouterConfig", func(c *Config) { c.RouterConfig = json_util.RawMessage(`{"changed":true}`) }}, + {"DNSConfig", func(c *Config) { c.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"]}`) }}, + {"OutboundConfigs", func(c *Config) { c.OutboundConfigs = json_util.RawMessage(`[{"tag":"x"}]`) }}, + {"Transport", func(c *Config) { c.Transport = json_util.RawMessage(`{"x":1}`) }}, + {"Policy", func(c *Config) { c.Policy = json_util.RawMessage(`{"levels":{}}`) }}, + {"API", func(c *Config) { c.API = json_util.RawMessage(`{"tag":"api"}`) }}, + {"Stats", func(c *Config) { c.Stats = json_util.RawMessage(`{"on":true}`) }}, + {"Reverse", func(c *Config) { c.Reverse = json_util.RawMessage(`{"bridges":[]}`) }}, + {"FakeDNS", func(c *Config) { c.FakeDNS = json_util.RawMessage(`[]`) }}, + {"Metrics", func(c *Config) { c.Metrics = json_util.RawMessage(`{"tag":"m"}`) }}, + } + for _, f := range fields { + t.Run(f.name, func(t *testing.T) { + a := makeConfig() + b := makeConfig() + f.mutator(b) + if a.Equals(b) { + t.Fatalf("mutating %s should break Equals", f.name) + } + }) + } +} diff --git a/xray/inbound_test.go b/xray/inbound_test.go new file mode 100644 index 00000000..28c4d177 --- /dev/null +++ b/xray/inbound_test.go @@ -0,0 +1,52 @@ +package xray + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/util/json_util" +) + +func makeInbound() InboundConfig { + return InboundConfig{ + Listen: json_util.RawMessage(`"0.0.0.0"`), + Port: 1234, + Protocol: "vless", + Settings: json_util.RawMessage(`{"clients":[{"id":"abc"}]}`), + StreamSettings: json_util.RawMessage(`{"network":"tcp"}`), + Tag: "inbound-1234", + Sniffing: json_util.RawMessage(`{"enabled":false}`), + } +} + +func TestInboundConfigEquals_Identical(t *testing.T) { + a := makeInbound() + b := makeInbound() + if !a.Equals(&b) { + t.Fatal("two identical inbounds should be Equals") + } +} + +func TestInboundConfigEquals_MutationsBreakEquality(t *testing.T) { + cases := []struct { + name string + mutator func(c *InboundConfig) + }{ + {"Listen", func(c *InboundConfig) { c.Listen = json_util.RawMessage(`"127.0.0.1"`) }}, + {"Port", func(c *InboundConfig) { c.Port = 9999 }}, + {"Protocol", func(c *InboundConfig) { c.Protocol = "vmess" }}, + {"Settings", func(c *InboundConfig) { c.Settings = json_util.RawMessage(`{"clients":[]}`) }}, + {"StreamSettings", func(c *InboundConfig) { c.StreamSettings = json_util.RawMessage(`{"network":"ws"}`) }}, + {"Tag", func(c *InboundConfig) { c.Tag = "inbound-other" }}, + {"Sniffing", func(c *InboundConfig) { c.Sniffing = json_util.RawMessage(`{"enabled":true}`) }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := makeInbound() + b := makeInbound() + tc.mutator(&b) + if a.Equals(&b) { + t.Fatalf("mutating %s should break Equals", tc.name) + } + }) + } +} diff --git a/xray/process.go b/xray/process.go index f1a1400a..89bd5fe5 100644 --- a/xray/process.go +++ b/xray/process.go @@ -7,7 +7,9 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "runtime" + "sort" "strings" "sync" "sync/atomic" @@ -368,6 +370,8 @@ func (p *process) startCommand(cmd *exec.Cmd) error { return err } + attachChildLifetime(cmd) + go p.waitForCommand(cmd) return nil } @@ -451,8 +455,42 @@ func (p *process) waitForExit(timeout time.Duration) error { } } -// writeCrashReport writes a crash report to the binary folder with a timestamped filename. +const ( + crashReportPrefix = "core_crash_" + crashReportSuffix = ".log" + maxCrashReports = 10 +) + +// writeCrashReport persists a captured xray crash chunk to the log folder +// with nanosecond-precision filename so restart-loop bursts don't overwrite +// each other, and prunes old reports to keep the folder bounded. func writeCrashReport(m []byte) error { - crashReportPath := config.GetBinFolderPath() + "/core_crash_" + time.Now().Format("20060102_150405") + ".log" - return os.WriteFile(crashReportPath, m, 0644) + dir := config.GetLogFolder() + if err := os.MkdirAll(dir, 0o770); err != nil { + return err + } + pruneOldCrashReports(dir, maxCrashReports-1) + name := crashReportPrefix + time.Now().Format("20060102_150405_000000000") + crashReportSuffix + return os.WriteFile(filepath.Join(dir, name), m, 0o640) +} + +func pruneOldCrashReports(dir string, keep int) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + var reports []string + for _, e := range entries { + n := e.Name() + if !e.IsDir() && strings.HasPrefix(n, crashReportPrefix) && strings.HasSuffix(n, crashReportSuffix) { + reports = append(reports, n) + } + } + if len(reports) <= keep { + return + } + sort.Strings(reports) + for _, old := range reports[:len(reports)-keep] { + _ = os.Remove(filepath.Join(dir, old)) + } } diff --git a/xray/process_other.go b/xray/process_other.go new file mode 100644 index 00000000..71208617 --- /dev/null +++ b/xray/process_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package xray + +import "os/exec" + +func attachChildLifetime(_ *exec.Cmd) {} diff --git a/xray/process_windows.go b/xray/process_windows.go new file mode 100644 index 00000000..ab04f3f8 --- /dev/null +++ b/xray/process_windows.go @@ -0,0 +1,66 @@ +//go:build windows + +package xray + +import ( + "os/exec" + "sync" + "unsafe" + + "github.com/mhsanaei/3x-ui/v3/logger" + "golang.org/x/sys/windows" +) + +var ( + killOnExitJobOnce sync.Once + killOnExitJob windows.Handle + killOnExitJobErr error +) + +func ensureKillOnExitJob() (windows.Handle, error) { + killOnExitJobOnce.Do(func() { + h, err := windows.CreateJobObject(nil, nil) + if err != nil { + killOnExitJobErr = err + return + } + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + _, err = windows.SetInformationJobObject( + h, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ) + if err != nil { + windows.CloseHandle(h) + killOnExitJobErr = err + return + } + killOnExitJob = h + }) + return killOnExitJob, killOnExitJobErr +} + +func attachChildLifetime(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + job, err := ensureKillOnExitJob() + if err != nil { + logger.Warning("xray: kill-on-exit job unavailable:", err) + return + } + h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid)) + if err != nil { + logger.Warning("xray: OpenProcess for job attach failed:", err) + return + } + defer windows.CloseHandle(h) + if err := windows.AssignProcessToJobObject(job, h); err != nil { + logger.Warning("xray: AssignProcessToJobObject failed:", err) + } +}