mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-26 18:02:30 +03:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db118cbcc9 | ||
|
|
e7ffae5329 | ||
|
|
f470bc7cf8 | ||
|
|
a8d5d0dfab | ||
|
|
b40f869f2a | ||
|
|
e08456269b | ||
|
|
f8e902a7b6 | ||
|
|
d6d2085d60 | ||
|
|
12d84c2a46 | ||
|
|
97f88fb1a9 | ||
|
|
f947fbd6c6 | ||
|
|
ba63fa8569 | ||
|
|
73ce11508e | ||
|
|
a4b3e999a1 | ||
|
|
d3db828b46 | ||
|
|
d1e733b9e9 | ||
|
|
f185d3315c | ||
|
|
756746dbca | ||
|
|
44291de989 | ||
|
|
b1d079fc24 | ||
|
|
14e2d4954a | ||
|
|
db86007ab8 | ||
|
|
a07c7b7f4e |
@@ -6,3 +6,4 @@ db
|
||||
cert
|
||||
pgdata
|
||||
*.db
|
||||
*.dump
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,6 +38,7 @@ Thumbs.db
|
||||
x-ui.db
|
||||
x-ui.db-shm
|
||||
x-ui.db-wal
|
||||
*.dump
|
||||
|
||||
# Ignore Docker specific files
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -86,10 +86,11 @@ Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `a
|
||||
|
||||
### Inside VS Code
|
||||
|
||||
The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent):
|
||||
The repo checks in two VS Code launch profiles in `.vscode/launch.json`: **Run 3x-ui (Debug)** for the default SQLite setup, and **Run 3x-ui (Postgres)** which points `XUI_DB_TYPE`/`XUI_DB_DSN` at a local PostgreSQL. The Postgres profile also prepends the PostgreSQL `bin` to `PATH` so the panel can find `pg_dump`/`pg_restore` (the `postgresql-client` tools used for DB backup/restore) — adjust the DSN and that path to your machine:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "vscode://schemas/launch",
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
@@ -106,6 +107,23 @@ The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy fr
|
||||
"XUI_BIN_FOLDER": "x-ui"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Run 3x-ui (Postgres)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true",
|
||||
"XUI_LOG_FOLDER": "x-ui",
|
||||
"XUI_BIN_FOLDER": "x-ui",
|
||||
"XUI_DB_TYPE": "postgres",
|
||||
"XUI_DB_DSN": "postgres://xui:xuipass@127.0.0.1:5432/xui?sslmode=disable",
|
||||
"PATH": "C:\\Program Files\\PostgreSQL\\18\\bin;${env:PATH}"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -117,14 +135,21 @@ The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`
|
||||
|
||||
### Architecture
|
||||
|
||||
The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either.
|
||||
The frontend ships **three Vite bundles**, each emitted into `web/dist/` and embedded into the Go binary at compile time via `embed.FS`:
|
||||
|
||||
- **`index.html`** — the admin panel, a **single-page app**. `src/main.tsx` mounts a `react-router` `createBrowserRouter` (see `src/routes.tsx`) under the `/panel` basename; every route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/groups`, `/panel/nodes`, `/panel/settings`, `/panel/xray`, `/panel/api-docs`) is lazy-loaded inside a shared `PanelLayout` (sidebar + header + `<Outlet>`).
|
||||
- **`login.html`** — the login + 2FA screen (`src/entries/login.tsx`), a standalone bundle.
|
||||
- **`subpage.html`** — the public subscription viewer (`src/entries/subpage.tsx`), a standalone bundle.
|
||||
|
||||
Panel navigation happens client-side through React Router, and per-route code is lazy-split so the initial panel load stays small. `login` and `subpage` stay separate documents because they are reached without an authenticated panel session.
|
||||
|
||||
### State and data flow
|
||||
|
||||
- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive.
|
||||
- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
|
||||
- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer.
|
||||
- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`.
|
||||
- **Server state via TanStack Query.** API reads go through `@tanstack/react-query` (`QueryProvider` in `src/main.tsx`, keys in `src/api/queryKeys.ts`); responses are cached and invalidated on mutation rather than blindly re-fetched, and WebSocket pushes feed back into the cache via `src/api/websocketBridge.ts`.
|
||||
- **Local UI state stays in the page** (`useState`); shared concerns go through contexts and hooks in `src/hooks/` (`useTheme`, `useWebSocket`, `useClients`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
|
||||
- **Zod is the single source of truth.** Schemas in `src/schemas/` define the xray config model; every API response is parsed through them, every form field validates against them, and TypeScript types are inferred with `z.infer` — never hand-written. Go-side types are mirrored into `src/generated/` by `npm run gen:zod` (do not hand-edit that folder).
|
||||
- **xray domain logic** — link generation, protocol defaults, form ⇄ wire adapters — lives as pure functions in `src/lib/xray/`. `src/models/` keeps only thin legacy types still being migrated onto schemas.
|
||||
- **HTTP** goes through `HttpUtil` in `src/utils/index.ts`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.ts`.
|
||||
|
||||
### i18n
|
||||
|
||||
@@ -134,21 +159,22 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
|
||||
|
||||
| Goal | Command |
|
||||
|------|---------|
|
||||
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
|
||||
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
|
||||
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
|
||||
|
||||
The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there.
|
||||
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
|
||||
|
||||
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from 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, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
|
||||
|
||||
### Adding a new page
|
||||
|
||||
1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`).
|
||||
2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
|
||||
3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
|
||||
4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
|
||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
|
||||
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
|
||||
Most new screens are **admin-panel routes** and need no new HTML or Vite entry:
|
||||
|
||||
1. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
|
||||
2. Register it in `src/routes.tsx` under the `/panel` tree (lazy-import it like the others).
|
||||
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it should be reachable from the nav.
|
||||
|
||||
Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable without the panel shell) needs the full entry treatment: add `frontend/<page>.html`, a `src/entries/<page>.tsx` bootstrap, register it in `rollupOptions.input` inside `vite.config.js`, and wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
|
||||
|
||||
### Conventions
|
||||
|
||||
@@ -157,27 +183,40 @@ The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/pa
|
||||
- **Function components + hooks** everywhere. No class components.
|
||||
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
|
||||
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
|
||||
- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes.
|
||||
- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode.
|
||||
- **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
|
||||
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
|
||||
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
|
||||
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — currently `8.0.16` — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
|
||||
|
||||
### Project layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── *.html — Vite entry HTML, one per panel route
|
||||
├── index.html — admin panel SPA entry
|
||||
├── login.html — login + 2FA entry
|
||||
├── subpage.html — public subscription viewer entry
|
||||
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
|
||||
├── eslint.config.js — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks)
|
||||
├── eslint.config.js — ESLint flat config (@eslint/js + typescript-eslint + react-hooks)
|
||||
├── vite.config.js
|
||||
├── vitest.config.ts
|
||||
├── scripts/ — build-openapi.mjs (endpoints.ts → openapi.json)
|
||||
└── src/
|
||||
├── entries/ — per-page bootstrap (createRoot + render)
|
||||
├── pages/ — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
|
||||
├── components/ — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
|
||||
├── hooks/ — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
|
||||
├── api/ — Axios setup + CSRF interceptor + WebSocket client
|
||||
├── main.tsx — admin SPA bootstrap (router + providers)
|
||||
├── routes.tsx — react-router routes mounted under /panel
|
||||
├── entries/ — bootstrap for the standalone bundles (login, subpage)
|
||||
├── layouts/ — PanelLayout + AppSidebar
|
||||
├── pages/ — one folder per route (index, inbounds, clients, groups, nodes, settings, xray, api-docs) plus login, sub
|
||||
├── components/ — cross-page React components
|
||||
├── hooks/ — reusable hooks (useTheme, useWebSocket, useClients, useDatepicker, …)
|
||||
├── api/ — Axios + CSRF interceptor, TanStack Query provider/keys, WebSocket client
|
||||
├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/)
|
||||
├── models/ — Inbound, DBInbound, Outbound, Status, reality-targets, …
|
||||
├── lib/xray/ — pure xray logic: link generation, defaults, form ⇄ wire adapters
|
||||
├── schemas/ — Zod source of truth for the xray config model
|
||||
├── generated/ — code-generated Zod + TS types from Go (do not hand-edit)
|
||||
├── models/ — thin legacy types still being migrated
|
||||
├── styles/ — shared CSS (page-cards, …)
|
||||
└── utils/ — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
|
||||
├── test/ — Vitest specs + golden fixtures
|
||||
└── utils/ — HttpUtil, ClipboardManager, SizeFormatter, …
|
||||
```
|
||||
|
||||
For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
|
||||
@@ -202,7 +241,7 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
|
||||
3. Run the relevant checks before pushing:
|
||||
- `go build ./...`
|
||||
- `go test ./...` (when Go code changed)
|
||||
- `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed)
|
||||
- `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
|
||||
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
|
||||
5. Open the PR against `main` with a brief description of what changed and how to test it.
|
||||
|
||||
@@ -218,9 +257,8 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
|
||||
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
||||
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
||||
|
||||
## Issues and discussion
|
||||
## Issues
|
||||
|
||||
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
|
||||
- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
|
||||
|
||||
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
|
||||
|
||||
@@ -27,6 +27,16 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
||||
# Ports to exempt from the ban so an over-limit proxy client can never lock
|
||||
# the administrator out of SSH or the panel. The ban still covers every other
|
||||
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
|
||||
# added later without regenerating these files.
|
||||
SSH_PORTS=$(grep -oE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | grep -oE '[0-9]+' | paste -sd, -)
|
||||
[ -z "$SSH_PORTS" ] && SSH_PORTS="22"
|
||||
PANEL_PORT=$(/app/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
EXEMPT_PORTS="$SSH_PORTS"
|
||||
[ -n "$PANEL_PORT" ] && EXEMPT_PORTS="$EXEMPT_PORTS,$PANEL_PORT"
|
||||
|
||||
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||
[INCLUDES]
|
||||
before = iptables-allports.conf
|
||||
@@ -42,16 +52,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
protocol = tcp
|
||||
chain = INPUT
|
||||
exemptports = $EXEMPT_PORTS
|
||||
EOF
|
||||
|
||||
fail2ban-client -x start
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.2.7
|
||||
3.2.8
|
||||
218
database/dump_sqlite.go
Normal file
218
database/dump_sqlite.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// DumpSQLite writes a portable SQL text dump of the SQLite database at srcPath
|
||||
// to outPath. The output mirrors the `sqlite3 .dump` format (schema + data +
|
||||
// indexes wrapped in a transaction), so it can be rebuilt with RestoreSQLite or
|
||||
// loaded by the sqlite3 CLI. The source database is opened read-only in effect
|
||||
// and left untouched.
|
||||
func DumpSQLite(srcPath, outPath string) error {
|
||||
data, err := DumpSQLiteToBytes(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outPath, data, 0o644)
|
||||
}
|
||||
|
||||
// DumpSQLiteToBytes builds the same `sqlite3 .dump`-style SQL text as DumpSQLite
|
||||
// but returns it in memory, which the panel uses to stream a migration download.
|
||||
func DumpSQLiteToBytes(srcPath string) ([]byte, error) {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return nil, fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("PRAGMA foreign_keys=OFF;\n")
|
||||
b.WriteString("BEGIN TRANSACTION;\n")
|
||||
|
||||
// Tables in creation order, each followed by its data.
|
||||
type object struct{ name, ddl string }
|
||||
var tables []object
|
||||
rows, err := sqlDB.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY rowid`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var o object
|
||||
if err := rows.Scan(&o.name, &o.ddl); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
tables = append(tables, o)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, t := range tables {
|
||||
b.WriteString(t.ddl)
|
||||
b.WriteString(";\n")
|
||||
if err := dumpTableData(sqlDB, t.name, &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// AUTOINCREMENT bookkeeping, restored verbatim like the sqlite3 CLI does.
|
||||
if sqliteTableExists(sqlDB, "sqlite_sequence") {
|
||||
b.WriteString("DELETE FROM sqlite_sequence;\n")
|
||||
if err := dumpTableData(sqlDB, "sqlite_sequence", &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes, triggers and views after the data is in place.
|
||||
rows2, err := sqlDB.Query(`SELECT sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY rowid`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows2.Next() {
|
||||
var ddl string
|
||||
if err := rows2.Scan(&ddl); err != nil {
|
||||
rows2.Close()
|
||||
return nil, err
|
||||
}
|
||||
b.WriteString(ddl)
|
||||
b.WriteString(";\n")
|
||||
}
|
||||
if err := rows2.Err(); err != nil {
|
||||
rows2.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows2.Close()
|
||||
|
||||
b.WriteString("COMMIT;\n")
|
||||
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
// RestoreSQLite rebuilds a SQLite database at dstPath from a SQL text dump
|
||||
// produced by DumpSQLite (or `sqlite3 .dump`). dstPath must not already exist so
|
||||
// an existing database is never clobbered silently.
|
||||
func RestoreSQLite(dumpPath, dstPath string) error {
|
||||
script, err := os.ReadFile(dumpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dstPath); err == nil {
|
||||
return fmt.Errorf("destination already exists: %s", dstPath)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// mattn/go-sqlite3 executes every statement in a multi-statement string.
|
||||
if _, err := sqlDB.Exec(string(script)); err != nil {
|
||||
sqlDB.Close()
|
||||
os.Remove(dstPath)
|
||||
return fmt.Errorf("restore failed: %w", err)
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// dumpTableData appends one INSERT statement per row of table to b.
|
||||
func dumpTableData(db *sql.DB, table string, b *strings.Builder) error {
|
||||
rows, err := db.Query(`SELECT * FROM "` + table + `"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n := len(cols)
|
||||
prefix := `INSERT INTO "` + table + `" VALUES(`
|
||||
|
||||
for rows.Next() {
|
||||
vals := make([]any, n)
|
||||
ptrs := make([]any, n)
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return err
|
||||
}
|
||||
b.WriteString(prefix)
|
||||
for i, v := range vals {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(sqliteLiteral(v))
|
||||
}
|
||||
b.WriteString(");\n")
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// sqliteLiteral renders a scanned column value as a SQLite SQL literal.
|
||||
func sqliteLiteral(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return "NULL"
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'g', -1, 64)
|
||||
case bool:
|
||||
if x {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
case string:
|
||||
return quoteSQLiteText(x)
|
||||
case []byte:
|
||||
if utf8.Valid(x) {
|
||||
return quoteSQLiteText(string(x))
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("X'")
|
||||
for _, c := range x {
|
||||
fmt.Fprintf(&sb, "%02x", c)
|
||||
}
|
||||
sb.WriteByte('\'')
|
||||
return sb.String()
|
||||
default:
|
||||
return quoteSQLiteText(fmt.Sprintf("%v", x))
|
||||
}
|
||||
}
|
||||
|
||||
func quoteSQLiteText(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
func sqliteTableExists(db *sql.DB, name string) bool {
|
||||
var found string
|
||||
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&found)
|
||||
return err == nil
|
||||
}
|
||||
137
database/dump_sqlite_test.go
Normal file
137
database/dump_sqlite_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestCopyAllModelsIntoSQLite exercises the same AutoMigrate + copyTable
|
||||
// machinery that ExportPostgresToSQLite relies on, but with a SQLite source so
|
||||
// it needs no external database. The Postgres source path uses identical gorm
|
||||
// reads (see MigrateData), so this validates the destination-side copy.
|
||||
func TestCopyAllModelsIntoSQLite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
srcPath := filepath.Join(dir, "src.db")
|
||||
dstPath := filepath.Join(dir, "dst.db")
|
||||
|
||||
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer closeGorm(src)
|
||||
for _, m := range migrationModels() {
|
||||
if err := src.AutoMigrate(m); err != nil {
|
||||
t.Fatalf("automigrate src %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed a few rows across parent/child tables and a composite-PK table.
|
||||
if err := src.Create(&model.User{Username: "admin", Password: "x"}).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
if err := src.Create(&model.Inbound{UserId: 1, Remark: "in", Port: 443, Protocol: "vless", Tag: "inbound-443"}).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
if err := src.Create(&xray.ClientTraffic{InboundId: 1, Email: "a@b.c", Enable: true, Up: 10, Down: 20}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
|
||||
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open dst: %v", err)
|
||||
}
|
||||
defer closeGorm(dst)
|
||||
if err := copyAllModels(src, dst); err != nil {
|
||||
t.Fatalf("copyAllModels: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
model any
|
||||
want int64
|
||||
}{
|
||||
{&model.User{}, 1},
|
||||
{&model.Inbound{}, 1},
|
||||
{&xray.ClientTraffic{}, 1},
|
||||
} {
|
||||
var got int64
|
||||
if err := dst.Model(tc.model).Count(&got).Error; err != nil {
|
||||
t.Fatalf("count %T: %v", tc.model, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("%T: got %d rows, want %d", tc.model, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Spot-check a copied value survived the round-trip.
|
||||
var ct xray.ClientTraffic
|
||||
if err := dst.Where("email = ?", "a@b.c").First(&ct).Error; err != nil {
|
||||
t.Fatalf("read back traffic: %v", err)
|
||||
}
|
||||
if ct.Up != 10 || ct.Down != 20 || !ct.Enable {
|
||||
t.Errorf("traffic mismatch: %+v", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDumpAndRestoreSQLiteRoundTrip dumps a seeded SQLite db to .dump text and
|
||||
// rebuilds it, asserting the row survives.
|
||||
func TestDumpAndRestoreSQLiteRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
srcPath := filepath.Join(dir, "src.db")
|
||||
dumpPath := filepath.Join(dir, "out.dump")
|
||||
dstPath := filepath.Join(dir, "rebuilt.db")
|
||||
|
||||
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
if err := src.AutoMigrate(&model.Setting{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
if err := src.Create(&model.Setting{Key: "secret", Value: "o'brien \"quote\""}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if sqlDB, _ := src.DB(); sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
if err := DumpSQLite(srcPath, dumpPath); err != nil {
|
||||
t.Fatalf("DumpSQLite: %v", err)
|
||||
}
|
||||
if fi, err := os.Stat(dumpPath); err != nil || fi.Size() == 0 {
|
||||
t.Fatalf("dump missing/empty: %v", err)
|
||||
}
|
||||
if err := RestoreSQLite(dumpPath, dstPath); err != nil {
|
||||
t.Fatalf("RestoreSQLite: %v", err)
|
||||
}
|
||||
|
||||
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open dst: %v", err)
|
||||
}
|
||||
defer closeGorm(dst)
|
||||
var s model.Setting
|
||||
if err := dst.Where("key = ?", "secret").First(&s).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if s.Value != "o'brien \"quote\"" {
|
||||
t.Errorf("value mismatch after round-trip: %q", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// closeGorm closes the underlying *sql.DB so Windows can delete the temp file.
|
||||
func closeGorm(db *gorm.DB) {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
if s, err := db.DB(); err == nil {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,23 @@ func MigrateData(srcPath, dstDSN string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
|
||||
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
|
||||
// client_traffics rows whose inbound was deleted. Drop it here too so copying
|
||||
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
|
||||
if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
|
||||
return fmt.Errorf("drop legacy foreign key: %w", err)
|
||||
}
|
||||
|
||||
// Empty the destination tables so the migration is idempotent: a fresh
|
||||
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
|
||||
// panel start, and a partially-failed earlier run leaves rows behind. Either
|
||||
// way a plain INSERT with explicit ids would collide on users_pkey, so clear
|
||||
// our tables (only) before copying.
|
||||
if err := truncatePostgresTables(dst, migrationModels()); err != nil {
|
||||
return fmt.Errorf("clear destination tables: %w", err)
|
||||
}
|
||||
|
||||
totalRows := 0
|
||||
for _, m := range migrationModels() {
|
||||
n, err := copyTable(src, dst, m)
|
||||
@@ -105,6 +122,62 @@ func MigrateData(srcPath, dstDSN string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportPostgresToSQLite copies every row from the PostgreSQL database described
|
||||
// by srcDSN into a fresh SQLite file at dstPath. It is the reverse of
|
||||
// MigrateData and is used to hand a PostgreSQL-backed panel a portable .db file.
|
||||
// dstPath is created/overwritten; the PostgreSQL source is left untouched.
|
||||
func ExportPostgresToSQLite(srcDSN, dstPath string) error {
|
||||
if srcDSN == "" {
|
||||
return errors.New("source DSN is required")
|
||||
}
|
||||
if err := os.MkdirAll(path.Dir(dstPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Start from an empty file so AutoMigrate creates the canonical schema.
|
||||
if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
src, err := gorm.Open(postgres.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open postgres source: %w", err)
|
||||
}
|
||||
srcSQL, err := src.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcSQL.Close()
|
||||
|
||||
// No WAL: keep all data in the main file so it is complete once closed.
|
||||
dst, err := gorm.Open(sqlite.Open(dstPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sqlite destination: %w", err)
|
||||
}
|
||||
dstSQL, err := dst.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstSQL.Close()
|
||||
|
||||
return copyAllModels(src, dst)
|
||||
}
|
||||
|
||||
// copyAllModels (re)creates the schema on dst and copies every migrated table
|
||||
// from src to dst in FK-safe order. src/dst may be any gorm backend.
|
||||
func copyAllModels(src, dst *gorm.DB) error {
|
||||
for _, m := range migrationModels() {
|
||||
if err := dst.AutoMigrate(m); err != nil {
|
||||
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
for _, m := range migrationModels() {
|
||||
if _, err := copyTable(src, dst, m); err != nil {
|
||||
return fmt.Errorf("copy %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||
const batchSize = 500
|
||||
|
||||
@@ -157,6 +230,26 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// truncatePostgresTables empties every migrated table on dst in a single
|
||||
// statement, resetting identity sequences. CASCADE covers the inbound/client
|
||||
// foreign keys regardless of insertion order. Only the panel's own tables are
|
||||
// touched, never the rest of the schema.
|
||||
func truncatePostgresTables(dst *gorm.DB, models []any) error {
|
||||
tables := make([]string, 0, len(models))
|
||||
for _, m := range models {
|
||||
stmt := &gorm.Statement{DB: dst}
|
||||
if err := stmt.Parse(m); err != nil {
|
||||
return err
|
||||
}
|
||||
tables = append(tables, `"`+stmt.Schema.Table+`"`)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Println("Clearing destination tables...")
|
||||
return dst.Exec("TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE").Error
|
||||
}
|
||||
|
||||
// resetPostgresSequences advances each migrated table's id sequence past MAX(id),
|
||||
// otherwise the next INSERT-without-id would clash with copied rows.
|
||||
func resetPostgresSequences(dst *gorm.DB) error {
|
||||
|
||||
@@ -396,6 +396,9 @@ type Node struct {
|
||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||
LastError string `json:"lastError"`
|
||||
|
||||
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||
|
||||
InboundCount int `json:"inboundCount" gorm:"-"`
|
||||
ClientCount int `json:"clientCount" gorm:"-"`
|
||||
OnlineCount int `json:"onlineCount" gorm:"-"`
|
||||
|
||||
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
@@ -17,7 +17,7 @@
|
||||
"axios": "^1.17.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next": "^26.3.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.2",
|
||||
@@ -1934,9 +1934,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1954,9 +1951,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1974,9 +1968,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1994,9 +1985,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2014,9 +2002,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2034,9 +2019,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5087,9 +5069,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -5615,9 +5597,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5639,9 +5618,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5663,9 +5639,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5687,9 +5660,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"axios": "^1.17.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next": "^26.3.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.2",
|
||||
|
||||
@@ -1495,6 +1495,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/getMigration": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.",
|
||||
"operationId": "get_panel_api_server_getMigration",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/getNewUUID": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -5761,7 +5791,7 @@
|
||||
"tags": [
|
||||
"Subscription Server"
|
||||
],
|
||||
"summary": "Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
|
||||
"summary": "Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
|
||||
"operationId": "get_clashPath_subid",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -34,7 +34,9 @@ export interface AllSetting {
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subDomain: string;
|
||||
subEmailInRemark: boolean;
|
||||
@@ -42,9 +44,8 @@ export interface AllSetting {
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFragment: string;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonNoises: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
@@ -121,7 +122,9 @@ export interface AllSettingView {
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subDomain: string;
|
||||
subEmailInRemark: boolean;
|
||||
@@ -129,9 +132,8 @@ export interface AllSettingView {
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFragment: string;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonNoises: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
@@ -320,6 +322,8 @@ export interface Node {
|
||||
apiToken: string;
|
||||
basePath: string;
|
||||
clientCount: number;
|
||||
configDirty: boolean;
|
||||
configDirtyAt: number;
|
||||
cpuPct: number;
|
||||
createdAt: number;
|
||||
depletedCount: number;
|
||||
|
||||
@@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEmailInRemark: z.boolean(),
|
||||
@@ -44,9 +46,8 @@ export const AllSettingSchema = z.object({
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFragment: z.string(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonNoises: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
@@ -124,7 +125,9 @@ export const AllSettingViewSchema = z.object({
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEmailInRemark: z.boolean(),
|
||||
@@ -132,9 +135,8 @@ export const AllSettingViewSchema = z.object({
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFragment: z.string(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonNoises: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
@@ -337,6 +339,8 @@ export const NodeSchema = z.object({
|
||||
apiToken: z.string(),
|
||||
basePath: z.string(),
|
||||
clientCount: z.number().int(),
|
||||
configDirty: z.boolean(),
|
||||
configDirtyAt: z.number().int(),
|
||||
cpuPct: z.number(),
|
||||
createdAt: z.number().int(),
|
||||
depletedCount: z.number().int(),
|
||||
|
||||
@@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { OutboundProtocols } from '@/schemas/primitives';
|
||||
|
||||
// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
|
||||
// paths under `name`; the parent modal owns the Form instance.
|
||||
//
|
||||
// Naming convention inside Form.List: AntD prefixes Form.Item `name`
|
||||
// with the Form.List's own `name`. So Form.Items inside the render
|
||||
// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
|
||||
// Form.Lists also use relative names. Using absolute paths here would
|
||||
// double up the prefix and silently route reads/writes to the wrong
|
||||
// storage path.
|
||||
|
||||
export interface FinalMaskFormProps {
|
||||
name: NamePath;
|
||||
network: string;
|
||||
protocol: string;
|
||||
form: FormInstance;
|
||||
// When true, all sections (TCP / UDP / QUIC) are shown regardless of
|
||||
// network/protocol. Used by the global sub-JSON finalmask editor where
|
||||
// the masks apply to every stream rather than one specific transport.
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
|
||||
@@ -99,12 +93,12 @@ function defaultUdpHop(): Record<string, unknown> {
|
||||
return { ports: '20000-50000', interval: '5-10' };
|
||||
}
|
||||
|
||||
export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
|
||||
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
||||
const base = asPath(name);
|
||||
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
|
||||
const showTcp = TCP_NETWORKS.includes(network);
|
||||
const showUdp = isHysteria || network === 'kcp';
|
||||
const showQuic = isHysteria || network === 'xhttp';
|
||||
const showTcp = showAll || TCP_NETWORKS.includes(network);
|
||||
const showUdp = showAll || isHysteria || network === 'kcp';
|
||||
const showQuic = showAll || isHysteria || network === 'xhttp';
|
||||
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
|
||||
const hasQuicParams = quicParams != null;
|
||||
|
||||
@@ -392,13 +386,13 @@ function UdpMaskItem({
|
||||
const options = isHysteria
|
||||
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
|
||||
: [
|
||||
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'realm', label: 'Realm' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
];
|
||||
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'realm', label: 'Realm' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -137,6 +137,7 @@ function applyExternalProxyTLSObj(
|
||||
if (alpn.length > 0) obj.alpn = alpn;
|
||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||
if (pins.length > 0) obj.pcs = pins;
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
|
||||
}
|
||||
|
||||
export interface GenVmessLinkInput {
|
||||
@@ -232,6 +233,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
||||
if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
|
||||
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
|
||||
if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
|
||||
if (tlsSettings.settings.echConfigList.length > 0) obj.ech = tlsSettings.settings.echConfigList;
|
||||
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
|
||||
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
|
||||
}
|
||||
@@ -279,6 +281,7 @@ function applyExternalProxyTLSParams(
|
||||
if (alpn.length > 0) params.set('alpn', alpn);
|
||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||
if (pins.length > 0) params.set('pcs', pins);
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
|
||||
}
|
||||
|
||||
export interface GenVlessLinkInput {
|
||||
|
||||
@@ -203,6 +203,8 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
|
||||
tls.fingerprint = params.get('fp') ?? '';
|
||||
const alpn = params.get('alpn');
|
||||
if (alpn) tls.alpn = alpn.split(',');
|
||||
tls.echConfigList = params.get('ech') ?? '';
|
||||
tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
|
||||
} else if (stream.security === 'reality') {
|
||||
const reality = stream.realitySettings as Raw;
|
||||
reality.serverName = params.get('sni') ?? '';
|
||||
|
||||
@@ -55,10 +55,11 @@ export class AllSetting {
|
||||
subURI = '';
|
||||
subJsonURI = '';
|
||||
subClashURI = '';
|
||||
subJsonFragment = '';
|
||||
subJsonNoises = '';
|
||||
subClashEnableRouting = false;
|
||||
subClashRules = '';
|
||||
subJsonMux = '';
|
||||
subJsonRules = '';
|
||||
subJsonFinalMask = '';
|
||||
|
||||
timeLocation = 'Local';
|
||||
|
||||
|
||||
@@ -307,6 +307,11 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/server/getDb',
|
||||
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getMigration',
|
||||
summary: 'Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewUUID',
|
||||
@@ -1109,7 +1114,7 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/{clashPath}:subid',
|
||||
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
|
||||
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
|
||||
params: [
|
||||
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||
],
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function ClientBulkAddModal({
|
||||
)}
|
||||
{form.emailMethod < 2 && (
|
||||
<Form.Item label={t('pages.clients.clientCount')}>
|
||||
<InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
|
||||
<InputNumber value={form.quantity} min={1} max={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ import type { ClientFilters } from './filters';
|
||||
import './ClientsPage.css';
|
||||
|
||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||
const DISABLED_PAGE_SIZE = 200;
|
||||
|
||||
function UngroupIcon() {
|
||||
return (
|
||||
@@ -276,10 +277,7 @@ export default function ClientsPage() {
|
||||
const activeCount = activeFilterCount(filters);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageSize > 0) {
|
||||
|
||||
setTablePageSize(pageSize);
|
||||
}
|
||||
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
|
||||
}, [pageSize]);
|
||||
|
||||
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
||||
|
||||
@@ -16,6 +16,7 @@ const newEntry = () => ({
|
||||
fingerprint: '',
|
||||
alpn: [],
|
||||
pinnedPeerCertSha256: [],
|
||||
echConfigList: '',
|
||||
});
|
||||
|
||||
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
||||
@@ -92,9 +93,9 @@ export default function ExternalProxyForm({
|
||||
/>
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('host')}>
|
||||
<Field label={t('pages.inbounds.address')}>
|
||||
<Form.Item name={[field.name, 'dest']} noStyle>
|
||||
<Input placeholder={t('host')} />
|
||||
<Input placeholder={t('pages.inbounds.address')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.port')}>
|
||||
@@ -125,7 +126,7 @@ export default function ExternalProxyForm({
|
||||
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
||||
<Field label="SNI">
|
||||
<Form.Item name={[field.name, 'sni']} noStyle>
|
||||
<Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
|
||||
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.form.fingerprint')}>
|
||||
@@ -157,6 +158,11 @@ export default function ExternalProxyForm({
|
||||
</Form.Item>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('pages.inbounds.form.echConfig')}>
|
||||
<Form.Item name={[field.name, 'echConfigList']} noStyle>
|
||||
<Input placeholder={t('pages.inbounds.form.echConfig')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
|
||||
|
||||
@@ -25,6 +25,10 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
||||
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
|
||||
}
|
||||
|
||||
function exportMigration() {
|
||||
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getMigration';
|
||||
}
|
||||
|
||||
function importDb() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
@@ -82,6 +86,16 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
||||
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
|
||||
</div>
|
||||
|
||||
<div className="backup-item">
|
||||
<div className="backup-meta">
|
||||
<div className="backup-title">{t('pages.index.migrationDownload')}</div>
|
||||
<div className="backup-description">
|
||||
{isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} />
|
||||
</div>
|
||||
|
||||
<div className="backup-item">
|
||||
<div className="backup-meta">
|
||||
<div className="backup-title">{t('pages.index.importDatabase')}</div>
|
||||
|
||||
55
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx
Normal file
55
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Form } from 'antd';
|
||||
|
||||
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
||||
|
||||
interface SubJsonFinalMaskFormProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}
|
||||
|
||||
function hasValue(v: unknown): boolean {
|
||||
if (v == null) return false;
|
||||
if (Array.isArray(v)) return v.some(hasValue);
|
||||
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
|
||||
if (typeof v === 'string') return v.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseFinalMask(raw: string): FinalMaskStreamSettings {
|
||||
try {
|
||||
if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
|
||||
} catch {
|
||||
return { tcp: [], udp: [] };
|
||||
}
|
||||
return { tcp: [], udp: [] };
|
||||
}
|
||||
|
||||
export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [initial] = useState(() => parseFinalMask(value));
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (finalmask === undefined) return;
|
||||
const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
|
||||
if (next !== value) onChangeRef.current(next);
|
||||
}, [finalmask, value]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '160px' }}
|
||||
wrapperCol={{ flex: 'auto' }}
|
||||
colon={false}
|
||||
initialValues={{ finalmask: initial }}
|
||||
>
|
||||
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
@@ -10,19 +8,17 @@ import {
|
||||
Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PartitionOutlined,
|
||||
PlusOutlined,
|
||||
ScissorOutlined,
|
||||
RocketOutlined,
|
||||
SendOutlined,
|
||||
SettingOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath, normalizePath } from './uriPath';
|
||||
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
|
||||
import './SubscriptionFormatsTab.css';
|
||||
|
||||
interface SubscriptionFormatsTabProps {
|
||||
@@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FRAGMENT = {
|
||||
packets: 'tlshello',
|
||||
length: '100-200',
|
||||
interval: '10-20',
|
||||
maxSplit: '300-400',
|
||||
};
|
||||
const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [
|
||||
{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' },
|
||||
];
|
||||
const DEFAULT_MUX = {
|
||||
enabled: true,
|
||||
concurrency: 8,
|
||||
@@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const fragment = allSetting.subJsonFragment !== '';
|
||||
const noisesEnabled = allSetting.subJsonNoises !== '';
|
||||
const muxEnabled = allSetting.subJsonMux !== '';
|
||||
const directEnabled = allSetting.subJsonRules !== '';
|
||||
|
||||
const fragmentObj = useMemo(
|
||||
() => (fragment ? readJson<typeof DEFAULT_FRAGMENT>(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT),
|
||||
[allSetting.subJsonFragment, fragment],
|
||||
);
|
||||
|
||||
function setFragmentEnabled(v: boolean) {
|
||||
updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' });
|
||||
}
|
||||
|
||||
function setFragmentField<K extends keyof typeof DEFAULT_FRAGMENT>(key: K, value: string) {
|
||||
if (value === '') return;
|
||||
const next = { ...fragmentObj, [key]: value };
|
||||
updateSetting({ subJsonFragment: JSON.stringify(next) });
|
||||
}
|
||||
|
||||
const noisesArray = useMemo(
|
||||
() => (noisesEnabled ? readJson<typeof DEFAULT_NOISES>(allSetting.subJsonNoises, DEFAULT_NOISES) : []),
|
||||
[allSetting.subJsonNoises, noisesEnabled],
|
||||
);
|
||||
|
||||
function setNoisesEnabled(v: boolean) {
|
||||
updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' });
|
||||
}
|
||||
|
||||
function setNoisesArray(next: typeof DEFAULT_NOISES) {
|
||||
if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) });
|
||||
}
|
||||
|
||||
function addNoise() {
|
||||
setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]);
|
||||
}
|
||||
|
||||
function removeNoise(index: number) {
|
||||
const next = [...noisesArray];
|
||||
next.splice(index, 1);
|
||||
setNoisesArray(next);
|
||||
}
|
||||
|
||||
function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) {
|
||||
const next = [...noisesArray];
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
setNoisesArray(next);
|
||||
}
|
||||
|
||||
const muxObj = useMemo(
|
||||
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
|
||||
[allSetting.subJsonMux, muxEnabled],
|
||||
@@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
|
||||
label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
|
||||
<Switch checked={fragment} onChange={setFragmentEnabled} />
|
||||
</SettingListItem>
|
||||
{fragment && (
|
||||
<div className="format-settings">
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
|
||||
<Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
|
||||
onChange={(e) => setFragmentField('packets', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
|
||||
<Input value={fragmentObj.length} placeholder="100-200"
|
||||
onChange={(e) => setFragmentField('length', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
|
||||
<Input value={fragmentObj.interval} placeholder="10-20"
|
||||
onChange={(e) => setFragmentField('interval', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
|
||||
<Input value={fragmentObj.maxSplit} placeholder="300-400"
|
||||
onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
|
||||
</SettingListItem>
|
||||
</div>
|
||||
)}
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
|
||||
<SubJsonFinalMaskForm
|
||||
value={allSetting.subJsonFinalMask}
|
||||
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
|
||||
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
|
||||
</SettingListItem>
|
||||
{noisesEnabled && (
|
||||
<div className="format-settings-list">
|
||||
{noisesArray.map((noise, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
size="small"
|
||||
className="noise-card"
|
||||
title={t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
|
||||
extra={noisesArray.length > 1 ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => removeNoise(index)}
|
||||
/>
|
||||
) : null}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
|
||||
<Select
|
||||
value={noise.type}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateNoiseField(index, 'type', v)}
|
||||
options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
|
||||
<Input value={noise.packet} placeholder="5-10"
|
||||
onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
|
||||
<Input value={noise.delay} placeholder="10-20"
|
||||
onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
|
||||
<Select
|
||||
value={noise.applyTo}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateNoiseField(index, 'applyTo', v)}
|
||||
options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
|
||||
{t('pages.settings.subFormats.addNoise')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
@@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
key: '4',
|
||||
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
|
||||
@@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
|
||||
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<Divider>Clash / Mihomo</Divider>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
|
||||
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
|
||||
<Input.TextArea
|
||||
value={allSetting.subClashRules}
|
||||
rows={8}
|
||||
placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
|
||||
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -182,7 +182,7 @@ export const ClientBulkAddFormSchema = z.object({
|
||||
lastNum: z.number().int().min(1),
|
||||
emailPrefix: z.string(),
|
||||
emailPostfix: z.string(),
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
quantity: z.number().int().min(1).max(1000),
|
||||
subId: z.string(),
|
||||
group: z.string(),
|
||||
comment: z.string(),
|
||||
|
||||
@@ -23,5 +23,6 @@ export const ExternalProxyEntrySchema = z.object({
|
||||
),
|
||||
alpn: z.array(AlpnSchema).optional(),
|
||||
pinnedPeerCertSha256: z.array(z.string()).optional(),
|
||||
echConfigList: z.string().optional(),
|
||||
});
|
||||
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
|
||||
|
||||
@@ -59,10 +59,11 @@ export const AllSettingSchema = z.object({
|
||||
subURI: z.string().optional(),
|
||||
subJsonURI: z.string().optional(),
|
||||
subClashURI: z.string().optional(),
|
||||
subJsonFragment: z.string().optional(),
|
||||
subJsonNoises: z.string().optional(),
|
||||
subClashEnableRouting: z.boolean().optional(),
|
||||
subClashRules: z.string().optional(),
|
||||
subJsonMux: z.string().optional(),
|
||||
subJsonRules: z.string().optional(),
|
||||
subJsonFinalMask: z.string().optional(),
|
||||
timeLocation: z.string().optional(),
|
||||
ldapEnable: z.boolean().optional(),
|
||||
ldapHost: z.string().optional(),
|
||||
|
||||
@@ -360,6 +360,21 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
|
||||
const stream = parsed!.streamSettings as Record<string, unknown>;
|
||||
expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto');
|
||||
});
|
||||
|
||||
it('round-trips ech and pcs from a TLS vless link', () => {
|
||||
const ech = 'AFb+DQBSAAAgACAL7gYwrvaSFCIEs34G3SkfpuIbjMuYQxAiJsPK1oO7cwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAMxMjMAAA==';
|
||||
const pcs = '6fbc15ba46dfed152ad6c8d2129dd774707dd667a9ab4965476fa0f79ba82670';
|
||||
const link = 'vless://e3d307ae-c074-4aa3-af08-4f9e0f1d298b@localhost:15282?'
|
||||
+ 'alpn=h3&ech=' + encodeURIComponent(ech) + '&encryption=none&fp=firefox&host=&'
|
||||
+ 'mode=packet-up&path=%2F&pcs=' + pcs + '&security=tls&sni=123&type=xhttp#i5sboxj07w';
|
||||
const parsed = parseVlessLink(link);
|
||||
expect(parsed).not.toBeNull();
|
||||
const tls = (parsed!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
|
||||
expect(tls.echConfigList).toBe(ech);
|
||||
expect(tls.pinnedPeerCertSha256).toBe(pcs);
|
||||
expect(tls.serverName).toBe('123');
|
||||
expect(tls.fingerprint).toBe('firefox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWireguardLink', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import i18next from 'i18next';
|
||||
import { getMessage } from './messageBus';
|
||||
|
||||
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
|
||||
@@ -32,6 +33,14 @@ export class HttpUtil {
|
||||
}
|
||||
const messageType = msg.success ? 'success' : 'error';
|
||||
getMessage()[messageType](msg.msg);
|
||||
if (
|
||||
msg.success &&
|
||||
msg.obj &&
|
||||
typeof msg.obj === 'object' &&
|
||||
(msg.obj as { nodePending?: unknown }).nodePending === true
|
||||
) {
|
||||
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
|
||||
}
|
||||
}
|
||||
|
||||
static _respToMsg(resp: AxiosResponse | undefined): Msg {
|
||||
@@ -858,13 +867,13 @@ export class LanguageManager {
|
||||
});
|
||||
|
||||
if (LanguageManager.isSupportLanguage(lang)) {
|
||||
CookieManager.setCookie('lang', lang);
|
||||
CookieManager.setCookie('lang', lang, 365);
|
||||
} else {
|
||||
CookieManager.setCookie('lang', 'en-US');
|
||||
CookieManager.setCookie('lang', 'en-US', 365);
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
CookieManager.setCookie('lang', 'en-US');
|
||||
CookieManager.setCookie('lang', 'en-US', 365);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
@@ -875,7 +884,7 @@ export class LanguageManager {
|
||||
if (!LanguageManager.isSupportLanguage(language)) {
|
||||
language = 'en-US';
|
||||
}
|
||||
CookieManager.setCookie('lang', language);
|
||||
CookieManager.setCookie('lang', language, 365);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
||||
42
install.sh
42
install.sh
@@ -297,7 +297,7 @@ setup_ssl_certificate() {
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
|
||||
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
|
||||
rm -rf ~/.acme.sh/${domain} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc 2> /dev/null
|
||||
rm -rf "$certPath" 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
@@ -431,8 +431,8 @@ setup_ip_certificate() {
|
||||
echo -e "${red}Failed to issue IP certificate${plain}"
|
||||
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
|
||||
rm -rf ${certDir} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
@@ -451,8 +451,8 @@ setup_ip_certificate() {
|
||||
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
|
||||
echo -e "${red}Certificate files not found after installation${plain}"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
|
||||
rm -rf ${certDir} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
@@ -524,14 +524,30 @@ ssl_cert_issue() {
|
||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||
SSL_ISSUED_DOMAIN="${domain}"
|
||||
|
||||
# detect existing certificate and reuse it if present
|
||||
# detect existing certificate and reuse it only if its files are actually
|
||||
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
|
||||
# certs under ${domain}; a failed issuance can leave a domain entry in --list
|
||||
# with no usable cert files, which must not be reused (it produces a 0-byte
|
||||
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
|
||||
local cert_exists=0
|
||||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||
else
|
||||
local acmeCertDir=""
|
||||
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
|
||||
acmeCertDir=~/.acme.sh/${domain}_ecc
|
||||
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
|
||||
acmeCertDir=~/.acme.sh/${domain}
|
||||
fi
|
||||
if [[ -n "${acmeCertDir}" ]]; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||
else
|
||||
echo -e "${yellow}Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
fi
|
||||
fi
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||
fi
|
||||
|
||||
@@ -563,7 +579,7 @@ ssl_cert_issue() {
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
return 1
|
||||
else
|
||||
@@ -617,7 +633,7 @@ ssl_cert_issue() {
|
||||
else
|
||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
fi
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
return 1
|
||||
|
||||
39
main.go
39
main.go
@@ -466,8 +466,14 @@ func main() {
|
||||
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
|
||||
var migrateDsn string
|
||||
var migrateSrc string
|
||||
var migrateDump string
|
||||
var migrateRestore string
|
||||
var migrateOut 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)")
|
||||
migrateDbCmd.StringVar(&migrateDump, "dump", "", "Write a portable SQL text dump of --src to this file (.db -> .dump)")
|
||||
migrateDbCmd.StringVar(&migrateRestore, "restore", "", "Rebuild a SQLite database from this SQL text dump (.dump -> .db); requires --out")
|
||||
migrateDbCmd.StringVar(&migrateOut, "out", "", "Destination SQLite file for --restore (must not already exist)")
|
||||
|
||||
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
||||
var port int
|
||||
@@ -512,7 +518,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(" migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
|
||||
fmt.Println(" setting set settings")
|
||||
}
|
||||
|
||||
@@ -541,13 +547,30 @@ func main() {
|
||||
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)
|
||||
switch {
|
||||
case migrateDump != "":
|
||||
if err := database.DumpSQLite(src, migrateDump); err != nil {
|
||||
fmt.Println("dump failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Dumped %s -> %s\n", src, migrateDump)
|
||||
case migrateRestore != "":
|
||||
if migrateOut == "" {
|
||||
fmt.Println("--out is required when using --restore: the destination .db path (must not exist)")
|
||||
return
|
||||
}
|
||||
if err := database.RestoreSQLite(migrateRestore, migrateOut); err != nil {
|
||||
fmt.Println("restore failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Restored %s -> %s\n", migrateRestore, migrateOut)
|
||||
case migrateDsn != "":
|
||||
if err := database.MigrateData(src, migrateDsn); err != nil {
|
||||
fmt.Println("migration failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
fmt.Println("nothing to do: pass --dump <file>, --restore <file> --out <db>, or --dsn <postgres-dsn>")
|
||||
}
|
||||
case "setting":
|
||||
err := settingCmd.Parse(os.Args[2:])
|
||||
|
||||
27
sub/sub.go
27
sub/sub.go
@@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubUpdates = "10"
|
||||
}
|
||||
|
||||
SubJsonFragment, err := s.settingService.GetSubJsonFragment()
|
||||
if err != nil {
|
||||
SubJsonFragment = ""
|
||||
}
|
||||
|
||||
SubJsonNoises, err := s.settingService.GetSubJsonNoises()
|
||||
if err != nil {
|
||||
SubJsonNoises = ""
|
||||
}
|
||||
|
||||
SubJsonMux, err := s.settingService.GetSubJsonMux()
|
||||
if err != nil {
|
||||
SubJsonMux = ""
|
||||
@@ -140,6 +130,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubJsonRules = ""
|
||||
}
|
||||
|
||||
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
|
||||
if err != nil {
|
||||
SubJsonFinalMask = ""
|
||||
}
|
||||
|
||||
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
|
||||
if err != nil {
|
||||
SubClashEnableRouting = false
|
||||
}
|
||||
|
||||
SubClashRules, err := s.settingService.GetSubClashRules()
|
||||
if err != nil {
|
||||
SubClashRules = ""
|
||||
}
|
||||
|
||||
SubTitle, err := s.settingService.GetSubTitle()
|
||||
if err != nil {
|
||||
SubTitle = ""
|
||||
@@ -226,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
|
||||
s.sub = NewSUBController(
|
||||
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
||||
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
|
||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||
|
||||
return engine, nil
|
||||
|
||||
@@ -15,17 +15,13 @@ import (
|
||||
|
||||
type SubClashService struct {
|
||||
inboundService service.InboundService
|
||||
enableRouting bool
|
||||
clashRules string
|
||||
SubService *SubService
|
||||
}
|
||||
|
||||
type ClashConfig struct {
|
||||
Proxies []map[string]any `yaml:"proxies"`
|
||||
ProxyGroups []map[string]any `yaml:"proxy-groups"`
|
||||
Rules []string `yaml:"rules"`
|
||||
}
|
||||
|
||||
func NewSubClashService(subService *SubService) *SubClashService {
|
||||
return &SubClashService{SubService: subService}
|
||||
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
|
||||
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
|
||||
}
|
||||
|
||||
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
||||
@@ -76,14 +72,20 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
}
|
||||
proxyNames = append(proxyNames, "DIRECT")
|
||||
|
||||
config := ClashConfig{
|
||||
Proxies: proxies,
|
||||
ProxyGroups: []map[string]any{{
|
||||
config := map[string]any{
|
||||
"proxies": proxies,
|
||||
"proxy-groups": []map[string]any{{
|
||||
"name": "PROXY",
|
||||
"type": "select",
|
||||
"proxies": proxyNames,
|
||||
}},
|
||||
Rules: []string{"MATCH,PROXY"},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
|
||||
if s.enableRouting {
|
||||
if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
finalYAML, err := yaml.Marshal(config)
|
||||
@@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
|
||||
maps.Copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func mergeClashRulesYAML(base map[string]any, raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var custom any
|
||||
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
|
||||
mergeClashRules(base, linesToClashRules(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
switch typed := custom.(type) {
|
||||
case []any:
|
||||
mergeClashRules(base, typed)
|
||||
case map[string]any:
|
||||
if rules, ok := typed["rules"]; ok {
|
||||
if ruleList, ok := asAnySlice(rules); ok {
|
||||
mergeClashRules(base, ruleList)
|
||||
}
|
||||
}
|
||||
default:
|
||||
mergeClashRules(base, linesToClashRules(raw))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeClashRules(base map[string]any, customRules []any) {
|
||||
if len(customRules) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
baseRules, _ := asAnySlice(base["rules"])
|
||||
if hasClashMatchRule(customRules) {
|
||||
base["rules"] = customRules
|
||||
return
|
||||
}
|
||||
|
||||
merged := make([]any, 0, len(customRules)+len(baseRules))
|
||||
merged = append(merged, customRules...)
|
||||
merged = append(merged, baseRules...)
|
||||
base["rules"] = merged
|
||||
}
|
||||
|
||||
func asAnySlice(value any) ([]any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return typed, true
|
||||
case []string:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, true
|
||||
case []map[string]any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func hasClashMatchRule(rules []any) bool {
|
||||
for _, rule := range rules {
|
||||
ruleText, ok := rule.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(ruleText, ",", 2)
|
||||
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func linesToClashRules(raw string) []any {
|
||||
lines := strings.Split(raw, "\n")
|
||||
rules := make([]any, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, line)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
@@ -62,10 +62,11 @@ func NewSUBController(
|
||||
showInfo bool,
|
||||
rModel string,
|
||||
update string,
|
||||
jsonFragment string,
|
||||
jsonNoise string,
|
||||
jsonMux string,
|
||||
jsonRules string,
|
||||
jsonFinalMask string,
|
||||
clashEnableRouting bool,
|
||||
clashRules string,
|
||||
subTitle string,
|
||||
subSupportUrl string,
|
||||
subProfileUrl string,
|
||||
@@ -90,8 +91,8 @@ func NewSUBController(
|
||||
updateInterval: update,
|
||||
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||
subClashService: NewSubClashService(sub),
|
||||
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
|
||||
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
|
||||
}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
|
||||
@@ -21,7 +21,7 @@ var defaultJson string
|
||||
type SubJsonService struct {
|
||||
configJson map[string]any
|
||||
defaultOutbounds []json_util.RawMessage
|
||||
fragmentOrNoises bool
|
||||
finalMask string
|
||||
mux string
|
||||
|
||||
inboundService service.InboundService
|
||||
@@ -29,7 +29,7 @@ type SubJsonService struct {
|
||||
}
|
||||
|
||||
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
||||
func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
|
||||
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
|
||||
var configJson map[string]any
|
||||
var defaultOutbounds []json_util.RawMessage
|
||||
json.Unmarshal([]byte(defaultJson), &configJson)
|
||||
@@ -40,31 +40,6 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
||||
}
|
||||
}
|
||||
|
||||
fragmentOrNoises := false
|
||||
if fragment != "" || noises != "" {
|
||||
fragmentOrNoises = true
|
||||
defaultOutboundsSettings := map[string]any{
|
||||
"domainStrategy": "UseIP",
|
||||
"redirect": "",
|
||||
}
|
||||
|
||||
if fragment != "" {
|
||||
defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
|
||||
}
|
||||
|
||||
if noises != "" {
|
||||
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
|
||||
}
|
||||
|
||||
defaultDirectOutbound := map[string]any{
|
||||
"protocol": "freedom",
|
||||
"settings": defaultOutboundsSettings,
|
||||
"tag": "direct_out",
|
||||
}
|
||||
jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ")
|
||||
defaultOutbounds = append(defaultOutbounds, jsonBytes)
|
||||
}
|
||||
|
||||
if rules != "" {
|
||||
var newRules []any
|
||||
routing, _ := configJson["routing"].(map[string]any)
|
||||
@@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
||||
return &SubJsonService{
|
||||
configJson: configJson,
|
||||
defaultOutbounds: defaultOutbounds,
|
||||
fragmentOrNoises: fragmentOrNoises,
|
||||
finalMask: finalMask,
|
||||
mux: mux,
|
||||
SubService: subService,
|
||||
}
|
||||
@@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||
}
|
||||
delete(streamSettings, "sockopt")
|
||||
|
||||
if s.fragmentOrNoises {
|
||||
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
|
||||
if s.finalMask != "" {
|
||||
s.applyGlobalFinalMask(streamSettings)
|
||||
}
|
||||
|
||||
// remove proxy protocol
|
||||
@@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
|
||||
var fm map[string]any
|
||||
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
|
||||
return
|
||||
}
|
||||
merged := mergeFinalMask(streamSettings["finalmask"], fm)
|
||||
if len(merged) > 0 {
|
||||
streamSettings["finalmask"] = merged
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
|
||||
netSettings, ok := setting.(map[string]any)
|
||||
if ok {
|
||||
@@ -272,6 +258,9 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
|
||||
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
||||
tlsData["fingerprint"] = fingerprint
|
||||
}
|
||||
if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
|
||||
tlsData["echConfigList"] = ech
|
||||
}
|
||||
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
|
||||
tlsData["pinnedPeerCertSha256"] = pins
|
||||
}
|
||||
@@ -307,17 +296,6 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
|
||||
|
||||
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
usersData := make([]UserVnext, 1)
|
||||
|
||||
usersData[0].ID = client.ID
|
||||
usersData[0].Email = client.Email
|
||||
usersData[0].Security = client.Security
|
||||
vnextData := make([]VnextSetting, 1)
|
||||
vnextData[0] = VnextSetting{
|
||||
Address: inbound.Listen,
|
||||
Port: inbound.Port,
|
||||
Users: usersData,
|
||||
}
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
@@ -325,8 +303,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
security := client.Security
|
||||
if security == "" {
|
||||
security = "auto"
|
||||
}
|
||||
outbound.Settings = map[string]any{
|
||||
"vnext": vnextData,
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"id": client.ID,
|
||||
"security": security,
|
||||
"level": 8,
|
||||
}
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
@@ -347,24 +334,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
encryption, _ := inboundSettings["encryption"].(string)
|
||||
|
||||
user := map[string]any{
|
||||
settings := map[string]any{
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"id": client.ID,
|
||||
"level": 8,
|
||||
"encryption": encryption,
|
||||
"level": 8,
|
||||
}
|
||||
if client.Flow != "" {
|
||||
user["flow"] = client.Flow
|
||||
}
|
||||
|
||||
vnext := map[string]any{
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"users": []any{user},
|
||||
}
|
||||
|
||||
outbound.Settings = map[string]any{
|
||||
"vnext": []any{vnext},
|
||||
settings["flow"] = client.Flow
|
||||
}
|
||||
outbound.Settings = settings
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
}
|
||||
@@ -400,9 +380,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
outbound.Settings = map[string]any{
|
||||
"servers": serverData,
|
||||
|
||||
settings := map[string]any{
|
||||
"address": serverData[0].Address,
|
||||
"port": serverData[0].Port,
|
||||
"password": serverData[0].Password,
|
||||
"level": 8,
|
||||
}
|
||||
if inbound.Protocol == model.Shadowsocks {
|
||||
settings["method"] = serverData[0].Method
|
||||
}
|
||||
outbound.Settings = settings
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
@@ -442,7 +430,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
||||
newStream["hysteriaSettings"] = outHyStream
|
||||
|
||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||
newStream["finalmask"] = finalmask
|
||||
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
|
||||
}
|
||||
|
||||
newStream["network"] = "hysteria"
|
||||
@@ -454,6 +442,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeFinalMask(base any, extra map[string]any) map[string]any {
|
||||
merged := map[string]any{}
|
||||
if baseMap, ok := base.(map[string]any); ok {
|
||||
for key, value := range baseMap {
|
||||
switch key {
|
||||
case "tcp", "udp":
|
||||
if masks, ok := value.([]any); ok {
|
||||
merged[key] = append([]any(nil), masks...)
|
||||
}
|
||||
default:
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range extra {
|
||||
switch key {
|
||||
case "tcp", "udp":
|
||||
baseMasks, _ := merged[key].([]any)
|
||||
extraMasks, _ := value.([]any)
|
||||
if len(extraMasks) > 0 {
|
||||
merged[key] = append(baseMasks, extraMasks...)
|
||||
}
|
||||
case "quicParams":
|
||||
if _, exists := merged[key]; !exists {
|
||||
merged[key] = value
|
||||
}
|
||||
default:
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
type Outbound struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Tag string `json:"tag"`
|
||||
@@ -462,18 +485,6 @@ type Outbound struct {
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
type VnextSetting struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
Users []UserVnext `json:"users"`
|
||||
}
|
||||
|
||||
type UserVnext struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Security string `json:"security,omitempty"`
|
||||
}
|
||||
|
||||
type ServerSetting struct {
|
||||
Password string `json:"password"`
|
||||
Level int `json:"level"`
|
||||
|
||||
148
sub/subJsonService_test.go
Normal file
148
sub/subJsonService_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
)
|
||||
|
||||
func hasDirectOutOutbound(svc *SubJsonService) bool {
|
||||
for _, raw := range svc.defaultOutbounds {
|
||||
var outbound map[string]any
|
||||
if err := json.Unmarshal(raw, &outbound); err != nil {
|
||||
continue
|
||||
}
|
||||
if outbound["tag"] == "direct_out" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func outboundSettings(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal outbound: %v", err)
|
||||
}
|
||||
settings, _ := parsed["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
t.Fatal("outbound has no settings")
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
|
||||
if hasDirectOutOutbound(svc) {
|
||||
t.Fatal("direct_out outbound must never be emitted")
|
||||
}
|
||||
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
if _, ok := stream["sockopt"]; ok {
|
||||
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
|
||||
}
|
||||
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
if finalmask == nil {
|
||||
t.Fatal("streamSettings is missing finalmask")
|
||||
}
|
||||
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
if len(tcp) != 1 {
|
||||
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
|
||||
}
|
||||
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
|
||||
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
|
||||
}
|
||||
|
||||
udp, _ := finalmask["udp"].([]any)
|
||||
if len(udp) != 1 {
|
||||
t.Fatalf("udp masks len = %d, want 1", len(udp))
|
||||
}
|
||||
|
||||
quic, _ := finalmask["quicParams"].(map[string]any)
|
||||
if quic == nil || quic["congestion"] != "bbr" {
|
||||
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
||||
"finalmask":{"tcp":[{"type":"sudoku"}]}
|
||||
}`)
|
||||
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
if len(tcp) != 2 {
|
||||
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
|
||||
}
|
||||
a, _ := tcp[0].(map[string]any)
|
||||
b, _ := tcp[1].(map[string]any)
|
||||
if a["type"] != "sudoku" || b["type"] != "fragment" {
|
||||
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
|
||||
}
|
||||
if _, ok := stream["sockopt"]; ok {
|
||||
t.Fatal("legacy direct_out sockopt must never be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceVlessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vless outbound must not use vnext")
|
||||
}
|
||||
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
|
||||
t.Fatalf("flat vless settings wrong: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceVmessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
|
||||
client := model.Client{ID: "uuid-2"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vmess outbound must not use vnext")
|
||||
}
|
||||
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
|
||||
t.Fatalf("flat vmess settings wrong: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceServerFlattened(t *testing.T) {
|
||||
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
|
||||
client := model.Client{Password: "p4ss"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
|
||||
if _, ok := settings["servers"]; ok {
|
||||
t.Fatal("trojan outbound must not use servers array")
|
||||
}
|
||||
if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
|
||||
t.Fatalf("flat trojan settings wrong: %#v", settings)
|
||||
}
|
||||
|
||||
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
|
||||
if ssSettings["method"] != "aes-256-gcm" {
|
||||
t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
|
||||
}
|
||||
}
|
||||
@@ -894,6 +894,11 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
|
||||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||
params["fp"], _ = fpValue.(string)
|
||||
}
|
||||
if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
|
||||
if ech, _ := echValue.(string); ech != "" {
|
||||
params["ech"] = ech
|
||||
}
|
||||
}
|
||||
if pins, ok := pinnedSha256List(tlsSettings); ok {
|
||||
params["pcs"] = strings.Join(pins, ",")
|
||||
}
|
||||
@@ -919,6 +924,11 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
|
||||
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||
obj["fp"], _ = fpValue.(string)
|
||||
}
|
||||
if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
|
||||
if ech, _ := echValue.(string); ech != "" {
|
||||
obj["ech"] = ech
|
||||
}
|
||||
}
|
||||
if pins, ok := pinnedSha256List(tlsSettings); ok {
|
||||
obj["pcs"] = strings.Join(pins, ",")
|
||||
}
|
||||
@@ -1043,6 +1053,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st
|
||||
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||
obj["pcs"] = joinAnyStrings(pins)
|
||||
}
|
||||
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
|
||||
obj["ech"] = ech
|
||||
}
|
||||
}
|
||||
|
||||
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
|
||||
@@ -1061,6 +1074,9 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
|
||||
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||
params["pcs"] = joinAnyStrings(pins)
|
||||
}
|
||||
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
|
||||
params["ech"] = ech
|
||||
}
|
||||
}
|
||||
|
||||
// applyExternalProxyHysteriaParams overrides the cert pin for a single
|
||||
@@ -1133,6 +1149,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
|
||||
}
|
||||
settings["pinnedPeerCertSha256"] = pins
|
||||
}
|
||||
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
|
||||
settings, _ := tlsSettings["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
tlsSettings["settings"] = settings
|
||||
}
|
||||
settings["echConfigList"] = ech
|
||||
}
|
||||
}
|
||||
|
||||
func externalProxySNI(ep map[string]any) (string, bool) {
|
||||
|
||||
@@ -575,6 +575,47 @@ func TestApplyExternalProxyTLSParams_ExplicitSNIOverridesUpstream(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxy_ECHPropagates(t *testing.T) {
|
||||
const ech = "ech-config-base64"
|
||||
|
||||
t.Run("url params", func(t *testing.T) {
|
||||
params := map[string]string{"security": "tls"}
|
||||
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
if params["ech"] != ech {
|
||||
t.Fatalf("ech param = %q, want %q", params["ech"], ech)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vmess obj", func(t *testing.T) {
|
||||
obj := map[string]any{}
|
||||
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||
applyExternalProxyTLSObj(ep, obj, "tls")
|
||||
if obj["ech"] != ech {
|
||||
t.Fatalf("ech obj = %v, want %q", obj["ech"], ech)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json stream settings", func(t *testing.T) {
|
||||
stream := map[string]any{"security": "tls", "tlsSettings": map[string]any{}}
|
||||
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||
applyExternalProxyTLSToStream(ep, stream, "tls")
|
||||
settings, _ := stream["tlsSettings"].(map[string]any)["settings"].(map[string]any)
|
||||
if settings["echConfigList"] != ech {
|
||||
t.Fatalf("echConfigList = %v, want %q", settings["echConfigList"], ech)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-tls security drops ech", func(t *testing.T) {
|
||||
params := map[string]string{}
|
||||
ep := map[string]any{"echConfigList": ech}
|
||||
applyExternalProxyTLSParams(ep, params, "none")
|
||||
if _, ok := params["ech"]; ok {
|
||||
t.Fatalf("ech must not be set when security != tls")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"security": "tls",
|
||||
|
||||
@@ -3,6 +3,8 @@ package controller
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
@@ -16,6 +18,21 @@ func notifyClientsChanged() {
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
|
||||
}
|
||||
|
||||
func parseInboundIdsQuery(raw string) []int {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type ClientController struct {
|
||||
clientService service.ClientService
|
||||
inboundService service.InboundService
|
||||
@@ -115,7 +132,7 @@ func (a *ClientController) create(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
@@ -129,12 +146,13 @@ func (a *ClientController) update(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
|
||||
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
@@ -172,7 +190,7 @@ func (a *ClientController) attach(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
@@ -452,7 +470,7 @@ func (a *ClientController) detach(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||
g.GET("/getConfigJson", a.getConfigJson)
|
||||
g.GET("/getDb", a.getDb)
|
||||
g.GET("/getMigration", a.getMigration)
|
||||
g.GET("/getNewUUID", a.getNewUUID)
|
||||
g.GET("/getWebCertFiles", a.getWebCertFiles)
|
||||
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
|
||||
@@ -300,6 +301,24 @@ func (a *ServerController) getDb(c *gin.Context) {
|
||||
c.Writer.Write(db)
|
||||
}
|
||||
|
||||
// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
|
||||
// .db SQLite database on PostgreSQL, so the data can seed the other backend.
|
||||
func (a *ServerController) getMigration(c *gin.Context) {
|
||||
data, filename, err := a.serverService.GetMigration()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
if !filenameRegex.MatchString(filename) {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Writer.Write(data)
|
||||
}
|
||||
|
||||
// importDB imports a database file and restarts the Xray service.
|
||||
func (a *ServerController) importDB(c *gin.Context) {
|
||||
file, _, err := c.Request.FormFile("db")
|
||||
|
||||
@@ -182,6 +182,16 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
// pendingNodeObj returns a response object flagging that the save committed
|
||||
// locally but a backing node was offline/disabled, so the change will be
|
||||
// mirrored to the node once it reconnects. Returns nil when nothing is pending.
|
||||
func pendingNodeObj(pending bool) any {
|
||||
if pending {
|
||||
return gin.H{"nodePending": true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pureJsonMsg sends a pure JSON message response with custom status code.
|
||||
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
||||
c.JSON(statusCode, entity.Msg{
|
||||
|
||||
@@ -83,10 +83,11 @@ type AllSetting struct {
|
||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
|
||||
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
|
||||
|
||||
// LDAP settings
|
||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
const (
|
||||
nodeTrafficSyncConcurrency = 8
|
||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||
nodeReconcileTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type NodeTrafficSyncJob struct {
|
||||
@@ -151,21 +152,37 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
}
|
||||
|
||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
rt, err := mgr.RemoteFor(n)
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
||||
return
|
||||
}
|
||||
|
||||
if n.ConfigDirty {
|
||||
reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
|
||||
reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n.Id)
|
||||
reconcileCancel()
|
||||
if reconcileErr != nil {
|
||||
logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
|
||||
return
|
||||
}
|
||||
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
|
||||
logger.Warning("node traffic sync: clear dirty for", n.Name, "failed:", clearErr)
|
||||
}
|
||||
j.structural.set()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
snap, err := rt.FetchTrafficSnapshot(ctx)
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
|
||||
j.inboundService.ClearNodeOnlineClients(n.Id)
|
||||
return
|
||||
}
|
||||
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap)
|
||||
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
|
||||
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
|
||||
return
|
||||
|
||||
@@ -191,6 +191,19 @@ func (r *Remote) cacheDel(tag string) {
|
||||
delete(r.remoteIDByTag, tag)
|
||||
}
|
||||
|
||||
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
|
||||
if err := r.refreshRemoteIDs(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
tags := make([]string, 0, len(r.remoteIDByTag))
|
||||
for tag := range r.remoteIDByTag {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||
if err != nil {
|
||||
@@ -286,15 +299,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
|
||||
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 {
|
||||
func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/del/"+url.PathEscape(email), nil)
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
body := map[string]any{"inboundIds": []int{id}}
|
||||
_, err = r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -304,12 +319,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
|
||||
func (r *Remote) UpdateUser(ctx context.Context, ib *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 {
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
|
||||
"?inboundIds=" + strconv.Itoa(id)
|
||||
if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
216
web/service/api_scale_postgres_test.go
Normal file
216
web/service/api_scale_postgres_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
rows := make([]xray.ClientTraffic, len(clients))
|
||||
for i := range clients {
|
||||
rows[i] = xray.ClientTraffic{
|
||||
InboundId: inboundId,
|
||||
Email: clients[i].Email,
|
||||
Enable: true,
|
||||
Total: clients[i].TotalGB,
|
||||
ExpiryTime: clients[i].ExpiryTime,
|
||||
}
|
||||
}
|
||||
if err := db.CreateInBatches(rows, 1000).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllAPIsPostgresScale exercises every client/inbound/group service method
|
||||
// reachable from the REST API at 100k/200k clients, asserting none crash on the
|
||||
// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
|
||||
func TestAllAPIsPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
settingSvc := &SettingService{}
|
||||
const userId = 1
|
||||
const m = 2000
|
||||
sizes := []int{50000, 100000, 200000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
||||
for i := range clients {
|
||||
clients[i].ExpiryTime = exp
|
||||
clients[i].TotalGB = 100 << 30
|
||||
}
|
||||
ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := db.Create(ib2).Error; err != nil {
|
||||
t.Fatalf("create inbound2: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
run := func(name string, fn func() error) {
|
||||
start := time.Now()
|
||||
if err := fn(); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
|
||||
|
||||
seedClientTraffics(t, ib.Id, clients)
|
||||
db.Exec("ANALYZE")
|
||||
|
||||
emails := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
emails[i] = clients[i].Email
|
||||
}
|
||||
emailsM := emails[:m]
|
||||
|
||||
run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
|
||||
run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
|
||||
run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
|
||||
run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
|
||||
run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
|
||||
run("ListPaged+search", func() error {
|
||||
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
|
||||
return err
|
||||
})
|
||||
run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
|
||||
run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
|
||||
run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
|
||||
|
||||
run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
|
||||
run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
|
||||
run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
|
||||
run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
|
||||
run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
|
||||
|
||||
run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
|
||||
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
|
||||
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
|
||||
run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
|
||||
|
||||
run("UpdateByEmail", func() error {
|
||||
upd := clients[n/3]
|
||||
upd.Comment = "touched"
|
||||
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
|
||||
return err
|
||||
})
|
||||
run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
|
||||
run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
|
||||
|
||||
depEmails := emails[:1000]
|
||||
for _, batch := range chunkStrings(depEmails, sqlInChunk) {
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
|
||||
t.Fatalf("mark depleted: %v", err)
|
||||
}
|
||||
}
|
||||
run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
|
||||
|
||||
run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
|
||||
// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
|
||||
// find one client) vs new path (UUID/subId read from the indexed clients table).
|
||||
func TestGetClientTrafficByEmailABScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
const reps = 10
|
||||
sizes := []int{50000, 100000, 200000}
|
||||
|
||||
oldImpl := func(email string) error {
|
||||
tr, client, err := inboundSvc.GetClientByEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tr != nil && client != nil {
|
||||
tr.UUID = client.ID
|
||||
tr.SubId = client.SubID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
seedClientTraffics(t, ib.Id, clients)
|
||||
db.Exec("ANALYZE")
|
||||
|
||||
targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
|
||||
|
||||
start := time.Now()
|
||||
for i := 0; i < reps; i++ {
|
||||
if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
|
||||
t.Fatalf("new GetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
}
|
||||
newDur := time.Since(start) / reps
|
||||
|
||||
start = time.Now()
|
||||
for i := 0; i < reps; i++ {
|
||||
if err := oldImpl(targets[i%len(targets)]); err != nil {
|
||||
t.Fatalf("old GetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
}
|
||||
oldDur := time.Since(start) / reps
|
||||
|
||||
t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
|
||||
newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
|
||||
float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
|
||||
})
|
||||
}
|
||||
}
|
||||
149
web/service/bulk_traffic_test.go
Normal file
149
web/service/bulk_traffic_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
func mkTraffic(t *testing.T, inboundId int, email string, up, down, total, expiry int64, enable bool) {
|
||||
t.Helper()
|
||||
row := xray.ClientTraffic{
|
||||
InboundId: inboundId,
|
||||
Email: email,
|
||||
Up: up,
|
||||
Down: down,
|
||||
Total: total,
|
||||
ExpiryTime: expiry,
|
||||
Enable: enable,
|
||||
}
|
||||
if err := database.GetDB().Create(&row).Error; err != nil {
|
||||
t.Fatalf("create traffic %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
func trafficOf(t *testing.T, email string) xray.ClientTraffic {
|
||||
t.Helper()
|
||||
var row xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("load traffic %s: %v", email, err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
|
||||
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
|
||||
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
|
||||
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
|
||||
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
|
||||
|
||||
affected, err := svc.BulkResetTraffic(inboundSvc, []string{"alice@x", "bob@x"})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkResetTraffic: %v", err)
|
||||
}
|
||||
if affected != 2 {
|
||||
t.Fatalf("expected 2 affected, got %d", affected)
|
||||
}
|
||||
|
||||
for _, e := range []string{"alice@x", "bob@x"} {
|
||||
tr := trafficOf(t, e)
|
||||
if tr.Up != 0 || tr.Down != 0 {
|
||||
t.Fatalf("%s: expected up/down 0, got up=%d down=%d", e, tr.Up, tr.Down)
|
||||
}
|
||||
if !tr.Enable {
|
||||
t.Fatalf("%s: expected re-enabled", e)
|
||||
}
|
||||
}
|
||||
|
||||
carol := trafficOf(t, "carol@x")
|
||||
if carol.Up != 7 {
|
||||
t.Fatalf("carol not in list should be untouched, got up=%d", carol.Up)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
|
||||
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
|
||||
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
past := time.Now().Add(-time.Hour).UnixMilli()
|
||||
mkTraffic(t, ib.Id, "alice@x", 60, 60, 100, 0, true)
|
||||
mkTraffic(t, ib.Id, "bob@x", 10, 10, 100, 0, true)
|
||||
mkTraffic(t, ib.Id, "carol@x", 0, 0, 0, past, true)
|
||||
|
||||
deleted, _, err := svc.DelDepleted(inboundSvc)
|
||||
if err != nil {
|
||||
t.Fatalf("DelDepleted: %v", err)
|
||||
}
|
||||
if deleted != 2 {
|
||||
t.Fatalf("expected 2 deleted (alice traffic-depleted, carol expired), got %d", deleted)
|
||||
}
|
||||
|
||||
if _, err := svc.GetRecordByEmail(nil, "bob@x"); err != nil {
|
||||
t.Fatalf("bob should survive: %v", err)
|
||||
}
|
||||
for _, e := range []string{"alice@x", "carol@x"} {
|
||||
if _, err := svc.GetRecordByEmail(nil, e); err == nil {
|
||||
t.Fatalf("%s should be deleted", e)
|
||||
}
|
||||
}
|
||||
|
||||
reloaded, _ := inboundSvc.GetInbound(ib.Id)
|
||||
jsonClients, _ := inboundSvc.GetClients(reloaded)
|
||||
if len(jsonClients) != 1 || jsonClients[0].Email != "bob@x" {
|
||||
t.Fatalf("settings JSON should contain only bob, got %d clients", len(jsonClients))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
mkTraffic(t, ib.Id, "alice@x", 1, 2, 0, 0, true)
|
||||
|
||||
tr, err := inboundSvc.GetClientTrafficByEmail("alice@x")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
if tr == nil {
|
||||
t.Fatalf("expected traffic, got nil")
|
||||
}
|
||||
if tr.UUID != "11111111-1111-1111-1111-111111111111" {
|
||||
t.Fatalf("UUID not enriched from clients table, got %q", tr.UUID)
|
||||
}
|
||||
if tr.SubId != "sa" {
|
||||
t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,92 @@ func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error)
|
||||
return mgr.RuntimeFor(ib.NodeID)
|
||||
}
|
||||
|
||||
func (s *InboundService) nodePushPlan(ib *model.Inbound) (runtime.Runtime, bool, bool, error) {
|
||||
if ib.NodeID == nil {
|
||||
rt, err := s.runtimeFor(ib)
|
||||
if err != nil {
|
||||
return nil, false, false, nil
|
||||
}
|
||||
return rt, true, false, nil
|
||||
}
|
||||
nodeSvc := NodeService{}
|
||||
enabled, status, _, _, err := nodeSvc.NodeSyncState(*ib.NodeID)
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
if !enabled || status == "offline" {
|
||||
return nil, false, true, nil
|
||||
}
|
||||
rt, err := s.runtimeFor(ib)
|
||||
if err != nil {
|
||||
return nil, false, true, nil
|
||||
}
|
||||
return rt, true, false, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) NodeIsPending(nodeID *int) bool {
|
||||
if nodeID == nil {
|
||||
return false
|
||||
}
|
||||
return (&NodeService{}).IsNodePending(*nodeID)
|
||||
}
|
||||
|
||||
func (s *InboundService) AnyNodePending(inboundIds []int) bool {
|
||||
if len(inboundIds) == 0 {
|
||||
return false
|
||||
}
|
||||
nodeSvc := NodeService{}
|
||||
for _, id := range inboundIds {
|
||||
ib, err := s.GetInbound(id)
|
||||
if err != nil || ib.NodeID == nil {
|
||||
continue
|
||||
}
|
||||
if nodeSvc.IsNodePending(*ib.NodeID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, nodeID int) error {
|
||||
if rt == nil || nodeID <= 0 {
|
||||
return nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
var inbounds []*model.Inbound
|
||||
if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
remoteTags, err := rt.ListRemoteTags(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := nodeTagPrefix(&nodeID)
|
||||
desiredTags := make(map[string]struct{}, len(inbounds)*2)
|
||||
for _, ib := range inbounds {
|
||||
desiredTags[ib.Tag] = struct{}{}
|
||||
if prefix != "" {
|
||||
if stripped, found := strings.CutPrefix(ib.Tag, prefix); found {
|
||||
desiredTags[stripped] = struct{}{}
|
||||
} else {
|
||||
desiredTags[prefix+ib.Tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rt.UpdateInbound(ctx, ib, ib); err != nil {
|
||||
return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
for _, tag := range remoteTags {
|
||||
if _, want := desiredTags[tag]; want {
|
||||
continue
|
||||
}
|
||||
if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil {
|
||||
return fmt.Errorf("reconcile delete %q: %w", tag, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CopyClientsResult struct {
|
||||
Added []string `json:"added"`
|
||||
Skipped []string `json:"skipped"`
|
||||
@@ -83,8 +169,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
|
||||
emails = append(emails, e)
|
||||
}
|
||||
var extra []xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil {
|
||||
logger.Warning("enrichClientStats:", err)
|
||||
var loadErr error
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var page []xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
||||
loadErr = err
|
||||
break
|
||||
}
|
||||
extra = append(extra, page...)
|
||||
}
|
||||
if loadErr != nil {
|
||||
logger.Warning("enrichClientStats:", loadErr)
|
||||
} else {
|
||||
byEmail := make(map[string]xray.ClientTraffic, len(extra))
|
||||
for _, st := range extra {
|
||||
@@ -438,6 +533,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
|
||||
shared := make(map[string]bool, len(emails))
|
||||
want := make(map[string]struct{}, len(emails))
|
||||
for _, e := range emails {
|
||||
e = strings.ToLower(strings.TrimSpace(e))
|
||||
if e != "" {
|
||||
want[e] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(want) == 0 {
|
||||
return shared, nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
var rows []string
|
||||
query := fmt.Sprintf(
|
||||
"SELECT DISTINCT LOWER(%s) %s WHERE inbounds.id != ?",
|
||||
database.JSONFieldText("client.value", "email"),
|
||||
database.JSONClientsFromInbound(),
|
||||
)
|
||||
if err := db.Raw(query, exceptInboundId).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range rows {
|
||||
e = strings.ToLower(strings.TrimSpace(e))
|
||||
if _, ok := want[e]; ok {
|
||||
shared[e] = true
|
||||
}
|
||||
}
|
||||
return shared, 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) {
|
||||
@@ -535,11 +661,17 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
markDirty := false
|
||||
defer func() {
|
||||
if err == nil {
|
||||
tx.Commit()
|
||||
} else {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
if markDirty && inbound.NodeID != nil {
|
||||
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||
logger.Warning("mark node dirty failed:", dErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -560,20 +692,25 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
|
||||
needRestart := false
|
||||
if inbound.Enable {
|
||||
rt, rterr := s.runtimeFor(inbound)
|
||||
if rterr != nil {
|
||||
err = rterr
|
||||
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||
if perr != nil {
|
||||
err = perr
|
||||
return inbound, false, err
|
||||
}
|
||||
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
|
||||
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
|
||||
} else {
|
||||
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
|
||||
if inbound.NodeID != nil {
|
||||
err = err1
|
||||
return inbound, false, err
|
||||
if dirty {
|
||||
markDirty = true
|
||||
}
|
||||
if push {
|
||||
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
|
||||
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
|
||||
} else {
|
||||
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
|
||||
if inbound.NodeID != nil {
|
||||
markDirty = true
|
||||
} else {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,24 +721,31 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
needRestart := false
|
||||
markDirty := false
|
||||
var ib model.Inbound
|
||||
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
|
||||
if loadErr == nil {
|
||||
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
|
||||
rt, push, dirty, perr := s.nodePushPlan(&ib)
|
||||
if perr != nil {
|
||||
logger.Warning("DelInbound: node lookup failed, deleting central row anyway:", perr)
|
||||
markDirty = true
|
||||
} else if push {
|
||||
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 {
|
||||
markDirty = true
|
||||
}
|
||||
}
|
||||
} else if ib.NodeID == nil {
|
||||
needRestart = true
|
||||
} else if dirty {
|
||||
markDirty = true
|
||||
}
|
||||
} else {
|
||||
logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id)
|
||||
@@ -617,6 +761,11 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
if err := db.Delete(model.Inbound{}, id).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if markDirty && ib.NodeID != nil {
|
||||
if dErr := (&NodeService{}).MarkNodeDirty(*ib.NodeID); dErr != nil {
|
||||
logger.Warning("mark node dirty failed:", dErr)
|
||||
}
|
||||
}
|
||||
if !database.IsPostgres() {
|
||||
var count int64
|
||||
if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
||||
@@ -700,12 +849,9 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
inbound.Enable = enable
|
||||
|
||||
needRestart := false
|
||||
rt, rterr := s.runtimeFor(inbound)
|
||||
if rterr != nil {
|
||||
if inbound.NodeID != nil {
|
||||
return false, rterr
|
||||
}
|
||||
return true, nil
|
||||
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||
if perr != nil {
|
||||
return false, perr
|
||||
}
|
||||
|
||||
// Remote nodes interpret DelInbound as a real row delete (it hits
|
||||
@@ -714,13 +860,24 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
// PATCH the remote row via UpdateInbound instead — preserves the
|
||||
// settings/client history and just flips the enable flag.
|
||||
if inbound.NodeID != nil {
|
||||
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
||||
logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
|
||||
return false, err
|
||||
if push {
|
||||
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
||||
logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if dirty {
|
||||
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||
logger.Warning("mark node dirty failed:", dErr)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if !push {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
|
||||
!strings.Contains(err.Error(), "not found") {
|
||||
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
|
||||
@@ -767,11 +924,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
|
||||
markDirty := false
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
} else {
|
||||
tx.Commit()
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
if markDirty && oldInbound.NodeID != nil {
|
||||
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||
logger.Warning("mark node dirty failed:", dErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -860,17 +1023,20 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
inbound.Tag = oldInbound.Tag
|
||||
|
||||
needRestart := false
|
||||
rt, rterr := s.runtimeFor(oldInbound)
|
||||
if rterr != nil {
|
||||
if oldInbound.NodeID != nil {
|
||||
err = rterr
|
||||
return inbound, false, err
|
||||
}
|
||||
needRestart = true
|
||||
} else {
|
||||
oldSnapshot := *oldInbound
|
||||
oldSnapshot.Tag = tag
|
||||
if oldInbound.NodeID == nil {
|
||||
rt, push, dirty, perr := s.nodePushPlan(oldInbound)
|
||||
if perr != nil {
|
||||
err = perr
|
||||
return inbound, false, err
|
||||
}
|
||||
if dirty {
|
||||
markDirty = true
|
||||
}
|
||||
if oldInbound.NodeID == nil {
|
||||
if !push {
|
||||
needRestart = true
|
||||
} else {
|
||||
oldSnapshot := *oldInbound
|
||||
oldSnapshot.Tag = tag
|
||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
|
||||
logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
|
||||
}
|
||||
@@ -886,16 +1052,18 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !inbound.Enable {
|
||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
|
||||
err = err2
|
||||
return inbound, false, err
|
||||
}
|
||||
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
|
||||
err = err2
|
||||
return inbound, false, err
|
||||
}
|
||||
} else if push {
|
||||
oldSnapshot := *oldInbound
|
||||
oldSnapshot.Tag = tag
|
||||
if !inbound.Enable {
|
||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
|
||||
logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
|
||||
markDirty = true
|
||||
}
|
||||
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
|
||||
logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
|
||||
markDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,17 +1431,17 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
|
||||
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
|
||||
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
|
||||
var structuralChange bool
|
||||
err := submitTrafficWrite(func() error {
|
||||
var inner error
|
||||
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap)
|
||||
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty)
|
||||
return inner
|
||||
})
|
||||
return structuralChange, err
|
||||
}
|
||||
|
||||
func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
|
||||
func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
|
||||
if snap == nil || nodeID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
@@ -1385,6 +1553,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
|
||||
c, ok := tagToCentral[snapIb.Tag]
|
||||
if !ok {
|
||||
if dirty {
|
||||
continue
|
||||
}
|
||||
// Try snap.Tag first; on collision fall back to the n<id>-
|
||||
// prefixed form so local+node can both own the same port.
|
||||
pickFreeTag := func() (string, error) {
|
||||
@@ -1451,42 +1622,48 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
|
||||
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
||||
|
||||
updates := map[string]any{
|
||||
"enable": snapIb.Enable,
|
||||
"remark": snapIb.Remark,
|
||||
"listen": snapIb.Listen,
|
||||
"port": snapIb.Port,
|
||||
"protocol": snapIb.Protocol,
|
||||
"total": snapIb.Total,
|
||||
"expiry_time": snapIb.ExpiryTime,
|
||||
"settings": snapIb.Settings,
|
||||
"stream_settings": snapIb.StreamSettings,
|
||||
"sniffing": snapIb.Sniffing,
|
||||
"traffic_reset": snapIb.TrafficReset,
|
||||
updates := map[string]any{}
|
||||
if !dirty {
|
||||
updates["enable"] = snapIb.Enable
|
||||
updates["remark"] = snapIb.Remark
|
||||
updates["listen"] = snapIb.Listen
|
||||
updates["port"] = snapIb.Port
|
||||
updates["protocol"] = snapIb.Protocol
|
||||
updates["total"] = snapIb.Total
|
||||
updates["expiry_time"] = snapIb.ExpiryTime
|
||||
updates["settings"] = snapIb.Settings
|
||||
updates["stream_settings"] = snapIb.StreamSettings
|
||||
updates["sniffing"] = snapIb.Sniffing
|
||||
updates["traffic_reset"] = snapIb.TrafficReset
|
||||
}
|
||||
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
||||
updates["up"] = snapIb.Up
|
||||
updates["down"] = snapIb.Down
|
||||
}
|
||||
|
||||
if c.Settings != snapIb.Settings ||
|
||||
if !dirty && (c.Settings != snapIb.Settings ||
|
||||
c.Remark != snapIb.Remark ||
|
||||
c.Listen != snapIb.Listen ||
|
||||
c.Port != snapIb.Port ||
|
||||
c.Total != snapIb.Total ||
|
||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||
c.Enable != snapIb.Enable {
|
||||
c.Enable != snapIb.Enable) {
|
||||
structuralChange = true
|
||||
}
|
||||
|
||||
if err := tx.Model(model.Inbound{}).
|
||||
Where("id = ?", c.Id).
|
||||
Updates(updates).Error; err != nil {
|
||||
return false, err
|
||||
if len(updates) > 0 {
|
||||
if err := tx.Model(model.Inbound{}).
|
||||
Where("id = ?", c.Id).
|
||||
Updates(updates).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range central {
|
||||
if dirty {
|
||||
continue
|
||||
}
|
||||
if _, kept := snapTags[c.Tag]; kept {
|
||||
continue
|
||||
}
|
||||
@@ -1541,6 +1718,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
|
||||
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
||||
if dirty {
|
||||
continue
|
||||
}
|
||||
row := &xray.ClientTraffic{
|
||||
InboundId: c.Id,
|
||||
Email: cs.Email,
|
||||
@@ -1575,12 +1755,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
structuralChange = true
|
||||
}
|
||||
|
||||
// Only allow the node to disable a client (cs.Enable=false), never
|
||||
// to re-enable one the panel has already disabled. A stale snapshot
|
||||
// from the node arriving after a central disable would otherwise
|
||||
// overwrite enable=false back to true, letting the client accumulate
|
||||
// far more traffic than their limit before being disabled again.
|
||||
enableExpr := "CASE WHEN ? = 0 THEN 0 ELSE enable END"
|
||||
if err := tx.Exec(
|
||||
fmt.Sprintf(
|
||||
`UPDATE client_traffics
|
||||
SET up = up + ?, down = down + ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
|
||||
SET up = up + ?, down = down + ?, enable = %s, total = ?, expiry_time = ?, reset = ?,
|
||||
last_online = %s
|
||||
WHERE email = ?`,
|
||||
enableExpr,
|
||||
database.GreatestExpr("last_online", "?"),
|
||||
),
|
||||
deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
|
||||
@@ -1595,6 +1782,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
|
||||
for k, existing := range centralCS {
|
||||
if dirty {
|
||||
continue
|
||||
}
|
||||
if k.inboundID != c.Id {
|
||||
continue
|
||||
}
|
||||
@@ -1626,6 +1816,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if dirty {
|
||||
continue
|
||||
}
|
||||
var oldEmailsRows []string
|
||||
if err := tx.Table("clients").
|
||||
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||
@@ -1742,15 +1935,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
|
||||
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
|
||||
var disabledNodeIDs []int
|
||||
err = submitTrafficWrite(func() error {
|
||||
var inner error
|
||||
needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
return inner
|
||||
})
|
||||
if err == nil && len(disabledNodeIDs) > 0 {
|
||||
s.restartRemoteNodesOnDisable(disabledNodeIDs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
|
||||
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
|
||||
var err error
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
@@ -1764,11 +1961,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
}()
|
||||
err = s.addInboundTraffic(tx, inboundTraffics)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return false, false, nil, err
|
||||
}
|
||||
err = s.addClientTraffic(tx, clientTraffics)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
return false, false, nil, err
|
||||
}
|
||||
|
||||
needRestart0, count, err := s.autoRenewClients(tx)
|
||||
@@ -1779,7 +1976,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
}
|
||||
|
||||
disabledClientsCount := int64(0)
|
||||
needRestart1, count, err := s.disableInvalidClients(tx)
|
||||
needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx)
|
||||
if err != nil {
|
||||
logger.Warning("Error in disabling invalid clients:", err)
|
||||
} else if count > 0 {
|
||||
@@ -1793,7 +1990,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v inbounds disabled", count)
|
||||
}
|
||||
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
|
||||
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, disabledNodeIDs, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
||||
@@ -1828,9 +2025,16 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
emails = append(emails, traffic.Email)
|
||||
}
|
||||
dbClientTraffics := make([]*xray.ClientTraffic, 0, len(traffics))
|
||||
// Match purely by email. client_traffics is email-keyed (one shared row per
|
||||
// email regardless of how many inbounds the client is attached to), and these
|
||||
// emails come from the local xray's report, so they always belong to a client
|
||||
// attached to a local inbound. The old `inbound_id NOT IN (node inbounds)`
|
||||
// filter dropped the local traffic of a client attached to both a node and the
|
||||
// mother inbound whenever the node inbound happened to be attached first — its
|
||||
// shared row then carried the node inbound's id (AddClientStat uses OnConflict
|
||||
// DoNothing and never refreshes it), so the local poll skipped it entirely.
|
||||
err = tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN (?) AND inbound_id NOT IN (?)", emails,
|
||||
tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NOT NULL")).
|
||||
Where("email IN (?)", emails).
|
||||
Find(&dbClientTraffics).Error
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2149,7 +2353,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
||||
return needRestart, count, err
|
||||
}
|
||||
|
||||
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
|
||||
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
|
||||
now := time.Now().Unix() * 1000
|
||||
needRestart := false
|
||||
|
||||
@@ -2158,10 +2362,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
Where("((total > 0 AND up + down >= total) OR (expiry_time > 0 AND expiry_time <= ?)) AND enable = ?", now, true).
|
||||
Find(&depletedRows).Error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
return false, 0, nil, err
|
||||
}
|
||||
if len(depletedRows) == 0 {
|
||||
return false, 0, nil
|
||||
return false, 0, nil, nil
|
||||
}
|
||||
|
||||
depletedEmails := make([]string, 0, len(depletedRows))
|
||||
@@ -2189,7 +2393,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
WHERE clients.email IN ?
|
||||
`, depletedEmails).Scan(&targets).Error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
return false, 0, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2236,7 +2440,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
err = result.Error
|
||||
count := result.RowsAffected
|
||||
if err != nil {
|
||||
return needRestart, count, err
|
||||
return needRestart, count, nil, err
|
||||
}
|
||||
|
||||
if len(depletedEmails) > 0 {
|
||||
@@ -2247,6 +2451,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
}
|
||||
}
|
||||
|
||||
disabledNodeIDs := make(map[int]struct{})
|
||||
for inboundID, group := range remoteByInbound {
|
||||
emails := make(map[string]struct{}, len(group))
|
||||
for _, t := range group {
|
||||
@@ -2255,10 +2460,43 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
|
||||
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
|
||||
needRestart = true
|
||||
} else {
|
||||
for _, t := range group {
|
||||
if t.NodeID != nil {
|
||||
disabledNodeIDs[*t.NodeID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return needRestart, count, nil
|
||||
nodeIDs := make([]int, 0, len(disabledNodeIDs))
|
||||
for nodeID := range disabledNodeIDs {
|
||||
nodeIDs = append(nodeIDs, nodeID)
|
||||
}
|
||||
|
||||
return needRestart, count, nodeIDs, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
|
||||
restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable()
|
||||
if err != nil {
|
||||
logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err)
|
||||
return
|
||||
}
|
||||
if !restartOnDisable {
|
||||
return
|
||||
}
|
||||
for _, nodeID := range nodeIDs {
|
||||
nodeIDCopy := nodeID
|
||||
rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
|
||||
if rtErr != nil {
|
||||
logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
|
||||
continue
|
||||
}
|
||||
if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
|
||||
logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markClientsDisabledInSettings flips client.enable=false in the inbound's
|
||||
@@ -2438,6 +2676,32 @@ func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
|
||||
return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
|
||||
const chunk = 400
|
||||
for start := 0; start < len(emails); start += chunk {
|
||||
end := min(start+chunk, len(emails))
|
||||
batch := emails[start:end]
|
||||
if err := tx.Where("email IN ?", batch).Delete(xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboundService) delClientIPsByEmails(tx *gorm.DB, emails []string) error {
|
||||
const chunk = 400
|
||||
for start := 0; start < len(emails); start += chunk {
|
||||
end := min(start+chunk, len(emails))
|
||||
if err := tx.Where("client_email IN ?", emails[start:end]).Delete(model.InboundClientIps{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
|
||||
db := database.GetDB()
|
||||
var traffics []*xray.ClientTraffic
|
||||
@@ -2556,12 +2820,20 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.Email == clientEmail && client.Enable {
|
||||
rt, rterr := s.runtimeFor(inbound)
|
||||
if rterr != nil {
|
||||
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||
if perr != nil {
|
||||
return false, perr
|
||||
}
|
||||
if !push {
|
||||
if inbound.NodeID != nil {
|
||||
return false, rterr
|
||||
if dirty {
|
||||
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||
logger.Warning("mark node dirty failed:", dErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
needRestart = true
|
||||
}
|
||||
needRestart = true
|
||||
break
|
||||
}
|
||||
cipher := ""
|
||||
@@ -2584,6 +2856,11 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
})
|
||||
if err1 == nil {
|
||||
logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
|
||||
} else if inbound.NodeID != nil {
|
||||
logger.Warning("Error in enabling client on", rt.Name(), ":", err1)
|
||||
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||
logger.Warning("mark node dirty failed:", dErr)
|
||||
}
|
||||
} else {
|
||||
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
|
||||
needRestart = true
|
||||
@@ -2991,16 +3268,33 @@ func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, e
|
||||
}
|
||||
|
||||
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
|
||||
// Prefer retrieving along with client to reflect actual enabled state from inbound settings
|
||||
t, client, err := s.GetClientByEmail(email)
|
||||
db := database.GetDB()
|
||||
var traffics []*xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error; err != nil {
|
||||
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
|
||||
return nil, err
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
t := traffics[0]
|
||||
|
||||
if rec, rErr := s.clientService.GetRecordByEmail(db, email); rErr == nil && rec != nil {
|
||||
c := rec.ToClient()
|
||||
t.UUID = c.ID
|
||||
t.SubId = c.SubID
|
||||
return t, nil
|
||||
}
|
||||
|
||||
t2, client, err := s.GetClientByEmail(email)
|
||||
if err != nil {
|
||||
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
|
||||
return nil, err
|
||||
}
|
||||
if t != nil && client != nil {
|
||||
t.UUID = client.ID
|
||||
t.SubId = client.SubID
|
||||
return t, nil
|
||||
if t2 != nil && client != nil {
|
||||
t2.UUID = client.ID
|
||||
t2.SubId = client.SubID
|
||||
return t2, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -3329,6 +3623,9 @@ func (s *InboundService) MigrateDB() {
|
||||
}
|
||||
|
||||
func (s *InboundService) GetOnlineClients() []string {
|
||||
if p == nil {
|
||||
return []string{}
|
||||
}
|
||||
return p.GetOnlineClients()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,20 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
// TestAddClientTraffic_MatchesDespiteStaleInboundId reproduces the production bug where
|
||||
// client_traffics rows survive an inbound delete+recreate with a stale inbound_id (the
|
||||
// shared-by-email row keeps the deleted inbound's id, and AddClientStat's OnConflict-
|
||||
// DoNothing never refreshes it). The old `inbound_id IN (local inbounds)` filter dropped
|
||||
// those rows, so local traffic and online status stopped updating. The fix matches by
|
||||
// email and only excludes rows owned by a node inbound, so a stale local row is still
|
||||
// updated while a genuine node-owned row is left untouched.
|
||||
func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
|
||||
// TestAddClientTraffic_MatchesByEmail covers two scenarios that share one fix:
|
||||
// client_traffics is keyed by email (one shared row per email no matter how many
|
||||
// inbounds the client is attached to), so local traffic must be applied by email
|
||||
// regardless of which inbound_id the row happens to carry.
|
||||
//
|
||||
// - staleEmail: the row points at an inbound id that no longer exists (a deleted
|
||||
// earlier incarnation, AddClientStat's OnConflict-DoNothing never refreshes it).
|
||||
// - dualEmail: the client is attached to both a node inbound and the mother inbound,
|
||||
// but the node inbound was attached first, so the shared row carries the node
|
||||
// inbound's id (issue #4921). The old `inbound_id NOT IN (node inbounds)` filter
|
||||
// dropped this client's local traffic, leaving it stuck at zero and offline.
|
||||
//
|
||||
// Both must have their local traffic counted.
|
||||
func TestAddClientTraffic_MatchesByEmail(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
@@ -27,11 +33,9 @@ func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
const localEmail = "local-user"
|
||||
const nodeEmail = "node-user"
|
||||
const staleEmail = "stale-user"
|
||||
const dualEmail = "dual-user"
|
||||
|
||||
// A local inbound exists, but the local client's traffic row points at an inbound id
|
||||
// that no longer exists (a deleted earlier incarnation) — the stale-pointer scenario.
|
||||
localInbound := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 40001, Protocol: model.VLESS}
|
||||
if err := db.Create(localInbound).Error; err != nil {
|
||||
t.Fatalf("create local inbound: %v", err)
|
||||
@@ -42,39 +46,44 @@ func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
|
||||
t.Fatalf("create node inbound: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: localEmail, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create stale local client_traffics: %v", err)
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: staleEmail, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create stale client_traffics: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: nodeEmail, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create node client_traffics: %v", err)
|
||||
// Attached to both inbounds, but the node inbound won the OnConflict so the
|
||||
// shared row is owned by the node inbound id.
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: dualEmail, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create dual client_traffics: %v", err)
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
|
||||
{Email: localEmail, Up: 10, Down: 20},
|
||||
{Email: nodeEmail, Up: 30, Down: 40},
|
||||
{Email: staleEmail, Up: 10, Down: 20},
|
||||
{Email: dualEmail, Up: 30, Down: 40},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("addClientTraffic: %v", err)
|
||||
}
|
||||
|
||||
var local xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", localEmail).First(&local).Error; err != nil {
|
||||
t.Fatalf("reload local row: %v", err)
|
||||
var stale xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", staleEmail).First(&stale).Error; err != nil {
|
||||
t.Fatalf("reload stale row: %v", err)
|
||||
}
|
||||
if local.Up != 10 || local.Down != 20 {
|
||||
t.Errorf("stale-pointer local row not updated: up=%d down=%d, want 10/20", local.Up, local.Down)
|
||||
if stale.Up != 10 || stale.Down != 20 {
|
||||
t.Errorf("stale-pointer row not updated: up=%d down=%d, want 10/20", stale.Up, stale.Down)
|
||||
}
|
||||
if local.LastOnline == 0 {
|
||||
t.Errorf("stale-pointer local row LastOnline not set")
|
||||
if stale.LastOnline == 0 {
|
||||
t.Errorf("stale-pointer row LastOnline not set")
|
||||
}
|
||||
|
||||
var node xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", nodeEmail).First(&node).Error; err != nil {
|
||||
t.Fatalf("reload node row: %v", err)
|
||||
var dual xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", dualEmail).First(&dual).Error; err != nil {
|
||||
t.Fatalf("reload dual row: %v", err)
|
||||
}
|
||||
if node.Up != 0 || node.Down != 0 {
|
||||
t.Errorf("node-owned row should not be touched by local traffic: up=%d down=%d, want 0/0", node.Up, node.Down)
|
||||
if dual.Up != 30 || dual.Down != 40 {
|
||||
t.Errorf("node-owned row not updated by local traffic (issue #4921): up=%d down=%d, want 30/40", dual.Up, dual.Down)
|
||||
}
|
||||
if dual.LastOnline == 0 {
|
||||
t.Errorf("node-owned row LastOnline not set (client stayed offline)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -480,6 +480,50 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NodeService) MarkNodeDirty(id int) error {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Model(model.Node{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"config_dirty": true,
|
||||
"config_dirty_at": time.Now().UnixMilli(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Model(model.Node{}).
|
||||
Where("id = ? AND config_dirty_at = ?", id, dirtyAt).
|
||||
Update("config_dirty", false).Error
|
||||
}
|
||||
|
||||
func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
|
||||
if id <= 0 {
|
||||
return false, "", false, 0, errors.New("invalid node id")
|
||||
}
|
||||
var row model.Node
|
||||
err = database.GetDB().Model(model.Node{}).
|
||||
Select("enable", "status", "config_dirty", "config_dirty_at").
|
||||
Where("id = ?", id).
|
||||
First(&row).Error
|
||||
if err != nil {
|
||||
return false, "", false, 0, err
|
||||
}
|
||||
return row.Enable, row.Status, row.ConfigDirty, row.ConfigDirtyAt, nil
|
||||
}
|
||||
|
||||
func (s *NodeService) IsNodePending(id int) bool {
|
||||
enabled, status, dirty, _, err := s.NodeSyncState(id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !enabled || status != "online" || dirty
|
||||
}
|
||||
|
||||
func nodeMetricKey(id int, metric string) string {
|
||||
return "node:" + strconv.Itoa(id) + ":" + metric
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats .
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
|
||||
}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
104
web/service/node_dirty_test.go
Normal file
104
web/service/node_dirty_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/runtime"
|
||||
)
|
||||
|
||||
// While a node is config-dirty (a local edit committed before it could be
|
||||
// mirrored to the node), the traffic pull must not overwrite the central
|
||||
// inbound's config columns from the node's stale snapshot — only traffic
|
||||
// counters may advance. Otherwise a reconnecting node reverts the edit.
|
||||
func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
id := node.Id
|
||||
|
||||
const desiredSettings = `{"clients":[{"email":"a@x"}]}`
|
||||
central := &model.Inbound{
|
||||
UserId: 1,
|
||||
NodeID: &id,
|
||||
Tag: "in-443-tcp",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: desiredSettings,
|
||||
}
|
||||
if err := db.Create(central).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "in-443-tcp",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"b@x"}]}`,
|
||||
Up: 500,
|
||||
Down: 700,
|
||||
}},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(id, snap, true); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked dirty: %v", err)
|
||||
}
|
||||
|
||||
var got model.Inbound
|
||||
if err := db.First(&got, central.Id).Error; err != nil {
|
||||
t.Fatalf("reload inbound: %v", err)
|
||||
}
|
||||
if got.Settings != desiredSettings {
|
||||
t.Fatalf("dirty pull overwrote settings: want %q got %q", desiredSettings, got.Settings)
|
||||
}
|
||||
if got.Up != 500 || got.Down != 700 {
|
||||
t.Fatalf("traffic counters not applied while dirty: up=%d down=%d", got.Up, got.Down)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
|
||||
// edit that re-dirties the node during a reconcile is not silently cleared.
|
||||
func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
node := &model.Node{Name: "n2", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
nodeSvc := NodeService{}
|
||||
if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
|
||||
t.Fatalf("MarkNodeDirty: %v", err)
|
||||
}
|
||||
_, _, dirty, dirtyAt, err := nodeSvc.NodeSyncState(node.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("NodeSyncState: %v", err)
|
||||
}
|
||||
if !dirty {
|
||||
t.Fatal("node should be dirty after MarkNodeDirty")
|
||||
}
|
||||
|
||||
if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt-1); err != nil {
|
||||
t.Fatalf("ClearNodeDirty stale token: %v", err)
|
||||
}
|
||||
if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); !stillDirty {
|
||||
t.Fatal("stale-token clear must not clear the dirty flag")
|
||||
}
|
||||
|
||||
if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt); err != nil {
|
||||
t.Fatalf("ClearNodeDirty matching token: %v", err)
|
||||
}
|
||||
if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); stillDirty {
|
||||
t.Fatal("matching-token clear must clear the dirty flag")
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1156,6 +1156,41 @@ func (s *ServerService) GetDb() ([]byte, error) {
|
||||
return fileContents, nil
|
||||
}
|
||||
|
||||
// GetMigration produces a cross-engine migration file plus its filename: on a
|
||||
// SQLite panel it returns a portable .dump (SQL text), and on a PostgreSQL panel
|
||||
// it returns a .db SQLite database built from the live data. Either output can
|
||||
// then seed a panel running on the other backend.
|
||||
func (s *ServerService) GetMigration() ([]byte, string, error) {
|
||||
if database.IsPostgres() {
|
||||
tmp, err := os.CreateTemp("", "x-ui-migration-*.db")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
tmp.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := database.ExportPostgresToSQLite(config.GetDBDSN(), tmpPath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := os.ReadFile(tmpPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, "x-ui.db", nil
|
||||
}
|
||||
|
||||
// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
|
||||
if err := database.Checkpoint(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := database.DumpSQLiteToBytes(config.GetDBPath())
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, "x-ui.dump", nil
|
||||
}
|
||||
|
||||
func (s *ServerService) ImportDB(file multipart.File) error {
|
||||
if database.IsPostgres() {
|
||||
return s.importPostgresDB(file)
|
||||
|
||||
@@ -79,10 +79,11 @@ var defaultValueMap = map[string]string{
|
||||
"subClashEnable": "false",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subJsonFragment": "",
|
||||
"subJsonNoises": "",
|
||||
"subClashEnableRouting": "false",
|
||||
"subClashRules": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonFinalMask": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
"nord": "",
|
||||
@@ -658,12 +659,12 @@ func (s *SettingService) GetSubClashURI() (string, error) {
|
||||
return s.getString("subClashURI")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
||||
return s.getString("subJsonFragment")
|
||||
func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
|
||||
return s.getBool("subClashEnableRouting")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonNoises() (string, error) {
|
||||
return s.getString("subJsonNoises")
|
||||
func (s *SettingService) GetSubClashRules() (string, error) {
|
||||
return s.getString("subClashRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonMux() (string, error) {
|
||||
@@ -674,6 +675,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
|
||||
return s.getString("subJsonRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
|
||||
return s.getString("subJsonFinalMask")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetDatepicker() (string, error) {
|
||||
return s.getString("datepicker")
|
||||
}
|
||||
|
||||
431
web/service/sync_scale_postgres_test.go
Normal file
431
web/service/sync_scale_postgres_test.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func syncInboundOld(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 err := tx.Create(incoming).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
row = incoming
|
||||
} else {
|
||||
row.Flow = incoming.Flow
|
||||
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
|
||||
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
||||
row.UpdatedAt = preservedUpdatedAt
|
||||
if err := tx.Save(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ClientRecord{}).
|
||||
Where("id = ?", row.Id).
|
||||
UpdateColumn("updated_at", preservedUpdatedAt).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 makeScaleClients(n int) []model.Client {
|
||||
out := make([]model.Client, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = model.Client{
|
||||
ID: uuid.NewString(),
|
||||
Email: fmt.Sprintf("user-%07d@scale", i),
|
||||
SubID: fmt.Sprintf("sub-%07d", i),
|
||||
Enable: true,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSyncInboundPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{
|
||||
Tag: fmt.Sprintf("scale-%d", n),
|
||||
Enable: true,
|
||||
Port: 40000,
|
||||
Protocol: model.VLESS,
|
||||
Settings: clientsSettings(t, clients),
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
seed := time.Since(start)
|
||||
|
||||
clients[n/2].Enable = !clients[n/2].Enable
|
||||
start = time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("toggle SyncInbound (new): %v", err)
|
||||
}
|
||||
toggleNew := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("noop SyncInbound (new): %v", err)
|
||||
}
|
||||
noopNew := time.Since(start)
|
||||
|
||||
toggleOld := time.Duration(0)
|
||||
if n <= 10000 {
|
||||
clients[n/2].Enable = !clients[n/2].Enable
|
||||
start = time.Now()
|
||||
if err := syncInboundOld(db, ib.Id, clients); err != nil {
|
||||
t.Fatalf("toggle SyncInbound (old): %v", err)
|
||||
}
|
||||
toggleOld = time.Since(start)
|
||||
}
|
||||
|
||||
var linkCount, recCount int64
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
if int(linkCount) != n || int(recCount) != n {
|
||||
t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
|
||||
}
|
||||
|
||||
oldStr, speedup := "skipped", ""
|
||||
if toggleOld > 0 {
|
||||
oldStr = toggleOld.Round(time.Millisecond).String()
|
||||
speedup = fmt.Sprintf(" speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
|
||||
}
|
||||
t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
|
||||
n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
|
||||
noopNew.Round(time.Millisecond), oldStr, speedup)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func maxDur(d, floor time.Duration) time.Duration {
|
||||
if d < floor {
|
||||
return floor
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestAddDelClientPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 20000, 50000, 100000, 200000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{
|
||||
Tag: fmt.Sprintf("adddel-%d", n),
|
||||
Enable: true,
|
||||
Port: 40000,
|
||||
Protocol: model.VLESS,
|
||||
Settings: clientsSettings(t, clients),
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
newC := model.Client{
|
||||
ID: uuid.NewString(),
|
||||
Email: "added-client@scale",
|
||||
SubID: "added-sub",
|
||||
Enable: true,
|
||||
}
|
||||
addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
|
||||
start := time.Now()
|
||||
if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
|
||||
t.Fatalf("AddInboundClient: %v", err)
|
||||
}
|
||||
addDur := time.Since(start)
|
||||
|
||||
delId := clients[n/2].ID
|
||||
start = time.Now()
|
||||
if _, err := svc.DelInboundClient(inboundSvc, ib.Id, delId, false); err != nil {
|
||||
t.Fatalf("DelInboundClient: %v", err)
|
||||
}
|
||||
delDur := time.Since(start)
|
||||
|
||||
var recCount, linkCount int64
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
|
||||
t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
|
||||
addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupAndListPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
sizes := []int{5000, 100000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
db.Exec("ANALYZE")
|
||||
emails := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
emails[i] = clients[i].Email
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
|
||||
t.Fatalf("AddToGroup: %v", err)
|
||||
}
|
||||
addDur := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
if _, err := svc.RemoveFromGroup(emails); err != nil {
|
||||
t.Fatalf("RemoveFromGroup: %v", err)
|
||||
}
|
||||
rmDur := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
list, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
listDur := time.Since(start)
|
||||
if len(list) != n {
|
||||
t.Fatalf("List returned %d, want %d", len(list), n)
|
||||
}
|
||||
|
||||
t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
|
||||
addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelAllClientsPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 50000, 100000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{Tag: fmt.Sprintf("delall-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
emails, err := inboundSvc.EmailsByInbound(ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("EmailsByInbound: %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
res, _, err := svc.BulkDelete(inboundSvc, emails, false)
|
||||
if err != nil {
|
||||
t.Fatalf("BulkDelete: %v", err)
|
||||
}
|
||||
dur := time.Since(start)
|
||||
|
||||
var recCount, linkCount int64
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
if recCount != 0 || linkCount != 0 {
|
||||
t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
|
||||
}
|
||||
t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkOpsPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 20000, 50000, 100000}
|
||||
const m = 2000
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
||||
for i := range clients {
|
||||
clients[i].ExpiryTime = exp
|
||||
clients[i].TotalGB = 100 << 30
|
||||
}
|
||||
ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := db.Create(ib2).Error; err != nil {
|
||||
t.Fatalf("create inbound2: %v", err)
|
||||
}
|
||||
|
||||
emailsM := make([]string, m)
|
||||
for i := 0; i < m; i++ {
|
||||
emailsM[i] = clients[i].Email
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
|
||||
t.Fatalf("BulkAdjust: %v", err)
|
||||
}
|
||||
adjustDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
|
||||
t.Fatalf("BulkAttach: %v", err)
|
||||
}
|
||||
attachDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
|
||||
t.Fatalf("BulkDetach: %v", err)
|
||||
}
|
||||
detachDur := time.Since(t0)
|
||||
|
||||
payloads := make([]ClientCreatePayload, m)
|
||||
for i := 0; i < m; i++ {
|
||||
payloads[i] = ClientCreatePayload{
|
||||
Client: model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
|
||||
InboundIds: []int{ib.Id},
|
||||
}
|
||||
}
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
|
||||
t.Fatalf("BulkCreate: %v", err)
|
||||
}
|
||||
createDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
|
||||
t.Fatalf("BulkDelete: %v", err)
|
||||
}
|
||||
deleteDur := time.Since(t0)
|
||||
|
||||
t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
|
||||
adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
|
||||
createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -495,6 +495,10 @@ func (t *Tgbot) OnReceive() {
|
||||
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
|
||||
|
||||
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
if !t.isCommandForCurrentBot(&message) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use goroutine with worker pool for concurrent command processing
|
||||
go func() {
|
||||
messageWorkerPool <- struct{}{} // Acquire worker
|
||||
@@ -684,6 +688,22 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool {
|
||||
return isCommandForBot(message.Text, botUsername())
|
||||
}
|
||||
|
||||
func botUsername() string {
|
||||
if bot == nil {
|
||||
return ""
|
||||
}
|
||||
return bot.Username()
|
||||
}
|
||||
|
||||
func isCommandForBot(text string, username string) bool {
|
||||
_, commandUsername, _ := tu.ParseCommand(text)
|
||||
return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username)
|
||||
}
|
||||
|
||||
// sendResponse sends the response message based on the onlyMessage flag.
|
||||
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
||||
if onlyMessage {
|
||||
|
||||
@@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
|
||||
t.Fatal("Dial must be nil when no proxy is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
|
||||
if !isCommandForBot("/status", "panel_bot") {
|
||||
t.Fatal("untargeted commands must remain accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
|
||||
if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
|
||||
t.Fatal("commands targeted to this bot must be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
|
||||
if isCommandForBot("/status@other_bot", "panel_bot") {
|
||||
t.Fatal("commands targeted to another bot must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
|
||||
if !isCommandForBot("/status@panel_bot", "") {
|
||||
t.Fatal("commands must remain accepted when the current bot username is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات",
|
||||
"backupPostgresNote": "تعمل هذه اللوحة على PostgreSQL. يقوم «النسخ الاحتياطي» بتنزيل أرشيف pg_dump (.dump)، و«الاستعادة» تعيد تحميله عبر pg_restore. يجب أن تكون أدوات عميل PostgreSQL (pg_dump و pg_restore) مثبَّتة على الخادم.",
|
||||
"exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
|
||||
"importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية."
|
||||
"importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
|
||||
"migrationDownload": "تنزيل ملف الترحيل",
|
||||
"migrationDownloadDesc": "انقر لتنزيل تصدير .dump محمول (نص SQL) لقاعدة بيانات SQLite الخاصة بك.",
|
||||
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "الواردات",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "أقصى نافذة إرسال",
|
||||
"externalProxy": "وكيل خارجي",
|
||||
"forceTls": "فرض TLS",
|
||||
"sniPlaceholder": "SNI (افتراضياً host)",
|
||||
"fingerprint": "بصمة",
|
||||
"defaultOption": "افتراضي",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
|
||||
"subRoutingRules": "قواعد التوجيه",
|
||||
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
|
||||
"subClashEnableRouting": "تفعيل التوجيه",
|
||||
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
|
||||
"subClashRoutingRules": "قواعد التوجيه العامة",
|
||||
"subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
|
||||
"subListen": "IP الاستماع",
|
||||
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
|
||||
"subPort": "بورت الاستماع",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "حد IP الافتراضي"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
|
||||
"packets": "الحزم",
|
||||
"length": "الطول",
|
||||
"interval": "الفاصل",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "An error occurred while retrieving the config file.",
|
||||
"backupPostgresNote": "This panel runs on PostgreSQL. Back Up downloads a pg_dump archive (.dump) and Restore loads it back with pg_restore. The server needs the PostgreSQL client tools (pg_dump and pg_restore) installed.",
|
||||
"exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
|
||||
"importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data."
|
||||
"importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data.",
|
||||
"migrationDownload": "Download Migration",
|
||||
"migrationDownloadDesc": "Click to download a portable .dump (SQL text) export of your SQLite database.",
|
||||
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Inbounds",
|
||||
@@ -467,6 +470,7 @@
|
||||
"inboundClientAddSuccess": "Inbound client(s) have been added.",
|
||||
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
|
||||
"inboundClientUpdateSuccess": "Inbound client has been updated.",
|
||||
"savedNodeOfflineWillSync": "Saved locally. A backing node is offline or disabled — the change will sync once it reconnects.",
|
||||
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
|
||||
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
|
||||
"resetAllTrafficSuccess": "All traffic has been reset.",
|
||||
@@ -548,7 +552,6 @@
|
||||
"maxSendingWindow": "Max Sending Window",
|
||||
"externalProxy": "External Proxy",
|
||||
"forceTls": "Force TLS",
|
||||
"sniPlaceholder": "SNI (defaults to host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "Default",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1004,10 @@
|
||||
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
|
||||
"subRoutingRules": "Routing rules",
|
||||
"subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
|
||||
"subClashEnableRouting": "Enable routing",
|
||||
"subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
|
||||
"subClashRoutingRules": "Global routing rules",
|
||||
"subClashRoutingRulesDesc": "Default Clash/Mihomo rules prepended to every generated YAML subscription before MATCH,PROXY.",
|
||||
"subListen": "Listen IP",
|
||||
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
|
||||
"subPort": "Listen Port",
|
||||
@@ -1067,6 +1074,8 @@
|
||||
"defaultIpLimit": "Default IP limit"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "xray finalmask masks (TCP/UDP) and QUIC tuning injected into every JSON subscription stream. Requires a recent xray client.",
|
||||
"packets": "Packets",
|
||||
"length": "Length",
|
||||
"interval": "Interval",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "Ocurrió un error al obtener el archivo de configuración",
|
||||
"backupPostgresNote": "Este panel funciona con PostgreSQL. «Copia de seguridad» descarga un archivo pg_dump (.dump) y «Restaurar» lo vuelve a cargar con pg_restore. El servidor necesita tener instaladas las herramientas cliente de PostgreSQL (pg_dump y pg_restore).",
|
||||
"exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.",
|
||||
"importDatabasePgDesc": "Haz clic para seleccionar y subir un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales."
|
||||
"importDatabasePgDesc": "Haz clic para seleccionar y subir un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales.",
|
||||
"migrationDownload": "Descargar migración",
|
||||
"migrationDownloadDesc": "Haz clic para descargar una exportación portable .dump (texto SQL) de tu base de datos SQLite.",
|
||||
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Entradas",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "Máx. ventana de envío",
|
||||
"externalProxy": "Proxy externo",
|
||||
"forceTls": "Forzar TLS",
|
||||
"sniPlaceholder": "SNI (por defecto = host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "Por defecto",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
|
||||
"subRoutingRules": "Reglas de enrutamiento",
|
||||
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
|
||||
"subClashEnableRouting": "Habilitar enrutamiento",
|
||||
"subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
|
||||
"subClashRoutingRules": "Reglas globales de enrutamiento",
|
||||
"subClashRoutingRulesDesc": "Reglas Clash/Mihomo agregadas al inicio de cada suscripción YAML antes de MATCH,PROXY.",
|
||||
"subListen": "Listening IP",
|
||||
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
|
||||
"subPort": "Puerto de Suscripción",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "Límite IP por defecto"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Máscaras finalmask de xray (TCP/UDP) y ajustes QUIC inyectados en cada flujo de suscripción JSON. Requiere un cliente xray reciente.",
|
||||
"packets": "Paquetes",
|
||||
"length": "Longitud",
|
||||
"interval": "Intervalo",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "خطا در دریافت فایل پیکربندی",
|
||||
"backupPostgresNote": "این پنل روی PostgreSQL اجرا میشود. «پشتیبانگیری» یک آرشیو pg_dump (.dump) دانلود میکند و «بازیابی» آن را با pg_restore بازمیگرداند. سرور باید ابزارهای کلاینت PostgreSQL (pg_dump و pg_restore) را نصب داشته باشد.",
|
||||
"exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
|
||||
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند."
|
||||
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند.",
|
||||
"migrationDownload": "دانلود فایل مهاجرت",
|
||||
"migrationDownloadDesc": "برای دانلود یک خروجی قابلحمل .dump (متن SQL) از پایگاهدادهٔ SQLite خود کلیک کنید.",
|
||||
"migrationDownloadPgDesc": "برای دانلود یک پایگاهدادهٔ SQLite با پسوند .db که از دادههای PostgreSQL شما ساخته میشود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "ورودیها",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "حداکثر پنجره ارسال",
|
||||
"externalProxy": "پراکسی خارجی",
|
||||
"forceTls": "اجبار TLS",
|
||||
"sniPlaceholder": "SNI (پیشفرض همان host)",
|
||||
"fingerprint": "اثرانگشت",
|
||||
"defaultOption": "پیشفرض",
|
||||
"routeMark": "علامت مسیر",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "تنظیمات سراسری برای فعالسازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
|
||||
"subRoutingRules": "قوانین مسیریابی",
|
||||
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
|
||||
"subClashEnableRouting": "فعالسازی مسیریابی",
|
||||
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراکهای YAML تولیدشده وارد کن.",
|
||||
"subClashRoutingRules": "قوانین مسیریابی سراسری",
|
||||
"subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده میشوند.",
|
||||
"subListen": "آدرس آیپی",
|
||||
"subListenDesc": "آدرس آیپی برای سرویس سابسکریپشن. برای گوش دادن بهتمام آیپیها خالیبگذارید",
|
||||
"subPort": "پورت",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "محدودیت IP پیشفرض"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "ماسکهای finalmask ایکسری (TCP/UDP) و تنظیمات QUIC که داخل همهی stream های اشتراک JSON تزریق میشوند. به نسخهی جدید هستهی xray در کلاینت نیاز دارد.",
|
||||
"packets": "بستهها",
|
||||
"length": "طول",
|
||||
"interval": "بازه",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi",
|
||||
"backupPostgresNote": "Panel ini berjalan di PostgreSQL. «Cadangkan» mengunduh arsip pg_dump (.dump) dan «Pulihkan» memuatnya kembali dengan pg_restore. Server memerlukan alat klien PostgreSQL (pg_dump dan pg_restore) terpasang.",
|
||||
"exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
|
||||
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini."
|
||||
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini.",
|
||||
"migrationDownload": "Unduh migrasi",
|
||||
"migrationDownloadDesc": "Klik untuk mengunduh ekspor .dump (teks SQL) portabel dari basis data SQLite Anda.",
|
||||
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Inbound",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "Maks. jendela pengiriman",
|
||||
"externalProxy": "Proxy eksternal",
|
||||
"forceTls": "Paksa TLS",
|
||||
"sniPlaceholder": "SNI (default = host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "Default",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
|
||||
"subRoutingRules": "Aturan routing",
|
||||
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
|
||||
"subClashEnableRouting": "Aktifkan routing",
|
||||
"subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
|
||||
"subClashRoutingRules": "Aturan routing global",
|
||||
"subClashRoutingRulesDesc": "Aturan Clash/Mihomo yang ditambahkan di awal setiap langganan YAML sebelum MATCH,PROXY.",
|
||||
"subListen": "IP Pendengar",
|
||||
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
|
||||
"subPort": "Port Pendengar",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "Batas IP default"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Mask finalmask xray (TCP/UDP) dan penyetelan QUIC yang disuntikkan ke setiap stream langganan JSON. Membutuhkan klien xray terbaru.",
|
||||
"packets": "Paket",
|
||||
"length": "Panjang",
|
||||
"interval": "Interval",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "設定ファイルの取得中にエラーが発生しました",
|
||||
"backupPostgresNote": "このパネルは PostgreSQL で動作しています。「バックアップ」は pg_dump アーカイブ (.dump) をダウンロードし、「復元」は pg_restore で読み込み直します。サーバーに PostgreSQL クライアントツール (pg_dump と pg_restore) がインストールされている必要があります。",
|
||||
"exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
|
||||
"importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。"
|
||||
"importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
|
||||
"migrationDownload": "移行ファイルをダウンロード",
|
||||
"migrationDownloadDesc": "SQLite データベースのポータブルな .dump(SQL テキスト)エクスポートをダウンロードするにはクリックします。",
|
||||
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "インバウンド",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "最大送信ウィンドウ",
|
||||
"externalProxy": "外部プロキシ",
|
||||
"forceTls": "TLS を強制",
|
||||
"sniPlaceholder": "SNI (デフォルトは host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "デフォルト",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
|
||||
"subRoutingRules": "ルーティングルール",
|
||||
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
|
||||
"subClashEnableRouting": "ルーティングを有効化",
|
||||
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
|
||||
"subClashRoutingRules": "グローバルルーティングルール",
|
||||
"subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
|
||||
"subListen": "監視IP",
|
||||
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
|
||||
"subPort": "監視ポート",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "デフォルト IP 制限"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
|
||||
"packets": "パケット",
|
||||
"length": "長さ",
|
||||
"interval": "間隔",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração",
|
||||
"backupPostgresNote": "Este painel é executado em PostgreSQL. «Backup» baixa um arquivo pg_dump (.dump) e «Restaurar» o recarrega com pg_restore. O servidor precisa ter as ferramentas cliente do PostgreSQL (pg_dump e pg_restore) instaladas.",
|
||||
"exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.",
|
||||
"importDatabasePgDesc": "Clique para selecionar e enviar um arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais."
|
||||
"importDatabasePgDesc": "Clique para selecionar e enviar um arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais.",
|
||||
"migrationDownload": "Baixar migração",
|
||||
"migrationDownloadDesc": "Clique para baixar uma exportação portátil .dump (texto SQL) do seu banco de dados SQLite.",
|
||||
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Entradas",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "Máx. janela de envio",
|
||||
"externalProxy": "Proxy externo",
|
||||
"forceTls": "Forçar TLS",
|
||||
"sniPlaceholder": "SNI (padrão = host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "Padrão",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
|
||||
"subRoutingRules": "Regras de roteamento",
|
||||
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
|
||||
"subClashEnableRouting": "Ativar roteamento",
|
||||
"subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
|
||||
"subClashRoutingRules": "Regras globais de roteamento",
|
||||
"subClashRoutingRulesDesc": "Regras Clash/Mihomo adicionadas ao início de cada assinatura YAML antes de MATCH,PROXY.",
|
||||
"subListen": "IP de Escuta",
|
||||
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
|
||||
"subPort": "Porta de Escuta",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "Limite de IP padrão"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Máscaras finalmask do xray (TCP/UDP) e ajustes QUIC injetados em cada fluxo de assinatura JSON. Requer um cliente xray recente.",
|
||||
"packets": "Pacotes",
|
||||
"length": "Comprimento",
|
||||
"interval": "Intervalo",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "Произошла ошибка при получении конфигурационного файла",
|
||||
"backupPostgresNote": "Эта панель работает на PostgreSQL. «Резервная копия» скачивает архив pg_dump (.dump), а «Восстановление» загружает его обратно через pg_restore. На сервере должны быть установлены клиентские инструменты PostgreSQL (pg_dump и pg_restore).",
|
||||
"exportDatabasePgDesc": "Нажмите, чтобы скачать дамп PostgreSQL (.dump) текущей базы данных на ваше устройство.",
|
||||
"importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить файл .dump для восстановления базы данных PostgreSQL. Это заменит все текущие данные."
|
||||
"importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить файл .dump для восстановления базы данных PostgreSQL. Это заменит все текущие данные.",
|
||||
"migrationDownload": "Скачать файл миграции",
|
||||
"migrationDownloadDesc": "Нажмите, чтобы скачать переносимый экспорт .dump (текст SQL) вашей базы данных SQLite.",
|
||||
"migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Входящие",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "Макс. окно отправки",
|
||||
"externalProxy": "External Proxy",
|
||||
"forceTls": "Принудительный TLS",
|
||||
"sniPlaceholder": "SNI (по умолчанию = host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "По умолчанию",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
|
||||
"subRoutingRules": "Правила маршрутизации",
|
||||
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
|
||||
"subClashEnableRouting": "Включить маршрутизацию",
|
||||
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
|
||||
"subClashRoutingRules": "Глобальные правила маршрутизации",
|
||||
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
|
||||
"subListen": "Прослушивание IP",
|
||||
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
|
||||
"subPort": "Порт подписки",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "Лимит IP по умолчанию"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
|
||||
"packets": "Пакеты",
|
||||
"length": "Длина",
|
||||
"interval": "Интервал",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu",
|
||||
"backupPostgresNote": "Bu panel PostgreSQL üzerinde çalışıyor. «Yedekle» bir pg_dump arşivi (.dump) indirir, «Geri Yükle» ise onu pg_restore ile geri yükler. Sunucuda PostgreSQL istemci araçlarının (pg_dump ve pg_restore) kurulu olması gerekir.",
|
||||
"exportDatabasePgDesc": "Mevcut veritabanınızın PostgreSQL dökümünü (.dump) cihazınıza indirmek için tıklayın.",
|
||||
"importDatabasePgDesc": "PostgreSQL veritabanınızı geri yüklemek için bir .dump dosyası seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır."
|
||||
"importDatabasePgDesc": "PostgreSQL veritabanınızı geri yüklemek için bir .dump dosyası seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
|
||||
"migrationDownload": "Geçiş dosyasını indir",
|
||||
"migrationDownloadDesc": "SQLite veritabanınızın taşınabilir .dump (SQL metni) dışa aktarımını indirmek için tıklayın.",
|
||||
"migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Gelenler",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "Maks. gönderme penceresi",
|
||||
"externalProxy": "Harici proxy",
|
||||
"forceTls": "TLS zorla",
|
||||
"sniPlaceholder": "SNI (varsayılan host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "Varsayılan",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
|
||||
"subRoutingRules": "Yönlendirme kuralları",
|
||||
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
|
||||
"subClashEnableRouting": "Yönlendirmeyi etkinleştir",
|
||||
"subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekle.",
|
||||
"subClashRoutingRules": "Genel yönlendirme kuralları",
|
||||
"subClashRoutingRulesDesc": "Her YAML aboneliğinin başına MATCH,PROXY öncesinde eklenen Clash/Mihomo kuralları.",
|
||||
"subListen": "Dinleme IP",
|
||||
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
|
||||
"subPort": "Dinleme Portu",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "Varsayılan IP limiti"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Her JSON abonelik akışına eklenen xray finalmask maskeleri (TCP/UDP) ve QUIC ayarları. Güncel bir xray istemcisi gerektirir.",
|
||||
"packets": "Paketler",
|
||||
"length": "Uzunluk",
|
||||
"interval": "Aralık",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "Виникла помилка під час отримання файлу конфігурації",
|
||||
"backupPostgresNote": "Ця панель працює на PostgreSQL. «Резервна копія» завантажує архів pg_dump (.dump), а «Відновлення» завантажує його назад через pg_restore. На сервері мають бути встановлені клієнтські інструменти PostgreSQL (pg_dump і pg_restore).",
|
||||
"exportDatabasePgDesc": "Натисніть, щоб завантажити дамп PostgreSQL (.dump) вашої поточної бази даних на ваш пристрій.",
|
||||
"importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити файл .dump для відновлення бази даних PostgreSQL. Це замінить усі поточні дані."
|
||||
"importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити файл .dump для відновлення бази даних PostgreSQL. Це замінить усі поточні дані.",
|
||||
"migrationDownload": "Завантажити файл міграції",
|
||||
"migrationDownloadDesc": "Натисніть, щоб завантажити переносний експорт .dump (текст SQL) вашої бази даних SQLite.",
|
||||
"migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Вхідні",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "Макс. вікно відправки",
|
||||
"externalProxy": "External Proxy",
|
||||
"forceTls": "Примусовий TLS",
|
||||
"sniPlaceholder": "SNI (за замовчуванням = host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "За замовчуванням",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
|
||||
"subRoutingRules": "Правила маршрутизації",
|
||||
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
|
||||
"subClashEnableRouting": "Увімкнути маршрутизацію",
|
||||
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
|
||||
"subClashRoutingRules": "Глобальні правила маршрутизації",
|
||||
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
|
||||
"subListen": "Слухати IP",
|
||||
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
|
||||
"subPort": "Слухати порт",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "Ліміт IP за замовч."
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
|
||||
"packets": "Пакети",
|
||||
"length": "Довжина",
|
||||
"interval": "Інтервал",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình",
|
||||
"backupPostgresNote": "Bảng điều khiển này chạy trên PostgreSQL. «Sao lưu» tải xuống một tệp lưu trữ pg_dump (.dump) và «Khôi phục» nạp lại bằng pg_restore. Máy chủ cần cài đặt các công cụ máy khách PostgreSQL (pg_dump và pg_restore).",
|
||||
"exportDatabasePgDesc": "Nhấn để tải xuống bản kết xuất PostgreSQL (.dump) của cơ sở dữ liệu hiện tại về thiết bị của bạn.",
|
||||
"importDatabasePgDesc": "Nhấn để chọn và tải lên một tệp .dump nhằm khôi phục cơ sở dữ liệu PostgreSQL của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại."
|
||||
"importDatabasePgDesc": "Nhấn để chọn và tải lên một tệp .dump nhằm khôi phục cơ sở dữ liệu PostgreSQL của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
|
||||
"migrationDownload": "Tải tệp di trú",
|
||||
"migrationDownloadDesc": "Nhấp để tải xuống bản xuất .dump (văn bản SQL) di động của cơ sở dữ liệu SQLite của bạn.",
|
||||
"migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Inbound",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "Cửa sổ gửi tối đa",
|
||||
"externalProxy": "Proxy ngoài",
|
||||
"forceTls": "Bắt buộc TLS",
|
||||
"sniPlaceholder": "SNI (mặc định = host)",
|
||||
"fingerprint": "Fingerprint",
|
||||
"defaultOption": "Mặc định",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
|
||||
"subRoutingRules": "Quy tắc định tuyến",
|
||||
"subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
|
||||
"subClashEnableRouting": "Bật định tuyến",
|
||||
"subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
|
||||
"subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
|
||||
"subClashRoutingRulesDesc": "Quy tắc Clash/Mihomo được thêm vào đầu mỗi đăng ký YAML trước MATCH,PROXY.",
|
||||
"subListen": "Listening IP",
|
||||
"subListenDesc": "Mặc định để trống để nghe tất cả các IP",
|
||||
"subPort": "Cổng gói đăng ký",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "Giới hạn IP mặc định"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Mask finalmask của xray (TCP/UDP) và tinh chỉnh QUIC được thêm vào mọi luồng đăng ký JSON. Yêu cầu client xray mới hơn.",
|
||||
"packets": "Gói",
|
||||
"length": "Độ dài",
|
||||
"interval": "Khoảng",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "检索配置文件时出错",
|
||||
"backupPostgresNote": "此面板运行在 PostgreSQL 上。「备份」会下载一个 pg_dump 归档(.dump),「恢复」会通过 pg_restore 重新载入。服务器需要安装 PostgreSQL 客户端工具(pg_dump 和 pg_restore)。",
|
||||
"exportDatabasePgDesc": "点击将当前数据库的 PostgreSQL 转储(.dump)下载到您的设备。",
|
||||
"importDatabasePgDesc": "点击选择并上传 .dump 文件以恢复您的 PostgreSQL 数据库。此操作将替换所有当前数据。"
|
||||
"importDatabasePgDesc": "点击选择并上传 .dump 文件以恢复您的 PostgreSQL 数据库。此操作将替换所有当前数据。",
|
||||
"migrationDownload": "下载迁移文件",
|
||||
"migrationDownloadDesc": "点击下载当前 SQLite 数据库的可移植 .dump(SQL 文本)导出文件。",
|
||||
"migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "入站",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "最大发送窗口",
|
||||
"externalProxy": "外部代理",
|
||||
"forceTls": "强制 TLS",
|
||||
"sniPlaceholder": "SNI (默认为 host)",
|
||||
"fingerprint": "指纹",
|
||||
"defaultOption": "默认",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
|
||||
"subRoutingRules": "路由規則",
|
||||
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
|
||||
"subClashEnableRouting": "启用路由",
|
||||
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
|
||||
"subClashRoutingRules": "全局路由规则",
|
||||
"subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
|
||||
"subListen": "监听 IP",
|
||||
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
|
||||
"subPort": "监听端口",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "默认 IP 限制"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
|
||||
"packets": "数据包",
|
||||
"length": "长度",
|
||||
"interval": "间隔",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"getConfigError": "檢索設定檔時發生錯誤",
|
||||
"backupPostgresNote": "此面板執行於 PostgreSQL 上。「備份」會下載一個 pg_dump 封存檔(.dump),「還原」會透過 pg_restore 重新載入。伺服器需要安裝 PostgreSQL 用戶端工具(pg_dump 與 pg_restore)。",
|
||||
"exportDatabasePgDesc": "點擊將目前資料庫的 PostgreSQL 傾印(.dump)下載到您的裝置。",
|
||||
"importDatabasePgDesc": "點擊選擇並上傳 .dump 檔案以還原您的 PostgreSQL 資料庫。此操作將取代所有目前的資料。"
|
||||
"importDatabasePgDesc": "點擊選擇並上傳 .dump 檔案以還原您的 PostgreSQL 資料庫。此操作將取代所有目前的資料。",
|
||||
"migrationDownload": "下載遷移檔案",
|
||||
"migrationDownloadDesc": "點擊下載目前 SQLite 資料庫的可攜式 .dump(SQL 文字)匯出檔。",
|
||||
"migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "入站",
|
||||
@@ -548,7 +551,6 @@
|
||||
"maxSendingWindow": "最大發送視窗",
|
||||
"externalProxy": "外部代理",
|
||||
"forceTls": "強制 TLS",
|
||||
"sniPlaceholder": "SNI (預設為 host)",
|
||||
"fingerprint": "指紋",
|
||||
"defaultOption": "預設",
|
||||
"routeMark": "Route Mark",
|
||||
@@ -1001,6 +1003,10 @@
|
||||
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
|
||||
"subRoutingRules": "路由規則",
|
||||
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
|
||||
"subClashEnableRouting": "啟用路由",
|
||||
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
|
||||
"subClashRoutingRules": "全域路由規則",
|
||||
"subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
|
||||
"subListen": "監聽 IP",
|
||||
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
|
||||
"subPort": "監聽埠",
|
||||
@@ -1067,6 +1073,8 @@
|
||||
"defaultIpLimit": "預設 IP 限制"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
|
||||
"packets": "封包",
|
||||
"length": "長度",
|
||||
"interval": "間隔",
|
||||
|
||||
69
x-ui.sh
69
x-ui.sh
@@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
|
||||
echo "Panel paths set for domain: $domain"
|
||||
echo " - Certificate File: $webCertFile"
|
||||
echo " - Private Key File: $webKeyFile"
|
||||
# Register the acme.sh install-cert hook so auto-renewal copies the
|
||||
# renewed cert to these paths and reloads the panel. Without it acme.sh
|
||||
# renews but never updates /root/cert, silently serving a stale cert.
|
||||
if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
~/.acme.sh/acme.sh --installcert -d "${domain}" \
|
||||
--key-file "${webKeyFile}" \
|
||||
--fullchain-file "${webCertFile}" \
|
||||
--reloadcmd "x-ui restart" 2>&1 || true
|
||||
echo "Registered acme.sh auto-renewal hook for ${domain}."
|
||||
fi
|
||||
restart
|
||||
else
|
||||
echo "Certificate or private key not found for domain: $domain."
|
||||
@@ -1448,8 +1458,8 @@ ssl_cert_issue_for_ip() {
|
||||
LOGE "Failed to issue certificate for IP: ${server_ip}"
|
||||
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
|
||||
rm -rf ${certPath} 2> /dev/null
|
||||
return 1
|
||||
else
|
||||
@@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
|
||||
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
|
||||
LOGE "Certificate files not found after installation"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
|
||||
rm -rf ${certPath} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
@@ -1576,14 +1586,30 @@ ssl_cert_issue() {
|
||||
LOGD "Your domain is: ${domain}, checking it..."
|
||||
SSL_ISSUED_DOMAIN="${domain}"
|
||||
|
||||
# detect existing certificate and reuse it if present
|
||||
# detect existing certificate and reuse it only if its files are actually
|
||||
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
|
||||
# certs under ${domain}; a failed issuance can leave a domain entry in --list
|
||||
# with no usable cert files, which must not be reused (it produces a 0-byte
|
||||
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
|
||||
local cert_exists=0
|
||||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||
LOGI "Existing certificate found for ${domain}, will reuse it."
|
||||
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
|
||||
else
|
||||
local acmeCertDir=""
|
||||
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
|
||||
acmeCertDir=~/.acme.sh/${domain}_ecc
|
||||
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
|
||||
acmeCertDir=~/.acme.sh/${domain}
|
||||
fi
|
||||
if [[ -n "${acmeCertDir}" ]]; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||
LOGI "Existing certificate found for ${domain}, will reuse it."
|
||||
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
|
||||
else
|
||||
LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
fi
|
||||
fi
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
LOGI "Your domain is ready for issuing certificates now..."
|
||||
fi
|
||||
|
||||
@@ -1611,7 +1637,7 @@ ssl_cert_issue() {
|
||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
if [ $? -ne 0 ]; then
|
||||
LOGE "Issuing certificate failed, please check logs."
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
exit 1
|
||||
else
|
||||
LOGE "Issuing certificate succeeded, installing certificates..."
|
||||
@@ -1664,7 +1690,7 @@ ssl_cert_issue() {
|
||||
else
|
||||
LOGE "Installing certificate failed, exiting."
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
@@ -2248,6 +2274,18 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
||||
# Ports to exempt from the ban so an over-limit proxy client can never lock
|
||||
# the administrator out of SSH or the panel. The ban still covers every other
|
||||
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
|
||||
# added later without regenerating these files.
|
||||
local ssh_ports
|
||||
ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
|
||||
[[ -z "${ssh_ports}" ]] && ssh_ports="22"
|
||||
local panel_port
|
||||
panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
local exempt_ports="${ssh_ports}"
|
||||
[[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
|
||||
|
||||
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
|
||||
[INCLUDES]
|
||||
before = iptables-allports.conf
|
||||
@@ -2263,16 +2301,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
protocol = tcp
|
||||
chain = INPUT
|
||||
exemptports = ${exempt_ports}
|
||||
EOF
|
||||
|
||||
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
|
||||
@@ -2690,7 +2729,7 @@ migrate_to_postgres() {
|
||||
echo ""
|
||||
echo -e "${yellow}This copies your current SQLite data into a PostgreSQL database,${plain}"
|
||||
echo -e "${yellow}then switches the panel to PostgreSQL and restarts it.${plain}"
|
||||
echo -e "${yellow}The destination PostgreSQL database must be empty.${plain}"
|
||||
echo -e "${red}Any existing panel tables in the destination will be cleared and overwritten.${plain}"
|
||||
confirm "Continue?" "n" || return 0
|
||||
|
||||
local dsn="" pg_mode
|
||||
|
||||
Reference in New Issue
Block a user