mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 05:05:01 +03:00
Compare commits
43 Commits
v3.2.7
...
98ba88037c
| Author | SHA1 | Date | |
|---|---|---|---|
| 98ba88037c | |||
| d739bcf71e | |||
| b0fe21c804 | |||
| f6558571b4 | |||
| 4e253588ae | |||
| c6f15cd53f | |||
| a014c01725 | |||
| e56f6c63f6 | |||
| 83799d71b0 | |||
| 483952cfa0 | |||
| 668c0922ca | |||
| 1b2a17f7e3 | |||
| e6c1ce9aa9 | |||
| 6ed6f57b5c | |||
| e409bc305d | |||
| 2b4e199a97 | |||
| 75bc6e8076 | |||
| eeb19b7240 | |||
| 5b9db13e55 | |||
| 0706b0b3a8 | |||
| db118cbcc9 | |||
| e7ffae5329 | |||
| f470bc7cf8 | |||
| a8d5d0dfab | |||
| b40f869f2a | |||
| e08456269b | |||
| f8e902a7b6 | |||
| d6d2085d60 | |||
| 12d84c2a46 | |||
| 97f88fb1a9 | |||
| f947fbd6c6 | |||
| ba63fa8569 | |||
| 73ce11508e | |||
| a4b3e999a1 | |||
| d3db828b46 | |||
| d1e733b9e9 | |||
| f185d3315c | |||
| 756746dbca | |||
| 44291de989 | |||
| b1d079fc24 | |||
| 14e2d4954a | |||
| db86007ab8 | |||
| a07c7b7f4e |
@@ -6,3 +6,4 @@ db
|
|||||||
cert
|
cert
|
||||||
pgdata
|
pgdata
|
||||||
*.db
|
*.db
|
||||||
|
*.dump
|
||||||
|
|||||||
@@ -37,6 +37,23 @@ jobs:
|
|||||||
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||||
go test $(cat /tmp/go-packages.txt)
|
go test $(cat /tmp/go-packages.txt)
|
||||||
|
|
||||||
|
codegen:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- name: Regenerate schemas, examples and OpenAPI
|
||||||
|
run: npm run gen
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Fail if generated files are stale (run 'npm run gen' and commit)
|
||||||
|
run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
|
||||||
|
|
||||||
govulncheck:
|
govulncheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Thumbs.db
|
|||||||
x-ui.db
|
x-ui.db
|
||||||
x-ui.db-shm
|
x-ui.db-shm
|
||||||
x-ui.db-wal
|
x-ui.db-wal
|
||||||
|
*.dump
|
||||||
|
|
||||||
# Ignore Docker specific files
|
# Ignore Docker specific files
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) | [Türkçe](/README.tr_TR.md)
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||||
|
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||||
|
</picture>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
|
||||||
|
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
|
||||||
|
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
|
||||||
|
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
|
||||||
|
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
|
||||||
|
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş, gelişmiş ve açık kaynaklı bir web kontrol panelidir. Tek bir VPS'den çok düğümlü (multi-node) kurulumlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
|
||||||
|
|
||||||
|
Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen 3X-UI; daha geniş protokol desteği, iyileştirilmiş kararlılık, kullanıcı başına (per-client) trafik takibi ve birçok yaşam kalitesi (QoL) özelliği ekler.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Bu proje yalnızca kişisel kullanım içindir. Lütfen yasa dışı amaçlarla veya üretim (production) ortamında kullanmayın.
|
||||||
|
|
||||||
|
## Özellikler
|
||||||
|
|
||||||
|
- **Çoklu protokol destekli bağlantı noktaları (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel ve TUN.
|
||||||
|
- **Modern aktarım (Transport) & güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvenli hale getirilmiştir.
|
||||||
|
- **Yedek bağlantılar (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port (örn. 443) üzerinden birden fazla protokol (örn. VLESS ve Trojan) sunma.
|
||||||
|
- **Kullanıcı bazlı yönetim** — Trafik kotaları, son kullanma tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıklamayla paylaşım bağlantıları, QR kodları ve abonelikler.
|
||||||
|
- **Trafik istatistikleri** — Bağlantı noktası, kullanıcı ve çıkış noktası (outbound) bazında istatistikler ve sıfırlama kontrolleri.
|
||||||
|
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panelden birden fazla sunucuyu yönetin ve ölçeklendirin.
|
||||||
|
- **Çıkış noktaları & yönlendirme (Outbound & Routing)** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve çıkış noktası proxy zincirleme.
|
||||||
|
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatıyla).
|
||||||
|
- **Telegram botu** (Uzaktan izleme ve yönetim için).
|
||||||
|
- **RESTful API** (Panel içi Swagger dokümantasyonu ile).
|
||||||
|
- **Esnek veritabanı** — SQLite (varsayılan) veya PostgreSQL.
|
||||||
|
- **13 Kullanıcı Arayüzü (UI) dili** (Karanlık ve aydınlık tema destekli).
|
||||||
|
- **Fail2ban entegrasyonu** (Kullanıcı bazlı IP sınırlarını zorlamak için).
|
||||||
|
|
||||||
|
## Ekran Görüntüleri
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Genişletmek için tıklayın</summary>
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
|
||||||
|
<img alt="Genel Bakış" src="./media/01-overview-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
|
||||||
|
<img alt="Bağlantı Noktaları" src="./media/02-add-inbound-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
|
||||||
|
<img alt="Kullanıcı Ekle" src="./media/03-add-client-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
|
||||||
|
<img alt="Yapılandırmalar" src="./media/05-add-nodes-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Hızlı Başlangıç
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu (path) oluşturulur. Kurulumdan sonra `x-ui` komutunu çalıştırarak yönetim menüsünü açabilir; buradan hizmeti başlatabilir/durdurabilir, giriş bilgilerinizi görüntüleyebilir veya sıfırlayabilir, SSL sertifikalarını yönetebilir ve daha fazlasını yapabilirsiniz.
|
||||||
|
|
||||||
|
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
|
||||||
|
|
||||||
|
## Desteklenen Platformlar
|
||||||
|
|
||||||
|
**İşletim sistemleri:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine ve Windows.
|
||||||
|
|
||||||
|
**Mimariler:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||||
|
|
||||||
|
## Veritabanı Seçenekleri
|
||||||
|
|
||||||
|
3X-UI, kurulum sırasında seçilebilen iki arka uç (backend) destekler:
|
||||||
|
|
||||||
|
- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Sıfır kurulum gerektirir, küçük ve orta ölçekli dağıtımlar için idealdir.
|
||||||
|
- **PostgreSQL** — Yüksek kullanıcı sayıları veya çok düğümlü (multi-node) kurulumlar için önerilir. Yükleyici sizin için PostgreSQL'i yerel olarak kurabilir veya mevcut bir sunucuya DSN ile bağlanabilir.
|
||||||
|
|
||||||
|
Çalışma anında arka uç, ortam değişkenleri (environment variables) aracılığıyla seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
|
||||||
|
|
||||||
|
```
|
||||||
|
XUI_DB_TYPE=postgres
|
||||||
|
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mevcut bir SQLite kurulumunu PostgreSQL'e taşıma
|
||||||
|
|
||||||
|
```bash
|
||||||
|
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||||
|
# Ardından /etc/default/x-ui dosyasında XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
|
||||||
|
systemctl restart x-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Kaynak SQLite dosyasına dokunulmaz; yeni arka ucu doğruladıktan sonra eski dosyayı manuel olarak silebilirsiniz.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Dahili PostgreSQL hizmetiyle çalıştırmak için `docker-compose.yml` dosyasındaki iki `XUI_DB_*` ortam değişkeni satırının başındaki yorum işaretini kaldırın ve profille başlatın:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile postgres up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
İmaj, kullanıcı bazlı **IP sınırlarını** zorlamak için Fail2ban'i (varsayılan olarak etkindir) içerir. Fail2ban, ihlalcileri `iptables` ile engeller ve bu işlem `NET_ADMIN` yetkisi gerektirir. `docker-compose.yml` bunu `cap_add` aracılığıyla zaten sağlar; eğer container'ı bunun yerine `docker run` ile başlatırsanız yetkileri kendiniz eklemelisiniz, aksi takdirde engellemeler sadece günlüğe (log) kaydedilir ancak asla uygulanmaz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ortam Değişkenleri
|
||||||
|
|
||||||
|
| Değişken | Açıklama | Varsayılan |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `XUI_DB_TYPE` | Veritabanı arka ucu: `sqlite` veya `postgres` | `sqlite` |
|
||||||
|
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (`XUI_DB_TYPE=postgres` olduğunda) | — |
|
||||||
|
| `XUI_DB_FOLDER` | SQLite veritabanı dosyası için dizin | `/etc/x-ui` |
|
||||||
|
| `XUI_DB_MAX_OPEN_CONNS` | Maksimum açık bağlantı sayısı (PostgreSQL havuzu) | — |
|
||||||
|
| `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bağlantı sayısı (PostgreSQL havuzu) | — |
|
||||||
|
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP sınırı zorlamasını etkinleştir | `true` |
|
||||||
|
| `XUI_LOG_LEVEL` | Log detay seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
|
||||||
|
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
|
||||||
|
|
||||||
|
## Desteklenen Diller
|
||||||
|
|
||||||
|
Panel kullanıcı arayüzü 13 dilde mevcuttur:
|
||||||
|
|
||||||
|
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
||||||
|
|
||||||
|
## Katkıda Bulunma
|
||||||
|
|
||||||
|
Katkılara açığız. Lütfen bir sorun (issue) veya çekme isteği (pull request) açmadan önce [Katkıda Bulunma Rehberi](/CONTRIBUTING.md)'ni okuyun.
|
||||||
|
|
||||||
|
## Özel Teşekkürler
|
||||||
|
|
||||||
|
- [alireza0](https://github.com/alireza0/)
|
||||||
|
|
||||||
|
## Teşekkür
|
||||||
|
|
||||||
|
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Dahili İran alan adları ve güvenlik/reklam engelleme odaklı geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme kuralları._
|
||||||
|
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Rusya'daki engellenmiş alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kuralları içerir._
|
||||||
|
|
||||||
|
## Topluluk Araçları
|
||||||
|
|
||||||
|
3x-ui etrafında topluluk tarafından geliştirilen araçlar ve entegrasyonlar.
|
||||||
|
|
||||||
|
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Bağlantı noktalarını, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak yönetin._
|
||||||
|
|
||||||
|
## Projeyi Destekleyin
|
||||||
|
|
||||||
|
**Eğer bu proje sizin için faydalıysa, bir :star2: (yıldız) verebilirsiniz.**
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||||
|
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</br>
|
||||||
|
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||||
|
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Zaman İçindeki Yıldız Sayısı
|
||||||
|
|
||||||
|
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.2.7
|
3.2.8
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DumpSQLite writes a portable SQL text dump of the SQLite database at srcPath
|
||||||
|
// to outPath. The output mirrors the `sqlite3 .dump` format (schema + data +
|
||||||
|
// indexes wrapped in a transaction), so it can be rebuilt with RestoreSQLite or
|
||||||
|
// loaded by the sqlite3 CLI. The source database is opened read-only in effect
|
||||||
|
// and left untouched.
|
||||||
|
func DumpSQLite(srcPath, outPath string) error {
|
||||||
|
data, err := DumpSQLiteToBytes(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(outPath, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DumpSQLiteToBytes builds the same `sqlite3 .dump`-style SQL text as DumpSQLite
|
||||||
|
// but returns it in memory, which the panel uses to stream a migration download.
|
||||||
|
func DumpSQLiteToBytes(srcPath string) ([]byte, error) {
|
||||||
|
if _, err := os.Stat(srcPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gdb, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sqlDB, err := gdb.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("PRAGMA foreign_keys=OFF;\n")
|
||||||
|
b.WriteString("BEGIN TRANSACTION;\n")
|
||||||
|
|
||||||
|
// Tables in creation order, each followed by its data.
|
||||||
|
type object struct{ name, ddl string }
|
||||||
|
var tables []object
|
||||||
|
rows, err := sqlDB.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY rowid`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var o object
|
||||||
|
if err := rows.Scan(&o.name, &o.ddl); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tables = append(tables, o)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, t := range tables {
|
||||||
|
b.WriteString(t.ddl)
|
||||||
|
b.WriteString(";\n")
|
||||||
|
if err := dumpTableData(sqlDB, t.name, &b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTOINCREMENT bookkeeping, restored verbatim like the sqlite3 CLI does.
|
||||||
|
if sqliteTableExists(sqlDB, "sqlite_sequence") {
|
||||||
|
b.WriteString("DELETE FROM sqlite_sequence;\n")
|
||||||
|
if err := dumpTableData(sqlDB, "sqlite_sequence", &b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexes, triggers and views after the data is in place.
|
||||||
|
rows2, err := sqlDB.Query(`SELECT sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY rowid`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows2.Next() {
|
||||||
|
var ddl string
|
||||||
|
if err := rows2.Scan(&ddl); err != nil {
|
||||||
|
rows2.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.WriteString(ddl)
|
||||||
|
b.WriteString(";\n")
|
||||||
|
}
|
||||||
|
if err := rows2.Err(); err != nil {
|
||||||
|
rows2.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows2.Close()
|
||||||
|
|
||||||
|
b.WriteString("COMMIT;\n")
|
||||||
|
|
||||||
|
return []byte(b.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreSQLite rebuilds a SQLite database at dstPath from a SQL text dump
|
||||||
|
// produced by DumpSQLite (or `sqlite3 .dump`). dstPath must not already exist so
|
||||||
|
// an existing database is never clobbered silently.
|
||||||
|
func RestoreSQLite(dumpPath, dstPath string) error {
|
||||||
|
script, err := os.ReadFile(dumpPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dstPath); err == nil {
|
||||||
|
return fmt.Errorf("destination already exists: %s", dstPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
gdb, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sqlDB, err := gdb.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// mattn/go-sqlite3 executes every statement in a multi-statement string.
|
||||||
|
if _, err := sqlDB.Exec(string(script)); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
os.Remove(dstPath)
|
||||||
|
return fmt.Errorf("restore failed: %w", err)
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpTableData appends one INSERT statement per row of table to b.
|
||||||
|
func dumpTableData(db *sql.DB, table string, b *strings.Builder) error {
|
||||||
|
rows, err := db.Query(`SELECT * FROM "` + table + `"`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cols, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n := len(cols)
|
||||||
|
prefix := `INSERT INTO "` + table + `" VALUES(`
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
vals := make([]any, n)
|
||||||
|
ptrs := make([]any, n)
|
||||||
|
for i := range vals {
|
||||||
|
ptrs[i] = &vals[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(ptrs...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.WriteString(prefix)
|
||||||
|
for i, v := range vals {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
b.WriteString(sqliteLiteral(v))
|
||||||
|
}
|
||||||
|
b.WriteString(");\n")
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqliteLiteral renders a scanned column value as a SQLite SQL literal.
|
||||||
|
func sqliteLiteral(v any) string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return "NULL"
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(x, 10)
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(x, 'g', -1, 64)
|
||||||
|
case bool:
|
||||||
|
if x {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
case string:
|
||||||
|
return quoteSQLiteText(x)
|
||||||
|
case []byte:
|
||||||
|
if utf8.Valid(x) {
|
||||||
|
return quoteSQLiteText(string(x))
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("X'")
|
||||||
|
for _, c := range x {
|
||||||
|
fmt.Fprintf(&sb, "%02x", c)
|
||||||
|
}
|
||||||
|
sb.WriteByte('\'')
|
||||||
|
return sb.String()
|
||||||
|
default:
|
||||||
|
return quoteSQLiteText(fmt.Sprintf("%v", x))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteSQLiteText(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqliteTableExists(db *sql.DB, name string) bool {
|
||||||
|
var found string
|
||||||
|
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&found)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCopyAllModelsIntoSQLite exercises the same AutoMigrate + copyTable
|
||||||
|
// machinery that ExportPostgresToSQLite relies on, but with a SQLite source so
|
||||||
|
// it needs no external database. The Postgres source path uses identical gorm
|
||||||
|
// reads (see MigrateData), so this validates the destination-side copy.
|
||||||
|
func TestCopyAllModelsIntoSQLite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
srcPath := filepath.Join(dir, "src.db")
|
||||||
|
dstPath := filepath.Join(dir, "dst.db")
|
||||||
|
|
||||||
|
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open src: %v", err)
|
||||||
|
}
|
||||||
|
defer closeGorm(src)
|
||||||
|
for _, m := range migrationModels() {
|
||||||
|
if err := src.AutoMigrate(m); err != nil {
|
||||||
|
t.Fatalf("automigrate src %T: %v", m, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a few rows across parent/child tables and a composite-PK table.
|
||||||
|
if err := src.Create(&model.User{Username: "admin", Password: "x"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
if err := src.Create(&model.Inbound{UserId: 1, Remark: "in", Port: 443, Protocol: "vless", Tag: "inbound-443"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed inbound: %v", err)
|
||||||
|
}
|
||||||
|
if err := src.Create(&xray.ClientTraffic{InboundId: 1, Email: "a@b.c", Enable: true, Up: 10, Down: 20}).Error; err != nil {
|
||||||
|
t.Fatalf("seed traffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open dst: %v", err)
|
||||||
|
}
|
||||||
|
defer closeGorm(dst)
|
||||||
|
if err := copyAllModels(src, dst); err != nil {
|
||||||
|
t.Fatalf("copyAllModels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
model any
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{&model.User{}, 1},
|
||||||
|
{&model.Inbound{}, 1},
|
||||||
|
{&xray.ClientTraffic{}, 1},
|
||||||
|
} {
|
||||||
|
var got int64
|
||||||
|
if err := dst.Model(tc.model).Count(&got).Error; err != nil {
|
||||||
|
t.Fatalf("count %T: %v", tc.model, err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%T: got %d rows, want %d", tc.model, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot-check a copied value survived the round-trip.
|
||||||
|
var ct xray.ClientTraffic
|
||||||
|
if err := dst.Where("email = ?", "a@b.c").First(&ct).Error; err != nil {
|
||||||
|
t.Fatalf("read back traffic: %v", err)
|
||||||
|
}
|
||||||
|
if ct.Up != 10 || ct.Down != 20 || !ct.Enable {
|
||||||
|
t.Errorf("traffic mismatch: %+v", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDumpAndRestoreSQLiteRoundTrip dumps a seeded SQLite db to .dump text and
|
||||||
|
// rebuilds it, asserting the row survives.
|
||||||
|
func TestDumpAndRestoreSQLiteRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
srcPath := filepath.Join(dir, "src.db")
|
||||||
|
dumpPath := filepath.Join(dir, "out.dump")
|
||||||
|
dstPath := filepath.Join(dir, "rebuilt.db")
|
||||||
|
|
||||||
|
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open src: %v", err)
|
||||||
|
}
|
||||||
|
if err := src.AutoMigrate(&model.Setting{}); err != nil {
|
||||||
|
t.Fatalf("automigrate: %v", err)
|
||||||
|
}
|
||||||
|
if err := src.Create(&model.Setting{Key: "secret", Value: "o'brien \"quote\""}).Error; err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if sqlDB, _ := src.DB(); sqlDB != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := DumpSQLite(srcPath, dumpPath); err != nil {
|
||||||
|
t.Fatalf("DumpSQLite: %v", err)
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(dumpPath); err != nil || fi.Size() == 0 {
|
||||||
|
t.Fatalf("dump missing/empty: %v", err)
|
||||||
|
}
|
||||||
|
if err := RestoreSQLite(dumpPath, dstPath); err != nil {
|
||||||
|
t.Fatalf("RestoreSQLite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open dst: %v", err)
|
||||||
|
}
|
||||||
|
defer closeGorm(dst)
|
||||||
|
var s model.Setting
|
||||||
|
if err := dst.Where("key = ?", "secret").First(&s).Error; err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if s.Value != "o'brien \"quote\"" {
|
||||||
|
t.Errorf("value mismatch after round-trip: %q", s.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeGorm closes the underlying *sql.DB so Windows can delete the temp file.
|
||||||
|
func closeGorm(db *gorm.DB) {
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s, err := db.DB(); err == nil {
|
||||||
|
s.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,23 @@ func MigrateData(srcPath, dstDSN string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
|
||||||
|
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
|
||||||
|
// client_traffics rows whose inbound was deleted. Drop it here too so copying
|
||||||
|
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
|
||||||
|
if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
|
||||||
|
return fmt.Errorf("drop legacy foreign key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty the destination tables so the migration is idempotent: a fresh
|
||||||
|
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
|
||||||
|
// panel start, and a partially-failed earlier run leaves rows behind. Either
|
||||||
|
// way a plain INSERT with explicit ids would collide on users_pkey, so clear
|
||||||
|
// our tables (only) before copying.
|
||||||
|
if err := truncatePostgresTables(dst, migrationModels()); err != nil {
|
||||||
|
return fmt.Errorf("clear destination tables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
totalRows := 0
|
totalRows := 0
|
||||||
for _, m := range migrationModels() {
|
for _, m := range migrationModels() {
|
||||||
n, err := copyTable(src, dst, m)
|
n, err := copyTable(src, dst, m)
|
||||||
@@ -105,6 +122,62 @@ func MigrateData(srcPath, dstDSN string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportPostgresToSQLite copies every row from the PostgreSQL database described
|
||||||
|
// by srcDSN into a fresh SQLite file at dstPath. It is the reverse of
|
||||||
|
// MigrateData and is used to hand a PostgreSQL-backed panel a portable .db file.
|
||||||
|
// dstPath is created/overwritten; the PostgreSQL source is left untouched.
|
||||||
|
func ExportPostgresToSQLite(srcDSN, dstPath string) error {
|
||||||
|
if srcDSN == "" {
|
||||||
|
return errors.New("source DSN is required")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(path.Dir(dstPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Start from an empty file so AutoMigrate creates the canonical schema.
|
||||||
|
if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := gorm.Open(postgres.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open postgres source: %w", err)
|
||||||
|
}
|
||||||
|
srcSQL, err := src.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srcSQL.Close()
|
||||||
|
|
||||||
|
// No WAL: keep all data in the main file so it is complete once closed.
|
||||||
|
dst, err := gorm.Open(sqlite.Open(dstPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open sqlite destination: %w", err)
|
||||||
|
}
|
||||||
|
dstSQL, err := dst.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstSQL.Close()
|
||||||
|
|
||||||
|
return copyAllModels(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyAllModels (re)creates the schema on dst and copies every migrated table
|
||||||
|
// from src to dst in FK-safe order. src/dst may be any gorm backend.
|
||||||
|
func copyAllModels(src, dst *gorm.DB) error {
|
||||||
|
for _, m := range migrationModels() {
|
||||||
|
if err := dst.AutoMigrate(m); err != nil {
|
||||||
|
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range migrationModels() {
|
||||||
|
if _, err := copyTable(src, dst, m); err != nil {
|
||||||
|
return fmt.Errorf("copy %T: %w", m, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||||
const batchSize = 500
|
const batchSize = 500
|
||||||
|
|
||||||
@@ -157,6 +230,26 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
|||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// truncatePostgresTables empties every migrated table on dst in a single
|
||||||
|
// statement, resetting identity sequences. CASCADE covers the inbound/client
|
||||||
|
// foreign keys regardless of insertion order. Only the panel's own tables are
|
||||||
|
// touched, never the rest of the schema.
|
||||||
|
func truncatePostgresTables(dst *gorm.DB, models []any) error {
|
||||||
|
tables := make([]string, 0, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
stmt := &gorm.Statement{DB: dst}
|
||||||
|
if err := stmt.Parse(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tables = append(tables, `"`+stmt.Schema.Table+`"`)
|
||||||
|
}
|
||||||
|
if len(tables) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Println("Clearing destination tables...")
|
||||||
|
return dst.Exec("TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE").Error
|
||||||
|
}
|
||||||
|
|
||||||
// resetPostgresSequences advances each migrated table's id sequence past MAX(id),
|
// resetPostgresSequences advances each migrated table's id sequence past MAX(id),
|
||||||
// otherwise the next INSERT-without-id would clash with copied rows.
|
// otherwise the next INSERT-without-id would clash with copied rows.
|
||||||
func resetPostgresSequences(dst *gorm.DB) error {
|
func resetPostgresSequences(dst *gorm.DB) error {
|
||||||
|
|||||||
+72
-28
@@ -41,13 +41,13 @@ type User struct {
|
|||||||
|
|
||||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||||
type Inbound struct {
|
type Inbound struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` // Unique identifier
|
||||||
UserId int `json:"-"` // Associated user ID
|
UserId int `json:"-"` // Associated user ID
|
||||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
|
||||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||||
@@ -55,14 +55,22 @@ type Inbound struct {
|
|||||||
|
|
||||||
// Xray configuration fields
|
// Xray configuration fields
|
||||||
Listen string `json:"listen" form:"listen"`
|
Listen string `json:"listen" form:"listen"`
|
||||||
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"`
|
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
|
||||||
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
|
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun" example:"vless"`
|
||||||
Settings string `json:"settings" form:"settings"`
|
Settings string `json:"settings" form:"settings"`
|
||||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
|
||||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||||
|
|
||||||
|
// OriginNodeGuid is the panelGuid of the node that physically hosts this
|
||||||
|
// inbound, propagated up across hops (#4983). Empty for an inbound that
|
||||||
|
// lives on this panel's own xray; set to the originating node's GUID when
|
||||||
|
// the inbound was synced from a node (kept as-is across further hops). Lets
|
||||||
|
// the master attribute a deeply nested inbound to the real node instead of
|
||||||
|
// the intermediate one it was fetched through.
|
||||||
|
OriginNodeGuid string `json:"originNodeGuid,omitempty" form:"originNodeGuid" gorm:"column:origin_node_guid;index"`
|
||||||
|
|
||||||
// FallbackParent is populated by the API layer when this inbound is
|
// FallbackParent is populated by the API layer when this inbound is
|
||||||
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
|
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
|
||||||
// The frontend uses it to rewrite client-share links so they advertise
|
// The frontend uses it to rewrite client-share links so they advertise
|
||||||
@@ -370,39 +378,75 @@ type Setting struct {
|
|||||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||||
// status fields below.
|
// status fields below.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
|
||||||
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
|
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
|
||||||
Remark string `json:"remark" form:"remark"`
|
Remark string `json:"remark" form:"remark"`
|
||||||
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
|
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
|
||||||
Address string `json:"address" form:"address" validate:"required"`
|
Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
|
||||||
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
|
Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
|
||||||
BasePath string `json:"basePath" form:"basePath"`
|
BasePath string `json:"basePath" form:"basePath" example:"/"`
|
||||||
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"`
|
ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
|
||||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
|
||||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||||
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
|
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
|
||||||
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
|
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
|
||||||
|
|
||||||
|
// Guid is the remote panel's stable self-identifier (its panelGuid),
|
||||||
|
// learned from each heartbeat. It is the globally stable node identity used
|
||||||
|
// to attribute online clients/inbounds to the physical node across a chain
|
||||||
|
// of nodes (#4983); panel-local autoincrement ids don't survive a hop.
|
||||||
|
// Observed-state only — never user-edited.
|
||||||
|
Guid string `json:"guid" gorm:"column:guid;index"`
|
||||||
|
|
||||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||||
// truthful without us having to read LastHeartbeat separately.
|
// truthful without us having to read LastHeartbeat separately.
|
||||||
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
|
Status string `json:"status" gorm:"default:unknown" example:"online"` // online|offline|unknown
|
||||||
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` // unix seconds, 0 = never
|
||||||
LatencyMs int `json:"latencyMs"`
|
LatencyMs int `json:"latencyMs" example:"42"`
|
||||||
XrayVersion string `json:"xrayVersion"`
|
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
||||||
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
|
PanelVersion string `json:"panelVersion" gorm:"column:panel_version" example:"v3.x.x"`
|
||||||
CpuPct float64 `json:"cpuPct"`
|
CpuPct float64 `json:"cpuPct" example:"23.5"`
|
||||||
MemPct float64 `json:"memPct"`
|
MemPct float64 `json:"memPct" example:"45.1"`
|
||||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
||||||
LastError string `json:"lastError"`
|
LastError string `json:"lastError"`
|
||||||
|
|
||||||
InboundCount int `json:"inboundCount" gorm:"-"`
|
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||||
ClientCount int `json:"clientCount" gorm:"-"`
|
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||||
OnlineCount int `json:"onlineCount" gorm:"-"`
|
|
||||||
DepletedCount int `json:"depletedCount" gorm:"-"`
|
|
||||||
|
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
ClientCount int `json:"clientCount" gorm:"-" example:"27"`
|
||||||
|
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
|
||||||
|
DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
|
||||||
|
|
||||||
|
// ParentGuid + Transitive are set only when a node is surfaced as part of a
|
||||||
|
// node tree (#4983): direct nodes carry the master panel's own GUID, a
|
||||||
|
// transitive sub-node carries its parent node's GUID. Transitive nodes are
|
||||||
|
// read-only projections (Id == 0, not persisted) — never edited or deployed.
|
||||||
|
ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
|
||||||
|
Transitive bool `json:"transitive,omitempty" gorm:"-"`
|
||||||
|
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1700000000"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1700000000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeSummary is the read-only identity of a node as published one hop up: the
|
||||||
|
// view a panel exposes about the nodes it directly manages, so a master can
|
||||||
|
// surface transitive sub-nodes in a chained topology (#4983). Counts are
|
||||||
|
// computed by the consuming master from its own per-GUID data, never trusted
|
||||||
|
// from the child, so this carries identity/health only.
|
||||||
|
type NodeSummary struct {
|
||||||
|
Guid string `json:"guid"`
|
||||||
|
ParentGuid string `json:"parentGuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Scheme string `json:"scheme"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastHeartbeat int64 `json:"lastHeartbeat"`
|
||||||
|
LatencyMs int `json:"latencyMs"`
|
||||||
|
PanelVersion string `json:"panelVersion"`
|
||||||
|
XrayVersion string `json:"xrayVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomGeoResource struct {
|
type CustomGeoResource struct {
|
||||||
|
|||||||
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": [
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"gen": "npm run gen:zod && npm run gen:api",
|
||||||
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
|
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
|
||||||
"gen:zod": "cd .. && go run ./tools/openapigen"
|
"gen:zod": "cd .. && go run ./tools/openapigen"
|
||||||
},
|
},
|
||||||
@@ -29,7 +30,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",
|
||||||
|
|||||||
+2015
-120
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ import { join, dirname } from 'node:path';
|
|||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
import { sections } from '../src/pages/api-docs/endpoints.ts';
|
import { sections } from '../src/pages/api-docs/endpoints.ts';
|
||||||
|
import { EXAMPLES } from '../src/generated/examples.ts';
|
||||||
|
import { SCHEMAS } from '../src/generated/schemas.ts';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const outPath = join(__dirname, '..', 'public', 'openapi.json');
|
const outPath = join(__dirname, '..', 'public', 'openapi.json');
|
||||||
@@ -128,7 +130,22 @@ function buildOperation(ep, tag) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responses = {};
|
const responses = {};
|
||||||
const successExample = tryParseJson(ep.response);
|
let successExample = tryParseJson(ep.response);
|
||||||
|
let objSchema = {};
|
||||||
|
if (ep.responseSchema) {
|
||||||
|
const obj = EXAMPLES[ep.responseSchema];
|
||||||
|
if (obj === undefined) {
|
||||||
|
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
|
||||||
|
}
|
||||||
|
if (SCHEMAS[ep.responseSchema] === undefined) {
|
||||||
|
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
|
||||||
|
}
|
||||||
|
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
|
||||||
|
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
|
||||||
|
if (successExample === undefined) {
|
||||||
|
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
|
||||||
|
}
|
||||||
|
}
|
||||||
responses['200'] = {
|
responses['200'] = {
|
||||||
description: 'Successful response',
|
description: 'Successful response',
|
||||||
content: {
|
content: {
|
||||||
@@ -138,7 +155,7 @@ function buildOperation(ep, tag) {
|
|||||||
properties: {
|
properties: {
|
||||||
success: { type: 'boolean' },
|
success: { type: 'boolean' },
|
||||||
msg: { type: 'string' },
|
msg: { type: 'string' },
|
||||||
obj: {},
|
obj: objSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(successExample !== undefined ? { example: successExample } : {}),
|
...(successExample !== undefined ? { example: successExample } : {}),
|
||||||
@@ -192,13 +209,14 @@ function buildSpec() {
|
|||||||
title: '3X-UI Panel API',
|
title: '3X-UI Panel API',
|
||||||
version: PANEL_VERSION,
|
version: PANEL_VERSION,
|
||||||
description:
|
description:
|
||||||
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
|
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
{ url: '/', description: 'Current panel (basePath aware)' },
|
{ url: '/', description: 'Current panel (basePath aware)' },
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
securitySchemes: SECURITY_SCHEMES,
|
securitySchemes: SECURITY_SCHEMES,
|
||||||
|
schemas: SCHEMAS,
|
||||||
},
|
},
|
||||||
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
|
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
|
||||||
tags,
|
tags,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
|||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||||
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
||||||
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
|
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
|
||||||
return validated.obj;
|
return validated.obj;
|
||||||
@@ -47,7 +47,7 @@ export function useAllSettings() {
|
|||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||||
}
|
}
|
||||||
return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
|
return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next);
|
||||||
},
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const keys = {
|
|||||||
list: (params: unknown) => ['clients', 'list', params] as const,
|
list: (params: unknown) => ['clients', 'list', params] as const,
|
||||||
all: () => ['clients', 'all'] as const,
|
all: () => ['clients', 'all'] as const,
|
||||||
onlines: () => ['clients', 'onlines'] as const,
|
onlines: () => ['clients', 'onlines'] as const,
|
||||||
onlinesByNode: () => ['clients', 'onlinesByNode'] as const,
|
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
|
||||||
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
||||||
lastOnline: () => ['clients', 'lastOnline'] as const,
|
lastOnline: () => ['clients', 'lastOnline'] as const,
|
||||||
groups: () => ['clients', 'groups'] as const,
|
groups: () => ['clients', 'groups'] as const,
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||||
|
export const EXAMPLES: Record<string, unknown> = {
|
||||||
|
"AllSetting": {
|
||||||
|
"datepicker": "",
|
||||||
|
"expireDiff": 0,
|
||||||
|
"externalTrafficInformEnable": false,
|
||||||
|
"externalTrafficInformURI": "",
|
||||||
|
"ldapAutoCreate": false,
|
||||||
|
"ldapAutoDelete": false,
|
||||||
|
"ldapBaseDN": "",
|
||||||
|
"ldapBindDN": "",
|
||||||
|
"ldapDefaultExpiryDays": 0,
|
||||||
|
"ldapDefaultLimitIP": 0,
|
||||||
|
"ldapDefaultTotalGB": 0,
|
||||||
|
"ldapEnable": false,
|
||||||
|
"ldapFlagField": "",
|
||||||
|
"ldapHost": "",
|
||||||
|
"ldapInboundTags": "",
|
||||||
|
"ldapInvertFlag": false,
|
||||||
|
"ldapPassword": "",
|
||||||
|
"ldapPort": 0,
|
||||||
|
"ldapSyncCron": "",
|
||||||
|
"ldapTruthyValues": "",
|
||||||
|
"ldapUseTLS": false,
|
||||||
|
"ldapUserAttr": "",
|
||||||
|
"ldapUserFilter": "",
|
||||||
|
"ldapVlessField": "",
|
||||||
|
"pageSize": 0,
|
||||||
|
"panelProxy": "",
|
||||||
|
"remarkModel": "",
|
||||||
|
"restartXrayOnClientDisable": false,
|
||||||
|
"sessionMaxAge": 1,
|
||||||
|
"subAnnounce": "",
|
||||||
|
"subCertFile": "",
|
||||||
|
"subClashEnable": false,
|
||||||
|
"subClashEnableRouting": false,
|
||||||
|
"subClashPath": "",
|
||||||
|
"subClashRules": "",
|
||||||
|
"subClashURI": "",
|
||||||
|
"subDomain": "",
|
||||||
|
"subEmailInRemark": false,
|
||||||
|
"subEnable": false,
|
||||||
|
"subEnableRouting": false,
|
||||||
|
"subEncrypt": false,
|
||||||
|
"subJsonEnable": false,
|
||||||
|
"subJsonFinalMask": "",
|
||||||
|
"subJsonMux": "",
|
||||||
|
"subJsonPath": "",
|
||||||
|
"subJsonRules": "",
|
||||||
|
"subJsonURI": "",
|
||||||
|
"subKeyFile": "",
|
||||||
|
"subListen": "",
|
||||||
|
"subPath": "",
|
||||||
|
"subPort": 1,
|
||||||
|
"subProfileUrl": "",
|
||||||
|
"subRoutingRules": "",
|
||||||
|
"subShowInfo": false,
|
||||||
|
"subSupportUrl": "",
|
||||||
|
"subTitle": "",
|
||||||
|
"subURI": "",
|
||||||
|
"subUpdates": 0,
|
||||||
|
"tgBotAPIServer": "",
|
||||||
|
"tgBotBackup": false,
|
||||||
|
"tgBotChatId": "",
|
||||||
|
"tgBotEnable": false,
|
||||||
|
"tgBotLoginNotify": false,
|
||||||
|
"tgBotProxy": "",
|
||||||
|
"tgBotToken": "",
|
||||||
|
"tgCpu": 0,
|
||||||
|
"tgLang": "",
|
||||||
|
"tgRunTime": "",
|
||||||
|
"timeLocation": "",
|
||||||
|
"trafficDiff": 0,
|
||||||
|
"trustedProxyCIDRs": "",
|
||||||
|
"twoFactorEnable": false,
|
||||||
|
"twoFactorToken": "",
|
||||||
|
"webBasePath": "",
|
||||||
|
"webCertFile": "",
|
||||||
|
"webDomain": "",
|
||||||
|
"webKeyFile": "",
|
||||||
|
"webListen": "",
|
||||||
|
"webPort": 1
|
||||||
|
},
|
||||||
|
"AllSettingView": {
|
||||||
|
"datepicker": "",
|
||||||
|
"expireDiff": 0,
|
||||||
|
"externalTrafficInformEnable": false,
|
||||||
|
"externalTrafficInformURI": "",
|
||||||
|
"hasApiToken": false,
|
||||||
|
"hasLdapPassword": false,
|
||||||
|
"hasNordSecret": false,
|
||||||
|
"hasTgBotToken": false,
|
||||||
|
"hasTwoFactorToken": false,
|
||||||
|
"hasWarpSecret": false,
|
||||||
|
"ldapAutoCreate": false,
|
||||||
|
"ldapAutoDelete": false,
|
||||||
|
"ldapBaseDN": "",
|
||||||
|
"ldapBindDN": "",
|
||||||
|
"ldapDefaultExpiryDays": 0,
|
||||||
|
"ldapDefaultLimitIP": 0,
|
||||||
|
"ldapDefaultTotalGB": 0,
|
||||||
|
"ldapEnable": false,
|
||||||
|
"ldapFlagField": "",
|
||||||
|
"ldapHost": "",
|
||||||
|
"ldapInboundTags": "",
|
||||||
|
"ldapInvertFlag": false,
|
||||||
|
"ldapPassword": "",
|
||||||
|
"ldapPort": 0,
|
||||||
|
"ldapSyncCron": "",
|
||||||
|
"ldapTruthyValues": "",
|
||||||
|
"ldapUseTLS": false,
|
||||||
|
"ldapUserAttr": "",
|
||||||
|
"ldapUserFilter": "",
|
||||||
|
"ldapVlessField": "",
|
||||||
|
"pageSize": 0,
|
||||||
|
"panelProxy": "",
|
||||||
|
"remarkModel": "",
|
||||||
|
"restartXrayOnClientDisable": false,
|
||||||
|
"sessionMaxAge": 1,
|
||||||
|
"subAnnounce": "",
|
||||||
|
"subCertFile": "",
|
||||||
|
"subClashEnable": false,
|
||||||
|
"subClashEnableRouting": false,
|
||||||
|
"subClashPath": "",
|
||||||
|
"subClashRules": "",
|
||||||
|
"subClashURI": "",
|
||||||
|
"subDomain": "",
|
||||||
|
"subEmailInRemark": false,
|
||||||
|
"subEnable": false,
|
||||||
|
"subEnableRouting": false,
|
||||||
|
"subEncrypt": false,
|
||||||
|
"subJsonEnable": false,
|
||||||
|
"subJsonFinalMask": "",
|
||||||
|
"subJsonMux": "",
|
||||||
|
"subJsonPath": "",
|
||||||
|
"subJsonRules": "",
|
||||||
|
"subJsonURI": "",
|
||||||
|
"subKeyFile": "",
|
||||||
|
"subListen": "",
|
||||||
|
"subPath": "",
|
||||||
|
"subPort": 1,
|
||||||
|
"subProfileUrl": "",
|
||||||
|
"subRoutingRules": "",
|
||||||
|
"subShowInfo": false,
|
||||||
|
"subSupportUrl": "",
|
||||||
|
"subTitle": "",
|
||||||
|
"subURI": "",
|
||||||
|
"subUpdates": 0,
|
||||||
|
"tgBotAPIServer": "",
|
||||||
|
"tgBotBackup": false,
|
||||||
|
"tgBotChatId": "",
|
||||||
|
"tgBotEnable": false,
|
||||||
|
"tgBotLoginNotify": false,
|
||||||
|
"tgBotProxy": "",
|
||||||
|
"tgBotToken": "",
|
||||||
|
"tgCpu": 0,
|
||||||
|
"tgLang": "",
|
||||||
|
"tgRunTime": "",
|
||||||
|
"timeLocation": "",
|
||||||
|
"trafficDiff": 0,
|
||||||
|
"trustedProxyCIDRs": "",
|
||||||
|
"twoFactorEnable": false,
|
||||||
|
"twoFactorToken": "",
|
||||||
|
"webBasePath": "",
|
||||||
|
"webCertFile": "",
|
||||||
|
"webDomain": "",
|
||||||
|
"webKeyFile": "",
|
||||||
|
"webListen": "",
|
||||||
|
"webPort": 1
|
||||||
|
},
|
||||||
|
"ApiToken": {
|
||||||
|
"createdAt": 0,
|
||||||
|
"enabled": false,
|
||||||
|
"id": 0,
|
||||||
|
"name": "",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
|
"ApiTokenView": {
|
||||||
|
"createdAt": 1736000000,
|
||||||
|
"enabled": true,
|
||||||
|
"id": 2,
|
||||||
|
"name": "central-panel-a",
|
||||||
|
"token": "new-token-string"
|
||||||
|
},
|
||||||
|
"Client": {
|
||||||
|
"auth": "",
|
||||||
|
"comment": "",
|
||||||
|
"created_at": 0,
|
||||||
|
"email": "",
|
||||||
|
"enable": false,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"flow": "",
|
||||||
|
"group": "",
|
||||||
|
"id": "",
|
||||||
|
"limitIp": 0,
|
||||||
|
"password": "",
|
||||||
|
"reset": 0,
|
||||||
|
"reverse": null,
|
||||||
|
"security": "",
|
||||||
|
"subId": "",
|
||||||
|
"tgId": 0,
|
||||||
|
"totalGB": 0,
|
||||||
|
"updated_at": 0
|
||||||
|
},
|
||||||
|
"ClientInbound": {
|
||||||
|
"clientId": 0,
|
||||||
|
"createdAt": 0,
|
||||||
|
"flowOverride": "",
|
||||||
|
"inboundId": 0
|
||||||
|
},
|
||||||
|
"ClientRecord": {
|
||||||
|
"auth": "",
|
||||||
|
"comment": "",
|
||||||
|
"createdAt": 0,
|
||||||
|
"email": "",
|
||||||
|
"enable": false,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"flow": "",
|
||||||
|
"group": "",
|
||||||
|
"id": 0,
|
||||||
|
"limitIp": 0,
|
||||||
|
"password": "",
|
||||||
|
"reset": 0,
|
||||||
|
"reverse": null,
|
||||||
|
"security": "",
|
||||||
|
"subId": "",
|
||||||
|
"tgId": 0,
|
||||||
|
"totalGB": 0,
|
||||||
|
"updatedAt": 0,
|
||||||
|
"uuid": ""
|
||||||
|
},
|
||||||
|
"ClientReverse": {
|
||||||
|
"tag": ""
|
||||||
|
},
|
||||||
|
"ClientTraffic": {
|
||||||
|
"down": 2097152,
|
||||||
|
"email": "user1",
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 1735689600000,
|
||||||
|
"id": 14825,
|
||||||
|
"inboundId": 1,
|
||||||
|
"lastOnline": 1735680000000,
|
||||||
|
"reset": 0,
|
||||||
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
|
"total": 10737418240,
|
||||||
|
"up": 1048576,
|
||||||
|
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
|
||||||
|
},
|
||||||
|
"CustomGeoResource": {
|
||||||
|
"alias": "",
|
||||||
|
"createdAt": 0,
|
||||||
|
"id": 0,
|
||||||
|
"lastModified": "",
|
||||||
|
"lastUpdatedAt": 0,
|
||||||
|
"localPath": "",
|
||||||
|
"type": "",
|
||||||
|
"updatedAt": 0,
|
||||||
|
"url": ""
|
||||||
|
},
|
||||||
|
"FallbackParentInfo": {
|
||||||
|
"masterId": 0,
|
||||||
|
"path": ""
|
||||||
|
},
|
||||||
|
"HistoryOfSeeders": {
|
||||||
|
"id": 0,
|
||||||
|
"seederName": ""
|
||||||
|
},
|
||||||
|
"Inbound": {
|
||||||
|
"clientStats": [
|
||||||
|
{
|
||||||
|
"down": 2097152,
|
||||||
|
"email": "user1",
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 1735689600000,
|
||||||
|
"id": 14825,
|
||||||
|
"inboundId": 1,
|
||||||
|
"lastOnline": 1735680000000,
|
||||||
|
"reset": 0,
|
||||||
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
|
"total": 10737418240,
|
||||||
|
"up": 1048576,
|
||||||
|
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"down": 0,
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"fallbackParent": null,
|
||||||
|
"id": 1,
|
||||||
|
"lastTrafficResetTime": 0,
|
||||||
|
"listen": "",
|
||||||
|
"nodeId": null,
|
||||||
|
"originNodeGuid": "",
|
||||||
|
"port": 443,
|
||||||
|
"protocol": "vless",
|
||||||
|
"remark": "VLESS-443",
|
||||||
|
"settings": null,
|
||||||
|
"sniffing": null,
|
||||||
|
"streamSettings": null,
|
||||||
|
"tag": "in-443-tcp",
|
||||||
|
"total": 0,
|
||||||
|
"trafficReset": "never",
|
||||||
|
"up": 0
|
||||||
|
},
|
||||||
|
"InboundClientIps": {
|
||||||
|
"clientEmail": "",
|
||||||
|
"id": 0,
|
||||||
|
"ips": null
|
||||||
|
},
|
||||||
|
"InboundFallback": {
|
||||||
|
"alpn": "",
|
||||||
|
"childId": 0,
|
||||||
|
"dest": "",
|
||||||
|
"id": 0,
|
||||||
|
"masterId": 0,
|
||||||
|
"name": "",
|
||||||
|
"path": "",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"xver": 0
|
||||||
|
},
|
||||||
|
"InboundOption": {
|
||||||
|
"id": 1,
|
||||||
|
"port": 443,
|
||||||
|
"protocol": "vless",
|
||||||
|
"remark": "VLESS-443",
|
||||||
|
"ssMethod": "",
|
||||||
|
"tag": "in-443-tcp",
|
||||||
|
"tlsFlowCapable": true
|
||||||
|
},
|
||||||
|
"Msg": {
|
||||||
|
"msg": "",
|
||||||
|
"obj": null,
|
||||||
|
"success": false
|
||||||
|
},
|
||||||
|
"Node": {
|
||||||
|
"address": "node1.example.com",
|
||||||
|
"allowPrivateAddress": false,
|
||||||
|
"apiToken": "abcdef0123456789",
|
||||||
|
"basePath": "/",
|
||||||
|
"clientCount": 27,
|
||||||
|
"configDirty": false,
|
||||||
|
"configDirtyAt": 0,
|
||||||
|
"cpuPct": 23.5,
|
||||||
|
"createdAt": 1700000000,
|
||||||
|
"depletedCount": 1,
|
||||||
|
"enable": true,
|
||||||
|
"guid": "",
|
||||||
|
"id": 1,
|
||||||
|
"inboundCount": 5,
|
||||||
|
"lastError": "",
|
||||||
|
"lastHeartbeat": 1700000000,
|
||||||
|
"latencyMs": 42,
|
||||||
|
"memPct": 45.1,
|
||||||
|
"name": "de-fra-1",
|
||||||
|
"onlineCount": 3,
|
||||||
|
"panelVersion": "v3.x.x",
|
||||||
|
"parentGuid": "",
|
||||||
|
"pinnedCertSha256": "",
|
||||||
|
"port": 2053,
|
||||||
|
"remark": "",
|
||||||
|
"scheme": "https",
|
||||||
|
"status": "online",
|
||||||
|
"tlsVerifyMode": "verify",
|
||||||
|
"transitive": false,
|
||||||
|
"updatedAt": 1700000000,
|
||||||
|
"uptimeSecs": 86400,
|
||||||
|
"xrayVersion": "25.10.31"
|
||||||
|
},
|
||||||
|
"OutboundTraffics": {
|
||||||
|
"down": 0,
|
||||||
|
"id": 0,
|
||||||
|
"tag": "",
|
||||||
|
"total": 0,
|
||||||
|
"up": 0
|
||||||
|
},
|
||||||
|
"ProbeResultUI": {
|
||||||
|
"cpuPct": 12.5,
|
||||||
|
"error": "",
|
||||||
|
"latencyMs": 42,
|
||||||
|
"memPct": 45.2,
|
||||||
|
"panelVersion": "v3.x.x",
|
||||||
|
"status": "online",
|
||||||
|
"uptimeSecs": 86400,
|
||||||
|
"xrayVersion": "25.10.31"
|
||||||
|
},
|
||||||
|
"Setting": {
|
||||||
|
"id": 0,
|
||||||
|
"key": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"User": {
|
||||||
|
"id": 0,
|
||||||
|
"password": "",
|
||||||
|
"username": ""
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
|||||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||||
|
export type LoginStatus = number;
|
||||||
|
export type ProcessState = string;
|
||||||
export type Protocol = string;
|
export type Protocol = string;
|
||||||
|
export type SubLinkProvider = unknown;
|
||||||
|
export type transportBits = number;
|
||||||
|
|
||||||
export interface AllSetting {
|
export interface AllSetting {
|
||||||
datepicker: string;
|
datepicker: string;
|
||||||
@@ -34,7 +38,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 +48,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 +126,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 +136,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;
|
||||||
@@ -177,6 +183,14 @@ export interface ApiToken {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiTokenView {
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
auth?: string;
|
auth?: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
@@ -278,6 +292,7 @@ export interface Inbound {
|
|||||||
lastTrafficResetTime: number;
|
lastTrafficResetTime: number;
|
||||||
listen: string;
|
listen: string;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
|
originNodeGuid?: string;
|
||||||
port: number;
|
port: number;
|
||||||
protocol: Protocol;
|
protocol: Protocol;
|
||||||
remark: string;
|
remark: string;
|
||||||
@@ -308,6 +323,16 @@ export interface InboundFallback {
|
|||||||
xver: number;
|
xver: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InboundOption {
|
||||||
|
id: number;
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
remark: string;
|
||||||
|
ssMethod: string;
|
||||||
|
tag: string;
|
||||||
|
tlsFlowCapable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Msg {
|
export interface Msg {
|
||||||
msg: string;
|
msg: string;
|
||||||
obj: unknown;
|
obj: unknown;
|
||||||
@@ -320,10 +345,13 @@ 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;
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
|
guid: string;
|
||||||
id: number;
|
id: number;
|
||||||
inboundCount: number;
|
inboundCount: number;
|
||||||
lastError: string;
|
lastError: string;
|
||||||
@@ -333,12 +361,14 @@ export interface Node {
|
|||||||
name: string;
|
name: string;
|
||||||
onlineCount: number;
|
onlineCount: number;
|
||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
|
parentGuid?: string;
|
||||||
pinnedCertSha256: string;
|
pinnedCertSha256: string;
|
||||||
port: number;
|
port: number;
|
||||||
remark: string;
|
remark: string;
|
||||||
scheme: string;
|
scheme: string;
|
||||||
status: string;
|
status: string;
|
||||||
tlsVerifyMode: string;
|
tlsVerifyMode: string;
|
||||||
|
transitive?: boolean;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
uptimeSecs: number;
|
uptimeSecs: number;
|
||||||
xrayVersion: string;
|
xrayVersion: string;
|
||||||
@@ -352,6 +382,17 @@ export interface OutboundTraffics {
|
|||||||
up: number;
|
up: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProbeResultUI {
|
||||||
|
cpuPct: number;
|
||||||
|
error: string;
|
||||||
|
latencyMs: number;
|
||||||
|
memPct: number;
|
||||||
|
panelVersion: string;
|
||||||
|
status: string;
|
||||||
|
uptimeSecs: number;
|
||||||
|
xrayVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Setting {
|
export interface Setting {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
export const LoginStatusSchema = z.number().int();
|
||||||
|
export type LoginStatus = z.infer<typeof LoginStatusSchema>;
|
||||||
|
|
||||||
|
export const ProcessStateSchema = z.string();
|
||||||
|
export type ProcessState = z.infer<typeof ProcessStateSchema>;
|
||||||
|
|
||||||
export const ProtocolSchema = z.string();
|
export const ProtocolSchema = z.string();
|
||||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||||
|
|
||||||
|
export const SubLinkProviderSchema = z.unknown();
|
||||||
|
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||||
|
|
||||||
|
export const transportBitsSchema = z.number().int();
|
||||||
|
export type transportBits = z.infer<typeof transportBitsSchema>;
|
||||||
|
|
||||||
export const AllSettingSchema = z.object({
|
export const AllSettingSchema = z.object({
|
||||||
datepicker: z.string(),
|
datepicker: z.string(),
|
||||||
expireDiff: z.number().int().min(0),
|
expireDiff: z.number().int().min(0),
|
||||||
@@ -36,7 +48,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 +58,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 +137,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 +147,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(),
|
||||||
@@ -182,6 +196,15 @@ export const ApiTokenSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ApiToken = z.infer<typeof ApiTokenSchema>;
|
export type ApiToken = z.infer<typeof ApiTokenSchema>;
|
||||||
|
|
||||||
|
export const ApiTokenViewSchema = z.object({
|
||||||
|
createdAt: z.number().int(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
id: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
token: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
|
||||||
|
|
||||||
export const ClientSchema = z.object({
|
export const ClientSchema = z.object({
|
||||||
auth: z.string().optional(),
|
auth: z.string().optional(),
|
||||||
comment: z.string(),
|
comment: z.string(),
|
||||||
@@ -291,6 +314,7 @@ export const InboundSchema = z.object({
|
|||||||
lastTrafficResetTime: z.number().int(),
|
lastTrafficResetTime: z.number().int(),
|
||||||
listen: z.string(),
|
listen: z.string(),
|
||||||
nodeId: z.number().int().nullable().optional(),
|
nodeId: z.number().int().nullable().optional(),
|
||||||
|
originNodeGuid: z.string().optional(),
|
||||||
port: z.number().int().min(0).max(65535),
|
port: z.number().int().min(0).max(65535),
|
||||||
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
|
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
@@ -324,6 +348,17 @@ export const InboundFallbackSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
||||||
|
|
||||||
|
export const InboundOptionSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
port: z.number().int(),
|
||||||
|
protocol: z.string(),
|
||||||
|
remark: z.string(),
|
||||||
|
ssMethod: z.string(),
|
||||||
|
tag: z.string(),
|
||||||
|
tlsFlowCapable: z.boolean(),
|
||||||
|
});
|
||||||
|
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
||||||
|
|
||||||
export const MsgSchema = z.object({
|
export const MsgSchema = z.object({
|
||||||
msg: z.string(),
|
msg: z.string(),
|
||||||
obj: z.unknown(),
|
obj: z.unknown(),
|
||||||
@@ -337,10 +372,13 @@ 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(),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
|
guid: z.string(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
inboundCount: z.number().int(),
|
inboundCount: z.number().int(),
|
||||||
lastError: z.string(),
|
lastError: z.string(),
|
||||||
@@ -350,12 +388,14 @@ export const NodeSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
onlineCount: z.number().int(),
|
onlineCount: z.number().int(),
|
||||||
panelVersion: z.string(),
|
panelVersion: z.string(),
|
||||||
|
parentGuid: z.string().optional(),
|
||||||
pinnedCertSha256: z.string(),
|
pinnedCertSha256: z.string(),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
scheme: z.enum(['http', 'https']),
|
scheme: z.enum(['http', 'https']),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
||||||
|
transitive: z.boolean().optional(),
|
||||||
updatedAt: z.number().int(),
|
updatedAt: z.number().int(),
|
||||||
uptimeSecs: z.number().int(),
|
uptimeSecs: z.number().int(),
|
||||||
xrayVersion: z.string(),
|
xrayVersion: z.string(),
|
||||||
@@ -371,6 +411,18 @@ export const OutboundTrafficsSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
|
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
|
||||||
|
|
||||||
|
export const ProbeResultUISchema = z.object({
|
||||||
|
cpuPct: z.number(),
|
||||||
|
error: z.string(),
|
||||||
|
latencyMs: z.number().int(),
|
||||||
|
memPct: z.number(),
|
||||||
|
panelVersion: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
uptimeSecs: z.number().int(),
|
||||||
|
xrayVersion: z.string(),
|
||||||
|
});
|
||||||
|
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
|
||||||
|
|
||||||
export const SettingSchema = z.object({
|
export const SettingSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ async function fetchInboundOptions(): Promise<InboundOption[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDefaults(): Promise<Record<string, unknown>> {
|
async function fetchDefaults(): Promise<Record<string, unknown>> {
|
||||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
||||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||||
return validated.obj || {};
|
return validated.obj || {};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async function loadOnce(): Promise<void> {
|
|||||||
}
|
}
|
||||||
pending = (async () => {
|
pending = (async () => {
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||||
cachedValue = validated.obj?.datepicker || 'gregorian';
|
cachedValue = validated.obj?.datepicker || 'gregorian';
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export interface UseXraySettingResult {
|
|||||||
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||||
|
|
||||||
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
||||||
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
||||||
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
@@ -91,7 +91,7 @@ async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
||||||
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
|
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
||||||
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
@@ -200,7 +200,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const sentXraySetting = xraySettingRef.current;
|
const sentXraySetting = xraySettingRef.current;
|
||||||
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
||||||
const msg = await HttpUtil.post('/panel/xray/update', {
|
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||||
xraySetting: sentXraySetting,
|
xraySetting: sentXraySetting,
|
||||||
outboundTestUrl: sentTestUrl,
|
outboundTestUrl: sentTestUrl,
|
||||||
});
|
});
|
||||||
@@ -217,7 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
|
|
||||||
const resetTrafficMut = useMutation({
|
const resetTrafficMut = useMutation({
|
||||||
mutationFn: (tag: string) =>
|
mutationFn: (tag: string) =>
|
||||||
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
|
HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
||||||
},
|
},
|
||||||
@@ -228,7 +228,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||||
if (!msg?.success) return msg;
|
if (!msg?.success) return msg;
|
||||||
await PromiseUtil.sleep(500);
|
await PromiseUtil.sleep(500);
|
||||||
const r = await HttpUtil.get('/panel/xray/getXrayResult');
|
const r = await HttpUtil.get('/panel/api/xray/getXrayResult');
|
||||||
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
|
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
|
||||||
if (validated?.success) setRestartResult(validated.obj || '');
|
if (validated?.success) setRestartResult(validated.obj || '');
|
||||||
return msg;
|
return msg;
|
||||||
@@ -237,7 +237,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
|
|
||||||
const resetDefaultMut = useMutation({
|
const resetDefaultMut = useMutation({
|
||||||
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
||||||
const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
|
||||||
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
|
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
|
||||||
},
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
@@ -264,7 +264,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
[index]: { testing: true, result: null, mode: effMode },
|
[index]: { testing: true, result: null, mode: effMode },
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const raw = await HttpUtil.post('/panel/xray/testOutbound', {
|
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
|
||||||
outbound: JSON.stringify(outbound),
|
outbound: JSON.stringify(outbound),
|
||||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||||
mode: effMode,
|
mode: effMode,
|
||||||
|
|||||||
@@ -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'];
|
||||||
@@ -32,10 +26,10 @@ function asPath(name: NamePath): (string | number)[] {
|
|||||||
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'fragment':
|
case 'fragment':
|
||||||
return { packets: '1-3', length: '', delay: '', maxSplit: '' };
|
return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
|
||||||
case 'sudoku':
|
case 'sudoku':
|
||||||
return {
|
return {
|
||||||
password: '', ascii: '', customTable: '', customTables: '',
|
password: '', ascii: '', customTable: '', customTables: [''],
|
||||||
paddingMin: 0, paddingMax: 0,
|
paddingMin: 0, paddingMax: 0,
|
||||||
};
|
};
|
||||||
case 'header-custom':
|
case 'header-custom':
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -216,8 +210,12 @@ function TcpMaskItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
|
<Form.Item
|
||||||
<Input />
|
label="Length"
|
||||||
|
name={[fieldName, 'settings', 'length']}
|
||||||
|
rules={[{ validator: validateFragmentLength }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="e.g. 100-200" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
|
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
|
||||||
<Input />
|
<Input />
|
||||||
@@ -234,7 +232,9 @@ function TcpMaskItem({
|
|||||||
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
|
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
|
||||||
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
|
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
|
||||||
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
|
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
|
||||||
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
|
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
|
||||||
|
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
||||||
<InputNumber min={0} />
|
<InputNumber min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -263,6 +263,18 @@ function TcpMaskItem({
|
|||||||
// Walks a deep object path safely. Used inside shouldUpdate which gets
|
// Walks a deep object path safely. Used inside shouldUpdate which gets
|
||||||
// the whole form values blob; we need to compare a deep field across
|
// the whole form values blob; we need to compare a deep field across
|
||||||
// prev/curr without crashing on missing intermediates.
|
// prev/curr without crashing on missing intermediates.
|
||||||
|
function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
||||||
|
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
|
if (str.length === 0) {
|
||||||
|
return Promise.reject(new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'));
|
||||||
|
}
|
||||||
|
const min = Number(str.split('-')[0]);
|
||||||
|
if (!Number.isFinite(min) || min <= 0) {
|
||||||
|
return Promise.reject(new Error('Length minimum must be greater than 0 (e.g. 100-200)'));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
function getDeep(obj: unknown, path: (string | number)[]): unknown {
|
function getDeep(obj: unknown, path: (string | number)[]): unknown {
|
||||||
let cur: unknown = obj;
|
let cur: unknown = obj;
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import type { StreamSettings } from '@/schemas/api/inbound';
|
import type { StreamSettings } from '@/schemas/api/inbound';
|
||||||
import type { Sniffing } from '@/schemas/primitives';
|
import type { Sniffing } from '@/schemas/primitives';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
|
|
||||||
// Plain-data adapter between the panel's stored inbound row shape and
|
// Plain-data adapter between the panel's stored inbound row shape and
|
||||||
// the typed InboundFormValues that Form.useForm<T> carries inside
|
// the typed InboundFormValues that Form.useForm<T> carries inside
|
||||||
@@ -279,10 +280,13 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
|||||||
if (Array.isArray(settingsPruned.clients)) {
|
if (Array.isArray(settingsPruned.clients)) {
|
||||||
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
||||||
}
|
}
|
||||||
const streamPruned = values.streamSettings
|
let streamPruned = values.streamSettings
|
||||||
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (streamPruned) stripTlsCertUseFile(streamPruned);
|
if (streamPruned) {
|
||||||
|
streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
|
||||||
|
stripTlsCertUseFile(streamPruned);
|
||||||
|
}
|
||||||
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
||||||
const payload: WireInboundPayload = {
|
const payload: WireInboundPayload = {
|
||||||
up: values.up,
|
up: values.up,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||||
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
import { Wireguard } from '@/utils';
|
import { Wireguard } from '@/utils';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -519,8 +520,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
|
|||||||
userLevel: s.userLevel || undefined,
|
userLevel: s.userLevel || undefined,
|
||||||
proxyProtocol: s.proxyProtocol || undefined,
|
proxyProtocol: s.proxyProtocol || undefined,
|
||||||
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
||||||
noises: s.noises.length > 0 ? s.noises : undefined,
|
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
|
||||||
finalRules: s.finalRules.length > 0
|
finalRules: s.finalRules && s.finalRules.length > 0
|
||||||
? s.finalRules.map((r) => ({
|
? s.finalRules.map((r) => ({
|
||||||
action: r.action,
|
action: r.action,
|
||||||
network: r.network || undefined,
|
network: r.network || undefined,
|
||||||
@@ -588,7 +589,7 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
|
|||||||
if (!xmuxEnabled) delete cleaned.xmux;
|
if (!xmuxEnabled) delete cleaned.xmux;
|
||||||
next.xhttpSettings = dropEmptyStrings(cleaned);
|
next.xhttpSettings = dropEmptyStrings(cleaned);
|
||||||
}
|
}
|
||||||
return next;
|
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function muxAllowed(values: OutboundFormValues): boolean {
|
function muxAllowed(values: OutboundFormValues): boolean {
|
||||||
|
|||||||
@@ -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') ?? '';
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// Shapes the streamSettings subtree that 3x-ui persists to match what
|
||||||
|
// xray-core actually consumes. The panel's Zod defaults mirror the full
|
||||||
|
// SplitHTTPConfig / SockoptObject schema, but many fields are mode-specific
|
||||||
|
// (packet-up vs stream-one) or side-specific (inbound vs outbound). Emitting
|
||||||
|
// them anyway bloats configs and — for sockopt — can inject doc-example
|
||||||
|
// values like tcpWindowClamp: 600 that throttle throughput.
|
||||||
|
|
||||||
|
export type StreamWireSide = 'inbound' | 'outbound';
|
||||||
|
|
||||||
|
const PACKET_UP_FIELDS = [
|
||||||
|
'scMaxEachPostBytes',
|
||||||
|
'scMinPostsIntervalMs',
|
||||||
|
'scMaxBufferedPosts',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
|
||||||
|
|
||||||
|
const PLACEMENT_STRING_FIELDS = [
|
||||||
|
'sessionPlacement',
|
||||||
|
'sessionKey',
|
||||||
|
'seqPlacement',
|
||||||
|
'seqKey',
|
||||||
|
'uplinkDataPlacement',
|
||||||
|
'uplinkDataKey',
|
||||||
|
'uplinkHTTPMethod',
|
||||||
|
'xPaddingKey',
|
||||||
|
'xPaddingHeader',
|
||||||
|
'xPaddingPlacement',
|
||||||
|
'xPaddingMethod',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||||
|
return v != null && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyString(v: unknown): v is string {
|
||||||
|
return typeof v === 'string' && v.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulHeaders(headers: unknown): boolean {
|
||||||
|
return isRecord(headers) && Object.keys(headers).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates REALITY inbound `target` / `dest` (must include a port). */
|
||||||
|
export function validateRealityTarget(target: string): string | undefined {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return 'pages.inbounds.form.realityTargetRequired';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unix socket destinations (rare, but valid in xray-core).
|
||||||
|
if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure port → localhost:port in xray-core.
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
const port = Number(trimmed);
|
||||||
|
if (port >= 1 && port <= 65535) return undefined;
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastColon = trimmed.lastIndexOf(':');
|
||||||
|
if (lastColon <= 0 || lastColon === trimmed.length - 1) {
|
||||||
|
return 'pages.inbounds.form.realityTargetNeedsPort';
|
||||||
|
}
|
||||||
|
|
||||||
|
const portPart = trimmed.slice(lastColon + 1);
|
||||||
|
if (!/^\d+$/.test(portPart)) {
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
const port = Number(portPart);
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
const v = obj[key];
|
||||||
|
if (v === '' || v == null) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] === false) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] === 0) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeXhttpForWire(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
side: StreamWireSide,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...raw };
|
||||||
|
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
|
||||||
|
|
||||||
|
delete out.enableXmux;
|
||||||
|
|
||||||
|
if (side === 'inbound') {
|
||||||
|
delete out.xmux;
|
||||||
|
delete out.scMinPostsIntervalMs;
|
||||||
|
delete out.uplinkChunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
|
||||||
|
|
||||||
|
if (!hasMeaningfulHeaders(out.headers)) {
|
||||||
|
delete out.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.xPaddingObfsMode !== true) {
|
||||||
|
delete out.xPaddingObfsMode;
|
||||||
|
dropEmptyStrings(out, [
|
||||||
|
'xPaddingKey',
|
||||||
|
'xPaddingHeader',
|
||||||
|
'xPaddingPlacement',
|
||||||
|
'xPaddingMethod',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
|
||||||
|
if (out.noSSEHeader !== true) delete out.noSSEHeader;
|
||||||
|
if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
|
||||||
|
if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
|
||||||
|
|
||||||
|
if (mode === 'stream-one') {
|
||||||
|
for (const key of PACKET_UP_FIELDS) delete out[key];
|
||||||
|
for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
|
||||||
|
} else if (mode === 'stream-up') {
|
||||||
|
for (const key of PACKET_UP_FIELDS) delete out[key];
|
||||||
|
if (side === 'outbound') {
|
||||||
|
delete out.scStreamUpServerSecs;
|
||||||
|
}
|
||||||
|
} else if (mode === 'packet-up') {
|
||||||
|
delete out.scStreamUpServerSecs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSockoptForWire(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const out: Record<string, unknown> = { ...raw };
|
||||||
|
|
||||||
|
dropZeroNumbers(out, [
|
||||||
|
'tcpWindowClamp',
|
||||||
|
'tcpMaxSeg',
|
||||||
|
'tcpUserTimeout',
|
||||||
|
'tcpKeepAliveIdle',
|
||||||
|
'tcpKeepAliveInterval',
|
||||||
|
'mark',
|
||||||
|
]);
|
||||||
|
|
||||||
|
dropFalseFlags(out, [
|
||||||
|
'acceptProxyProtocol',
|
||||||
|
'tcpFastOpen',
|
||||||
|
'tcpMptcp',
|
||||||
|
'penetrate',
|
||||||
|
'V6Only',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (out.tproxy === 'off') delete out.tproxy;
|
||||||
|
if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
|
||||||
|
if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
|
||||||
|
if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
|
||||||
|
if (nonEmptyString(out.interface) === false) delete out.interface;
|
||||||
|
if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
|
||||||
|
delete out.trustedXForwardedFor;
|
||||||
|
}
|
||||||
|
if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
|
||||||
|
delete out.customSockopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const he = out.happyEyeballs;
|
||||||
|
if (isRecord(he)) {
|
||||||
|
const heOut: Record<string, unknown> = { ...he };
|
||||||
|
if (heOut.tryDelayMs === 0) delete heOut.tryDelayMs;
|
||||||
|
if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
|
||||||
|
if (heOut.interleave === 1) delete heOut.interleave;
|
||||||
|
if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
|
||||||
|
if (Object.keys(heOut).length === 0) {
|
||||||
|
delete out.happyEyeballs;
|
||||||
|
} else {
|
||||||
|
out.happyEyeballs = heOut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
|
||||||
|
|
||||||
|
if (Object.keys(out).length === 0) return undefined;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStreamSettingsForWire(
|
||||||
|
stream: Record<string, unknown>,
|
||||||
|
opts: { side: StreamWireSide },
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...stream };
|
||||||
|
|
||||||
|
const xhttp = out.xhttpSettings;
|
||||||
|
if (isRecord(xhttp)) {
|
||||||
|
out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockopt = out.sockopt;
|
||||||
|
if (isRecord(sockopt)) {
|
||||||
|
const normalized = normalizeSockoptForWire(sockopt);
|
||||||
|
if (normalized) {
|
||||||
|
out.sockopt = normalized;
|
||||||
|
} else {
|
||||||
|
delete out.sockopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ export type DBInboundInit = Partial<{
|
|||||||
sniffing: RawJsonField;
|
sniffing: RawJsonField;
|
||||||
clientStats: ClientStats[];
|
clientStats: ClientStats[];
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
|
originNodeGuid: string;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export class DBInbound {
|
|||||||
sniffing: RawJsonField;
|
sniffing: RawJsonField;
|
||||||
clientStats: ClientStats[];
|
clientStats: ClientStats[];
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
|
originNodeGuid: string;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
|
|
||||||
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
||||||
@@ -108,6 +110,7 @@ export class DBInbound {
|
|||||||
this.sniffing = "";
|
this.sniffing = "";
|
||||||
this.clientStats = [];
|
this.clientStats = [];
|
||||||
this.nodeId = null;
|
this.nodeId = null;
|
||||||
|
this.originNodeGuid = "";
|
||||||
this.fallbackParent = null;
|
this.fallbackParent = null;
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default function ApiDocsPage() {
|
|||||||
docExpansion="list"
|
docExpansion="list"
|
||||||
deepLinking={false}
|
deepLinking={false}
|
||||||
tryItOutEnabled
|
tryItOutEnabled
|
||||||
|
persistAuthorization
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface Endpoint {
|
|||||||
response?: string;
|
response?: string;
|
||||||
errorResponse?: string;
|
errorResponse?: string;
|
||||||
errorStatus?: number;
|
errorStatus?: number;
|
||||||
|
responseSchema?: string;
|
||||||
|
responseSchemaArray?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionHeader {
|
export interface SubscriptionHeader {
|
||||||
@@ -107,22 +109,22 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/inbounds/list',
|
path: '/panel/api/inbounds/list',
|
||||||
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
|
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
|
||||||
response:
|
responseSchema: 'Inbound',
|
||||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}',
|
responseSchemaArray: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/inbounds/list/slim',
|
path: '/panel/api/inbounds/list/slim',
|
||||||
summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).',
|
summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).',
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}',
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/inbounds/options',
|
path: '/panel/api/inbounds/options',
|
||||||
summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
|
summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
|
||||||
response:
|
responseSchema: 'InboundOption',
|
||||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "protocol": "vless",\n "port": 443,\n "tlsFlowCapable": true\n }\n ]\n}',
|
responseSchemaArray: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -307,6 +309,11 @@ export const sections: readonly Section[] = [
|
|||||||
path: '/panel/api/server/getDb',
|
path: '/panel/api/server/getDb',
|
||||||
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
|
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/getMigration',
|
||||||
|
summary: 'Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/server/getNewUUID',
|
path: '/panel/api/server/getNewUUID',
|
||||||
@@ -319,6 +326,12 @@ export const sections: readonly Section[] = [
|
|||||||
summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
|
summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/descendants',
|
||||||
|
summary: 'Read-only summaries (guid, parentGuid, name, address, status, versions) of the nodes this panel manages. A parent panel calls it on a node (via the node API token) to surface transitive sub-nodes in a chained topology. Counts are computed by the parent, not returned here.',
|
||||||
|
response: '{\n "success": true,\n "obj": [\n {\n "guid": "c3d4-...",\n "parentGuid": "a1b2-...",\n "name": "Node3",\n "address": "10.0.0.3",\n "status": "online"\n }\n ]\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/server/getNewX25519Cert',
|
path: '/panel/api/server/getNewX25519Cert',
|
||||||
@@ -677,15 +690,15 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/onlinesByNode',
|
path: '/panel/api/clients/onlinesByGuid',
|
||||||
summary: 'Online client emails grouped by the node that reported them. The local panel uses key "0"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.',
|
summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "0": ["user1"],\n "3": ["user1", "user2"]\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["user1"],\n "c3d4-...": ["user1", "user2"]\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/activeInbounds',
|
path: '/panel/api/clients/activeInbounds',
|
||||||
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key "0"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
|
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "0": ["inbound-443", "inbound-8443"]\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["in-443-tcp", "in-8443-tcp"]\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -700,7 +713,7 @@ export const sections: readonly Section[] = [
|
|||||||
params: [
|
params: [
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||||
],
|
],
|
||||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
responseSchema: 'ClientTraffic',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -737,7 +750,8 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/nodes/list',
|
path: '/panel/api/nodes/list',
|
||||||
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false,\n "status": "online",\n "lastHeartbeat": 1700000000,\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 23.5,\n "memPct": 45.1,\n "uptimeSecs": 86400,\n "lastError": "",\n "inboundCount": 5,\n "clientCount": 27,\n "onlineCount": 3,\n "depletedCount": 1,\n "createdAt": 1700000000,\n "updatedAt": 1700000000\n }\n ]\n}',
|
responseSchema: 'Node',
|
||||||
|
responseSchemaArray: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -794,7 +808,7 @@ export const sections: readonly Section[] = [
|
|||||||
path: '/panel/api/nodes/test',
|
path: '/panel/api/nodes/test',
|
||||||
summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
|
summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
|
||||||
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||||
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}',
|
responseSchema: 'ProbeResultUI',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -903,28 +917,28 @@ export const sections: readonly Section[] = [
|
|||||||
id: 'settings',
|
id: 'settings',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
description:
|
description:
|
||||||
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
|
'Panel configuration and user credentials. All endpoints live under /panel/api/setting and require a logged-in session or Bearer token.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/all',
|
path: '/panel/api/setting/all',
|
||||||
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
|
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/defaultSettings',
|
path: '/panel/api/setting/defaultSettings',
|
||||||
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/update',
|
path: '/panel/api/setting/update',
|
||||||
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
|
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
|
||||||
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
|
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/updateUser',
|
path: '/panel/api/setting/updateUser',
|
||||||
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
|
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
|
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
|
||||||
@@ -936,12 +950,12 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/restartPanel',
|
path: '/panel/api/setting/restartPanel',
|
||||||
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/setting/getDefaultJsonConfig',
|
path: '/panel/api/setting/getDefaultJsonConfig',
|
||||||
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
|
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -951,28 +965,28 @@ export const sections: readonly Section[] = [
|
|||||||
id: 'api-tokens',
|
id: 'api-tokens',
|
||||||
title: 'API Tokens',
|
title: 'API Tokens',
|
||||||
description:
|
description:
|
||||||
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request.',
|
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request — the token is a full-admin credential.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/setting/apiTokens',
|
path: '/panel/api/setting/apiTokens',
|
||||||
summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
|
summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
|
||||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/apiTokens/create',
|
path: '/panel/api/setting/apiTokens/create',
|
||||||
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
|
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
||||||
],
|
],
|
||||||
body: '{\n "name": "central-panel-a"\n}',
|
body: '{\n "name": "central-panel-a"\n}',
|
||||||
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}',
|
responseSchema: 'ApiTokenView',
|
||||||
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
|
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/apiTokens/delete/:id',
|
path: '/panel/api/setting/apiTokens/delete/:id',
|
||||||
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
|
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||||
@@ -981,7 +995,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/apiTokens/setEnabled/:id',
|
path: '/panel/api/setting/apiTokens/setEnabled/:id',
|
||||||
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
|
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||||
@@ -997,32 +1011,32 @@ export const sections: readonly Section[] = [
|
|||||||
id: 'xray-settings',
|
id: 'xray-settings',
|
||||||
title: 'Xray Settings',
|
title: 'Xray Settings',
|
||||||
description:
|
description:
|
||||||
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
|
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/',
|
path: '/panel/api/xray/',
|
||||||
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
|
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"in-443-tcp\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/xray/getDefaultJsonConfig',
|
path: '/panel/api/xray/getDefaultJsonConfig',
|
||||||
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).',
|
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/api/setting/getDefaultJsonConfig).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/xray/getOutboundsTraffic',
|
path: '/panel/api/xray/getOutboundsTraffic',
|
||||||
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
|
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/xray/getXrayResult',
|
path: '/panel/api/xray/getXrayResult',
|
||||||
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/update',
|
path: '/panel/api/xray/update',
|
||||||
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
|
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
|
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
|
||||||
@@ -1031,7 +1045,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/warp/:action',
|
path: '/panel/api/xray/warp/:action',
|
||||||
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
|
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
|
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
|
||||||
@@ -1042,7 +1056,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/nord/:action',
|
path: '/panel/api/xray/nord/:action',
|
||||||
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
|
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
|
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
|
||||||
@@ -1053,7 +1067,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/resetOutboundsTraffic',
|
path: '/panel/api/xray/resetOutboundsTraffic',
|
||||||
summary: 'Reset traffic counters for a specific outbound by tag.',
|
summary: 'Reset traffic counters for a specific outbound by tag.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
|
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
|
||||||
@@ -1062,7 +1076,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/testOutbound',
|
path: '/panel/api/xray/testOutbound',
|
||||||
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
|
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
|
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
|
||||||
@@ -1109,7 +1123,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.' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ export default function ClientBulkAddModal({
|
|||||||
[form.inboundIds, flowCapableIds],
|
[form.inboundIds, flowCapableIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ss2022Method = useMemo(() => {
|
||||||
|
for (const id of form.inboundIds || []) {
|
||||||
|
const ib = (inbounds || []).find((row) => row.id === id);
|
||||||
|
const method = ib?.ssMethod;
|
||||||
|
if (method && method.substring(0, 4) === '2022') return method;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [form.inboundIds, inbounds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showFlow && form.flow) {
|
if (!showFlow && form.flow) {
|
||||||
|
|
||||||
@@ -153,7 +162,9 @@ export default function ClientBulkAddModal({
|
|||||||
email,
|
email,
|
||||||
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
||||||
id: RandomUtil.randomUUID(),
|
id: RandomUtil.randomUUID(),
|
||||||
password: RandomUtil.randomLowerAndNum(16),
|
password: ss2022Method
|
||||||
|
? RandomUtil.randomShadowsocksPassword(ss2022Method)
|
||||||
|
: RandomUtil.randomLowerAndNum(16),
|
||||||
auth: RandomUtil.randomLowerAndNum(16),
|
auth: RandomUtil.randomLowerAndNum(16),
|
||||||
flow: showFlow ? (form.flow || '') : '',
|
flow: showFlow ? (form.flow || '') : '',
|
||||||
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
|
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
|
||||||
@@ -249,7 +260,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,21 @@ export default function ClientFormModal({
|
|||||||
return ids;
|
return ids;
|
||||||
}, [inbounds]);
|
}, [inbounds]);
|
||||||
|
|
||||||
|
const ss2022Method = useMemo(() => {
|
||||||
|
for (const id of form.inboundIds || []) {
|
||||||
|
const ib = (inbounds || []).find((row) => row.id === id);
|
||||||
|
const method = ib?.ssMethod;
|
||||||
|
if (method && method.substring(0, 4) === '2022') return method;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [form.inboundIds, inbounds]);
|
||||||
|
|
||||||
|
function regeneratePassword() {
|
||||||
|
update('password', ss2022Method
|
||||||
|
? RandomUtil.randomShadowsocksPassword(ss2022Method)
|
||||||
|
: RandomUtil.randomLowerAndNum(16));
|
||||||
|
}
|
||||||
|
|
||||||
const showFlow = useMemo(
|
const showFlow = useMemo(
|
||||||
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
|
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
|
||||||
[form.inboundIds, flowCapableIds],
|
[form.inboundIds, flowCapableIds],
|
||||||
@@ -257,6 +272,15 @@ export default function ClientFormModal({
|
|||||||
}
|
}
|
||||||
}, [showReverseTag, form.reverseTag]);
|
}, [showReverseTag, form.reverseTag]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ss2022Method) return;
|
||||||
|
setForm((prev) => (
|
||||||
|
RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method)
|
||||||
|
? prev
|
||||||
|
: { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) }
|
||||||
|
));
|
||||||
|
}, [ss2022Method]);
|
||||||
|
|
||||||
const inboundOptions = useMemo(
|
const inboundOptions = useMemo(
|
||||||
() => (inbounds || [])
|
() => (inbounds || [])
|
||||||
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
||||||
@@ -433,7 +457,7 @@ export default function ClientFormModal({
|
|||||||
<Form.Item label={t('pages.clients.password')}>
|
<Form.Item label={t('pages.clients.password')}>
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('password', RandomUtil.randomLowerAndNum(16))} />
|
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -875,6 +875,7 @@ export default function InboundFormModal({
|
|||||||
colon={false}
|
colon={false}
|
||||||
labelCol={{ sm: { span: 8 } }}
|
labelCol={{ sm: { span: 8 } }}
|
||||||
wrapperCol={{ sm: { span: 14 } }}
|
wrapperCol={{ sm: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
onValuesChange={onValuesChange}
|
onValuesChange={onValuesChange}
|
||||||
>
|
>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
|||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||||
|
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||||
|
|
||||||
interface RealityFormProps {
|
interface RealityFormProps {
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
@@ -44,10 +45,24 @@ export default function RealityForm({
|
|||||||
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('pages.inbounds.form.target')}>
|
<Form.Item
|
||||||
|
label={t('pages.inbounds.form.target')}
|
||||||
|
extra={t('pages.inbounds.form.realityTargetHint')}
|
||||||
|
>
|
||||||
<Space.Compact block>
|
<Space.Compact block>
|
||||||
<Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
|
<Form.Item
|
||||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
name={['streamSettings', 'realitySettings', 'target']}
|
||||||
|
noStyle
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: async (_, value) => {
|
||||||
|
const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
|
||||||
|
if (errKey) throw new Error(t(errKey));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function SockoptForm({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||||
|
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} />
|
<InputNumber min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
|||||||
// node's own paths (fetched through the central panel), not this panel's.
|
// node's own paths (fetched through the central panel), not this panel's.
|
||||||
const msg = typeof nodeId === 'number'
|
const msg = typeof nodeId === 'number'
|
||||||
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
||||||
: await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
|
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
if (!msg?.success) {
|
if (!msg?.success) {
|
||||||
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -58,13 +58,14 @@ async function fetchOnlineClients(): Promise<string[]> {
|
|||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Online emails grouped by node id (local panel = key 0), used to scope the
|
// Online emails grouped by the panelGuid of the node that physically hosts each
|
||||||
// per-inbound online rollup so a client online on one node is not shown
|
// client, used to scope the per-inbound online rollup so a client online on one
|
||||||
// online on every node's inbounds.
|
// node is not shown online on every node's inbounds — and a client on a
|
||||||
async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> {
|
// sub-node is attributed to that sub-node, not the node it syncs through (#4983).
|
||||||
const msg = await HttpUtil.post('/panel/api/clients/onlinesByNode', undefined, { silent: true });
|
async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByNode');
|
const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
|
||||||
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByNode');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
|
||||||
|
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
|
||||||
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
|
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +80,11 @@ async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
|
|||||||
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
|
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNodeOnlineMap(data: Record<string, string[]>): Map<number, Set<string>> {
|
function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
|
||||||
const map = new Map<number, Set<string>>();
|
const map = new Map<string, Set<string>>();
|
||||||
for (const [key, emails] of Object.entries(data)) {
|
for (const [key, emails] of Object.entries(data)) {
|
||||||
if (!Array.isArray(emails)) continue;
|
if (!Array.isArray(emails)) continue;
|
||||||
map.set(Number(key), new Set(emails));
|
map.set(key, new Set(emails));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
|
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
|
||||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
||||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||||
return validated.obj ?? {};
|
return validated.obj ?? {};
|
||||||
@@ -117,9 +118,9 @@ export function useInbounds() {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onlinesByNodeQuery = useQuery({
|
const onlinesByGuidQuery = useQuery({
|
||||||
queryKey: keys.clients.onlinesByNode(),
|
queryKey: keys.clients.onlinesByGuid(),
|
||||||
queryFn: fetchOnlineClientsByNode,
|
queryFn: fetchOnlineClientsByGuid,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,16 +183,17 @@ export function useInbounds() {
|
|||||||
const onlineClientsRef = useRef<string[]>([]);
|
const onlineClientsRef = useRef<string[]>([]);
|
||||||
onlineClientsRef.current = onlineClients;
|
onlineClientsRef.current = onlineClients;
|
||||||
|
|
||||||
// Online emails keyed by node id (local inbounds = key 0). The rollup
|
// Online emails keyed by the hosting node's panelGuid. The rollup reads this
|
||||||
// reads this so each inbound only counts clients online on its own node.
|
// so each inbound only counts clients online on the node that physically
|
||||||
const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map());
|
// hosts it, attributing a sub-node's clients to that sub-node (#4983).
|
||||||
|
const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
// Recently-active inbound tags keyed by node id. A node missing from this
|
// Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
|
||||||
// map means "no per-inbound activity reported" (e.g. remote nodes), so the
|
// missing from this map means "no per-inbound activity reported" (e.g. remote
|
||||||
// rollup leaves that node's inbounds ungated and falls back to the email
|
// nodes), so the rollup leaves that node's inbounds ungated and falls back to
|
||||||
// signal. A present node gates: a client only counts online on an inbound
|
// the email signal. A present GUID gates: a client only counts online on an
|
||||||
// whose tag carried traffic this window.
|
// inbound whose tag carried traffic this window.
|
||||||
const activeByNodeRef = useRef<Map<number, Set<string>>>(new Map());
|
const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
|
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
@@ -209,13 +211,17 @@ export function useInbounds() {
|
|||||||
const comments = new Map<string, string>();
|
const comments = new Map<string, string>();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const nodeId = dbInbound.nodeId ?? 0;
|
// Attribution key: the GUID of the node that physically hosts this
|
||||||
const nodeOnline = onlineByNodeRef.current.get(nodeId);
|
// inbound. Local inbounds carry the panel's own GUID (filled server-side);
|
||||||
|
// a node-managed inbound carries its origin node's GUID, or falls back to
|
||||||
|
// the master-local synthetic id for an old-build node without one (#4983).
|
||||||
|
const guid = dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
|
||||||
|
const nodeOnline = onlineByGuidRef.current.get(guid);
|
||||||
// A node absent from the active map reports no per-inbound activity, so
|
// A node absent from the active map reports no per-inbound activity, so
|
||||||
// leave its inbounds ungated. When present, only mark a client online on
|
// leave its inbounds ungated. When present, only mark a client online on
|
||||||
// this inbound if its tag actually carried traffic — that's what stops a
|
// this inbound if its tag actually carried traffic — that's what stops a
|
||||||
// multi-inbound client lighting up every inbound it's attached to.
|
// multi-inbound client lighting up every inbound it's attached to.
|
||||||
const activeForNode = activeByNodeRef.current.get(nodeId);
|
const activeForNode = activeByGuidRef.current.get(guid);
|
||||||
const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
|
const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
|
||||||
|
|
||||||
if (dbInbound.enable) {
|
if (dbInbound.enable) {
|
||||||
@@ -305,15 +311,15 @@ export function useInbounds() {
|
|||||||
}, [onlinesQuery.data]);
|
}, [onlinesQuery.data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onlinesByNodeQuery.data) {
|
if (onlinesByGuidQuery.data) {
|
||||||
onlineByNodeRef.current = toNodeOnlineMap(onlinesByNodeQuery.data);
|
onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
|
||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
}
|
}
|
||||||
}, [onlinesByNodeQuery.data, rebuildClientCount]);
|
}, [onlinesByGuidQuery.data, rebuildClientCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeInboundsQuery.data) {
|
if (activeInboundsQuery.data) {
|
||||||
activeByNodeRef.current = toNodeOnlineMap(activeInboundsQuery.data);
|
activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
|
||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
}
|
}
|
||||||
}, [activeInboundsQuery.data, rebuildClientCount]);
|
}, [activeInboundsQuery.data, rebuildClientCount]);
|
||||||
@@ -336,7 +342,7 @@ export function useInbounds() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
||||||
@@ -367,16 +373,16 @@ export function useInbounds() {
|
|||||||
const applyTrafficEvent = useCallback(
|
const applyTrafficEvent = useCallback(
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
if (!payload || typeof payload !== 'object') return;
|
if (!payload || typeof payload !== 'object') return;
|
||||||
const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
|
const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
|
||||||
if (Array.isArray(p.onlineClients)) {
|
if (Array.isArray(p.onlineClients)) {
|
||||||
onlineClientsRef.current = p.onlineClients;
|
onlineClientsRef.current = p.onlineClients;
|
||||||
setOnlineClients(p.onlineClients);
|
setOnlineClients(p.onlineClients);
|
||||||
}
|
}
|
||||||
if (p.onlineByNode && typeof p.onlineByNode === 'object') {
|
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
||||||
onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode);
|
onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
|
||||||
}
|
}
|
||||||
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
||||||
activeByNodeRef.current = toNodeOnlineMap(p.activeInbounds);
|
activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
|
||||||
}
|
}
|
||||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
|||||||
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
|
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportMigration() {
|
||||||
|
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getMigration';
|
||||||
|
}
|
||||||
|
|
||||||
function importDb() {
|
function importDb() {
|
||||||
const fileInput = document.createElement('input');
|
const fileInput = document.createElement('input');
|
||||||
fileInput.type = 'file';
|
fileInput.type = 'file';
|
||||||
@@ -48,7 +52,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
|||||||
}
|
}
|
||||||
|
|
||||||
onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
|
onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
|
||||||
const restart = await HttpUtil.post('/panel/setting/restartPanel');
|
const restart = await HttpUtil.post('/panel/api/setting/restartPanel');
|
||||||
if (restart?.success) {
|
if (restart?.success) {
|
||||||
await PromiseUtil.sleep(5000);
|
await PromiseUtil.sleep(5000);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -82,6 +86,16 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
|||||||
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
|
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-item">
|
||||||
|
<div className="backup-meta">
|
||||||
|
<div className="backup-title">{t('pages.index.migrationDownload')}</div>
|
||||||
|
<div className="backup-description">
|
||||||
|
{isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="backup-item">
|
<div className="backup-item">
|
||||||
<div className="backup-meta">
|
<div className="backup-meta">
|
||||||
<div className="backup-title">{t('pages.index.importDatabase')}</div>
|
<div className="backup-title">{t('pages.index.importDatabase')}</div>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function IndexPage() {
|
|||||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/setting/defaultSettings').then((msg) => {
|
HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
|
||||||
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
|
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
|
||||||
});
|
});
|
||||||
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import type { BadgeProps } from 'antd';
|
import type { BadgeProps } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
|
ApartmentOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CloudDownloadOutlined,
|
CloudDownloadOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
@@ -56,7 +57,7 @@ function isUpdateEligible(n: NodeRecord): boolean {
|
|||||||
|
|
||||||
interface NodeRow extends NodeRecord {
|
interface NodeRow extends NodeRecord {
|
||||||
url: string;
|
url: string;
|
||||||
key: number;
|
key: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeStatus(status?: string): BadgeProps['status'] {
|
function badgeStatus(status?: string): BadgeProps['status'] {
|
||||||
@@ -131,14 +132,49 @@ export default function NodeList({
|
|||||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
const dataSource = useMemo<NodeRow[]>(
|
// Map a node GUID to its display name so a transitive sub-node can show which
|
||||||
() => nodes.map((n) => ({
|
// parent it is reached through (#4983).
|
||||||
|
const nameByGuid = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
||||||
|
return m;
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
// Order direct nodes first, each immediately followed by its transitive
|
||||||
|
// sub-nodes, so the table reads as a parent -> child tree without colliding
|
||||||
|
// with the per-row history expander (transitive nodes carry id 0).
|
||||||
|
const dataSource = useMemo<NodeRow[]>(() => {
|
||||||
|
const toRow = (n: NodeRecord): NodeRow => ({
|
||||||
...n,
|
...n,
|
||||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||||
key: n.id,
|
key: n.transitive ? `t-${n.guid || ''}` : n.id,
|
||||||
})),
|
});
|
||||||
[nodes],
|
const childrenByParent = new Map<string, NodeRecord[]>();
|
||||||
);
|
for (const n of nodes) {
|
||||||
|
if (n.transitive && n.parentGuid) {
|
||||||
|
const arr = childrenByParent.get(n.parentGuid) || [];
|
||||||
|
arr.push(n);
|
||||||
|
childrenByParent.set(n.parentGuid, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordered: NodeRow[] = [];
|
||||||
|
const added = new Set<string>();
|
||||||
|
const push = (n: NodeRecord) => {
|
||||||
|
const row = toRow(n);
|
||||||
|
ordered.push(row);
|
||||||
|
added.add(String(row.key));
|
||||||
|
};
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.transitive) continue;
|
||||||
|
push(n);
|
||||||
|
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
|
||||||
|
}
|
||||||
|
// Transitive nodes whose parent isn't in the list still get shown.
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
function toggleExpanded(id: number) {
|
function toggleExpanded(id: number) {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
@@ -153,7 +189,11 @@ export default function NodeList({
|
|||||||
title: t('pages.nodes.actions'),
|
title: t('pages.nodes.actions'),
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 190,
|
width: 190,
|
||||||
render: (_value, record) => (
|
render: (_value, record) => record.transitive ? (
|
||||||
|
<Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
|
||||||
|
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title={t('pages.nodes.probe')}>
|
<Tooltip title={t('pages.nodes.probe')}>
|
||||||
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||||
@@ -177,7 +217,9 @@ export default function NodeList({
|
|||||||
dataIndex: 'enable',
|
dataIndex: 'enable',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_value, record) => (
|
render: (_value, record) => record.transitive ? (
|
||||||
|
<span style={{ opacity: 0.4 }}>—</span>
|
||||||
|
) : (
|
||||||
<Switch
|
<Switch
|
||||||
checked={!!record.enable}
|
checked={!!record.enable}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -190,8 +232,11 @@ export default function NodeList({
|
|||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (_value, record) => (
|
render: (_value, record) => (
|
||||||
<div className="name-cell">
|
<div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
|
||||||
<span className="name">{record.name}</span>
|
<span className="name">
|
||||||
|
{record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
|
||||||
|
{record.name}
|
||||||
|
</span>
|
||||||
{record.remark && <span className="remark">{record.remark}</span>}
|
{record.remark && <span className="remark">{record.remark}</span>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -316,7 +361,7 @@ export default function NodeList({
|
|||||||
width: 120,
|
width: 120,
|
||||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||||
},
|
},
|
||||||
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
|
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size="small" hoverable>
|
<Card size="small" hoverable>
|
||||||
@@ -340,7 +385,18 @@ export default function NodeList({
|
|||||||
<div>{t('noData')}</div>
|
<div>{t('noData')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dataSource.map((record) => (
|
dataSource.map((record) => record.transitive ? (
|
||||||
|
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
|
||||||
|
<div className="card-head">
|
||||||
|
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
||||||
|
<StatusDot status={record.status} />
|
||||||
|
<span className="node-name">{record.name}</span>
|
||||||
|
<div className="card-actions">
|
||||||
|
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div key={record.id} className="node-card">
|
<div key={record.id} className="node-card">
|
||||||
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
||||||
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
||||||
@@ -501,8 +557,8 @@ export default function NodeList({
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
rowSelection={dataSource.length > 1 ? {
|
rowSelection={dataSource.length > 1 ? {
|
||||||
selectedRowKeys: selectedIds,
|
selectedRowKeys: selectedIds,
|
||||||
onChange: (keys) => onSelectionChange(keys as number[]),
|
onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||||
getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
|
getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
|
||||||
} : undefined}
|
} : undefined}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: (
|
||||||
@@ -514,6 +570,7 @@ export default function NodeList({
|
|||||||
}}
|
}}
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||||
|
rowExpandable: (record) => !record.transitive,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
const sendUpdateUser = useCallback(async () => {
|
const sendUpdateUser = useCallback(async () => {
|
||||||
setUpdating(true);
|
setUpdating(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/updateUser', user) as ApiMsg;
|
const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg;
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
await HttpUtil.post('/logout');
|
await HttpUtil.post('/logout');
|
||||||
const basePath = window.X_UI_BASE_PATH || '/';
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
@@ -124,7 +124,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
const loadApiTokens = useCallback(async () => {
|
const loadApiTokens = useCallback(async () => {
|
||||||
setApiTokensLoading(true);
|
setApiTokensLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.get('/panel/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
|
const msg = await HttpUtil.get('/panel/api/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
|
||||||
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
|
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
|
||||||
} finally {
|
} finally {
|
||||||
setApiTokensLoading(false);
|
setApiTokensLoading(false);
|
||||||
@@ -156,7 +156,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
}
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
|
const msg = await HttpUtil.post('/panel/api/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
await loadApiTokens();
|
await loadApiTokens();
|
||||||
@@ -178,7 +178,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`) as ApiMsg;
|
const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`) as ApiMsg;
|
||||||
if (msg?.success) await loadApiTokens();
|
if (msg?.success) await loadApiTokens();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -186,7 +186,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
|
|
||||||
async function toggleTokenEnabled(row: ApiTokenRow) {
|
async function toggleTokenEnabled(row: ApiTokenRow) {
|
||||||
const target = !row.enabled;
|
const target = !row.enabled;
|
||||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
|
const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
|
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function SettingsPage() {
|
|||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
setSpinning(true);
|
setSpinning(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/restartPanel') as ApiMsg;
|
const msg = await HttpUtil.post('/panel/api/setting/restartPanel') as ApiMsg;
|
||||||
if (!msg?.success) return;
|
if (!msg?.success) return;
|
||||||
await PromiseUtil.sleep(5000);
|
await PromiseUtil.sleep(5000);
|
||||||
window.location.replace(rebuildUrlAfterRestart());
|
window.location.replace(rebuildUrlAfterRestart());
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function SubPage() {
|
|||||||
if (!subUrl) return '';
|
if (!subUrl) return '';
|
||||||
const separator = subUrl.includes('?') ? '&' : '?';
|
const separator = subUrl.includes('?') ? '&' : '?';
|
||||||
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
||||||
const base64Url = btoa(rawUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
const base64Url = btoa(rawUrl);
|
||||||
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
||||||
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
|
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
||||||
|
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||||
@@ -84,6 +85,49 @@ export default function BasicsTab({
|
|||||||
| { domainStrategy?: string }
|
| { domainStrategy?: string }
|
||||||
| undefined)?.domainStrategy ?? 'AsIs';
|
| undefined)?.domainStrategy ?? 'AsIs';
|
||||||
|
|
||||||
|
const directFreedomOutbound = templateSettings?.outbounds?.find(
|
||||||
|
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
|
||||||
|
);
|
||||||
|
const directHappyEyeballs = (() => {
|
||||||
|
const sockopt = (directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined)
|
||||||
|
?.sockopt;
|
||||||
|
const raw = sockopt?.happyEyeballs;
|
||||||
|
if (raw == null || typeof raw !== 'object') return null;
|
||||||
|
return HappyEyeballsSchema.parse(raw);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const setDirectHappyEyeballs = useCallback(
|
||||||
|
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
|
||||||
|
mutate((tt) => {
|
||||||
|
if (!tt.outbounds) tt.outbounds = [];
|
||||||
|
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
|
||||||
|
if (idx < 0) {
|
||||||
|
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
|
||||||
|
idx = tt.outbounds.length - 1;
|
||||||
|
}
|
||||||
|
const ob = tt.outbounds[idx];
|
||||||
|
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
|
||||||
|
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
|
||||||
|
if (next == null) {
|
||||||
|
delete sockopt.happyEyeballs;
|
||||||
|
} else {
|
||||||
|
sockopt.happyEyeballs = next;
|
||||||
|
}
|
||||||
|
if (Object.keys(sockopt).length === 0) {
|
||||||
|
delete stream.sockopt;
|
||||||
|
} else {
|
||||||
|
stream.sockopt = sockopt;
|
||||||
|
}
|
||||||
|
if (Object.keys(stream).length === 0) {
|
||||||
|
delete ob.streamSettings;
|
||||||
|
} else {
|
||||||
|
ob.streamSettings = stream;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
|
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
|
||||||
const log = (templateSettings?.log || {}) as Record<string, unknown>;
|
const log = (templateSettings?.log || {}) as Record<string, unknown>;
|
||||||
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
|
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
|
||||||
@@ -124,6 +168,53 @@ export default function BasicsTab({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.xray.FreedomHappyEyeballs')}
|
||||||
|
description={t('pages.xray.FreedomHappyEyeballsDesc')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={directHappyEyeballs != null}
|
||||||
|
onChange={(checked) => {
|
||||||
|
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{directHappyEyeballs != null && (
|
||||||
|
<>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.inbounds.form.tryDelayMs')}
|
||||||
|
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={directHappyEyeballs.tryDelayMs}
|
||||||
|
placeholder="150"
|
||||||
|
onChange={(v) => setDirectHappyEyeballs({
|
||||||
|
...directHappyEyeballs,
|
||||||
|
tryDelayMs: typeof v === 'number' ? v : 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.inbounds.form.prioritizeIPv6')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={directHappyEyeballs.prioritizeIPv6}
|
||||||
|
onChange={(checked) => setDirectHappyEyeballs({
|
||||||
|
...directHappyEyeballs,
|
||||||
|
prioritizeIPv6: checked,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
title={t('pages.xray.RoutingStrategy')}
|
title={t('pages.xray.RoutingStrategy')}
|
||||||
description={t('pages.xray.RoutingStrategyDesc')}
|
description={t('pages.xray.RoutingStrategyDesc')}
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ export default function OutboundFormModal({
|
|||||||
colon={false}
|
colon={false}
|
||||||
labelCol={{ md: { span: 8 } }}
|
labelCol={{ md: { span: 8 } }}
|
||||||
wrapperCol={{ md: { span: 14 } }}
|
wrapperCol={{ md: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
onValuesChange={onValuesChange}
|
onValuesChange={onValuesChange}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export default function SockoptForm({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||||
|
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ export default function NordModal({
|
|||||||
}, [filteredServers]);
|
}, [filteredServers]);
|
||||||
|
|
||||||
const fetchCountries = useCallback(async () => {
|
const fetchCountries = useCallback(async () => {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/countries');
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
|
||||||
if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
|
if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/data');
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/data');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
const next = msg.obj ? JSON.parse(msg.obj) : null;
|
const next = msg.obj ? JSON.parse(msg.obj) : null;
|
||||||
setNordData(next);
|
setNordData(next);
|
||||||
@@ -113,7 +113,7 @@ export default function NordModal({
|
|||||||
async function login() {
|
async function login() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/reg', { token });
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
setNordData(JSON.parse(msg.obj));
|
setNordData(JSON.parse(msg.obj));
|
||||||
await fetchCountries();
|
await fetchCountries();
|
||||||
@@ -126,7 +126,7 @@ export default function NordModal({
|
|||||||
async function saveKey() {
|
async function saveKey() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/setKey', { key: manualKey });
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
setNordData(JSON.parse(msg.obj));
|
setNordData(JSON.parse(msg.obj));
|
||||||
await fetchCountries();
|
await fetchCountries();
|
||||||
@@ -139,7 +139,7 @@ export default function NordModal({
|
|||||||
async function logout() {
|
async function logout() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/xray/nord/del');
|
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
onRemoveOutbound(nordOutboundIndex);
|
onRemoveOutbound(nordOutboundIndex);
|
||||||
onRemoveRoutingRules({ prefix: 'nord-' });
|
onRemoveRoutingRules({ prefix: 'nord-' });
|
||||||
@@ -166,7 +166,7 @@ export default function NordModal({
|
|||||||
setServerId(null);
|
setServerId(null);
|
||||||
setCityId(null);
|
setCityId(null);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/servers', { countryId: newCountryId });
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
|
||||||
if (!msg?.success || !msg.obj) return;
|
if (!msg?.success || !msg.obj) return;
|
||||||
const data = JSON.parse(msg.obj);
|
const data = JSON.parse(msg.obj);
|
||||||
const locations = data.locations || [];
|
const locations = data.locations || [];
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function WarpModal({
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/warp/data');
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/data');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
const raw = msg.obj;
|
const raw = msg.obj;
|
||||||
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
|
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
|
||||||
@@ -133,7 +133,7 @@ export default function WarpModal({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const keys = Wireguard.generateKeypair();
|
const keys = Wireguard.generateKeypair();
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/warp/reg', keys);
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/reg', keys);
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
const resp = JSON.parse(msg.obj);
|
const resp = JSON.parse(msg.obj);
|
||||||
setWarpData(resp.data);
|
setWarpData(resp.data);
|
||||||
@@ -148,7 +148,7 @@ export default function WarpModal({
|
|||||||
async function getConfig() {
|
async function getConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/warp/config');
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/config');
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
const parsed = JSON.parse(msg.obj);
|
const parsed = JSON.parse(msg.obj);
|
||||||
setWarpConfig(parsed);
|
setWarpConfig(parsed);
|
||||||
@@ -164,7 +164,7 @@ export default function WarpModal({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLicenseError('');
|
setLicenseError('');
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/warp/license', { license: warpPlus });
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus });
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
setWarpData(JSON.parse(msg.obj));
|
setWarpData(JSON.parse(msg.obj));
|
||||||
setWarpConfig(null);
|
setWarpConfig(null);
|
||||||
@@ -180,7 +180,7 @@ export default function WarpModal({
|
|||||||
async function delConfig() {
|
async function delConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/xray/warp/del');
|
const msg = await HttpUtil.post('/panel/api/xray/warp/del');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
setWarpData(null);
|
setWarpData(null);
|
||||||
setWarpConfig(null);
|
setWarpConfig(null);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const InboundOptionSchema = z.object({
|
|||||||
protocol: z.string().optional(),
|
protocol: z.string().optional(),
|
||||||
port: z.number().optional(),
|
port: z.number().optional(),
|
||||||
tlsFlowCapable: z.boolean().optional(),
|
tlsFlowCapable: z.boolean().optional(),
|
||||||
|
ssMethod: z.string().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
||||||
@@ -182,7 +183,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(),
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export const NodeRecordSchema = z.object({
|
|||||||
allowPrivateAddress: z.boolean().optional(),
|
allowPrivateAddress: z.boolean().optional(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
|
||||||
pinnedCertSha256: z.string().optional(),
|
pinnedCertSha256: z.string().optional(),
|
||||||
|
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
||||||
|
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
||||||
|
guid: z.string().optional(),
|
||||||
|
parentGuid: z.string().optional(),
|
||||||
|
transitive: z.boolean().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export const NodeListSchema = z.array(NodeRecordSchema);
|
export const NodeListSchema = z.array(NodeRecordSchema);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -57,14 +57,18 @@ export const SockoptStreamSettingsSchema = z.object({
|
|||||||
tcpMptcp: z.boolean().default(false),
|
tcpMptcp: z.boolean().default(false),
|
||||||
penetrate: z.boolean().default(false),
|
penetrate: z.boolean().default(false),
|
||||||
domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
|
domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
|
||||||
tcpMaxSeg: z.number().int().min(0).default(1440),
|
// 0 = omit on the wire; xray-core skips sockopt fields <= 0 and uses OS defaults.
|
||||||
|
// Non-zero defaults here previously came from the xray docs *example* (clamp 600,
|
||||||
|
// maxSeg 1440, userTimeout 10000) and were written into every config when the
|
||||||
|
// panel sockopt switch was enabled, throttling long-haul links.
|
||||||
|
tcpMaxSeg: z.number().int().min(0).default(0),
|
||||||
dialerProxy: z.string().default(''),
|
dialerProxy: z.string().default(''),
|
||||||
tcpKeepAliveInterval: z.number().int().min(0).default(45),
|
tcpKeepAliveInterval: z.number().int().min(0).default(0),
|
||||||
tcpKeepAliveIdle: z.number().int().min(0).default(45),
|
tcpKeepAliveIdle: z.number().int().min(0).default(0),
|
||||||
tcpUserTimeout: z.number().int().min(0).default(10000),
|
tcpUserTimeout: z.number().int().min(0).default(0),
|
||||||
tcpcongestion: TcpCongestionSchema.default('bbr'),
|
tcpcongestion: TcpCongestionSchema.default('bbr'),
|
||||||
V6Only: z.boolean().default(false),
|
V6Only: z.boolean().default(false),
|
||||||
tcpWindowClamp: z.number().int().min(0).default(600),
|
tcpWindowClamp: z.number().int().min(0).default(0),
|
||||||
interface: z.string().default(''),
|
interface: z.string().default(''),
|
||||||
trustedXForwardedFor: z.array(z.string()).default([]),
|
trustedXForwardedFor: z.array(z.string()).default([]),
|
||||||
addressPortStrategy: AddressPortStrategySchema.default('none'),
|
addressPortStrategy: AddressPortStrategySchema.default('none'),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
|
|||||||
"mark": 0,
|
"mark": 0,
|
||||||
"penetrate": false,
|
"penetrate": false,
|
||||||
"tcpFastOpen": false,
|
"tcpFastOpen": false,
|
||||||
"tcpKeepAliveIdle": 45,
|
"tcpKeepAliveIdle": 0,
|
||||||
"tcpKeepAliveInterval": 45,
|
"tcpKeepAliveInterval": 0,
|
||||||
"tcpMaxSeg": 1440,
|
"tcpMaxSeg": 0,
|
||||||
"tcpMptcp": false,
|
"tcpMptcp": false,
|
||||||
"tcpUserTimeout": 10000,
|
"tcpUserTimeout": 0,
|
||||||
"tcpWindowClamp": 600,
|
"tcpWindowClamp": 0,
|
||||||
"tcpcongestion": "bbr",
|
"tcpcongestion": "bbr",
|
||||||
"tproxy": "off",
|
"tproxy": "off",
|
||||||
"trustedXForwardedFor": [],
|
"trustedXForwardedFor": [],
|
||||||
@@ -87,12 +87,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
|
|||||||
"mark": 255,
|
"mark": 255,
|
||||||
"penetrate": true,
|
"penetrate": true,
|
||||||
"tcpFastOpen": false,
|
"tcpFastOpen": false,
|
||||||
"tcpKeepAliveIdle": 45,
|
"tcpKeepAliveIdle": 0,
|
||||||
"tcpKeepAliveInterval": 45,
|
"tcpKeepAliveInterval": 0,
|
||||||
"tcpMaxSeg": 1440,
|
"tcpMaxSeg": 0,
|
||||||
"tcpMptcp": false,
|
"tcpMptcp": false,
|
||||||
"tcpUserTimeout": 10000,
|
"tcpUserTimeout": 0,
|
||||||
"tcpWindowClamp": 600,
|
"tcpWindowClamp": 0,
|
||||||
"tcpcongestion": "bbr",
|
"tcpcongestion": "bbr",
|
||||||
"tproxy": "tproxy",
|
"tproxy": "tproxy",
|
||||||
"trustedXForwardedFor": [],
|
"trustedXForwardedFor": [],
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { ZodType } from 'zod';
|
||||||
|
|
||||||
|
import { EXAMPLES } from '@/generated/examples';
|
||||||
|
import * as zodSchemas from '@/generated/zod';
|
||||||
|
|
||||||
|
const registry = zodSchemas as unknown as Record<string, ZodType>;
|
||||||
|
const names = Object.keys(EXAMPLES);
|
||||||
|
|
||||||
|
describe('generated response examples', () => {
|
||||||
|
it('has at least one example to validate', () => {
|
||||||
|
expect(names.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs every example with a generated zod schema', () => {
|
||||||
|
const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function');
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(names)('EXAMPLES.%s satisfies its generated zod schema', (name) => {
|
||||||
|
const schema = registry[`${name}Schema`];
|
||||||
|
const result = schema.safeParse(EXAMPLES[name]);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(
|
||||||
|
`EXAMPLES.${name} does not match ${name}Schema:\n${JSON.stringify(result.error.issues, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
|
||||||
|
import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
|
||||||
|
import {
|
||||||
|
normalizeSockoptForWire,
|
||||||
|
normalizeStreamSettingsForWire,
|
||||||
|
normalizeXhttpForWire,
|
||||||
|
validateRealityTarget,
|
||||||
|
} from '@/lib/xray/stream-wire-normalize';
|
||||||
|
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||||
|
|
||||||
|
describe('validateRealityTarget', () => {
|
||||||
|
it('accepts host:port and bare port', () => {
|
||||||
|
expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
|
||||||
|
expect(validateRealityTarget('443')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects host without port', () => {
|
||||||
|
expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
|
||||||
|
expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeXhttpForWire stream-one', () => {
|
||||||
|
it('drops packet-up and stream-up-only fields on inbound', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
scMinPostsIntervalMs: '30',
|
||||||
|
scMaxBufferedPosts: 30,
|
||||||
|
scStreamUpServerSecs: '20-80',
|
||||||
|
enableXmux: false,
|
||||||
|
headers: {},
|
||||||
|
}, 'inbound');
|
||||||
|
|
||||||
|
expect(out).toMatchObject({
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
});
|
||||||
|
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
|
||||||
|
expect(out).not.toHaveProperty('scMaxBufferedPosts');
|
||||||
|
expect(out).not.toHaveProperty('scStreamUpServerSecs');
|
||||||
|
expect(out).not.toHaveProperty('enableXmux');
|
||||||
|
expect(out).not.toHaveProperty('headers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps xmux on outbound stream-one', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
xmux: { maxConcurrency: '16-32' },
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
}, 'outbound');
|
||||||
|
|
||||||
|
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
|
||||||
|
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeSockoptForWire', () => {
|
||||||
|
it('omits doc-example defaults that throttle throughput', () => {
|
||||||
|
const out = normalizeSockoptForWire({
|
||||||
|
tcpWindowClamp: 0,
|
||||||
|
tcpMaxSeg: 0,
|
||||||
|
tcpUserTimeout: 0,
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
domainStrategy: 'AsIs',
|
||||||
|
tproxy: 'off',
|
||||||
|
mark: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).toEqual({
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves happyEyeballs on freedom-style outbound', () => {
|
||||||
|
const out = normalizeSockoptForWire({
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
interleave: 1,
|
||||||
|
maxConcurrentTry: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out?.happyEyeballs).toMatchObject({
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
});
|
||||||
|
expect(out?.domainStrategy).toBe('UseIP');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeStreamSettingsForWire reality', () => {
|
||||||
|
it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
|
||||||
|
const out = normalizeStreamSettingsForWire({
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
target: 'play.google.com:443',
|
||||||
|
privateKey: 'priv',
|
||||||
|
serverNames: ['play.google.com'],
|
||||||
|
shortIds: ['abcd'],
|
||||||
|
settings: {
|
||||||
|
publicKey: 'pub',
|
||||||
|
fingerprint: 'chrome',
|
||||||
|
spiderX: '/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, { side: 'inbound' });
|
||||||
|
|
||||||
|
const reality = out.realitySettings as Record<string, unknown>;
|
||||||
|
expect(reality.target).toBe('play.google.com:443');
|
||||||
|
expect(reality.privateKey).toBe('priv');
|
||||||
|
const settings = reality.settings as Record<string, unknown>;
|
||||||
|
expect(settings.publicKey).toBe('pub');
|
||||||
|
expect(settings.spiderX).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes client realitySettings through unchanged on outbound', () => {
|
||||||
|
const out = normalizeStreamSettingsForWire({
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
publicKey: 'pub',
|
||||||
|
fingerprint: 'chrome',
|
||||||
|
serverName: 'play.google.com',
|
||||||
|
shortId: 'abcd',
|
||||||
|
spiderX: '/x',
|
||||||
|
},
|
||||||
|
}, { side: 'outbound' });
|
||||||
|
|
||||||
|
const reality = out.realitySettings as Record<string, unknown>;
|
||||||
|
expect(reality.publicKey).toBe('pub');
|
||||||
|
expect(reality.serverName).toBe('play.google.com');
|
||||||
|
expect(reality.spiderX).toBe('/x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inbound formValuesToWirePayload integration', () => {
|
||||||
|
it('emits lean stream-one xhttp + sockopt on save', () => {
|
||||||
|
const values = {
|
||||||
|
remark: 't',
|
||||||
|
enable: true,
|
||||||
|
port: 443,
|
||||||
|
listen: '0.0.0.0',
|
||||||
|
tag: 'in-443',
|
||||||
|
expiryTime: 0,
|
||||||
|
sniffing: { enabled: false },
|
||||||
|
up: 0,
|
||||||
|
down: 0,
|
||||||
|
total: 0,
|
||||||
|
trafficReset: 'never',
|
||||||
|
lastTrafficResetTime: 0,
|
||||||
|
nodeId: null,
|
||||||
|
protocol: 'vless',
|
||||||
|
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
|
||||||
|
streamSettings: {
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
target: 'play.google.com:443',
|
||||||
|
privateKey: 'priv',
|
||||||
|
serverNames: ['play.google.com'],
|
||||||
|
shortIds: ['44003d86dc1e'],
|
||||||
|
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
|
||||||
|
},
|
||||||
|
xhttpSettings: {
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
scMinPostsIntervalMs: '30',
|
||||||
|
enableXmux: false,
|
||||||
|
},
|
||||||
|
sockopt: {
|
||||||
|
tcpWindowClamp: 0,
|
||||||
|
tcpMaxSeg: 0,
|
||||||
|
tcpUserTimeout: 0,
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as InboundFormValues;
|
||||||
|
|
||||||
|
const payload = formValuesToWirePayload(values);
|
||||||
|
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
|
||||||
|
const xhttp = stream.xhttpSettings as Record<string, unknown>;
|
||||||
|
const sockopt = stream.sockopt as Record<string, unknown>;
|
||||||
|
const reality = stream.realitySettings as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
expect(sockopt).not.toHaveProperty('tcpWindowClamp');
|
||||||
|
expect(sockopt.tcpFastOpen).toBe(true);
|
||||||
|
const realitySettings = reality.settings as Record<string, unknown>;
|
||||||
|
expect(realitySettings.publicKey).toBe('pub');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('freedom outbound sockopt wire payload', () => {
|
||||||
|
it('preserves happyEyeballs on direct freedom outbound', () => {
|
||||||
|
const wire = outboundToWire({
|
||||||
|
protocol: 'freedom',
|
||||||
|
tag: 'direct',
|
||||||
|
settings: { domainStrategy: 'UseIP' },
|
||||||
|
streamSettings: {
|
||||||
|
sockopt: {
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
interleave: 1,
|
||||||
|
maxConcurrentTry: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Parameters<typeof outboundToWire>[0]);
|
||||||
|
|
||||||
|
expect(wire.streamSettings).toMatchObject({
|
||||||
|
sockopt: {
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
@@ -174,15 +183,22 @@ export class RandomUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
|
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
|
||||||
let length = 32;
|
const length = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
|
||||||
if (method === '2022-blake3-aes-128-gcm') {
|
|
||||||
length = 16;
|
|
||||||
}
|
|
||||||
const array = new Uint8Array(length);
|
const array = new Uint8Array(length);
|
||||||
window.crypto.getRandomValues(array);
|
window.crypto.getRandomValues(array);
|
||||||
return Base64.alternativeEncode(String.fromCharCode(...array));
|
return Base64.alternativeEncode(String.fromCharCode(...array));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isShadowsocks2022Password(password: string, method: string): boolean {
|
||||||
|
if (!method || method.substring(0, 4) !== '2022') return true;
|
||||||
|
const expected = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
|
||||||
|
try {
|
||||||
|
return window.atob(password).length === expected;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static randomBase64(length: number = 16): string {
|
static randomBase64(length: number = 16): string {
|
||||||
const array = new Uint8Array(length);
|
const array = new Uint8Array(length);
|
||||||
window.crypto.getRandomValues(array);
|
window.crypto.getRandomValues(array);
|
||||||
@@ -858,13 +874,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 +891,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function resolveDBPath() {
|
|||||||
return '/etc/x-ui/x-ui.db';
|
return '/etc/x-ui/x-ui.db';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PANEL_API_PREFIXES = ['panel/api/', 'panel/setting/', 'panel/xray/', 'panel/csrf-token'];
|
const PANEL_API_PREFIXES = ['panel/api/', 'panel/csrf-token'];
|
||||||
|
|
||||||
let cachedBasePath = '/';
|
let cachedBasePath = '/';
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -466,8 +466,14 @@ func main() {
|
|||||||
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
|
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
|
||||||
var migrateDsn string
|
var migrateDsn string
|
||||||
var migrateSrc string
|
var migrateSrc string
|
||||||
|
var migrateDump string
|
||||||
|
var migrateRestore string
|
||||||
|
var migrateOut string
|
||||||
migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
|
migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
|
||||||
migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
|
migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
|
||||||
|
migrateDbCmd.StringVar(&migrateDump, "dump", "", "Write a portable SQL text dump of --src to this file (.db -> .dump)")
|
||||||
|
migrateDbCmd.StringVar(&migrateRestore, "restore", "", "Rebuild a SQLite database from this SQL text dump (.dump -> .db); requires --out")
|
||||||
|
migrateDbCmd.StringVar(&migrateOut, "out", "", "Destination SQLite file for --restore (must not already exist)")
|
||||||
|
|
||||||
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
|
||||||
var port int
|
var port int
|
||||||
@@ -512,7 +518,7 @@ func main() {
|
|||||||
fmt.Println("Commands:")
|
fmt.Println("Commands:")
|
||||||
fmt.Println(" run run web panel")
|
fmt.Println(" run run web panel")
|
||||||
fmt.Println(" migrate migrate form other/old x-ui")
|
fmt.Println(" migrate migrate form other/old x-ui")
|
||||||
fmt.Println(" migrate-db copy data from the SQLite file into a PostgreSQL database")
|
fmt.Println(" migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
|
||||||
fmt.Println(" setting set settings")
|
fmt.Println(" setting set settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,14 +547,31 @@ func main() {
|
|||||||
if src == "" {
|
if src == "" {
|
||||||
src = config.GetDBPath()
|
src = config.GetDBPath()
|
||||||
}
|
}
|
||||||
if migrateDsn == "" {
|
switch {
|
||||||
fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable")
|
case migrateDump != "":
|
||||||
|
if err := database.DumpSQLite(src, migrateDump); err != nil {
|
||||||
|
fmt.Println("dump failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Dumped %s -> %s\n", src, migrateDump)
|
||||||
|
case migrateRestore != "":
|
||||||
|
if migrateOut == "" {
|
||||||
|
fmt.Println("--out is required when using --restore: the destination .db path (must not exist)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := database.RestoreSQLite(migrateRestore, migrateOut); err != nil {
|
||||||
|
fmt.Println("restore failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Restored %s -> %s\n", migrateRestore, migrateOut)
|
||||||
|
case migrateDsn != "":
|
||||||
if err := database.MigrateData(src, migrateDsn); err != nil {
|
if err := database.MigrateData(src, migrateDsn); err != nil {
|
||||||
fmt.Println("migration failed:", err)
|
fmt.Println("migration failed:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
fmt.Println("nothing to do: pass --dump <file>, --restore <file> --out <db>, or --dsn <postgres-dsn>")
|
||||||
|
}
|
||||||
case "setting":
|
case "setting":
|
||||||
err := settingCmd.Parse(os.Args[2:])
|
err := settingCmd.Parse(os.Args[2:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+111
-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,100 @@ 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:
|
||||||
|
for key, value := range typed {
|
||||||
|
if key == "rules" {
|
||||||
|
if ruleList, ok := asAnySlice(value); ok {
|
||||||
|
mergeClashRules(base, ruleList)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base[key] = value
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,22 @@ func isLoopbackHost(host string) bool {
|
|||||||
return ip != nil && ip.IsLoopback()
|
return ip != nil && ip.IsLoopback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listenIsInternalOnly reports whether a bind address is reachable only from
|
||||||
|
// the same host — a loopback IP or a unix-domain socket. Such an inbound can't
|
||||||
|
// be dialed directly by a remote client, so when it is the child side of a
|
||||||
|
// fallback its share link must be projected through the master. A public or
|
||||||
|
// wildcard listen (""/0.0.0.0/::) is reachable on its own port and advertises
|
||||||
|
// itself.
|
||||||
|
func listenIsInternalOnly(listen string) bool {
|
||||||
|
if listen == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if listen[0] == '@' || listen[0] == '/' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isLoopbackHost(listen)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubs retrieves subscription links for a given subscription ID and host.
|
// GetSubs retrieves subscription links for a given subscription ID and host.
|
||||||
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
||||||
s.PrepareForRequest(host)
|
s.PrepareForRequest(host)
|
||||||
@@ -260,10 +276,20 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
|||||||
// Returns true when a projection happened; sub services call this before
|
// Returns true when a projection happened; sub services call this before
|
||||||
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
|
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
|
||||||
// master's :443 + TLS state instead of its own loopback endpoint.
|
// master's :443 + TLS state instead of its own loopback endpoint.
|
||||||
|
//
|
||||||
|
// Projection only applies to a child that is not directly reachable on its
|
||||||
|
// own listen (loopback or a unix-domain socket). An inbound on a public or
|
||||||
|
// wildcard listen is reachable on its own port, so it advertises its own
|
||||||
|
// port + security even when a stale fallback rule still names it as a child —
|
||||||
|
// otherwise its share link would leak the master's port and Reality/TLS
|
||||||
|
// settings (#4987).
|
||||||
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
|
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
|
||||||
if inbound == nil {
|
if inbound == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !listenIsInternalOnly(inbound.Listen) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var master *model.Inbound
|
var master *model.Inbound
|
||||||
|
|
||||||
@@ -894,6 +920,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 +950,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 +1079,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 +1100,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 +1175,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) {
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ func TestIsRoutableHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListenIsInternalOnly(t *testing.T) {
|
||||||
|
// Reachable only from the same host -> a fallback child here must be
|
||||||
|
// projected through its master.
|
||||||
|
internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"}
|
||||||
|
for _, v := range internalOnly {
|
||||||
|
if !listenIsInternalOnly(v) {
|
||||||
|
t.Fatalf("listenIsInternalOnly(%q) = false, want true", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Directly reachable on its own port -> never projected, even if a stale
|
||||||
|
// fallback rule names it as a child (#4987).
|
||||||
|
reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"}
|
||||||
|
for _, v := range reachable {
|
||||||
|
if listenIsInternalOnly(v) {
|
||||||
|
t.Fatalf("listenIsInternalOnly(%q) = true, want false", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveInboundAddress(t *testing.T) {
|
func TestResolveInboundAddress(t *testing.T) {
|
||||||
const reqHost = "sub.example.com"
|
const reqHost = "sub.example.com"
|
||||||
|
|
||||||
@@ -575,6 +594,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",
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func emitExamples(w io.Writer, schemas []Schema, aliases []Alias) error {
|
||||||
|
byName := make(map[string]Schema, len(schemas))
|
||||||
|
for _, s := range schemas {
|
||||||
|
byName[s.Name] = s
|
||||||
|
}
|
||||||
|
aliasByName := make(map[string]Alias, len(aliases))
|
||||||
|
for _, a := range aliases {
|
||||||
|
aliasByName[a.Name] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
gen := &exampleGen{byName: byName, aliasByName: aliasByName}
|
||||||
|
|
||||||
|
out := make(map[string]any, len(schemas))
|
||||||
|
for _, s := range schemas {
|
||||||
|
out[s.Name] = gen.forSchema(s, map[string]bool{})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.MarshalIndent(out, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "export const EXAMPLES: Record<string, unknown> = %s;\n", payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exampleGen struct {
|
||||||
|
byName map[string]Schema
|
||||||
|
aliasByName map[string]Alias
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *exampleGen) forSchema(s Schema, visited map[string]bool) map[string]any {
|
||||||
|
obj := make(map[string]any, len(s.Fields))
|
||||||
|
for _, f := range s.Fields {
|
||||||
|
obj[f.JSONName] = g.forField(f, visited)
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *exampleGen) forField(f Field, visited map[string]bool) any {
|
||||||
|
if f.Example != "" {
|
||||||
|
return coerceExample(f.Example, baseKind(f.Type))
|
||||||
|
}
|
||||||
|
if v, ok := firstOneOf(f.Validate); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
bk := baseKind(f.Type)
|
||||||
|
if bk.Kind == KindInt || bk.Kind == KindNumber {
|
||||||
|
if v, ok := numericFloor(bk.Kind, f.Validate); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g.forType(f.Type, visited)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
|
||||||
|
switch t.Kind {
|
||||||
|
case KindString:
|
||||||
|
if t.Name == "datetime" {
|
||||||
|
return "2025-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case KindInt, KindNumber:
|
||||||
|
return 0
|
||||||
|
case KindBool:
|
||||||
|
return false
|
||||||
|
case KindArray:
|
||||||
|
if isVisitedRef(*t.Element, visited) {
|
||||||
|
return []any{}
|
||||||
|
}
|
||||||
|
return []any{g.forType(*t.Element, visited)}
|
||||||
|
case KindMap:
|
||||||
|
return map[string]any{}
|
||||||
|
case KindRef:
|
||||||
|
if t.Name == "nullable" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if alias, ok := g.aliasByName[t.Name]; ok {
|
||||||
|
return g.forType(alias.Underlying, visited)
|
||||||
|
}
|
||||||
|
schema, ok := g.byName[t.Name]
|
||||||
|
if !ok || visited[t.Name] {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
next := cloneVisited(visited)
|
||||||
|
next[t.Name] = true
|
||||||
|
return g.forSchema(schema, next)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseKind(t TypeRef) TypeRef {
|
||||||
|
if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
|
||||||
|
return *t.Inner
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVisitedRef(t TypeRef, visited map[string]bool) bool {
|
||||||
|
return t.Kind == KindRef && t.Name != "nullable" && visited[t.Name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneVisited(in map[string]bool) map[string]bool {
|
||||||
|
out := make(map[string]bool, len(in)+1)
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericFloor(kind TypeKind, rules []ValidateRule) (any, bool) {
|
||||||
|
for _, r := range rules {
|
||||||
|
if (r.Name == "gte" || r.Name == "min") && r.Param != "" {
|
||||||
|
return coerceExample(r.Param, TypeRef{Kind: kind}), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOneOf(rules []ValidateRule) (string, bool) {
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Name == "oneof" {
|
||||||
|
fields := strings.Fields(r.Param)
|
||||||
|
if len(fields) > 0 {
|
||||||
|
return fields[0], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceExample(ex string, t TypeRef) any {
|
||||||
|
switch t.Kind {
|
||||||
|
case KindInt:
|
||||||
|
if n, err := strconv.ParseInt(ex, 10, 64); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case KindNumber:
|
||||||
|
if n, err := strconv.ParseFloat(ex, 64); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case KindBool:
|
||||||
|
return ex == "true"
|
||||||
|
case KindString:
|
||||||
|
return ex
|
||||||
|
default:
|
||||||
|
var parsed any
|
||||||
|
if err := json.Unmarshal([]byte(ex), &parsed); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const examplesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func emitJSONSchema(w io.Writer, schemas []Schema, aliases []Alias) error {
|
||||||
|
byName := make(map[string]Schema, len(schemas))
|
||||||
|
for _, s := range schemas {
|
||||||
|
byName[s.Name] = s
|
||||||
|
}
|
||||||
|
aliasByName := make(map[string]Alias, len(aliases))
|
||||||
|
for _, a := range aliases {
|
||||||
|
aliasByName[a.Name] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
gen := &schemaGen{byName: byName, aliasByName: aliasByName}
|
||||||
|
|
||||||
|
out := make(map[string]any, len(schemas))
|
||||||
|
for _, s := range schemas {
|
||||||
|
out[s.Name] = gen.objectSchema(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.MarshalIndent(out, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "export const SCHEMAS: Record<string, unknown> = %s;\n", payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type schemaGen struct {
|
||||||
|
byName map[string]Schema
|
||||||
|
aliasByName map[string]Alias
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *schemaGen) objectSchema(s Schema) map[string]any {
|
||||||
|
props := make(map[string]any, len(s.Fields))
|
||||||
|
var required []string
|
||||||
|
for _, f := range s.Fields {
|
||||||
|
props[f.JSONName] = g.fieldSchema(f)
|
||||||
|
if !f.Optional {
|
||||||
|
required = append(required, f.JSONName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
obj := map[string]any{"type": "object", "properties": props}
|
||||||
|
if len(required) > 0 {
|
||||||
|
sort.Strings(required)
|
||||||
|
obj["required"] = required
|
||||||
|
}
|
||||||
|
if s.Doc != "" {
|
||||||
|
obj["description"] = s.Doc
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *schemaGen) fieldSchema(f Field) map[string]any {
|
||||||
|
sch := g.typeSchema(f.Type)
|
||||||
|
if ref, ok := sch["$ref"]; ok {
|
||||||
|
if f.Doc == "" && f.Example == "" {
|
||||||
|
return sch
|
||||||
|
}
|
||||||
|
wrap := map[string]any{"allOf": []any{map[string]any{"$ref": ref}}}
|
||||||
|
if f.Doc != "" {
|
||||||
|
wrap["description"] = f.Doc
|
||||||
|
}
|
||||||
|
if f.Example != "" {
|
||||||
|
wrap["example"] = coerceExample(f.Example, baseKind(f.Type))
|
||||||
|
}
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
applyConstraints(sch, f.Type, f.Validate)
|
||||||
|
if f.Doc != "" {
|
||||||
|
sch["description"] = f.Doc
|
||||||
|
}
|
||||||
|
if f.Example != "" {
|
||||||
|
sch["example"] = coerceExample(f.Example, baseKind(f.Type))
|
||||||
|
}
|
||||||
|
return sch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
|
||||||
|
switch t.Kind {
|
||||||
|
case KindString:
|
||||||
|
if t.Name == "datetime" {
|
||||||
|
return map[string]any{"type": "string", "format": "date-time"}
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "string"}
|
||||||
|
case KindInt:
|
||||||
|
return map[string]any{"type": "integer"}
|
||||||
|
case KindNumber:
|
||||||
|
return map[string]any{"type": "number"}
|
||||||
|
case KindBool:
|
||||||
|
return map[string]any{"type": "boolean"}
|
||||||
|
case KindArray:
|
||||||
|
return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
|
||||||
|
case KindMap:
|
||||||
|
return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
|
||||||
|
case KindAny, KindUnknown, KindRaw:
|
||||||
|
return map[string]any{}
|
||||||
|
case KindRef:
|
||||||
|
if t.Name == "nullable" {
|
||||||
|
inner := g.typeSchema(*t.Inner)
|
||||||
|
if ref, ok := inner["$ref"]; ok {
|
||||||
|
return map[string]any{"nullable": true, "allOf": []any{map[string]any{"$ref": ref}}}
|
||||||
|
}
|
||||||
|
inner["nullable"] = true
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
if alias, ok := g.aliasByName[t.Name]; ok {
|
||||||
|
return g.typeSchema(alias.Underlying)
|
||||||
|
}
|
||||||
|
if _, ok := g.byName[t.Name]; ok {
|
||||||
|
return map[string]any{"$ref": "#/components/schemas/" + t.Name}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyConstraints(sch map[string]any, t TypeRef, rules []ValidateRule) {
|
||||||
|
base := baseKind(t)
|
||||||
|
numeric := base.Kind == KindInt || base.Kind == KindNumber
|
||||||
|
str := base.Kind == KindString
|
||||||
|
for _, r := range rules {
|
||||||
|
switch r.Name {
|
||||||
|
case "gte":
|
||||||
|
if numeric {
|
||||||
|
sch["minimum"] = coerceExample(r.Param, base)
|
||||||
|
}
|
||||||
|
case "lte":
|
||||||
|
if numeric {
|
||||||
|
sch["maximum"] = coerceExample(r.Param, base)
|
||||||
|
}
|
||||||
|
case "gt":
|
||||||
|
if numeric {
|
||||||
|
sch["minimum"] = coerceExample(r.Param, base)
|
||||||
|
sch["exclusiveMinimum"] = true
|
||||||
|
}
|
||||||
|
case "lt":
|
||||||
|
if numeric {
|
||||||
|
sch["maximum"] = coerceExample(r.Param, base)
|
||||||
|
sch["exclusiveMaximum"] = true
|
||||||
|
}
|
||||||
|
case "min":
|
||||||
|
if numeric {
|
||||||
|
sch["minimum"] = coerceExample(r.Param, base)
|
||||||
|
} else if str {
|
||||||
|
if n, err := strconv.Atoi(r.Param); err == nil {
|
||||||
|
sch["minLength"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "max":
|
||||||
|
if numeric {
|
||||||
|
sch["maximum"] = coerceExample(r.Param, base)
|
||||||
|
} else if str {
|
||||||
|
if n, err := strconv.Atoi(r.Param); err == nil {
|
||||||
|
sch["maxLength"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "oneof":
|
||||||
|
vals := strings.Fields(r.Param)
|
||||||
|
if len(vals) > 0 {
|
||||||
|
enum := make([]any, len(vals))
|
||||||
|
for i, v := range vals {
|
||||||
|
enum[i] = v
|
||||||
|
}
|
||||||
|
sch["enum"] = enum
|
||||||
|
}
|
||||||
|
case "email":
|
||||||
|
if str {
|
||||||
|
sch["format"] = "email"
|
||||||
|
}
|
||||||
|
case "url":
|
||||||
|
if str {
|
||||||
|
sch["format"] = "uri"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,14 @@ func run(root, outDir string) error {
|
|||||||
"ClientTraffic",
|
"ClientTraffic",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: resolveRel(root, "web/service"),
|
||||||
|
StructAllow: setOf(
|
||||||
|
"InboundOption",
|
||||||
|
"ApiTokenView",
|
||||||
|
"ProbeResultUI",
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
schemas, aliases, err := walkPackages(requests)
|
schemas, aliases, err := walkPackages(requests)
|
||||||
@@ -94,6 +102,14 @@ func run(root, outDir string) error {
|
|||||||
if err := emitTypes(typesBuf, schemas, aliases); err != nil {
|
if err := emitTypes(typesBuf, schemas, aliases); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
examplesBuf := &bytes.Buffer{}
|
||||||
|
if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
schemasBuf := &bytes.Buffer{}
|
||||||
|
if err := emitJSONSchema(schemasBuf, schemas, aliases); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -101,6 +117,12 @@ func run(root, outDir string) error {
|
|||||||
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(target, "schemas.ts"), schemasBuf.Bytes(), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
|
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type Field struct {
|
|||||||
Skip bool
|
Skip bool
|
||||||
Validate []ValidateRule
|
Validate []ValidateRule
|
||||||
Doc string
|
Doc string
|
||||||
|
Example string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypeRef struct {
|
type TypeRef struct {
|
||||||
@@ -59,10 +60,11 @@ type ValidateRule struct {
|
|||||||
Param string
|
Param string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseStructTag(raw string) (json string, validate string, gormHasDash bool) {
|
func parseStructTag(raw string) (json string, validate string, example string, gormHasDash bool) {
|
||||||
tag := reflect.StructTag(strings.Trim(raw, "`"))
|
tag := reflect.StructTag(strings.Trim(raw, "`"))
|
||||||
json = tag.Get("json")
|
json = tag.Get("json")
|
||||||
validate = tag.Get("validate")
|
validate = tag.Get("validate")
|
||||||
|
example = tag.Get("example")
|
||||||
if g := tag.Get("gorm"); g != "" {
|
if g := tag.Get("gorm"); g != "" {
|
||||||
for part := range strings.SplitSeq(g, ";") {
|
for part := range strings.SplitSeq(g, ";") {
|
||||||
if strings.TrimSpace(part) == "-" {
|
if strings.TrimSpace(part) == "-" {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
|
|||||||
if fld.Tag != nil {
|
if fld.Tag != nil {
|
||||||
tag = fld.Tag.Value
|
tag = fld.Tag.Value
|
||||||
}
|
}
|
||||||
jsonTag, validateTag, gormDash := parseStructTag(tag)
|
jsonTag, validateTag, exampleTag, gormDash := parseStructTag(tag)
|
||||||
if gormDash && jsonTag == "" {
|
if gormDash && jsonTag == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -132,6 +132,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
|
|||||||
Optional: omitempty || isPointer(fld.Type),
|
Optional: omitempty || isPointer(fld.Type),
|
||||||
Validate: validate,
|
Validate: validate,
|
||||||
Doc: doc,
|
Doc: doc,
|
||||||
|
Example: exampleTag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
|
|||||||
Optional: omitempty || isPointer(fld.Type),
|
Optional: omitempty || isPointer(fld.Type),
|
||||||
Validate: validate,
|
Validate: validate,
|
||||||
Doc: doc,
|
Doc: doc,
|
||||||
|
Example: exampleTag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -582,12 +582,14 @@ prompt_and_setup_ssl() {
|
|||||||
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
|
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
|
||||||
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
|
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
|
||||||
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
||||||
|
echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
|
||||||
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
||||||
|
echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
|
||||||
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
||||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||||
|
|
||||||
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
|
# Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
|
||||||
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
|
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
|
||||||
ssl_choice="2"
|
ssl_choice="2"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -706,6 +708,41 @@ prompt_and_setup_ssl() {
|
|||||||
|
|
||||||
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
|
4)
|
||||||
|
echo ""
|
||||||
|
echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
|
||||||
|
echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
|
||||||
|
echo -e "${yellow}Only safe when:${plain}"
|
||||||
|
echo -e "${yellow} • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
|
||||||
|
echo -e "${yellow} • You access the panel exclusively via SSH tunnel${plain}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SSL_SCHEME="http"
|
||||||
|
SSL_HOST="${server_ip}"
|
||||||
|
|
||||||
|
local bind_local=""
|
||||||
|
read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
|
||||||
|
if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
|
||||||
|
${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
|
||||||
|
SSL_HOST="127.0.0.1"
|
||||||
|
echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
|
||||||
|
echo -e " Standard SSH command:"
|
||||||
|
echo -e " ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
|
||||||
|
echo -e " If using an SSH key:"
|
||||||
|
echo -e " ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
|
||||||
|
echo -e " Then open in your browser:"
|
||||||
|
echo -e " ${yellow}http://localhost:2222/${web_base_path}${plain}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
|
||||||
|
else
|
||||||
|
echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||||
|
echo -e "${green}✓ SSL setup skipped.${plain}"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||||
SSL_HOST="${server_ip}"
|
SSL_HOST="${server_ip}"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type APIController struct {
|
|||||||
inboundController *InboundController
|
inboundController *InboundController
|
||||||
serverController *ServerController
|
serverController *ServerController
|
||||||
nodeController *NodeController
|
nodeController *NodeController
|
||||||
|
settingController *SettingController
|
||||||
|
xraySettingController *XraySettingController
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
apiTokenService service.ApiTokenService
|
apiTokenService service.ApiTokenService
|
||||||
@@ -79,6 +81,12 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
|||||||
|
|
||||||
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||||
|
|
||||||
|
// Settings + Xray config management live under the API surface too, so the
|
||||||
|
// same API token drives them. Paths are /panel/api/setting/* and
|
||||||
|
// /panel/api/xray/*.
|
||||||
|
a.settingController = NewSettingController(api)
|
||||||
|
a.xraySettingController = NewXraySettingController(api)
|
||||||
|
|
||||||
// Extra routes
|
// Extra routes
|
||||||
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
|||||||
case "node.go":
|
case "node.go":
|
||||||
basePath = "/panel/api/nodes"
|
basePath = "/panel/api/nodes"
|
||||||
case "setting.go":
|
case "setting.go":
|
||||||
basePath = "/panel/setting"
|
basePath = "/panel/api/setting"
|
||||||
case "xray_setting.go":
|
case "xray_setting.go":
|
||||||
basePath = "/panel/xray"
|
basePath = "/panel/api/xray"
|
||||||
case "custom_geo.go":
|
case "custom_geo.go":
|
||||||
basePath = "/panel/api/custom-geo"
|
basePath = "/panel/api/custom-geo"
|
||||||
case "websocket.go":
|
case "websocket.go":
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -55,7 +72,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/ips/:email", a.getIps)
|
g.POST("/ips/:email", a.getIps)
|
||||||
g.POST("/clearIps/:email", a.clearIps)
|
g.POST("/clearIps/:email", a.clearIps)
|
||||||
g.POST("/onlines", a.onlines)
|
g.POST("/onlines", a.onlines)
|
||||||
g.POST("/onlinesByNode", a.onlinesByNode)
|
g.POST("/onlinesByGuid", a.onlinesByGuid)
|
||||||
g.POST("/activeInbounds", a.activeInbounds)
|
g.POST("/activeInbounds", a.activeInbounds)
|
||||||
g.POST("/lastOnline", a.lastOnline)
|
g.POST("/lastOnline", a.lastOnline)
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -399,12 +417,12 @@ func (a *ClientController) onlines(c *gin.Context) {
|
|||||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) onlinesByNode(c *gin.Context) {
|
func (a *ClientController) onlinesByGuid(c *gin.Context) {
|
||||||
jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil)
|
jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) activeInbounds(c *gin.Context) {
|
func (a *ClientController) activeInbounds(c *gin.Context) {
|
||||||
jsonObj(c, a.inboundService.GetActiveInboundsByNode(), nil)
|
jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) lastOnline(c *gin.Context) {
|
func (a *ClientController) lastOnline(c *gin.Context) {
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
htmlpkg "html"
|
htmlpkg "html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -34,10 +35,42 @@ func ServeOpenAPISpec(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
|
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
|
||||||
|
// under a non-root web base path, Swagger UI "Try it out" and external
|
||||||
|
// generators must target that prefix, so rewrite the single server entry to
|
||||||
|
// the runtime base path before serving.
|
||||||
|
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
|
||||||
|
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
|
||||||
|
logger.Warning("openapi.json: could not inject base path:", err)
|
||||||
|
} else {
|
||||||
|
body = rebuilt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.Header("Cache-Control", "public, max-age=300")
|
c.Header("Cache-Control", "public, max-age=300")
|
||||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withServerBasePath rewrites the spec's `servers` entry so requests target the
|
||||||
|
// panel's configured web base path. Only the top-level `servers` field is
|
||||||
|
// replaced; every other field is preserved verbatim via json.RawMessage.
|
||||||
|
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
|
||||||
|
var doc map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(spec, &doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
servers, err := json.Marshal([]map[string]string{{
|
||||||
|
"url": strings.TrimSuffix(basePath, "/"),
|
||||||
|
"description": "Current panel",
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
doc["servers"] = servers
|
||||||
|
return json.Marshal(doc)
|
||||||
|
}
|
||||||
|
|
||||||
func serveDistPage(c *gin.Context, name string) {
|
func serveDistPage(c *gin.Context, name string) {
|
||||||
body, err := distFS.ReadFile("dist/" + name)
|
body, err := distFS.ReadFile("dist/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithServerBasePath(t *testing.T) {
|
||||||
|
spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`)
|
||||||
|
|
||||||
|
out, err := withServerBasePath(spec, "/test/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("withServerBasePath: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc map[string]any
|
||||||
|
if err := json.Unmarshal(out, &doc); err != nil {
|
||||||
|
t.Fatalf("unmarshal result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
servers, ok := doc["servers"].([]any)
|
||||||
|
if !ok || len(servers) != 1 {
|
||||||
|
t.Fatalf("servers = %v, want one entry", doc["servers"])
|
||||||
|
}
|
||||||
|
srv, _ := servers[0].(map[string]any)
|
||||||
|
if srv["url"] != "/test" {
|
||||||
|
t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if doc["openapi"] != "3.0.3" {
|
||||||
|
t.Errorf("openapi field not preserved: %v", doc["openapi"])
|
||||||
|
}
|
||||||
|
if _, ok := doc["paths"].(map[string]any)["/p"]; !ok {
|
||||||
|
t.Errorf("paths content not preserved verbatim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithServerBasePathInvalidJSON(t *testing.T) {
|
||||||
|
if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil {
|
||||||
|
t.Errorf("expected error on invalid spec, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *NodeController) list(c *gin.Context) {
|
func (a *NodeController) list(c *gin.Context) {
|
||||||
nodes, err := a.nodeService.GetAll()
|
nodes, err := a.nodeService.GetNodeTree()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||||
g.GET("/getConfigJson", a.getConfigJson)
|
g.GET("/getConfigJson", a.getConfigJson)
|
||||||
g.GET("/getDb", a.getDb)
|
g.GET("/getDb", a.getDb)
|
||||||
|
g.GET("/getMigration", a.getMigration)
|
||||||
g.GET("/getNewUUID", a.getNewUUID)
|
g.GET("/getNewUUID", a.getNewUUID)
|
||||||
g.GET("/getWebCertFiles", a.getWebCertFiles)
|
g.GET("/getWebCertFiles", a.getWebCertFiles)
|
||||||
|
g.GET("/descendants", a.descendants)
|
||||||
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
|
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
|
||||||
g.GET("/getNewmldsa65", a.getNewmldsa65)
|
g.GET("/getNewmldsa65", a.getNewmldsa65)
|
||||||
g.GET("/getNewmlkem768", a.getNewmlkem768)
|
g.GET("/getNewmlkem768", a.getNewmlkem768)
|
||||||
@@ -300,6 +302,24 @@ func (a *ServerController) getDb(c *gin.Context) {
|
|||||||
c.Writer.Write(db)
|
c.Writer.Write(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
|
||||||
|
// .db SQLite database on PostgreSQL, so the data can seed the other backend.
|
||||||
|
func (a *ServerController) getMigration(c *gin.Context) {
|
||||||
|
data, filename, err := a.serverService.GetMigration()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !filenameRegex.MatchString(filename) {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||||
|
c.Writer.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
// importDB imports a database file and restarts the Xray service.
|
// importDB imports a database file and restarts the Xray service.
|
||||||
func (a *ServerController) importDB(c *gin.Context) {
|
func (a *ServerController) importDB(c *gin.Context) {
|
||||||
file, _, err := c.Request.FormFile("db")
|
file, _, err := c.Request.FormFile("db")
|
||||||
@@ -315,6 +335,14 @@ func (a *ServerController) importDB(c *gin.Context) {
|
|||||||
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
|
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// descendants publishes read-only summaries of the nodes this panel manages so
|
||||||
|
// a parent panel can surface them as transitive sub-nodes in a chained
|
||||||
|
// topology. Called by the parent via the node's API token (#4983).
|
||||||
|
func (a *ServerController) descendants(c *gin.Context) {
|
||||||
|
data, err := (&service.NodeService{}).LocalDescendants()
|
||||||
|
jsonObj(c, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
// getWebCertFiles returns this panel's own web TLS certificate and key file
|
// getWebCertFiles returns this panel's own web TLS certificate and key file
|
||||||
// paths. The central panel calls it on a node (via the node's API token) so
|
// paths. The central panel calls it on a node (via the node's API token) so
|
||||||
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
|
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -10,12 +10,9 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// XUIController is the main controller for the X-UI panel, managing sub-controllers.
|
// XUIController is the main controller for the X-UI panel, serving the SPA shell.
|
||||||
type XUIController struct {
|
type XUIController struct {
|
||||||
BaseController
|
BaseController
|
||||||
|
|
||||||
settingController *SettingController
|
|
||||||
xraySettingController *XraySettingController
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewXUIController creates a new XUIController and initializes its routes.
|
// NewXUIController creates a new XUIController and initializes its routes.
|
||||||
@@ -49,9 +46,6 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
|||||||
// so they fetch the session token via this endpoint at startup and replay it
|
// so they fetch the session token via this endpoint at startup and replay it
|
||||||
// on subsequent unsafe requests through axios.
|
// on subsequent unsafe requests through axios.
|
||||||
g.GET("/csrf-token", a.csrfToken)
|
g.GET("/csrf-token", a.csrfToken)
|
||||||
|
|
||||||
a.settingController = NewSettingController(g)
|
|
||||||
a.xraySettingController = NewXraySettingController(g)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
|
// panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IPWithTimestamp tracks an IP address with its last seen timestamp
|
// IPWithTimestamp tracks an IP address with its last seen timestamp
|
||||||
@@ -184,6 +186,22 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
|||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
for email, ipTimestamps := range inboundClientIps {
|
for email, ipTimestamps := range inboundClientIps {
|
||||||
|
|
||||||
|
// The access log can still reference a client that was just renamed
|
||||||
|
// or deleted; its email no longer matches any inbound. Skip it (and
|
||||||
|
// drop any orphaned tracking row) instead of recreating a row and
|
||||||
|
// logging an ERROR every run until the log rotates out the old email
|
||||||
|
// (#4963).
|
||||||
|
inbound, err := j.getInboundByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
|
||||||
|
j.delInboundClientIps(email)
|
||||||
|
} else {
|
||||||
|
j.checkError(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to IPWithTimestamp slice
|
// Convert to IPWithTimestamp slice
|
||||||
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
||||||
for ip, timestamp := range ipTimestamps {
|
for ip, timestamp := range ipTimestamps {
|
||||||
@@ -196,7 +214,7 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime, enforce) || shouldCleanLog
|
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
@@ -311,14 +329,17 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
|
// delInboundClientIps drops the inbound_client_ips tracking row for an email
|
||||||
// Get the inbound configuration
|
// that no longer maps to any inbound (a renamed or deleted client), so stale
|
||||||
inbound, err := j.getInboundByEmail(clientEmail)
|
// access-log entries don't keep a ghost row alive (#4963).
|
||||||
if err != nil {
|
func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
|
||||||
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
|
db := database.GetDB()
|
||||||
return false
|
if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||||
|
j.checkError(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
|
||||||
if inbound.Settings == "" {
|
if inbound.Settings == "" {
|
||||||
logger.Debug("wrong data:", inbound)
|
logger.Debug("wrong data:", inbound)
|
||||||
return false
|
return false
|
||||||
@@ -411,7 +432,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||||||
inboundClientIps.Ips = string(jsonIps)
|
inboundClientIps.Ips = string(jsonIps)
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
err = db.Save(inboundClientIps).Error
|
err := db.Save(inboundClientIps).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to save inboundClientIps:", err)
|
logger.Error("failed to save inboundClientIps:", err)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -195,7 +195,11 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
|||||||
{IP: "128.71.1.1", Timestamp: now},
|
{IP: "128.71.1.1", Timestamp: now},
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
|
inbound, err := j.getInboundByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getInboundByEmail: %v", err)
|
||||||
|
}
|
||||||
|
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
|
||||||
|
|
||||||
if shouldCleanLog {
|
if shouldCleanLog {
|
||||||
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
||||||
@@ -244,7 +248,11 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|||||||
{IP: "192.0.2.9", Timestamp: now},
|
{IP: "192.0.2.9", Timestamp: now},
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
|
inbound, err := j.getInboundByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getInboundByEmail: %v", err)
|
||||||
|
}
|
||||||
|
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
|
||||||
|
|
||||||
if !shouldCleanLog {
|
if !shouldCleanLog {
|
||||||
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
||||||
@@ -321,6 +329,32 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #4963: a stale access-log entry for a renamed/deleted client (its email no
|
||||||
|
// longer maps to any inbound) must not create or resurrect an
|
||||||
|
// inbound_client_ips row, and must drop any orphan left behind — instead of
|
||||||
|
// spamming "failed to fetch inbound settings" every run.
|
||||||
|
func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
||||||
|
fakeFail2BanClient(t)
|
||||||
|
|
||||||
|
const staleEmail = "renamed-away"
|
||||||
|
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
||||||
|
// confirm the job removes it rather than leaving it to error forever.
|
||||||
|
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
||||||
|
writeXrayAccessLog(t, staleEmail, "203.0.113.5")
|
||||||
|
|
||||||
|
NewCheckClientIpJob().Run()
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count InboundClientIps: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("stale-email orphan row should be deleted, got %d row(s)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// readIpLimitLogPath reads the 3xipl.log path the same way the job
|
// readIpLimitLogPath reads the 3xipl.log path the same way the job
|
||||||
// does via xray.GetIPLimitLogPath but without importing xray here
|
// does via xray.GetIPLimitLogPath but without importing xray here
|
||||||
// just for the path helper (which would pull a lot more deps into the
|
// just for the path helper (which would pull a lot more deps into the
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func (j *NodeHeartbeatJob) Run() {
|
|||||||
if !websocket.HasClients() {
|
if !websocket.HasClients() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updated, err := j.nodeService.GetAll()
|
updated, err := j.nodeService.GetNodeTree()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
|
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
|
||||||
return
|
return
|
||||||
@@ -79,4 +79,14 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
|||||||
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
|
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
|
||||||
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
|
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
|
||||||
}
|
}
|
||||||
|
// Learn the nodes this node manages so the panel can surface them as
|
||||||
|
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
|
||||||
|
// be spent. Drop them when the node is unreachable.
|
||||||
|
if patch.Status == "online" {
|
||||||
|
dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||||
|
j.nodeService.RefreshDescendants(dctx, n)
|
||||||
|
dcancel()
|
||||||
|
} else {
|
||||||
|
j.nodeService.ClearDescendants(n.Id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -124,8 +125,8 @@ func (j *NodeTrafficSyncJob) Run() {
|
|||||||
}
|
}
|
||||||
websocket.BroadcastTraffic(map[string]any{
|
websocket.BroadcastTraffic(map[string]any{
|
||||||
"onlineClients": online,
|
"onlineClients": online,
|
||||||
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
|
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
|
||||||
"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
|
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
|
||||||
"lastOnlineMap": lastOnline,
|
"lastOnlineMap": lastOnline,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ func (j *XrayTrafficJob) Run() {
|
|||||||
"traffics": traffics,
|
"traffics": traffics,
|
||||||
"clientTraffics": clientTraffics,
|
"clientTraffics": clientTraffics,
|
||||||
"onlineClients": onlineClients,
|
"onlineClients": onlineClients,
|
||||||
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
|
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
|
||||||
"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
|
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
|
||||||
"lastOnlineMap": lastOnlineMap,
|
"lastOnlineMap": lastOnlineMap,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+61
-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
|
||||||
@@ -350,6 +370,24 @@ func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
|
|||||||
return &files, nil
|
return &files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDescendants fetches the node's read-only summaries of the nodes IT
|
||||||
|
// manages, so this panel can surface them as transitive sub-nodes in a chained
|
||||||
|
// topology (#4983). Best-effort: an old-build node without the endpoint returns
|
||||||
|
// an error the caller ignores.
|
||||||
|
func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) {
|
||||||
|
env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []model.NodeSummary
|
||||||
|
if len(env.Obj) > 0 {
|
||||||
|
if err := json.Unmarshal(env.Obj, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode descendants: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
|
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
|
||||||
_, err := r.do(ctx, http.MethodPost,
|
_, err := r.do(ctx, http.MethodPost,
|
||||||
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
|
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
|
||||||
@@ -364,6 +402,12 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
|
|||||||
type TrafficSnapshot struct {
|
type TrafficSnapshot struct {
|
||||||
Inbounds []*model.Inbound
|
Inbounds []*model.Inbound
|
||||||
OnlineEmails []string
|
OnlineEmails []string
|
||||||
|
// OnlineTree is the node's GUID-keyed online subtree (its own clients under
|
||||||
|
// its panelGuid plus every descendant under theirs). Preferred over the flat
|
||||||
|
// OnlineEmails so the master can attribute deeply nested clients to the real
|
||||||
|
// node across a chain (#4983). Empty when the node is an old build without
|
||||||
|
// the per-GUID endpoint — OnlineEmails is the fallback then.
|
||||||
|
OnlineTree map[string][]string
|
||||||
LastOnlineMap map[string]int64
|
LastOnlineMap map[string]int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,12 +422,20 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
|
|||||||
return nil, fmt.Errorf("decode inbound list: %w", err)
|
return nil, fmt.Errorf("decode inbound list: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer the GUID-keyed subtree; fall back to the flat list only when the
|
||||||
|
// node is an old build without the per-GUID endpoint (#4983).
|
||||||
|
envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil)
|
||||||
|
if err == nil && len(envTree.Obj) > 0 {
|
||||||
|
_ = json.Unmarshal(envTree.Obj, &snap.OnlineTree)
|
||||||
|
}
|
||||||
|
if len(snap.OnlineTree) == 0 {
|
||||||
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
|
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
|
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
|
||||||
} else if len(envOnlines.Obj) > 0 {
|
} else if len(envOnlines.Obj) > 0 {
|
||||||
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
|
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
|
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
|
||||||
if err != nil {
|
if err != 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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user