5 Angajamente b40f869f2a ... 0706b0b3a8

Autor SHA1 Permisiunea de a trimite mesaje. Dacă este dezactivată, utilizatorul nu va putea trimite nici un fel de mesaj Data
  Sanaei 0706b0b3a8 feat(x-ui.sh): add migrateDB command for SQLite .db <-> .dump (#4910) 10 ore în urmă
  MHSanaei db118cbcc9 v3.2.8 11 ore în urmă
  MHSanaei e7ffae5329 fix(outbound): import ech and pcs from TLS share links 11 ore în urmă
  MHSanaei f470bc7cf8 docs(contributing): refresh frontend guide and add Postgres launch profile 11 ore în urmă
  MHSanaei a8d5d0dfab fix(external-proxy): relabel "Host" as "Address", add per-entry ECH (#4935) 11 ore în urmă

+ 66 - 28
CONTRIBUTING.md

@@ -86,10 +86,11 @@ Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `a
 
 ### Inside VS Code
 
-The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent):
+The repo checks in two VS Code launch profiles in `.vscode/launch.json`: **Run 3x-ui (Debug)** for the default SQLite setup, and **Run 3x-ui (Postgres)** which points `XUI_DB_TYPE`/`XUI_DB_DSN` at a local PostgreSQL. The Postgres profile also prepends the PostgreSQL `bin` to `PATH` so the panel can find `pg_dump`/`pg_restore` (the `postgresql-client` tools used for DB backup/restore) — adjust the DSN and that path to your machine:
 
 ```jsonc
 {
+  "$schema": "vscode://schemas/launch",
   "version": "0.2.0",
   "configurations": [
     {
@@ -106,6 +107,23 @@ The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy fr
         "XUI_BIN_FOLDER": "x-ui"
       },
       "console": "integratedTerminal"
+    },
+    {
+      "name": "Run 3x-ui (Postgres)",
+      "type": "go",
+      "request": "launch",
+      "mode": "auto",
+      "program": "${workspaceFolder}",
+      "cwd": "${workspaceFolder}",
+      "env": {
+        "XUI_DEBUG": "true",
+        "XUI_LOG_FOLDER": "x-ui",
+        "XUI_BIN_FOLDER": "x-ui",
+        "XUI_DB_TYPE": "postgres",
+        "XUI_DB_DSN": "postgres://xui:[email protected]:5432/xui?sslmode=disable",
+        "PATH": "C:\\Program Files\\PostgreSQL\\18\\bin;${env:PATH}"
+      },
+      "console": "integratedTerminal"
     }
   ]
 }
@@ -117,14 +135,21 @@ The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`
 
 ### Architecture
 
-The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either.
+The frontend ships **three Vite bundles**, each emitted into `web/dist/` and embedded into the Go binary at compile time via `embed.FS`:
+
+- **`index.html`** — the admin panel, a **single-page app**. `src/main.tsx` mounts a `react-router` `createBrowserRouter` (see `src/routes.tsx`) under the `/panel` basename; every route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/groups`, `/panel/nodes`, `/panel/settings`, `/panel/xray`, `/panel/api-docs`) is lazy-loaded inside a shared `PanelLayout` (sidebar + header + `<Outlet>`).
+- **`login.html`** — the login + 2FA screen (`src/entries/login.tsx`), a standalone bundle.
+- **`subpage.html`** — the public subscription viewer (`src/entries/subpage.tsx`), a standalone bundle.
+
+Panel navigation happens client-side through React Router, and per-route code is lazy-split so the initial panel load stays small. `login` and `subpage` stay separate documents because they are reached without an authenticated panel session.
 
 ### State and data flow
 
-- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive.
-- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
-- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer.
-- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`.
+- **Server state via TanStack Query.** API reads go through `@tanstack/react-query` (`QueryProvider` in `src/main.tsx`, keys in `src/api/queryKeys.ts`); responses are cached and invalidated on mutation rather than blindly re-fetched, and WebSocket pushes feed back into the cache via `src/api/websocketBridge.ts`.
+- **Local UI state stays in the page** (`useState`); shared concerns go through contexts and hooks in `src/hooks/` (`useTheme`, `useWebSocket`, `useClients`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
+- **Zod is the single source of truth.** Schemas in `src/schemas/` define the xray config model; every API response is parsed through them, every form field validates against them, and TypeScript types are inferred with `z.infer` — never hand-written. Go-side types are mirrored into `src/generated/` by `npm run gen:zod` (do not hand-edit that folder).
+- **xray domain logic** — link generation, protocol defaults, form ⇄ wire adapters — lives as pure functions in `src/lib/xray/`. `src/models/` keeps only thin legacy types still being migrated onto schemas.
+- **HTTP** goes through `HttpUtil` in `src/utils/index.ts`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.ts`.
 
 ### i18n
 
@@ -134,21 +159,22 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
 
 | Goal | Command |
 |------|---------|
-| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
+| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
 | Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
 
-The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there.
+The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
 
 > **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
 
 ### Adding a new page
 
-1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`).
-2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
-3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
-4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
-5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
-6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
+Most new screens are **admin-panel routes** and need no new HTML or Vite entry:
+
+1. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
+2. Register it in `src/routes.tsx` under the `/panel` tree (lazy-import it like the others).
+3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it should be reachable from the nav.
+
+Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable without the panel shell) needs the full entry treatment: add `frontend/<page>.html`, a `src/entries/<page>.tsx` bootstrap, register it in `rollupOptions.input` inside `vite.config.js`, and wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
 
 ### Conventions
 
@@ -157,27 +183,40 @@ The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/pa
 - **Function components + hooks** everywhere. No class components.
 - **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
 - **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
-- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes.
-- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode.
+- **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
+- **Document new endpoints.** Every new `g.POST`/`g.GET` in `web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
+- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
+- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — currently `8.0.16` — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
 
 ### Project layout
 
 ```
 frontend/
-├── *.html                 — Vite entry HTML, one per panel route
+├── index.html             — admin panel SPA entry
+├── login.html             — login + 2FA entry
+├── subpage.html           — public subscription viewer entry
 ├── tsconfig.json          — strict, jsx: "react-jsx", paths "@/*" → "src/*"
-├── eslint.config.js       — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks)
+├── eslint.config.js       — ESLint flat config (@eslint/js + typescript-eslint + react-hooks)
 ├── vite.config.js
+├── vitest.config.ts
+├── scripts/               — build-openapi.mjs (endpoints.ts → openapi.json)
 └── src/
-    ├── entries/           — per-page bootstrap (createRoot + render)
-    ├── pages/             — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
-    ├── components/        — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
-    ├── hooks/             — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
-    ├── api/               — Axios setup + CSRF interceptor + WebSocket client
+    ├── main.tsx           — admin SPA bootstrap (router + providers)
+    ├── routes.tsx         — react-router routes mounted under /panel
+    ├── entries/           — bootstrap for the standalone bundles (login, subpage)
+    ├── layouts/           — PanelLayout + AppSidebar
+    ├── pages/             — one folder per route (index, inbounds, clients, groups, nodes, settings, xray, api-docs) plus login, sub
+    ├── components/        — cross-page React components
+    ├── hooks/             — reusable hooks (useTheme, useWebSocket, useClients, useDatepicker, …)
+    ├── api/               — Axios + CSRF interceptor, TanStack Query provider/keys, WebSocket client
     ├── i18n/              — react-i18next bootstrap (JSON lives in web/translation/)
-    ├── models/            — Inbound, DBInbound, Outbound, Status, reality-targets, …
+    ├── lib/xray/          — pure xray logic: link generation, defaults, form ⇄ wire adapters
+    ├── schemas/           — Zod source of truth for the xray config model
+    ├── generated/         — code-generated Zod + TS types from Go (do not hand-edit)
+    ├── models/            — thin legacy types still being migrated
     ├── styles/            — shared CSS (page-cards, …)
-    └── utils/             — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
+    ├── test/              — Vitest specs + golden fixtures
+    └── utils/             — HttpUtil, ClipboardManager, SizeFormatter, …
 ```
 
 For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
@@ -202,7 +241,7 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
 3. Run the relevant checks before pushing:
    - `go build ./...`
    - `go test ./...` (when Go code changed)
-   - `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed)
+   - `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
 4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
 5. Open the PR against `main` with a brief description of what changed and how to test it.
 
@@ -218,9 +257,8 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
 | `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
 | `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
 
-## Issues and discussion
+## Issues
 
 - Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
-- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
 
 Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.

+ 1 - 1
config/version

@@ -1 +1 @@
-3.2.7
+3.2.8

+ 2 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -137,6 +137,7 @@ function applyExternalProxyTLSObj(
   if (alpn.length > 0) obj.alpn = alpn;
   const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
   if (pins.length > 0) obj.pcs = pins;
+  if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
 }
 
 export interface GenVmessLinkInput {
@@ -280,6 +281,7 @@ function applyExternalProxyTLSParams(
   if (alpn.length > 0) params.set('alpn', alpn);
   const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
   if (pins.length > 0) params.set('pcs', pins);
+  if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
 }
 
 export interface GenVlessLinkInput {

+ 2 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -203,6 +203,8 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
     tls.fingerprint = params.get('fp') ?? '';
     const alpn = params.get('alpn');
     if (alpn) tls.alpn = alpn.split(',');
+    tls.echConfigList = params.get('ech') ?? '';
+    tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
   } else if (stream.security === 'reality') {
     const reality = stream.realitySettings as Raw;
     reality.serverName = params.get('sni') ?? '';

+ 9 - 3
frontend/src/pages/inbounds/form/transport/external-proxy.tsx

@@ -16,6 +16,7 @@ const newEntry = () => ({
   fingerprint: '',
   alpn: [],
   pinnedPeerCertSha256: [],
+  echConfigList: '',
 });
 
 function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
@@ -92,9 +93,9 @@ export default function ExternalProxyForm({
                                   />
                                 </Form.Item>
                               </Field>
-                              <Field label={t('host')}>
+                              <Field label={t('pages.inbounds.address')}>
                                 <Form.Item name={[field.name, 'dest']} noStyle>
-                                  <Input placeholder={t('host')} />
+                                  <Input placeholder={t('pages.inbounds.address')} />
                                 </Form.Item>
                               </Field>
                               <Field label={t('pages.inbounds.port')}>
@@ -125,7 +126,7 @@ export default function ExternalProxyForm({
                                     <div className="ext-proxy-grid ext-proxy-grid--tls">
                                       <Field label="SNI">
                                         <Form.Item name={[field.name, 'sni']} noStyle>
-                                          <Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
+                                          <Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
                                         </Form.Item>
                                       </Field>
                                       <Field label={t('pages.inbounds.form.fingerprint')}>
@@ -157,6 +158,11 @@ export default function ExternalProxyForm({
                                         </Form.Item>
                                       </Field>
                                     </div>
+                                    <Field label={t('pages.inbounds.form.echConfig')}>
+                                      <Form.Item name={[field.name, 'echConfigList']} noStyle>
+                                        <Input placeholder={t('pages.inbounds.form.echConfig')} />
+                                      </Form.Item>
+                                    </Field>
                                     <Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
                                       <Space.Compact block>
                                         <Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>

+ 1 - 0
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -23,5 +23,6 @@ export const ExternalProxyEntrySchema = z.object({
   ),
   alpn: z.array(AlpnSchema).optional(),
   pinnedPeerCertSha256: z.array(z.string()).optional(),
+  echConfigList: z.string().optional(),
 });
 export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;

+ 15 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -360,6 +360,21 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
     const stream = parsed!.streamSettings as Record<string, unknown>;
     expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto');
   });
+
+  it('round-trips ech and pcs from a TLS vless link', () => {
+    const ech = 'AFb+DQBSAAAgACAL7gYwrvaSFCIEs34G3SkfpuIbjMuYQxAiJsPK1oO7cwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAMxMjMAAA==';
+    const pcs = '6fbc15ba46dfed152ad6c8d2129dd774707dd667a9ab4965476fa0f79ba82670';
+    const link = 'vless://e3d307ae-c074-4aa3-af08-4f9e0f1d298b@localhost:15282?'
+      + 'alpn=h3&ech=' + encodeURIComponent(ech) + '&encryption=none&fp=firefox&host=&'
+      + 'mode=packet-up&path=%2F&pcs=' + pcs + '&security=tls&sni=123&type=xhttp#i5sboxj07w';
+    const parsed = parseVlessLink(link);
+    expect(parsed).not.toBeNull();
+    const tls = (parsed!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
+    expect(tls.echConfigList).toBe(ech);
+    expect(tls.pinnedPeerCertSha256).toBe(pcs);
+    expect(tls.serverName).toBe('123');
+    expect(tls.fingerprint).toBe('firefox');
+  });
 });
 
 describe('parseWireguardLink', () => {

+ 14 - 0
sub/subService.go

@@ -1053,6 +1053,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st
 	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
 		obj["pcs"] = joinAnyStrings(pins)
 	}
+	if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
+		obj["ech"] = ech
+	}
 }
 
 func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
@@ -1071,6 +1074,9 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
 	if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
 		params["pcs"] = joinAnyStrings(pins)
 	}
+	if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
+		params["ech"] = ech
+	}
 }
 
 // applyExternalProxyHysteriaParams overrides the cert pin for a single
@@ -1143,6 +1149,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
 		}
 		settings["pinnedPeerCertSha256"] = pins
 	}
+	if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
+		settings, _ := tlsSettings["settings"].(map[string]any)
+		if settings == nil {
+			settings = map[string]any{}
+			tlsSettings["settings"] = settings
+		}
+		settings["echConfigList"] = ech
+	}
 }
 
 func externalProxySNI(ep map[string]any) (string, bool) {

+ 41 - 0
sub/subService_test.go

@@ -575,6 +575,47 @@ func TestApplyExternalProxyTLSParams_ExplicitSNIOverridesUpstream(t *testing.T)
 	}
 }
 
+func TestApplyExternalProxy_ECHPropagates(t *testing.T) {
+	const ech = "ech-config-base64"
+
+	t.Run("url params", func(t *testing.T) {
+		params := map[string]string{"security": "tls"}
+		ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
+		applyExternalProxyTLSParams(ep, params, "tls")
+		if params["ech"] != ech {
+			t.Fatalf("ech param = %q, want %q", params["ech"], ech)
+		}
+	})
+
+	t.Run("vmess obj", func(t *testing.T) {
+		obj := map[string]any{}
+		ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
+		applyExternalProxyTLSObj(ep, obj, "tls")
+		if obj["ech"] != ech {
+			t.Fatalf("ech obj = %v, want %q", obj["ech"], ech)
+		}
+	})
+
+	t.Run("json stream settings", func(t *testing.T) {
+		stream := map[string]any{"security": "tls", "tlsSettings": map[string]any{}}
+		ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
+		applyExternalProxyTLSToStream(ep, stream, "tls")
+		settings, _ := stream["tlsSettings"].(map[string]any)["settings"].(map[string]any)
+		if settings["echConfigList"] != ech {
+			t.Fatalf("echConfigList = %v, want %q", settings["echConfigList"], ech)
+		}
+	})
+
+	t.Run("non-tls security drops ech", func(t *testing.T) {
+		params := map[string]string{}
+		ep := map[string]any{"echConfigList": ech}
+		applyExternalProxyTLSParams(ep, params, "none")
+		if _, ok := params["ech"]; ok {
+			t.Fatalf("ech must not be set when security != tls")
+		}
+	})
+}
+
 func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
 	stream := map[string]any{
 		"security": "tls",

+ 0 - 1
web/translation/ar-EG.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "أقصى نافذة إرسال",
         "externalProxy": "وكيل خارجي",
         "forceTls": "فرض TLS",
-        "sniPlaceholder": "SNI (افتراضياً host)",
         "fingerprint": "بصمة",
         "defaultOption": "افتراضي",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/en-US.json

@@ -552,7 +552,6 @@
         "maxSendingWindow": "Max Sending Window",
         "externalProxy": "External Proxy",
         "forceTls": "Force TLS",
-        "sniPlaceholder": "SNI (defaults to host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Default",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/es-ES.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Máx. ventana de envío",
         "externalProxy": "Proxy externo",
         "forceTls": "Forzar TLS",
-        "sniPlaceholder": "SNI (por defecto = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Por defecto",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/fa-IR.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "حداکثر پنجره ارسال",
         "externalProxy": "پراکسی خارجی",
         "forceTls": "اجبار TLS",
-        "sniPlaceholder": "SNI (پیش‌فرض همان host)",
         "fingerprint": "اثرانگشت",
         "defaultOption": "پیش‌فرض",
         "routeMark": "علامت مسیر",

+ 0 - 1
web/translation/id-ID.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Maks. jendela pengiriman",
         "externalProxy": "Proxy eksternal",
         "forceTls": "Paksa TLS",
-        "sniPlaceholder": "SNI (default = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Default",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/ja-JP.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "最大送信ウィンドウ",
         "externalProxy": "外部プロキシ",
         "forceTls": "TLS を強制",
-        "sniPlaceholder": "SNI (デフォルトは host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "デフォルト",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/pt-BR.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Máx. janela de envio",
         "externalProxy": "Proxy externo",
         "forceTls": "Forçar TLS",
-        "sniPlaceholder": "SNI (padrão = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Padrão",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/ru-RU.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Макс. окно отправки",
         "externalProxy": "External Proxy",
         "forceTls": "Принудительный TLS",
-        "sniPlaceholder": "SNI (по умолчанию = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "По умолчанию",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/tr-TR.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Maks. gönderme penceresi",
         "externalProxy": "Harici proxy",
         "forceTls": "TLS zorla",
-        "sniPlaceholder": "SNI (varsayılan host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Varsayılan",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/uk-UA.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Макс. вікно відправки",
         "externalProxy": "External Proxy",
         "forceTls": "Примусовий TLS",
-        "sniPlaceholder": "SNI (за замовчуванням = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "За замовчуванням",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/vi-VN.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "Cửa sổ gửi tối đa",
         "externalProxy": "Proxy ngoài",
         "forceTls": "Bắt buộc TLS",
-        "sniPlaceholder": "SNI (mặc định = host)",
         "fingerprint": "Fingerprint",
         "defaultOption": "Mặc định",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/zh-CN.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "最大发送窗口",
         "externalProxy": "外部代理",
         "forceTls": "强制 TLS",
-        "sniPlaceholder": "SNI (默认为 host)",
         "fingerprint": "指纹",
         "defaultOption": "默认",
         "routeMark": "Route Mark",

+ 0 - 1
web/translation/zh-TW.json

@@ -551,7 +551,6 @@
         "maxSendingWindow": "最大發送視窗",
         "externalProxy": "外部代理",
         "forceTls": "強制 TLS",
-        "sniPlaceholder": "SNI (預設為 host)",
         "fingerprint": "指紋",
         "defaultOption": "預設",
         "routeMark": "Route Mark",

+ 102 - 0
x-ui.sh

@@ -2795,6 +2795,7 @@ postgresql_menu() {
     echo -e "${green}\t6.${plain} Restart PostgreSQL"
     echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
     echo -e "${green}\t8.${plain} View PostgreSQL Log"
+    echo -e "${green}\t9.${plain} Convert SQLite ${green}.db <-> .dump${plain}"
     echo -e "${green}\t0.${plain} Back to Main Menu"
     read -rp "Choose an option: " choice
     case "$choice" in
@@ -2833,6 +2834,10 @@ postgresql_menu() {
             postgresql_log
             postgresql_menu
             ;;
+        9)
+            migrate_db_prompt
+            postgresql_menu
+            ;;
         *)
             echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
             postgresql_menu
@@ -2840,6 +2845,99 @@ postgresql_menu() {
     esac
 }
 
+# Convert between the panel's SQLite database and a portable .dump (SQL text)
+# file using the bundled x-ui binary. With no arguments it dumps the installed
+# panel database; an optional second argument overrides the output path.
+#   x-ui migrateDB [file.db|file.dump] [output]
+migrate_db() {
+    local input="$1" output="$2"
+    local default_db="/etc/x-ui/x-ui.db"
+    local bin="${xui_folder}/x-ui"
+
+    [[ -z "$input" ]] && input="$default_db"
+
+    if [[ ! -x "$bin" ]]; then
+        LOGE "x-ui binary not found at ${bin}. Is the panel installed?"
+        return 1
+    fi
+
+    if ! "$bin" migrate-db -h 2>&1 | grep -q -- '-dump'; then
+        LOGE "This x-ui build does not support .db <-> .dump conversion yet."
+        LOGE "Update the panel first (x-ui update) to a version with 'migrate-db --dump/--restore'."
+        return 1
+    fi
+
+    if [[ ! -f "$input" ]]; then
+        LOGE "Input file not found: ${input}"
+        echo -e "Usage: ${green}x-ui migrateDB [file.db|file.dump] [output]${plain}"
+        return 1
+    fi
+
+    local mode
+    case "$input" in
+        *.db | *.sqlite | *.sqlite3)
+            mode="dump"
+            ;;
+        *.dump | *.sql)
+            mode="restore"
+            ;;
+        *)
+            if head -c 16 "$input" | grep -q "SQLite format 3"; then
+                mode="dump"
+            else
+                mode="restore"
+            fi
+            ;;
+    esac
+
+    if [[ "$mode" == "dump" ]]; then
+        [[ -z "$output" ]] && output="${input%.*}.dump"
+        if [[ -f "$output" ]]; then
+            confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
+        fi
+        LOGI "Dumping SQLite database to SQL text:"
+        echo -e "  ${green}${input}${plain} -> ${green}${output}${plain}"
+        if "$bin" migrate-db --src "$input" --dump "$output"; then
+            LOGI "Done. Wrote ${output}."
+        else
+            LOGE "Dump failed."
+            return 1
+        fi
+    else
+        [[ -z "$output" ]] && output="${input%.*}.db"
+        if [[ "$output" == "$default_db" ]] && check_status > /dev/null 2>&1; then
+            LOGE "Refusing to restore into the live database (${default_db}) while x-ui is running."
+            LOGE "Stop the panel first (x-ui stop) or choose a different output path."
+            return 1
+        fi
+        if [[ -f "$output" ]]; then
+            confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
+            rm -f "$output"
+        fi
+        LOGI "Rebuilding SQLite database from SQL text:"
+        echo -e "  ${green}${input}${plain} -> ${green}${output}${plain}"
+        if "$bin" migrate-db --restore "$input" --out "$output"; then
+            LOGI "Done. Created ${output}."
+        else
+            LOGE "Restore failed."
+            rm -f "$output"
+            return 1
+        fi
+    fi
+}
+
+# Interactive wrapper around migrate_db for the menu: prompts for the paths and
+# lets migrate_db auto-detect the direction.
+migrate_db_prompt() {
+    local default_db="/etc/x-ui/x-ui.db"
+    local input output
+    echo -e "Convert between a SQLite ${green}.db${plain} and a portable ${green}.dump${plain} (direction auto-detected)."
+    read -rp "Input file [${default_db}]: " input
+    input="${input:-$default_db}"
+    read -rp "Output file (leave empty to auto-name next to input): " output
+    migrate_db "$input" "$output"
+}
+
 show_usage() {
     echo -e "┌────────────────────────────────────────────────────────────────┐
 │  ${blue}x-ui control menu usages (subcommands):${plain}                       │
@@ -2857,6 +2955,7 @@ show_usage() {
 │  ${blue}x-ui banlog${plain}                - Check Fail2ban ban logs          │
 │  ${blue}x-ui update${plain}                - Update                           │
 │  ${blue}x-ui update-all-geofiles${plain}   - Update all geo files             │
+│  ${blue}x-ui migrateDB [file]${plain}      - Convert .db <-> .dump (SQLite)   │
 │  ${blue}x-ui legacy${plain}                - Legacy version                   │
 │  ${blue}x-ui install${plain}               - Install                          │
 │  ${blue}x-ui uninstall${plain}             - Uninstall                        │
@@ -3045,6 +3144,9 @@ if [[ $# > 0 ]]; then
         "update-all-geofiles")
             check_install 0 && update_all_geofiles 0 && restart 0
             ;;
+        "migrateDB")
+            migrate_db "$2" "$3"
+            ;;
         *) show_usage ;;
     esac
 else