mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 17:15:24 +03:00
Compare commits
22 Commits
a07c7b7f4e
...
v3.2.8
| 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 |
+66
-28
@@ -86,10 +86,11 @@ Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `a
|
|||||||
|
|
||||||
### Inside VS Code
|
### 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
|
```jsonc
|
||||||
{
|
{
|
||||||
|
"$schema": "vscode://schemas/launch",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
@@ -106,6 +107,23 @@ The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy fr
|
|||||||
"XUI_BIN_FOLDER": "x-ui"
|
"XUI_BIN_FOLDER": "x-ui"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal"
|
"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
|
### 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
|
### 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.
|
- **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`.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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).
|
||||||
- **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`.
|
- **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
|
### i18n
|
||||||
|
|
||||||
@@ -134,21 +159,22 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
|
|||||||
|
|
||||||
| Goal | Command |
|
| 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. |
|
| 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.
|
> **`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
|
### 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">`).
|
Most new screens are **admin-panel routes** and need no new HTML or Vite entry:
|
||||||
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).
|
1. 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`.
|
2. Register it in `src/routes.tsx` under the `/panel` tree (lazy-import it like the others).
|
||||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
|
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it should be reachable from the nav.
|
||||||
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
|
|
||||||
|
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
|
### 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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
|
### Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
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/*"
|
├── 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
|
├── vite.config.js
|
||||||
|
├── vitest.config.ts
|
||||||
|
├── scripts/ — build-openapi.mjs (endpoints.ts → openapi.json)
|
||||||
└── src/
|
└── src/
|
||||||
├── entries/ — per-page bootstrap (createRoot + render)
|
├── main.tsx — admin SPA bootstrap (router + providers)
|
||||||
├── pages/ — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
|
├── routes.tsx — react-router routes mounted under /panel
|
||||||
├── components/ — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
|
├── entries/ — bootstrap for the standalone bundles (login, subpage)
|
||||||
├── hooks/ — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
|
├── layouts/ — PanelLayout + AppSidebar
|
||||||
├── api/ — Axios setup + CSRF interceptor + WebSocket client
|
├── 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/)
|
├── 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, …)
|
├── 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).
|
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:
|
3. Run the relevant checks before pushing:
|
||||||
- `go build ./...`
|
- `go build ./...`
|
||||||
- `go test ./...` (when Go code changed)
|
- `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.
|
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.
|
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_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
||||||
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
| `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)
|
- 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`.
|
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`.
|
||||||
|
|||||||
+13
-2
@@ -27,6 +27,16 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
|
|||||||
ignoreregex =
|
ignoreregex =
|
||||||
EOF
|
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
|
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||||
[INCLUDES]
|
[INCLUDES]
|
||||||
before = iptables-allports.conf
|
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]'
|
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
|
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
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||||
|
|
||||||
[Init]
|
[Init]
|
||||||
name = default
|
name = default
|
||||||
protocol = tcp
|
protocol = tcp
|
||||||
chain = INPUT
|
chain = INPUT
|
||||||
|
exemptports = $EXEMPT_PORTS
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
fail2ban-client -x start
|
fail2ban-client -x start
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.2.7
|
3.2.8
|
||||||
@@ -86,6 +86,14 @@ 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
|
// Empty the destination tables so the migration is idempotent: a fresh
|
||||||
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
|
// 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
|
// panel start, and a partially-failed earlier run leaves rows behind. Either
|
||||||
|
|||||||
@@ -396,6 +396,9 @@ type Node struct {
|
|||||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||||
LastError string `json:"lastError"`
|
LastError string `json:"lastError"`
|
||||||
|
|
||||||
|
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||||
|
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||||
|
|
||||||
InboundCount int `json:"inboundCount" gorm:"-"`
|
InboundCount int `json:"inboundCount" gorm:"-"`
|
||||||
ClientCount int `json:"clientCount" gorm:"-"`
|
ClientCount int `json:"clientCount" gorm:"-"`
|
||||||
OnlineCount int `json:"onlineCount" gorm:"-"`
|
OnlineCount int `json:"onlineCount" gorm:"-"`
|
||||||
|
|||||||
Generated
+4
-34
@@ -17,7 +17,7 @@
|
|||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.1",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"persian-calendar-suite": "^1.5.5",
|
"persian-calendar-suite": "^1.5.5",
|
||||||
"qs": "^6.15.2",
|
"qs": "^6.15.2",
|
||||||
@@ -1934,9 +1934,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1954,9 +1951,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1974,9 +1968,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1994,9 +1985,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2014,9 +2002,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2034,9 +2019,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5087,9 +5069,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.0",
|
"version": "26.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -5615,9 +5597,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5639,9 +5618,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5663,9 +5639,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5687,9 +5660,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.1",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"persian-calendar-suite": "^1.5.5",
|
"persian-calendar-suite": "^1.5.5",
|
||||||
"qs": "^6.15.2",
|
"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": {
|
"/panel/api/server/getNewUUID": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -5761,7 +5791,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Subscription Server"
|
"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",
|
"operationId": "get_clashPath_subid",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export interface AllSetting {
|
|||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
subClashEnable: boolean;
|
subClashEnable: boolean;
|
||||||
|
subClashEnableRouting: boolean;
|
||||||
subClashPath: string;
|
subClashPath: string;
|
||||||
|
subClashRules: string;
|
||||||
subClashURI: string;
|
subClashURI: string;
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEmailInRemark: boolean;
|
subEmailInRemark: boolean;
|
||||||
@@ -42,9 +44,8 @@ export interface AllSetting {
|
|||||||
subEnableRouting: boolean;
|
subEnableRouting: boolean;
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subJsonFragment: string;
|
subJsonFinalMask: string;
|
||||||
subJsonMux: string;
|
subJsonMux: string;
|
||||||
subJsonNoises: string;
|
|
||||||
subJsonPath: string;
|
subJsonPath: string;
|
||||||
subJsonRules: string;
|
subJsonRules: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
@@ -121,7 +122,9 @@ export interface AllSettingView {
|
|||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
subClashEnable: boolean;
|
subClashEnable: boolean;
|
||||||
|
subClashEnableRouting: boolean;
|
||||||
subClashPath: string;
|
subClashPath: string;
|
||||||
|
subClashRules: string;
|
||||||
subClashURI: string;
|
subClashURI: string;
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEmailInRemark: boolean;
|
subEmailInRemark: boolean;
|
||||||
@@ -129,9 +132,8 @@ export interface AllSettingView {
|
|||||||
subEnableRouting: boolean;
|
subEnableRouting: boolean;
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subJsonFragment: string;
|
subJsonFinalMask: string;
|
||||||
subJsonMux: string;
|
subJsonMux: string;
|
||||||
subJsonNoises: string;
|
|
||||||
subJsonPath: string;
|
subJsonPath: string;
|
||||||
subJsonRules: string;
|
subJsonRules: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
@@ -320,6 +322,8 @@ export interface Node {
|
|||||||
apiToken: string;
|
apiToken: string;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
|
configDirty: boolean;
|
||||||
|
configDirtyAt: number;
|
||||||
cpuPct: number;
|
cpuPct: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
depletedCount: number;
|
depletedCount: number;
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({
|
|||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
subClashEnable: z.boolean(),
|
subClashEnable: z.boolean(),
|
||||||
|
subClashEnableRouting: z.boolean(),
|
||||||
subClashPath: z.string(),
|
subClashPath: z.string(),
|
||||||
|
subClashRules: z.string(),
|
||||||
subClashURI: z.string(),
|
subClashURI: z.string(),
|
||||||
subDomain: z.string(),
|
subDomain: z.string(),
|
||||||
subEmailInRemark: z.boolean(),
|
subEmailInRemark: z.boolean(),
|
||||||
@@ -44,9 +46,8 @@ export const AllSettingSchema = z.object({
|
|||||||
subEnableRouting: z.boolean(),
|
subEnableRouting: z.boolean(),
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
subJsonEnable: z.boolean(),
|
subJsonEnable: z.boolean(),
|
||||||
subJsonFragment: z.string(),
|
subJsonFinalMask: z.string(),
|
||||||
subJsonMux: z.string(),
|
subJsonMux: z.string(),
|
||||||
subJsonNoises: z.string(),
|
|
||||||
subJsonPath: z.string(),
|
subJsonPath: z.string(),
|
||||||
subJsonRules: z.string(),
|
subJsonRules: z.string(),
|
||||||
subJsonURI: z.string(),
|
subJsonURI: z.string(),
|
||||||
@@ -124,7 +125,9 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
subClashEnable: z.boolean(),
|
subClashEnable: z.boolean(),
|
||||||
|
subClashEnableRouting: z.boolean(),
|
||||||
subClashPath: z.string(),
|
subClashPath: z.string(),
|
||||||
|
subClashRules: z.string(),
|
||||||
subClashURI: z.string(),
|
subClashURI: z.string(),
|
||||||
subDomain: z.string(),
|
subDomain: z.string(),
|
||||||
subEmailInRemark: z.boolean(),
|
subEmailInRemark: z.boolean(),
|
||||||
@@ -132,9 +135,8 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subEnableRouting: z.boolean(),
|
subEnableRouting: z.boolean(),
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
subJsonEnable: z.boolean(),
|
subJsonEnable: z.boolean(),
|
||||||
subJsonFragment: z.string(),
|
subJsonFinalMask: z.string(),
|
||||||
subJsonMux: z.string(),
|
subJsonMux: z.string(),
|
||||||
subJsonNoises: z.string(),
|
|
||||||
subJsonPath: z.string(),
|
subJsonPath: z.string(),
|
||||||
subJsonRules: z.string(),
|
subJsonRules: z.string(),
|
||||||
subJsonURI: z.string(),
|
subJsonURI: z.string(),
|
||||||
@@ -337,6 +339,8 @@ export const NodeSchema = z.object({
|
|||||||
apiToken: z.string(),
|
apiToken: z.string(),
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
clientCount: z.number().int(),
|
clientCount: z.number().int(),
|
||||||
|
configDirty: z.boolean(),
|
||||||
|
configDirtyAt: z.number().int(),
|
||||||
cpuPct: z.number(),
|
cpuPct: z.number(),
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
depletedCount: z.number().int(),
|
depletedCount: z.number().int(),
|
||||||
|
|||||||
@@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
|
|||||||
import { RandomUtil } from '@/utils';
|
import { RandomUtil } from '@/utils';
|
||||||
import { OutboundProtocols } from '@/schemas/primitives';
|
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 {
|
export interface FinalMaskFormProps {
|
||||||
name: NamePath;
|
name: NamePath;
|
||||||
network: string;
|
network: string;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
form: FormInstance;
|
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'];
|
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' };
|
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 base = asPath(name);
|
||||||
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
|
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
|
||||||
const showTcp = TCP_NETWORKS.includes(network);
|
const showTcp = showAll || TCP_NETWORKS.includes(network);
|
||||||
const showUdp = isHysteria || network === 'kcp';
|
const showUdp = showAll || isHysteria || network === 'kcp';
|
||||||
const showQuic = isHysteria || network === 'xhttp';
|
const showQuic = showAll || isHysteria || network === 'xhttp';
|
||||||
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
|
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
|
||||||
const hasQuicParams = quicParams != null;
|
const hasQuicParams = quicParams != null;
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ function applyExternalProxyTLSObj(
|
|||||||
if (alpn.length > 0) obj.alpn = alpn;
|
if (alpn.length > 0) obj.alpn = alpn;
|
||||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||||
if (pins.length > 0) obj.pcs = pins;
|
if (pins.length > 0) obj.pcs = pins;
|
||||||
|
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenVmessLinkInput {
|
export interface GenVmessLinkInput {
|
||||||
@@ -232,6 +233,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
|||||||
if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
|
if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
|
||||||
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
|
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
|
||||||
if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
|
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) {
|
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
|
||||||
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
|
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
|
||||||
}
|
}
|
||||||
@@ -279,6 +281,7 @@ function applyExternalProxyTLSParams(
|
|||||||
if (alpn.length > 0) params.set('alpn', alpn);
|
if (alpn.length > 0) params.set('alpn', alpn);
|
||||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||||
if (pins.length > 0) params.set('pcs', pins);
|
if (pins.length > 0) params.set('pcs', pins);
|
||||||
|
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenVlessLinkInput {
|
export interface GenVlessLinkInput {
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
|
|||||||
tls.fingerprint = params.get('fp') ?? '';
|
tls.fingerprint = params.get('fp') ?? '';
|
||||||
const alpn = params.get('alpn');
|
const alpn = params.get('alpn');
|
||||||
if (alpn) tls.alpn = alpn.split(',');
|
if (alpn) tls.alpn = alpn.split(',');
|
||||||
|
tls.echConfigList = params.get('ech') ?? '';
|
||||||
|
tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
|
||||||
} else if (stream.security === 'reality') {
|
} else if (stream.security === 'reality') {
|
||||||
const reality = stream.realitySettings as Raw;
|
const reality = stream.realitySettings as Raw;
|
||||||
reality.serverName = params.get('sni') ?? '';
|
reality.serverName = params.get('sni') ?? '';
|
||||||
|
|||||||
@@ -55,10 +55,11 @@ export class AllSetting {
|
|||||||
subURI = '';
|
subURI = '';
|
||||||
subJsonURI = '';
|
subJsonURI = '';
|
||||||
subClashURI = '';
|
subClashURI = '';
|
||||||
subJsonFragment = '';
|
subClashEnableRouting = false;
|
||||||
subJsonNoises = '';
|
subClashRules = '';
|
||||||
subJsonMux = '';
|
subJsonMux = '';
|
||||||
subJsonRules = '';
|
subJsonRules = '';
|
||||||
|
subJsonFinalMask = '';
|
||||||
|
|
||||||
timeLocation = 'Local';
|
timeLocation = 'Local';
|
||||||
|
|
||||||
|
|||||||
@@ -1114,7 +1114,7 @@ export const sections: readonly Section[] = [
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/{clashPath}:subid',
|
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: [
|
params: [
|
||||||
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export default function ClientBulkAddModal({
|
|||||||
)}
|
)}
|
||||||
{form.emailMethod < 2 && (
|
{form.emailMethod < 2 && (
|
||||||
<Form.Item label={t('pages.clients.clientCount')}>
|
<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>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import type { ClientFilters } from './filters';
|
|||||||
import './ClientsPage.css';
|
import './ClientsPage.css';
|
||||||
|
|
||||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||||
|
const DISABLED_PAGE_SIZE = 200;
|
||||||
|
|
||||||
function UngroupIcon() {
|
function UngroupIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -276,10 +277,7 @@ export default function ClientsPage() {
|
|||||||
const activeCount = activeFilterCount(filters);
|
const activeCount = activeFilterCount(filters);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageSize > 0) {
|
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
|
||||||
|
|
||||||
setTablePageSize(pageSize);
|
|
||||||
}
|
|
||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
||||||
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const newEntry = () => ({
|
|||||||
fingerprint: '',
|
fingerprint: '',
|
||||||
alpn: [],
|
alpn: [],
|
||||||
pinnedPeerCertSha256: [],
|
pinnedPeerCertSha256: [],
|
||||||
|
echConfigList: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
||||||
@@ -92,9 +93,9 @@ export default function ExternalProxyForm({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('host')}>
|
<Field label={t('pages.inbounds.address')}>
|
||||||
<Form.Item name={[field.name, 'dest']} noStyle>
|
<Form.Item name={[field.name, 'dest']} noStyle>
|
||||||
<Input placeholder={t('host')} />
|
<Input placeholder={t('pages.inbounds.address')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('pages.inbounds.port')}>
|
<Field label={t('pages.inbounds.port')}>
|
||||||
@@ -125,7 +126,7 @@ export default function ExternalProxyForm({
|
|||||||
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
||||||
<Field label="SNI">
|
<Field label="SNI">
|
||||||
<Form.Item name={[field.name, 'sni']} noStyle>
|
<Form.Item name={[field.name, 'sni']} noStyle>
|
||||||
<Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
|
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('pages.inbounds.form.fingerprint')}>
|
<Field label={t('pages.inbounds.form.fingerprint')}>
|
||||||
@@ -157,6 +158,11 @@ export default function ExternalProxyForm({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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')}>
|
<Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
|
||||||
<Space.Compact block>
|
<Space.Compact block>
|
||||||
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
|
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
|
||||||
|
|||||||
@@ -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 { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Select,
|
Select,
|
||||||
@@ -10,19 +8,17 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
|
||||||
PartitionOutlined,
|
PartitionOutlined,
|
||||||
PlusOutlined,
|
RocketOutlined,
|
||||||
ScissorOutlined,
|
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ThunderboltOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
import { sanitizePath, normalizePath } from './uriPath';
|
import { sanitizePath, normalizePath } from './uriPath';
|
||||||
|
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
|
||||||
import './SubscriptionFormatsTab.css';
|
import './SubscriptionFormatsTab.css';
|
||||||
|
|
||||||
interface SubscriptionFormatsTabProps {
|
interface SubscriptionFormatsTabProps {
|
||||||
@@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
|
|||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
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 = {
|
const DEFAULT_MUX = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
concurrency: 8,
|
concurrency: 8,
|
||||||
@@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
const fragment = allSetting.subJsonFragment !== '';
|
|
||||||
const noisesEnabled = allSetting.subJsonNoises !== '';
|
|
||||||
const muxEnabled = allSetting.subJsonMux !== '';
|
const muxEnabled = allSetting.subJsonMux !== '';
|
||||||
const directEnabled = allSetting.subJsonRules !== '';
|
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(
|
const muxObj = useMemo(
|
||||||
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
|
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
|
||||||
[allSetting.subJsonMux, muxEnabled],
|
[allSetting.subJsonMux, muxEnabled],
|
||||||
@@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '2',
|
key: '2',
|
||||||
label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
|
label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
|
||||||
<Switch checked={fragment} onChange={setFragmentEnabled} />
|
<SubJsonFinalMaskForm
|
||||||
</SettingListItem>
|
value={allSetting.subJsonFinalMask}
|
||||||
{fragment && (
|
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '3',
|
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),
|
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
@@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '5',
|
key: '4',
|
||||||
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
|
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
|
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
|
||||||
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
|
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
|
||||||
</SettingListItem>
|
</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),
|
lastNum: z.number().int().min(1),
|
||||||
emailPrefix: z.string(),
|
emailPrefix: z.string(),
|
||||||
emailPostfix: z.string(),
|
emailPostfix: z.string(),
|
||||||
quantity: z.number().int().min(1).max(100),
|
quantity: z.number().int().min(1).max(1000),
|
||||||
subId: z.string(),
|
subId: z.string(),
|
||||||
group: z.string(),
|
group: z.string(),
|
||||||
comment: z.string(),
|
comment: z.string(),
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ export const ExternalProxyEntrySchema = z.object({
|
|||||||
),
|
),
|
||||||
alpn: z.array(AlpnSchema).optional(),
|
alpn: z.array(AlpnSchema).optional(),
|
||||||
pinnedPeerCertSha256: z.array(z.string()).optional(),
|
pinnedPeerCertSha256: z.array(z.string()).optional(),
|
||||||
|
echConfigList: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
|
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
|
||||||
|
|||||||
@@ -59,10 +59,11 @@ export const AllSettingSchema = z.object({
|
|||||||
subURI: z.string().optional(),
|
subURI: z.string().optional(),
|
||||||
subJsonURI: z.string().optional(),
|
subJsonURI: z.string().optional(),
|
||||||
subClashURI: z.string().optional(),
|
subClashURI: z.string().optional(),
|
||||||
subJsonFragment: z.string().optional(),
|
subClashEnableRouting: z.boolean().optional(),
|
||||||
subJsonNoises: z.string().optional(),
|
subClashRules: z.string().optional(),
|
||||||
subJsonMux: z.string().optional(),
|
subJsonMux: z.string().optional(),
|
||||||
subJsonRules: z.string().optional(),
|
subJsonRules: z.string().optional(),
|
||||||
|
subJsonFinalMask: z.string().optional(),
|
||||||
timeLocation: z.string().optional(),
|
timeLocation: z.string().optional(),
|
||||||
ldapEnable: z.boolean().optional(),
|
ldapEnable: z.boolean().optional(),
|
||||||
ldapHost: z.string().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>;
|
const stream = parsed!.streamSettings as Record<string, unknown>;
|
||||||
expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto');
|
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', () => {
|
describe('parseWireguardLink', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import i18next from 'i18next';
|
||||||
import { getMessage } from './messageBus';
|
import { getMessage } from './messageBus';
|
||||||
|
|
||||||
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
|
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
|
||||||
@@ -32,6 +33,14 @@ export class HttpUtil {
|
|||||||
}
|
}
|
||||||
const messageType = msg.success ? 'success' : 'error';
|
const messageType = msg.success ? 'success' : 'error';
|
||||||
getMessage()[messageType](msg.msg);
|
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 {
|
static _respToMsg(resp: AxiosResponse | undefined): Msg {
|
||||||
@@ -858,13 +867,13 @@ export class LanguageManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (LanguageManager.isSupportLanguage(lang)) {
|
if (LanguageManager.isSupportLanguage(lang)) {
|
||||||
CookieManager.setCookie('lang', lang);
|
CookieManager.setCookie('lang', lang, 365);
|
||||||
} else {
|
} else {
|
||||||
CookieManager.setCookie('lang', 'en-US');
|
CookieManager.setCookie('lang', 'en-US', 365);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CookieManager.setCookie('lang', 'en-US');
|
CookieManager.setCookie('lang', 'en-US', 365);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,7 +884,7 @@ export class LanguageManager {
|
|||||||
if (!LanguageManager.isSupportLanguage(language)) {
|
if (!LanguageManager.isSupportLanguage(language)) {
|
||||||
language = 'en-US';
|
language = 'en-US';
|
||||||
}
|
}
|
||||||
CookieManager.setCookie('lang', language);
|
CookieManager.setCookie('lang', language, 365);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-8
@@ -297,7 +297,7 @@ setup_ssl_certificate() {
|
|||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
|
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}"
|
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
|
rm -rf "$certPath" 2> /dev/null
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -431,8 +431,8 @@ setup_ip_certificate() {
|
|||||||
echo -e "${red}Failed to issue IP certificate${plain}"
|
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}"
|
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
|
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
|
||||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
|
||||||
rm -rf ${certDir} 2> /dev/null
|
rm -rf ${certDir} 2> /dev/null
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -451,8 +451,8 @@ setup_ip_certificate() {
|
|||||||
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
|
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
|
||||||
echo -e "${red}Certificate files not found after installation${plain}"
|
echo -e "${red}Certificate files not found after installation${plain}"
|
||||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
|
||||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
|
||||||
rm -rf ${certDir} 2> /dev/null
|
rm -rf ${certDir} 2> /dev/null
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -524,14 +524,30 @@ ssl_cert_issue() {
|
|||||||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||||
SSL_ISSUED_DOMAIN="${domain}"
|
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
|
local cert_exists=0
|
||||||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||||
|
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
|
cert_exists=1
|
||||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
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}"
|
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||||
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||||
else
|
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}"
|
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -563,7 +579,7 @@ ssl_cert_issue() {
|
|||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
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
|
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||||
return 1
|
return 1
|
||||||
else
|
else
|
||||||
@@ -617,7 +633,7 @@ ssl_cert_issue() {
|
|||||||
else
|
else
|
||||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||||
if [[ ${cert_exists} -eq 0 ]]; then
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
rm -rf ~/.acme.sh/${domain}
|
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||||
fi
|
fi
|
||||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+16
-11
@@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
SubUpdates = "10"
|
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()
|
SubJsonMux, err := s.settingService.GetSubJsonMux()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SubJsonMux = ""
|
SubJsonMux = ""
|
||||||
@@ -140,6 +130,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
SubJsonRules = ""
|
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()
|
SubTitle, err := s.settingService.GetSubTitle()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SubTitle = ""
|
SubTitle = ""
|
||||||
@@ -226,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
|
|
||||||
s.sub = NewSUBController(
|
s.sub = NewSUBController(
|
||||||
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
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)
|
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||||
|
|
||||||
return engine, nil
|
return engine, nil
|
||||||
|
|||||||
+107
-12
@@ -15,17 +15,13 @@ import (
|
|||||||
|
|
||||||
type SubClashService struct {
|
type SubClashService struct {
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
|
enableRouting bool
|
||||||
|
clashRules string
|
||||||
SubService *SubService
|
SubService *SubService
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClashConfig struct {
|
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
|
||||||
Proxies []map[string]any `yaml:"proxies"`
|
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
|
||||||
ProxyGroups []map[string]any `yaml:"proxy-groups"`
|
|
||||||
Rules []string `yaml:"rules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSubClashService(subService *SubService) *SubClashService {
|
|
||||||
return &SubClashService{SubService: subService}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
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")
|
proxyNames = append(proxyNames, "DIRECT")
|
||||||
|
|
||||||
config := ClashConfig{
|
config := map[string]any{
|
||||||
Proxies: proxies,
|
"proxies": proxies,
|
||||||
ProxyGroups: []map[string]any{{
|
"proxy-groups": []map[string]any{{
|
||||||
"name": "PROXY",
|
"name": "PROXY",
|
||||||
"type": "select",
|
"type": "select",
|
||||||
"proxies": proxyNames,
|
"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)
|
finalYAML, err := yaml.Marshal(config)
|
||||||
@@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
|
|||||||
maps.Copy(dst, src)
|
maps.Copy(dst, src)
|
||||||
return dst
|
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,
|
showInfo bool,
|
||||||
rModel string,
|
rModel string,
|
||||||
update string,
|
update string,
|
||||||
jsonFragment string,
|
|
||||||
jsonNoise string,
|
|
||||||
jsonMux string,
|
jsonMux string,
|
||||||
jsonRules string,
|
jsonRules string,
|
||||||
|
jsonFinalMask string,
|
||||||
|
clashEnableRouting bool,
|
||||||
|
clashRules string,
|
||||||
subTitle string,
|
subTitle string,
|
||||||
subSupportUrl string,
|
subSupportUrl string,
|
||||||
subProfileUrl string,
|
subProfileUrl string,
|
||||||
@@ -90,8 +91,8 @@ func NewSUBController(
|
|||||||
updateInterval: update,
|
updateInterval: update,
|
||||||
|
|
||||||
subService: sub,
|
subService: sub,
|
||||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
|
||||||
subClashService: NewSubClashService(sub),
|
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
|
||||||
}
|
}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
|
|||||||
+82
-71
@@ -21,7 +21,7 @@ var defaultJson string
|
|||||||
type SubJsonService struct {
|
type SubJsonService struct {
|
||||||
configJson map[string]any
|
configJson map[string]any
|
||||||
defaultOutbounds []json_util.RawMessage
|
defaultOutbounds []json_util.RawMessage
|
||||||
fragmentOrNoises bool
|
finalMask string
|
||||||
mux string
|
mux string
|
||||||
|
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
@@ -29,7 +29,7 @@ type SubJsonService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
// 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 configJson map[string]any
|
||||||
var defaultOutbounds []json_util.RawMessage
|
var defaultOutbounds []json_util.RawMessage
|
||||||
json.Unmarshal([]byte(defaultJson), &configJson)
|
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 != "" {
|
if rules != "" {
|
||||||
var newRules []any
|
var newRules []any
|
||||||
routing, _ := configJson["routing"].(map[string]any)
|
routing, _ := configJson["routing"].(map[string]any)
|
||||||
@@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
|||||||
return &SubJsonService{
|
return &SubJsonService{
|
||||||
configJson: configJson,
|
configJson: configJson,
|
||||||
defaultOutbounds: defaultOutbounds,
|
defaultOutbounds: defaultOutbounds,
|
||||||
fragmentOrNoises: fragmentOrNoises,
|
finalMask: finalMask,
|
||||||
mux: mux,
|
mux: mux,
|
||||||
SubService: subService,
|
SubService: subService,
|
||||||
}
|
}
|
||||||
@@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
|||||||
}
|
}
|
||||||
delete(streamSettings, "sockopt")
|
delete(streamSettings, "sockopt")
|
||||||
|
|
||||||
if s.fragmentOrNoises {
|
if s.finalMask != "" {
|
||||||
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
|
s.applyGlobalFinalMask(streamSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove proxy protocol
|
// remove proxy protocol
|
||||||
@@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
|||||||
return streamSettings
|
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 {
|
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
|
||||||
netSettings, ok := setting.(map[string]any)
|
netSettings, ok := setting.(map[string]any)
|
||||||
if ok {
|
if ok {
|
||||||
@@ -272,6 +258,9 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
|
|||||||
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
||||||
tlsData["fingerprint"] = fingerprint
|
tlsData["fingerprint"] = fingerprint
|
||||||
}
|
}
|
||||||
|
if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
|
||||||
|
tlsData["echConfigList"] = ech
|
||||||
|
}
|
||||||
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
|
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
|
||||||
tlsData["pinnedPeerCertSha256"] = pins
|
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 {
|
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||||
outbound := Outbound{}
|
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.Protocol = string(inbound.Protocol)
|
||||||
outbound.Tag = "proxy"
|
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.Mux = json_util.RawMessage(s.mux)
|
||||||
}
|
}
|
||||||
outbound.StreamSettings = streamSettings
|
outbound.StreamSettings = streamSettings
|
||||||
|
|
||||||
|
security := client.Security
|
||||||
|
if security == "" {
|
||||||
|
security = "auto"
|
||||||
|
}
|
||||||
outbound.Settings = map[string]any{
|
outbound.Settings = map[string]any{
|
||||||
"vnext": vnextData,
|
"address": inbound.Listen,
|
||||||
|
"port": inbound.Port,
|
||||||
|
"id": client.ID,
|
||||||
|
"security": security,
|
||||||
|
"level": 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
@@ -347,24 +334,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
|||||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||||
encryption, _ := inboundSettings["encryption"].(string)
|
encryption, _ := inboundSettings["encryption"].(string)
|
||||||
|
|
||||||
user := map[string]any{
|
settings := map[string]any{
|
||||||
"id": client.ID,
|
|
||||||
"level": 8,
|
|
||||||
"encryption": encryption,
|
|
||||||
}
|
|
||||||
if client.Flow != "" {
|
|
||||||
user["flow"] = client.Flow
|
|
||||||
}
|
|
||||||
|
|
||||||
vnext := map[string]any{
|
|
||||||
"address": inbound.Listen,
|
"address": inbound.Listen,
|
||||||
"port": inbound.Port,
|
"port": inbound.Port,
|
||||||
"users": []any{user},
|
"id": client.ID,
|
||||||
|
"encryption": encryption,
|
||||||
|
"level": 8,
|
||||||
}
|
}
|
||||||
|
if client.Flow != "" {
|
||||||
outbound.Settings = map[string]any{
|
settings["flow"] = client.Flow
|
||||||
"vnext": []any{vnext},
|
|
||||||
}
|
}
|
||||||
|
outbound.Settings = settings
|
||||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -400,9 +380,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
|||||||
outbound.Mux = json_util.RawMessage(s.mux)
|
outbound.Mux = json_util.RawMessage(s.mux)
|
||||||
}
|
}
|
||||||
outbound.StreamSettings = streamSettings
|
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, "", " ")
|
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||||
return result
|
return result
|
||||||
@@ -442,7 +430,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
|||||||
newStream["hysteriaSettings"] = outHyStream
|
newStream["hysteriaSettings"] = outHyStream
|
||||||
|
|
||||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||||
newStream["finalmask"] = finalmask
|
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
|
||||||
}
|
}
|
||||||
|
|
||||||
newStream["network"] = "hysteria"
|
newStream["network"] = "hysteria"
|
||||||
@@ -454,6 +442,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
|||||||
return result
|
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 {
|
type Outbound struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
@@ -462,18 +485,6 @@ type Outbound struct {
|
|||||||
Settings map[string]any `json:"settings,omitempty"`
|
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 {
|
type ServerSetting struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
|
|||||||
@@ -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 {
|
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||||
params["fp"], _ = fpValue.(string)
|
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 {
|
if pins, ok := pinnedSha256List(tlsSettings); ok {
|
||||||
params["pcs"] = strings.Join(pins, ",")
|
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 {
|
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
|
||||||
obj["fp"], _ = fpValue.(string)
|
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 {
|
if pins, ok := pinnedSha256List(tlsSettings); ok {
|
||||||
obj["pcs"] = strings.Join(pins, ",")
|
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 {
|
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||||
obj["pcs"] = joinAnyStrings(pins)
|
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) {
|
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 {
|
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||||
params["pcs"] = joinAnyStrings(pins)
|
params["pcs"] = joinAnyStrings(pins)
|
||||||
}
|
}
|
||||||
|
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
|
||||||
|
params["ech"] = ech
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyExternalProxyHysteriaParams overrides the cert pin for a single
|
// 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
|
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) {
|
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) {
|
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||||
stream := map[string]any{
|
stream := map[string]any{
|
||||||
"security": "tls",
|
"security": "tls",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
@@ -16,6 +18,21 @@ func notifyClientsChanged() {
|
|||||||
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
|
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 {
|
type ClientController struct {
|
||||||
clientService service.ClientService
|
clientService service.ClientService
|
||||||
inboundService service.InboundService
|
inboundService service.InboundService
|
||||||
@@ -115,7 +132,7 @@ func (a *ClientController) create(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
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 {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
@@ -129,12 +146,13 @@ func (a *ClientController) update(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
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 {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
@@ -172,7 +190,7 @@ func (a *ClientController) attach(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
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 {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
@@ -452,7 +470,7 @@ func (a *ClientController) detach(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
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 {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,16 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
|||||||
c.JSON(http.StatusOK, m)
|
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.
|
// pureJsonMsg sends a pure JSON message response with custom status code.
|
||||||
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
||||||
c.JSON(statusCode, entity.Msg{
|
c.JSON(statusCode, entity.Msg{
|
||||||
|
|||||||
@@ -83,10 +83,11 @@ type AllSetting struct {
|
|||||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
||||||
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for 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
|
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
|
||||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
|
||||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||||
|
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
|
||||||
|
|
||||||
// LDAP settings
|
// LDAP settings
|
||||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
nodeTrafficSyncConcurrency = 8
|
nodeTrafficSyncConcurrency = 8
|
||||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||||
|
nodeReconcileTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type NodeTrafficSyncJob struct {
|
type NodeTrafficSyncJob struct {
|
||||||
@@ -151,21 +152,37 @@ func (j *NodeTrafficSyncJob) Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
rt, err := mgr.RemoteFor(n)
|
rt, err := mgr.RemoteFor(n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
||||||
return
|
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)
|
snap, err := rt.FetchTrafficSnapshot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
|
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
|
||||||
j.inboundService.ClearNodeOnlineClients(n.Id)
|
j.inboundService.ClearNodeOnlineClients(n.Id)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
|
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
|
||||||
return
|
return
|
||||||
|
|||||||
+29
-9
@@ -191,6 +191,19 @@ func (r *Remote) cacheDel(tag string) {
|
|||||||
delete(r.remoteIDByTag, tag)
|
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 {
|
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
||||||
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -286,15 +299,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser is idempotent: master's per-inbound Delete loop may call it
|
func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
|
||||||
// multiple times for the same node, and "not found" on the follow-ups is
|
|
||||||
// the expected success path.
|
|
||||||
func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
|
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_, err := r.do(ctx, http.MethodPost,
|
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||||
"panel/api/clients/del/"+url.PathEscape(email), nil)
|
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 {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -304,12 +319,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
|
|||||||
return err
|
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 == "" {
|
if oldEmail == "" {
|
||||||
oldEmail = payload.Email
|
oldEmail = payload.Email
|
||||||
}
|
}
|
||||||
if _, err := r.do(ctx, http.MethodPost,
|
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||||
"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+634
-163
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
svc := InboundService{}
|
svc := InboundService{}
|
||||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+377
-80
@@ -41,6 +41,92 @@ func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error)
|
|||||||
return mgr.RuntimeFor(ib.NodeID)
|
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 {
|
type CopyClientsResult struct {
|
||||||
Added []string `json:"added"`
|
Added []string `json:"added"`
|
||||||
Skipped []string `json:"skipped"`
|
Skipped []string `json:"skipped"`
|
||||||
@@ -83,8 +169,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
|
|||||||
emails = append(emails, e)
|
emails = append(emails, e)
|
||||||
}
|
}
|
||||||
var extra []xray.ClientTraffic
|
var extra []xray.ClientTraffic
|
||||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil {
|
var loadErr error
|
||||||
logger.Warning("enrichClientStats:", err)
|
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 {
|
} else {
|
||||||
byEmail := make(map[string]xray.ClientTraffic, len(extra))
|
byEmail := make(map[string]xray.ClientTraffic, len(extra))
|
||||||
for _, st := range extra {
|
for _, st := range extra {
|
||||||
@@ -438,6 +533,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
|
|||||||
return count > 0, nil
|
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.
|
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
|
||||||
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
|
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
|
||||||
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
|
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
|
||||||
@@ -535,11 +661,17 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err != nil {
|
||||||
tx.Commit()
|
|
||||||
} else {
|
|
||||||
tx.Rollback()
|
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,22 +692,27 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
if inbound.Enable {
|
if inbound.Enable {
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
err = rterr
|
err = perr
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
|
if dirty {
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
|
if push {
|
||||||
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
|
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
|
||||||
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
|
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
|
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
|
||||||
if inbound.NodeID != nil {
|
if inbound.NodeID != nil {
|
||||||
err = err1
|
markDirty = true
|
||||||
return inbound, false, err
|
} else {
|
||||||
}
|
|
||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return inbound, needRestart, err
|
return inbound, needRestart, err
|
||||||
}
|
}
|
||||||
@@ -584,25 +721,32 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
|||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
var ib model.Inbound
|
var ib model.Inbound
|
||||||
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
|
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
shouldPushToRuntime := ib.NodeID != nil || ib.Enable
|
shouldPushToRuntime := ib.NodeID != nil || ib.Enable
|
||||||
if shouldPushToRuntime {
|
if shouldPushToRuntime {
|
||||||
rt, rterr := s.runtimeFor(&ib)
|
rt, push, dirty, perr := s.nodePushPlan(&ib)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
logger.Warning("DelInbound: runtime lookup failed, deleting central row anyway:", rterr)
|
logger.Warning("DelInbound: node lookup failed, deleting central row anyway:", perr)
|
||||||
if ib.NodeID == nil {
|
markDirty = true
|
||||||
needRestart = true
|
} else if push {
|
||||||
}
|
if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
|
||||||
} else if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
|
|
||||||
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
|
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
|
||||||
} else {
|
} else {
|
||||||
logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
|
logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
|
||||||
if ib.NodeID == nil {
|
if ib.NodeID == nil {
|
||||||
needRestart = true
|
needRestart = true
|
||||||
|
} else {
|
||||||
|
markDirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if ib.NodeID == nil {
|
||||||
|
needRestart = true
|
||||||
|
} else if dirty {
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id)
|
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 {
|
if err := db.Delete(model.Inbound{}, id).Error; err != nil {
|
||||||
return needRestart, err
|
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() {
|
if !database.IsPostgres() {
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
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
|
inbound.Enable = enable
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if inbound.NodeID != nil {
|
return false, perr
|
||||||
return false, rterr
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote nodes interpret DelInbound as a real row delete (it hits
|
// 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
|
// PATCH the remote row via UpdateInbound instead — preserves the
|
||||||
// settings/client history and just flips the enable flag.
|
// settings/client history and just flips the enable flag.
|
||||||
if inbound.NodeID != nil {
|
if inbound.NodeID != nil {
|
||||||
|
if push {
|
||||||
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
||||||
logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
|
logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
|
||||||
return false, err
|
dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dirty {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !push {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
|
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
|
||||||
!strings.Contains(err.Error(), "not found") {
|
!strings.Contains(err.Error(), "not found") {
|
||||||
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
|
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()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
tx.Commit()
|
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
|
inbound.Tag = oldInbound.Tag
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
rt, rterr := s.runtimeFor(oldInbound)
|
rt, push, dirty, perr := s.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
err = perr
|
||||||
err = rterr
|
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
|
if dirty {
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
|
if oldInbound.NodeID == nil {
|
||||||
|
if !push {
|
||||||
needRestart = true
|
needRestart = true
|
||||||
} else {
|
} else {
|
||||||
oldSnapshot := *oldInbound
|
oldSnapshot := *oldInbound
|
||||||
oldSnapshot.Tag = tag
|
oldSnapshot.Tag = tag
|
||||||
if oldInbound.NodeID == nil {
|
|
||||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
|
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
|
||||||
logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
|
logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
|
||||||
}
|
}
|
||||||
@@ -886,16 +1052,18 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
} else if push {
|
||||||
|
oldSnapshot := *oldInbound
|
||||||
|
oldSnapshot.Tag = tag
|
||||||
if !inbound.Enable {
|
if !inbound.Enable {
|
||||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
|
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
|
||||||
err = err2
|
logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
|
||||||
return inbound, false, err
|
markDirty = true
|
||||||
}
|
}
|
||||||
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
|
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
|
||||||
err = err2
|
logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
|
||||||
return inbound, false, err
|
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
|
}).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
|
var structuralChange bool
|
||||||
err := submitTrafficWrite(func() error {
|
err := submitTrafficWrite(func() error {
|
||||||
var inner error
|
var inner error
|
||||||
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap)
|
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty)
|
||||||
return inner
|
return inner
|
||||||
})
|
})
|
||||||
return structuralChange, err
|
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 {
|
if snap == nil || nodeID <= 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1385,6 +1553,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
|
|
||||||
c, ok := tagToCentral[snapIb.Tag]
|
c, ok := tagToCentral[snapIb.Tag]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Try snap.Tag first; on collision fall back to the n<id>-
|
// Try snap.Tag first; on collision fall back to the n<id>-
|
||||||
// prefixed form so local+node can both own the same port.
|
// prefixed form so local+node can both own the same port.
|
||||||
pickFreeTag := func() (string, error) {
|
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
|
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
||||||
|
|
||||||
updates := map[string]any{
|
updates := map[string]any{}
|
||||||
"enable": snapIb.Enable,
|
if !dirty {
|
||||||
"remark": snapIb.Remark,
|
updates["enable"] = snapIb.Enable
|
||||||
"listen": snapIb.Listen,
|
updates["remark"] = snapIb.Remark
|
||||||
"port": snapIb.Port,
|
updates["listen"] = snapIb.Listen
|
||||||
"protocol": snapIb.Protocol,
|
updates["port"] = snapIb.Port
|
||||||
"total": snapIb.Total,
|
updates["protocol"] = snapIb.Protocol
|
||||||
"expiry_time": snapIb.ExpiryTime,
|
updates["total"] = snapIb.Total
|
||||||
"settings": snapIb.Settings,
|
updates["expiry_time"] = snapIb.ExpiryTime
|
||||||
"stream_settings": snapIb.StreamSettings,
|
updates["settings"] = snapIb.Settings
|
||||||
"sniffing": snapIb.Sniffing,
|
updates["stream_settings"] = snapIb.StreamSettings
|
||||||
"traffic_reset": snapIb.TrafficReset,
|
updates["sniffing"] = snapIb.Sniffing
|
||||||
|
updates["traffic_reset"] = snapIb.TrafficReset
|
||||||
}
|
}
|
||||||
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
||||||
updates["up"] = snapIb.Up
|
updates["up"] = snapIb.Up
|
||||||
updates["down"] = snapIb.Down
|
updates["down"] = snapIb.Down
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Settings != snapIb.Settings ||
|
if !dirty && (c.Settings != snapIb.Settings ||
|
||||||
c.Remark != snapIb.Remark ||
|
c.Remark != snapIb.Remark ||
|
||||||
c.Listen != snapIb.Listen ||
|
c.Listen != snapIb.Listen ||
|
||||||
c.Port != snapIb.Port ||
|
c.Port != snapIb.Port ||
|
||||||
c.Total != snapIb.Total ||
|
c.Total != snapIb.Total ||
|
||||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||||
c.Enable != snapIb.Enable {
|
c.Enable != snapIb.Enable) {
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
if err := tx.Model(model.Inbound{}).
|
if err := tx.Model(model.Inbound{}).
|
||||||
Where("id = ?", c.Id).
|
Where("id = ?", c.Id).
|
||||||
Updates(updates).Error; err != nil {
|
Updates(updates).Error; err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, c := range central {
|
for _, c := range central {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, kept := snapTags[c.Tag]; kept {
|
if _, kept := snapTags[c.Tag]; kept {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1541,6 +1718,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
row := &xray.ClientTraffic{
|
row := &xray.ClientTraffic{
|
||||||
InboundId: c.Id,
|
InboundId: c.Id,
|
||||||
Email: cs.Email,
|
Email: cs.Email,
|
||||||
@@ -1575,12 +1755,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
structuralChange = true
|
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(
|
if err := tx.Exec(
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
`UPDATE client_traffics
|
`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
|
last_online = %s
|
||||||
WHERE email = ?`,
|
WHERE email = ?`,
|
||||||
|
enableExpr,
|
||||||
database.GreatestExpr("last_online", "?"),
|
database.GreatestExpr("last_online", "?"),
|
||||||
),
|
),
|
||||||
deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
|
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 {
|
for k, existing := range centralCS {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if k.inboundID != c.Id {
|
if k.inboundID != c.Id {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1626,6 +1816,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var oldEmailsRows []string
|
var oldEmailsRows []string
|
||||||
if err := tx.Table("clients").
|
if err := tx.Table("clients").
|
||||||
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
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) {
|
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
|
||||||
|
var disabledNodeIDs []int
|
||||||
err = submitTrafficWrite(func() error {
|
err = submitTrafficWrite(func() error {
|
||||||
var inner error
|
var inner error
|
||||||
needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||||
return inner
|
return inner
|
||||||
})
|
})
|
||||||
|
if err == nil && len(disabledNodeIDs) > 0 {
|
||||||
|
s.restartRemoteNodesOnDisable(disabledNodeIDs)
|
||||||
|
}
|
||||||
return
|
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
|
var err error
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
@@ -1764,11 +1961,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
|||||||
}()
|
}()
|
||||||
err = s.addInboundTraffic(tx, inboundTraffics)
|
err = s.addInboundTraffic(tx, inboundTraffics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, err
|
return false, false, nil, err
|
||||||
}
|
}
|
||||||
err = s.addClientTraffic(tx, clientTraffics)
|
err = s.addClientTraffic(tx, clientTraffics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, err
|
return false, false, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
needRestart0, count, err := s.autoRenewClients(tx)
|
needRestart0, count, err := s.autoRenewClients(tx)
|
||||||
@@ -1779,7 +1976,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
|||||||
}
|
}
|
||||||
|
|
||||||
disabledClientsCount := int64(0)
|
disabledClientsCount := int64(0)
|
||||||
needRestart1, count, err := s.disableInvalidClients(tx)
|
needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("Error in disabling invalid clients:", err)
|
logger.Warning("Error in disabling invalid clients:", err)
|
||||||
} else if count > 0 {
|
} else if count > 0 {
|
||||||
@@ -1793,7 +1990,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
|||||||
} else if count > 0 {
|
} else if count > 0 {
|
||||||
logger.Debugf("%v inbounds disabled", count)
|
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 {
|
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)
|
emails = append(emails, traffic.Email)
|
||||||
}
|
}
|
||||||
dbClientTraffics := make([]*xray.ClientTraffic, 0, len(traffics))
|
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{}).
|
err = tx.Model(xray.ClientTraffic{}).
|
||||||
Where("email IN (?) AND inbound_id NOT IN (?)", emails,
|
Where("email IN (?)", emails).
|
||||||
tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NOT NULL")).
|
|
||||||
Find(&dbClientTraffics).Error
|
Find(&dbClientTraffics).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -2149,7 +2353,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
|||||||
return needRestart, count, err
|
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
|
now := time.Now().Unix() * 1000
|
||||||
needRestart := false
|
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).
|
Where("((total > 0 AND up + down >= total) OR (expiry_time > 0 AND expiry_time <= ?)) AND enable = ?", now, true).
|
||||||
Find(&depletedRows).Error
|
Find(&depletedRows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 0, err
|
return false, 0, nil, err
|
||||||
}
|
}
|
||||||
if len(depletedRows) == 0 {
|
if len(depletedRows) == 0 {
|
||||||
return false, 0, nil
|
return false, 0, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
depletedEmails := make([]string, 0, len(depletedRows))
|
depletedEmails := make([]string, 0, len(depletedRows))
|
||||||
@@ -2189,7 +2393,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
|||||||
WHERE clients.email IN ?
|
WHERE clients.email IN ?
|
||||||
`, depletedEmails).Scan(&targets).Error
|
`, depletedEmails).Scan(&targets).Error
|
||||||
if err != nil {
|
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
|
err = result.Error
|
||||||
count := result.RowsAffected
|
count := result.RowsAffected
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return needRestart, count, err
|
return needRestart, count, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(depletedEmails) > 0 {
|
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 {
|
for inboundID, group := range remoteByInbound {
|
||||||
emails := make(map[string]struct{}, len(group))
|
emails := make(map[string]struct{}, len(group))
|
||||||
for _, t := range 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 {
|
if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
|
||||||
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
|
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
|
||||||
needRestart = true
|
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
|
// 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
|
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) {
|
func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var traffics []*xray.ClientTraffic
|
var traffics []*xray.ClientTraffic
|
||||||
@@ -2556,12 +2820,20 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
|||||||
}
|
}
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.Email == clientEmail && client.Enable {
|
if client.Email == clientEmail && client.Enable {
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if inbound.NodeID != nil {
|
return false, perr
|
||||||
return false, rterr
|
|
||||||
}
|
}
|
||||||
|
if !push {
|
||||||
|
if inbound.NodeID != nil {
|
||||||
|
if dirty {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
needRestart = true
|
needRestart = true
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
cipher := ""
|
cipher := ""
|
||||||
@@ -2584,6 +2856,11 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
|||||||
})
|
})
|
||||||
if err1 == nil {
|
if err1 == nil {
|
||||||
logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
|
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 {
|
} else {
|
||||||
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
|
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
|
||||||
needRestart = true
|
needRestart = true
|
||||||
@@ -2991,16 +3268,33 @@ func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
|
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
|
||||||
// Prefer retrieving along with client to reflect actual enabled state from inbound settings
|
db := database.GetDB()
|
||||||
t, client, err := s.GetClientByEmail(email)
|
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 {
|
if err != nil {
|
||||||
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
|
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if t != nil && client != nil {
|
if t2 != nil && client != nil {
|
||||||
t.UUID = client.ID
|
t2.UUID = client.ID
|
||||||
t.SubId = client.SubID
|
t2.SubId = client.SubID
|
||||||
return t, nil
|
return t2, nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -3329,6 +3623,9 @@ func (s *InboundService) MigrateDB() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetOnlineClients() []string {
|
func (s *InboundService) GetOnlineClients() []string {
|
||||||
|
if p == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
return p.GetOnlineClients()
|
return p.GetOnlineClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,20 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAddClientTraffic_MatchesDespiteStaleInboundId reproduces the production bug where
|
// TestAddClientTraffic_MatchesByEmail covers two scenarios that share one fix:
|
||||||
// client_traffics rows survive an inbound delete+recreate with a stale inbound_id (the
|
// client_traffics is keyed by email (one shared row per email no matter how many
|
||||||
// shared-by-email row keeps the deleted inbound's id, and AddClientStat's OnConflict-
|
// inbounds the client is attached to), so local traffic must be applied by email
|
||||||
// DoNothing never refreshes it). The old `inbound_id IN (local inbounds)` filter dropped
|
// regardless of which inbound_id the row happens to carry.
|
||||||
// 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
|
// - staleEmail: the row points at an inbound id that no longer exists (a deleted
|
||||||
// updated while a genuine node-owned row is left untouched.
|
// earlier incarnation, AddClientStat's OnConflict-DoNothing never refreshes it).
|
||||||
func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
|
// - 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()
|
dbDir := t.TempDir()
|
||||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
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()
|
db := database.GetDB()
|
||||||
|
|
||||||
const localEmail = "local-user"
|
const staleEmail = "stale-user"
|
||||||
const nodeEmail = "node-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}
|
localInbound := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 40001, Protocol: model.VLESS}
|
||||||
if err := db.Create(localInbound).Error; err != nil {
|
if err := db.Create(localInbound).Error; err != nil {
|
||||||
t.Fatalf("create local inbound: %v", err)
|
t.Fatalf("create local inbound: %v", err)
|
||||||
@@ -42,39 +46,44 @@ func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
|
|||||||
t.Fatalf("create node inbound: %v", err)
|
t.Fatalf("create node inbound: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: localEmail, Enable: true}).Error; err != nil {
|
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: staleEmail, Enable: true}).Error; err != nil {
|
||||||
t.Fatalf("create stale local client_traffics: %v", err)
|
t.Fatalf("create stale client_traffics: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: nodeEmail, Enable: true}).Error; err != nil {
|
// Attached to both inbounds, but the node inbound won the OnConflict so the
|
||||||
t.Fatalf("create node client_traffics: %v", err)
|
// 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{}
|
svc := InboundService{}
|
||||||
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
|
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
|
||||||
{Email: localEmail, Up: 10, Down: 20},
|
{Email: staleEmail, Up: 10, Down: 20},
|
||||||
{Email: nodeEmail, Up: 30, Down: 40},
|
{Email: dualEmail, Up: 30, Down: 40},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("addClientTraffic: %v", err)
|
t.Fatalf("addClientTraffic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var local xray.ClientTraffic
|
var stale xray.ClientTraffic
|
||||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", localEmail).First(&local).Error; err != nil {
|
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", staleEmail).First(&stale).Error; err != nil {
|
||||||
t.Fatalf("reload local row: %v", err)
|
t.Fatalf("reload stale row: %v", err)
|
||||||
}
|
}
|
||||||
if local.Up != 10 || local.Down != 20 {
|
if stale.Up != 10 || stale.Down != 20 {
|
||||||
t.Errorf("stale-pointer local row not updated: up=%d down=%d, want 10/20", local.Up, local.Down)
|
t.Errorf("stale-pointer row not updated: up=%d down=%d, want 10/20", stale.Up, stale.Down)
|
||||||
}
|
}
|
||||||
if local.LastOnline == 0 {
|
if stale.LastOnline == 0 {
|
||||||
t.Errorf("stale-pointer local row LastOnline not set")
|
t.Errorf("stale-pointer row LastOnline not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
var node xray.ClientTraffic
|
var dual xray.ClientTraffic
|
||||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", nodeEmail).First(&node).Error; err != nil {
|
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", dualEmail).First(&dual).Error; err != nil {
|
||||||
t.Fatalf("reload node row: %v", err)
|
t.Fatalf("reload dual row: %v", err)
|
||||||
}
|
}
|
||||||
if node.Up != 0 || node.Down != 0 {
|
if dual.Up != 30 || dual.Down != 40 {
|
||||||
t.Errorf("node-owned row should not be touched by local traffic: up=%d down=%d, want 0/0", node.Up, node.Down)
|
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
|
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 {
|
func nodeMetricKey(id int, metric string) string {
|
||||||
return "node:" + strconv.Itoa(id) + ":" + metric
|
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{
|
snap := &runtime.TrafficSnapshot{
|
||||||
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
|
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)
|
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
svc := InboundService{}
|
||||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -79,10 +79,11 @@ var defaultValueMap = map[string]string{
|
|||||||
"subClashEnable": "false",
|
"subClashEnable": "false",
|
||||||
"subClashPath": "/clash/",
|
"subClashPath": "/clash/",
|
||||||
"subClashURI": "",
|
"subClashURI": "",
|
||||||
"subJsonFragment": "",
|
"subClashEnableRouting": "false",
|
||||||
"subJsonNoises": "",
|
"subClashRules": "",
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
|
"subJsonFinalMask": "",
|
||||||
"datepicker": "gregorian",
|
"datepicker": "gregorian",
|
||||||
"warp": "",
|
"warp": "",
|
||||||
"nord": "",
|
"nord": "",
|
||||||
@@ -658,12 +659,12 @@ func (s *SettingService) GetSubClashURI() (string, error) {
|
|||||||
return s.getString("subClashURI")
|
return s.getString("subClashURI")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
|
||||||
return s.getString("subJsonFragment")
|
return s.getBool("subClashEnableRouting")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetSubJsonNoises() (string, error) {
|
func (s *SettingService) GetSubClashRules() (string, error) {
|
||||||
return s.getString("subJsonNoises")
|
return s.getString("subClashRules")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetSubJsonMux() (string, error) {
|
func (s *SettingService) GetSubJsonMux() (string, error) {
|
||||||
@@ -674,6 +675,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
|
|||||||
return s.getString("subJsonRules")
|
return s.getString("subJsonRules")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
|
||||||
|
return s.getString("subJsonFinalMask")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetDatepicker() (string, error) {
|
func (s *SettingService) GetDatepicker() (string, error) {
|
||||||
return s.getString("datepicker")
|
return s.getString("datepicker")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")))
|
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
|
||||||
|
|
||||||
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
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
|
// Use goroutine with worker pool for concurrent command processing
|
||||||
go func() {
|
go func() {
|
||||||
messageWorkerPool <- struct{}{} // Acquire worker
|
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.
|
// sendResponse sends the response message based on the onlyMessage flag.
|
||||||
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
||||||
if onlyMessage {
|
if onlyMessage {
|
||||||
|
|||||||
@@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
|
|||||||
t.Fatal("Dial must be nil when no proxy is configured")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "أقصى نافذة إرسال",
|
"maxSendingWindow": "أقصى نافذة إرسال",
|
||||||
"externalProxy": "وكيل خارجي",
|
"externalProxy": "وكيل خارجي",
|
||||||
"forceTls": "فرض TLS",
|
"forceTls": "فرض TLS",
|
||||||
"sniPlaceholder": "SNI (افتراضياً host)",
|
|
||||||
"fingerprint": "بصمة",
|
"fingerprint": "بصمة",
|
||||||
"defaultOption": "افتراضي",
|
"defaultOption": "افتراضي",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
|
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
|
||||||
"subRoutingRules": "قواعد التوجيه",
|
"subRoutingRules": "قواعد التوجيه",
|
||||||
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
|
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
|
||||||
|
"subClashEnableRouting": "تفعيل التوجيه",
|
||||||
|
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
|
||||||
|
"subClashRoutingRules": "قواعد التوجيه العامة",
|
||||||
|
"subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
|
||||||
"subListen": "IP الاستماع",
|
"subListen": "IP الاستماع",
|
||||||
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
|
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
|
||||||
"subPort": "بورت الاستماع",
|
"subPort": "بورت الاستماع",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "حد IP الافتراضي"
|
"defaultIpLimit": "حد IP الافتراضي"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
|
||||||
"packets": "الحزم",
|
"packets": "الحزم",
|
||||||
"length": "الطول",
|
"length": "الطول",
|
||||||
"interval": "الفاصل",
|
"interval": "الفاصل",
|
||||||
|
|||||||
@@ -470,6 +470,7 @@
|
|||||||
"inboundClientAddSuccess": "Inbound client(s) have been added.",
|
"inboundClientAddSuccess": "Inbound client(s) have been added.",
|
||||||
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
|
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
|
||||||
"inboundClientUpdateSuccess": "Inbound client has been updated.",
|
"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.",
|
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
|
||||||
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
|
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
|
||||||
"resetAllTrafficSuccess": "All traffic has been reset.",
|
"resetAllTrafficSuccess": "All traffic has been reset.",
|
||||||
@@ -551,7 +552,6 @@
|
|||||||
"maxSendingWindow": "Max Sending Window",
|
"maxSendingWindow": "Max Sending Window",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
"forceTls": "Force TLS",
|
"forceTls": "Force TLS",
|
||||||
"sniPlaceholder": "SNI (defaults to host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Default",
|
"defaultOption": "Default",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1004,10 @@
|
|||||||
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
|
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
|
||||||
"subRoutingRules": "Routing rules",
|
"subRoutingRules": "Routing rules",
|
||||||
"subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
|
"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",
|
"subListen": "Listen IP",
|
||||||
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
|
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
|
||||||
"subPort": "Listen Port",
|
"subPort": "Listen Port",
|
||||||
@@ -1070,6 +1074,8 @@
|
|||||||
"defaultIpLimit": "Default IP limit"
|
"defaultIpLimit": "Default IP limit"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Packets",
|
||||||
"length": "Length",
|
"length": "Length",
|
||||||
"interval": "Interval",
|
"interval": "Interval",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Máx. ventana de envío",
|
"maxSendingWindow": "Máx. ventana de envío",
|
||||||
"externalProxy": "Proxy externo",
|
"externalProxy": "Proxy externo",
|
||||||
"forceTls": "Forzar TLS",
|
"forceTls": "Forzar TLS",
|
||||||
"sniPlaceholder": "SNI (por defecto = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Por defecto",
|
"defaultOption": "Por defecto",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
|
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
|
||||||
"subRoutingRules": "Reglas de enrutamiento",
|
"subRoutingRules": "Reglas de enrutamiento",
|
||||||
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
|
"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",
|
"subListen": "Listening IP",
|
||||||
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
|
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
|
||||||
"subPort": "Puerto de Suscripción",
|
"subPort": "Puerto de Suscripción",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "Límite IP por defecto"
|
"defaultIpLimit": "Límite IP por defecto"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Paquetes",
|
||||||
"length": "Longitud",
|
"length": "Longitud",
|
||||||
"interval": "Intervalo",
|
"interval": "Intervalo",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "حداکثر پنجره ارسال",
|
"maxSendingWindow": "حداکثر پنجره ارسال",
|
||||||
"externalProxy": "پراکسی خارجی",
|
"externalProxy": "پراکسی خارجی",
|
||||||
"forceTls": "اجبار TLS",
|
"forceTls": "اجبار TLS",
|
||||||
"sniPlaceholder": "SNI (پیشفرض همان host)",
|
|
||||||
"fingerprint": "اثرانگشت",
|
"fingerprint": "اثرانگشت",
|
||||||
"defaultOption": "پیشفرض",
|
"defaultOption": "پیشفرض",
|
||||||
"routeMark": "علامت مسیر",
|
"routeMark": "علامت مسیر",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "تنظیمات سراسری برای فعالسازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
|
"subEnableRoutingDesc": "تنظیمات سراسری برای فعالسازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
|
||||||
"subRoutingRules": "قوانین مسیریابی",
|
"subRoutingRules": "قوانین مسیریابی",
|
||||||
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
|
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
|
||||||
|
"subClashEnableRouting": "فعالسازی مسیریابی",
|
||||||
|
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراکهای YAML تولیدشده وارد کن.",
|
||||||
|
"subClashRoutingRules": "قوانین مسیریابی سراسری",
|
||||||
|
"subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده میشوند.",
|
||||||
"subListen": "آدرس آیپی",
|
"subListen": "آدرس آیپی",
|
||||||
"subListenDesc": "آدرس آیپی برای سرویس سابسکریپشن. برای گوش دادن بهتمام آیپیها خالیبگذارید",
|
"subListenDesc": "آدرس آیپی برای سرویس سابسکریپشن. برای گوش دادن بهتمام آیپیها خالیبگذارید",
|
||||||
"subPort": "پورت",
|
"subPort": "پورت",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "محدودیت IP پیشفرض"
|
"defaultIpLimit": "محدودیت IP پیشفرض"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "ماسکهای finalmask ایکسری (TCP/UDP) و تنظیمات QUIC که داخل همهی stream های اشتراک JSON تزریق میشوند. به نسخهی جدید هستهی xray در کلاینت نیاز دارد.",
|
||||||
"packets": "بستهها",
|
"packets": "بستهها",
|
||||||
"length": "طول",
|
"length": "طول",
|
||||||
"interval": "بازه",
|
"interval": "بازه",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Maks. jendela pengiriman",
|
"maxSendingWindow": "Maks. jendela pengiriman",
|
||||||
"externalProxy": "Proxy eksternal",
|
"externalProxy": "Proxy eksternal",
|
||||||
"forceTls": "Paksa TLS",
|
"forceTls": "Paksa TLS",
|
||||||
"sniPlaceholder": "SNI (default = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Default",
|
"defaultOption": "Default",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
|
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
|
||||||
"subRoutingRules": "Aturan routing",
|
"subRoutingRules": "Aturan routing",
|
||||||
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
|
"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",
|
"subListen": "IP Pendengar",
|
||||||
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
|
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
|
||||||
"subPort": "Port Pendengar",
|
"subPort": "Port Pendengar",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "Batas IP default"
|
"defaultIpLimit": "Batas IP default"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Paket",
|
||||||
"length": "Panjang",
|
"length": "Panjang",
|
||||||
"interval": "Interval",
|
"interval": "Interval",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "最大送信ウィンドウ",
|
"maxSendingWindow": "最大送信ウィンドウ",
|
||||||
"externalProxy": "外部プロキシ",
|
"externalProxy": "外部プロキシ",
|
||||||
"forceTls": "TLS を強制",
|
"forceTls": "TLS を強制",
|
||||||
"sniPlaceholder": "SNI (デフォルトは host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "デフォルト",
|
"defaultOption": "デフォルト",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
|
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
|
||||||
"subRoutingRules": "ルーティングルール",
|
"subRoutingRules": "ルーティングルール",
|
||||||
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
|
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
|
||||||
|
"subClashEnableRouting": "ルーティングを有効化",
|
||||||
|
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
|
||||||
|
"subClashRoutingRules": "グローバルルーティングルール",
|
||||||
|
"subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
|
||||||
"subListen": "監視IP",
|
"subListen": "監視IP",
|
||||||
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
|
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
|
||||||
"subPort": "監視ポート",
|
"subPort": "監視ポート",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "デフォルト IP 制限"
|
"defaultIpLimit": "デフォルト IP 制限"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
|
||||||
"packets": "パケット",
|
"packets": "パケット",
|
||||||
"length": "長さ",
|
"length": "長さ",
|
||||||
"interval": "間隔",
|
"interval": "間隔",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Máx. janela de envio",
|
"maxSendingWindow": "Máx. janela de envio",
|
||||||
"externalProxy": "Proxy externo",
|
"externalProxy": "Proxy externo",
|
||||||
"forceTls": "Forçar TLS",
|
"forceTls": "Forçar TLS",
|
||||||
"sniPlaceholder": "SNI (padrão = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Padrão",
|
"defaultOption": "Padrão",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
|
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
|
||||||
"subRoutingRules": "Regras de roteamento",
|
"subRoutingRules": "Regras de roteamento",
|
||||||
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
|
"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",
|
"subListen": "IP de Escuta",
|
||||||
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
|
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
|
||||||
"subPort": "Porta de Escuta",
|
"subPort": "Porta de Escuta",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "Limite de IP padrão"
|
"defaultIpLimit": "Limite de IP padrão"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Pacotes",
|
||||||
"length": "Comprimento",
|
"length": "Comprimento",
|
||||||
"interval": "Intervalo",
|
"interval": "Intervalo",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Макс. окно отправки",
|
"maxSendingWindow": "Макс. окно отправки",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
"forceTls": "Принудительный TLS",
|
"forceTls": "Принудительный TLS",
|
||||||
"sniPlaceholder": "SNI (по умолчанию = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "По умолчанию",
|
"defaultOption": "По умолчанию",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
|
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
|
||||||
"subRoutingRules": "Правила маршрутизации",
|
"subRoutingRules": "Правила маршрутизации",
|
||||||
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
|
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
|
||||||
|
"subClashEnableRouting": "Включить маршрутизацию",
|
||||||
|
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
|
||||||
|
"subClashRoutingRules": "Глобальные правила маршрутизации",
|
||||||
|
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
|
||||||
"subListen": "Прослушивание IP",
|
"subListen": "Прослушивание IP",
|
||||||
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
|
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
|
||||||
"subPort": "Порт подписки",
|
"subPort": "Порт подписки",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "Лимит IP по умолчанию"
|
"defaultIpLimit": "Лимит IP по умолчанию"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
|
||||||
"packets": "Пакеты",
|
"packets": "Пакеты",
|
||||||
"length": "Длина",
|
"length": "Длина",
|
||||||
"interval": "Интервал",
|
"interval": "Интервал",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Maks. gönderme penceresi",
|
"maxSendingWindow": "Maks. gönderme penceresi",
|
||||||
"externalProxy": "Harici proxy",
|
"externalProxy": "Harici proxy",
|
||||||
"forceTls": "TLS zorla",
|
"forceTls": "TLS zorla",
|
||||||
"sniPlaceholder": "SNI (varsayılan host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Varsayılan",
|
"defaultOption": "Varsayılan",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
|
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
|
||||||
"subRoutingRules": "Yönlendirme kuralları",
|
"subRoutingRules": "Yönlendirme kuralları",
|
||||||
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
|
"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",
|
"subListen": "Dinleme IP",
|
||||||
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
|
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
|
||||||
"subPort": "Dinleme Portu",
|
"subPort": "Dinleme Portu",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "Varsayılan IP limiti"
|
"defaultIpLimit": "Varsayılan IP limiti"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Paketler",
|
||||||
"length": "Uzunluk",
|
"length": "Uzunluk",
|
||||||
"interval": "Aralık",
|
"interval": "Aralık",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Макс. вікно відправки",
|
"maxSendingWindow": "Макс. вікно відправки",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
"forceTls": "Примусовий TLS",
|
"forceTls": "Примусовий TLS",
|
||||||
"sniPlaceholder": "SNI (за замовчуванням = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "За замовчуванням",
|
"defaultOption": "За замовчуванням",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
|
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
|
||||||
"subRoutingRules": "Правила маршрутизації",
|
"subRoutingRules": "Правила маршрутизації",
|
||||||
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
|
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
|
||||||
|
"subClashEnableRouting": "Увімкнути маршрутизацію",
|
||||||
|
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
|
||||||
|
"subClashRoutingRules": "Глобальні правила маршрутизації",
|
||||||
|
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
|
||||||
"subListen": "Слухати IP",
|
"subListen": "Слухати IP",
|
||||||
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
|
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
|
||||||
"subPort": "Слухати порт",
|
"subPort": "Слухати порт",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "Ліміт IP за замовч."
|
"defaultIpLimit": "Ліміт IP за замовч."
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
|
||||||
"packets": "Пакети",
|
"packets": "Пакети",
|
||||||
"length": "Довжина",
|
"length": "Довжина",
|
||||||
"interval": "Інтервал",
|
"interval": "Інтервал",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "Cửa sổ gửi tối đa",
|
"maxSendingWindow": "Cửa sổ gửi tối đa",
|
||||||
"externalProxy": "Proxy ngoài",
|
"externalProxy": "Proxy ngoài",
|
||||||
"forceTls": "Bắt buộc TLS",
|
"forceTls": "Bắt buộc TLS",
|
||||||
"sniPlaceholder": "SNI (mặc định = host)",
|
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Mặc định",
|
"defaultOption": "Mặc định",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,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)",
|
"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",
|
"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)",
|
"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",
|
"subListen": "Listening IP",
|
||||||
"subListenDesc": "Mặc định để trống để nghe tất cả các IP",
|
"subListenDesc": "Mặc định để trống để nghe tất cả các IP",
|
||||||
"subPort": "Cổng gói đăng ký",
|
"subPort": "Cổng gói đăng ký",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "Giới hạn IP mặc định"
|
"defaultIpLimit": "Giới hạn IP mặc định"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"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",
|
"packets": "Gói",
|
||||||
"length": "Độ dài",
|
"length": "Độ dài",
|
||||||
"interval": "Khoảng",
|
"interval": "Khoảng",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "最大发送窗口",
|
"maxSendingWindow": "最大发送窗口",
|
||||||
"externalProxy": "外部代理",
|
"externalProxy": "外部代理",
|
||||||
"forceTls": "强制 TLS",
|
"forceTls": "强制 TLS",
|
||||||
"sniPlaceholder": "SNI (默认为 host)",
|
|
||||||
"fingerprint": "指纹",
|
"fingerprint": "指纹",
|
||||||
"defaultOption": "默认",
|
"defaultOption": "默认",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
|
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
|
||||||
"subRoutingRules": "路由規則",
|
"subRoutingRules": "路由規則",
|
||||||
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
|
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
|
||||||
|
"subClashEnableRouting": "启用路由",
|
||||||
|
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
|
||||||
|
"subClashRoutingRules": "全局路由规则",
|
||||||
|
"subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
|
||||||
"subListen": "监听 IP",
|
"subListen": "监听 IP",
|
||||||
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
|
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
|
||||||
"subPort": "监听端口",
|
"subPort": "监听端口",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "默认 IP 限制"
|
"defaultIpLimit": "默认 IP 限制"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
|
||||||
"packets": "数据包",
|
"packets": "数据包",
|
||||||
"length": "长度",
|
"length": "长度",
|
||||||
"interval": "间隔",
|
"interval": "间隔",
|
||||||
|
|||||||
@@ -551,7 +551,6 @@
|
|||||||
"maxSendingWindow": "最大發送視窗",
|
"maxSendingWindow": "最大發送視窗",
|
||||||
"externalProxy": "外部代理",
|
"externalProxy": "外部代理",
|
||||||
"forceTls": "強制 TLS",
|
"forceTls": "強制 TLS",
|
||||||
"sniPlaceholder": "SNI (預設為 host)",
|
|
||||||
"fingerprint": "指紋",
|
"fingerprint": "指紋",
|
||||||
"defaultOption": "預設",
|
"defaultOption": "預設",
|
||||||
"routeMark": "Route Mark",
|
"routeMark": "Route Mark",
|
||||||
@@ -1004,6 +1003,10 @@
|
|||||||
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
|
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
|
||||||
"subRoutingRules": "路由規則",
|
"subRoutingRules": "路由規則",
|
||||||
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
|
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
|
||||||
|
"subClashEnableRouting": "啟用路由",
|
||||||
|
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
|
||||||
|
"subClashRoutingRules": "全域路由規則",
|
||||||
|
"subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
|
||||||
"subListen": "監聽 IP",
|
"subListen": "監聽 IP",
|
||||||
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
|
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
|
||||||
"subPort": "監聽埠",
|
"subPort": "監聽埠",
|
||||||
@@ -1070,6 +1073,8 @@
|
|||||||
"defaultIpLimit": "預設 IP 限制"
|
"defaultIpLimit": "預設 IP 限制"
|
||||||
},
|
},
|
||||||
"subFormats": {
|
"subFormats": {
|
||||||
|
"finalMask": "Final Mask",
|
||||||
|
"finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
|
||||||
"packets": "封包",
|
"packets": "封包",
|
||||||
"length": "長度",
|
"length": "長度",
|
||||||
"interval": "間隔",
|
"interval": "間隔",
|
||||||
|
|||||||
@@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
|
|||||||
echo "Panel paths set for domain: $domain"
|
echo "Panel paths set for domain: $domain"
|
||||||
echo " - Certificate File: $webCertFile"
|
echo " - Certificate File: $webCertFile"
|
||||||
echo " - Private Key File: $webKeyFile"
|
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
|
restart
|
||||||
else
|
else
|
||||||
echo "Certificate or private key not found for domain: $domain."
|
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 "Failed to issue certificate for IP: ${server_ip}"
|
||||||
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
|
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
|
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||||
rm -rf ~/.acme.sh/${server_ip} 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} 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
|
rm -rf ${certPath} 2> /dev/null
|
||||||
return 1
|
return 1
|
||||||
else
|
else
|
||||||
@@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
|
|||||||
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
|
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
|
||||||
LOGE "Certificate files not found after installation"
|
LOGE "Certificate files not found after installation"
|
||||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||||
rm -rf ~/.acme.sh/${server_ip} 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} 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
|
rm -rf ${certPath} 2> /dev/null
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -1576,14 +1586,30 @@ ssl_cert_issue() {
|
|||||||
LOGD "Your domain is: ${domain}, checking it..."
|
LOGD "Your domain is: ${domain}, checking it..."
|
||||||
SSL_ISSUED_DOMAIN="${domain}"
|
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
|
local cert_exists=0
|
||||||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||||
|
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
|
cert_exists=1
|
||||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||||
LOGI "Existing certificate found for ${domain}, will reuse it."
|
LOGI "Existing certificate found for ${domain}, will reuse it."
|
||||||
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
|
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
|
||||||
else
|
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..."
|
LOGI "Your domain is ready for issuing certificates now..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1611,7 +1637,7 @@ ssl_cert_issue() {
|
|||||||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
LOGE "Issuing certificate failed, please check logs."
|
LOGE "Issuing certificate failed, please check logs."
|
||||||
rm -rf ~/.acme.sh/${domain}
|
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
LOGE "Issuing certificate succeeded, installing certificates..."
|
LOGE "Issuing certificate succeeded, installing certificates..."
|
||||||
@@ -1664,7 +1690,7 @@ ssl_cert_issue() {
|
|||||||
else
|
else
|
||||||
LOGE "Installing certificate failed, exiting."
|
LOGE "Installing certificate failed, exiting."
|
||||||
if [[ ${cert_exists} -eq 0 ]]; then
|
if [[ ${cert_exists} -eq 0 ]]; then
|
||||||
rm -rf ~/.acme.sh/${domain}
|
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||||
fi
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -2248,6 +2274,18 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
|
|||||||
ignoreregex =
|
ignoreregex =
|
||||||
EOF
|
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
|
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
|
||||||
[INCLUDES]
|
[INCLUDES]
|
||||||
before = iptables-allports.conf
|
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]'
|
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}
|
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}
|
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||||||
|
|
||||||
[Init]
|
[Init]
|
||||||
name = default
|
name = default
|
||||||
protocol = tcp
|
protocol = tcp
|
||||||
chain = INPUT
|
chain = INPUT
|
||||||
|
exemptports = ${exempt_ports}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
|
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
|
||||||
|
|||||||
Reference in New Issue
Block a user