1
0

6 Коммиты 26c549a95a ... 3092326d9e

Автор SHA1 Сообщение Дата
  MHSanaei 3092326d9e refactor: replace custom geo manager with Xray-core native geodata auto-update 12 часов назад
  Rouzbeh† 4002be4ade feat: support latest Wireguard features from Xray-core (PRs #5643, #5833, #5850) (#5131) 13 часов назад
  Wenkai Xie f9b275dd23 fix(ui): keep client IP log modal above edit modal (#5137) 14 часов назад
  吉姆·塞尔夫 dbb269cf6a fix(ui): correct inline style syntax between clients count and active clients count on inbounds page (#5114) 14 часов назад
  Turan d047075f76 docs: add Turkish language link to other README files (#5138) 15 часов назад
  Sanaei 41645255f1 refactor: focused service files, leaf subpackages, and an internal/ layout (#5167) 15 часов назад
100 измененных файлов с 489 добавлено и 1110 удалено
  1. 2 1
      .dockerignore
  2. 4 4
      .github/workflows/ci.yml
  3. 18 19
      .github/workflows/claude-issue-bot.yml
  4. 2 2
      .github/workflows/release.yml
  5. 4 7
      .gitignore
  6. 9 9
      CONTRIBUTING.md
  7. 3 3
      Dockerfile
  8. 1 1
      README.ar_EG.md
  9. 1 1
      README.es_ES.md
  10. 1 1
      README.fa_IR.md
  11. 1 1
      README.md
  12. 1 1
      README.ru_RU.md
  13. 1 1
      README.zh_CN.md
  14. 1 1
      docs/custom-subscription-templates.md
  15. 5 5
      frontend/README.md
  16. 1 1
      frontend/eslint.config.js
  17. 1 1
      frontend/eslint.deprecated.config.js
  18. 0 305
      frontend/public/openapi.json
  19. 0 11
      frontend/src/generated/examples.ts
  20. 0 43
      frontend/src/generated/schemas.ts
  21. 0 13
      frontend/src/generated/types.ts
  22. 0 16
      frontend/src/generated/zod.ts
  23. 4 4
      frontend/src/i18n/react.ts
  24. 41 8
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  25. 1 1
      frontend/src/lib/xray/protocol-capabilities.ts
  26. 0 55
      frontend/src/pages/api-docs/endpoints.ts
  27. 5 0
      frontend/src/pages/clients/ClientFormModal.tsx
  28. 1 1
      frontend/src/pages/clients/FilterDrawer.tsx
  29. 1 1
      frontend/src/pages/groups/GroupAddClientsModal.tsx
  30. 1 1
      frontend/src/pages/groups/GroupRemoveClientsModal.tsx
  31. 1 1
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  32. 1 1
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  33. 1 1
      frontend/src/pages/inbounds/clients/DetachClientsModal.tsx
  34. 17 4
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  35. 16 1
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  36. 1 1
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  37. 0 114
      frontend/src/pages/index/CustomGeoFormModal.tsx
  38. 0 65
      frontend/src/pages/index/CustomGeoSection.css
  39. 0 283
      frontend/src/pages/index/CustomGeoSection.tsx
  40. 219 0
      frontend/src/pages/index/GeodataSection.tsx
  41. 9 3
      frontend/src/pages/index/VersionModal.tsx
  42. 9 2
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  43. 19 2
      frontend/src/schemas/protocols/inbound/wireguard.ts
  44. 0 21
      frontend/src/schemas/xray.ts
  45. 14 14
      frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap
  46. 1 1
      frontend/src/test/setup.components.ts
  47. 1 1
      frontend/tsconfig.json
  48. 1 1
      frontend/vite.config.js
  49. 7 0
      internal/config/config.go
  50. 0 0
      internal/config/name
  51. 0 0
      internal/config/version
  52. 5 6
      internal/database/db.go
  53. 1 1
      internal/database/db_seed_test.go
  54. 0 0
      internal/database/dialect.go
  55. 0 0
      internal/database/dump_sqlite.go
  56. 2 2
      internal/database/dump_sqlite_test.go
  57. 3 4
      internal/database/migrate_data.go
  58. 1 1
      internal/database/migrate_data_test.go
  59. 2 14
      internal/database/model/model.go
  60. 0 0
      internal/database/model/model_mtproto_test.go
  61. 0 0
      internal/database/model/model_test.go
  62. 0 0
      internal/database/model/node_client_traffic.go
  63. 1 1
      internal/logger/logger.go
  64. 2 2
      internal/mtproto/manager.go
  65. 1 1
      internal/mtproto/manager_test.go
  66. 0 0
      internal/mtproto/orphans_linux.go
  67. 0 0
      internal/mtproto/orphans_other.go
  68. 2 2
      internal/mtproto/process.go
  69. 0 0
      internal/mtproto/process_other.go
  70. 1 1
      internal/mtproto/process_windows.go
  71. 1 1
      internal/sub/build_urls_test.go
  72. 3 3
      internal/sub/clash_service.go
  73. 1 1
      internal/sub/clash_service_test.go
  74. 4 4
      internal/sub/controller.go
  75. 0 0
      internal/sub/controller_test.go
  76. 0 0
      internal/sub/default.json
  77. 2 2
      internal/sub/dist.go
  78. 5 5
      internal/sub/json_service.go
  79. 1 1
      internal/sub/json_service_test.go
  80. 2 2
      internal/sub/links.go
  81. 0 0
      internal/sub/links_test.go
  82. 9 9
      internal/sub/service.go
  83. 1 1
      internal/sub/service_test.go
  84. 3 3
      internal/sub/service_userinfo_test.go
  85. 8 8
      internal/sub/sub.go
  86. 1 1
      internal/util/common/err.go
  87. 0 0
      internal/util/common/format.go
  88. 0 0
      internal/util/common/format_test.go
  89. 0 0
      internal/util/common/multi_error.go
  90. 0 0
      internal/util/common/multi_error_test.go
  91. 0 0
      internal/util/crypto/crypto.go
  92. 0 0
      internal/util/crypto/crypto_test.go
  93. 0 0
      internal/util/json_util/json.go
  94. 0 0
      internal/util/json_util/json_test.go
  95. 0 0
      internal/util/ldap/ldap.go
  96. 0 0
      internal/util/link/outbound.go
  97. 0 0
      internal/util/link/outbound_test.go
  98. 0 0
      internal/util/netproxy/netproxy.go
  99. 0 0
      internal/util/netproxy/netproxy_test.go
  100. 0 0
      internal/util/netsafe/netsafe.go

+ 2 - 1
.dockerignore

@@ -1,9 +1,10 @@
 .git
 **/node_modules
-web/dist
+internal/web/dist
 build
 db
 cert
 pgdata
+x-ui/
 *.db
 *.dump

+ 4 - 4
.github/workflows/ci.yml

@@ -30,8 +30,8 @@ jobs:
         with:
           go-version-file: go.mod
           cache: true
-      - name: Stub web/dist for go:embed
-        run: mkdir -p web/dist && touch web/dist/.gitkeep
+      - name: Stub internal/web/dist for go:embed
+        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
       - name: Test
         run: |
           go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
@@ -62,8 +62,8 @@ jobs:
         with:
           go-version-file: go.mod
           cache: true
-      - name: Stub web/dist for go:embed
-        run: mkdir -p web/dist && touch web/dist/.gitkeep
+      - name: Stub internal/web/dist for go:embed
+        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
       - name: Install govulncheck
         run: go install golang.org/x/vuln/cmd/govulncheck@latest
       - name: Run govulncheck

Разница между файлами не показана из-за своего большого размера
+ 18 - 19
.github/workflows/claude-issue-bot.yml


+ 2 - 2
.github/workflows/release.yml

@@ -53,8 +53,8 @@ jobs:
           check-latest: true
 
       # Frontend dist must be built BEFORE go build — Go's //go:embed
-      # all:dist directive in web/web.go requires web/dist/ to exist
-      # at compile time. web/dist/ is .gitignored, so on a fresh CI
+      # all:dist directive in internal/web/web.go requires internal/web/dist/ to exist
+      # at compile time. internal/web/dist/ is .gitignored, so on a fresh CI
       # checkout it doesn't exist until vite emits it.
       - name: Setup Node.js
         uses: actions/setup-node@v6

+ 4 - 7
.gitignore

@@ -16,20 +16,17 @@ tmp/
 # Ignore build and distribution directories
 backup/
 bin/
+x-ui/
 dist/
-!web/dist/
-web/dist/*
-!web/dist/.gitkeep
+!internal/web/dist/
+internal/web/dist/*
+!internal/web/dist/.gitkeep
 release/
 node_modules/
 
 # Ignore compiled binaries
 main
 
-# Ignore script and executable files
-/release.sh
-/x-ui
-
 # Ignore OS specific files
 .DS_Store
 Thumbs.db

+ 9 - 9
CONTRIBUTING.md

@@ -135,7 +135,7 @@ The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`
 
 ### Architecture
 
-The frontend ships **three Vite bundles**, each emitted into `web/dist/` and embedded into the Go binary at compile time via `embed.FS`:
+The frontend ships **three Vite bundles**, each emitted into `internal/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.
@@ -153,7 +153,7 @@ Panel navigation happens client-side through React Router, and per-route code is
 
 ### i18n
 
-Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
+Locale strings live in `internal/web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
 
 ### Two dev workflows
 
@@ -184,7 +184,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
 - **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.
 - **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`).
+- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/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.
 
@@ -209,7 +209,7 @@ frontend/
     ├── 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 internal/web/translation/)
     ├── 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)
@@ -226,12 +226,12 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
 | Path | Contents |
 |------|----------|
 | `main.go` | Process entry point, CLI subcommands, signal handling |
-| `web/` | Gin HTTP server, controllers, services, embedded frontend assets |
+| `internal/web/` | Gin HTTP server, controllers, services, embedded frontend assets |
 | `frontend/` | React + Ant Design 6 + TypeScript source for the panel UI |
-| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
-| `xray/` | Xray-core process lifecycle and gRPC API client |
-| `sub/` | Subscription endpoints (raw, JSON, Clash) |
-| `config/` | Environment-variable helpers, paths, defaults |
+| `internal/database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
+| `internal/xray/` | Xray-core process lifecycle and gRPC API client |
+| `internal/sub/` | Subscription endpoints (raw, JSON, Clash) |
+| `internal/config/` | Environment-variable helpers, paths, defaults |
 | `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
 
 ## Sending a pull request

+ 3 - 3
Dockerfile

@@ -6,7 +6,7 @@ WORKDIR /src/frontend
 COPY frontend/package.json frontend/package-lock.json ./
 RUN npm ci
 COPY frontend/ ./
-COPY web/translation /src/web/translation
+COPY internal/web/translation /src/internal/web/translation
 RUN npm run build
 
 # ========================================================
@@ -23,7 +23,7 @@ RUN apk --no-cache --update add \
   unzip
 
 COPY . .
-COPY --from=frontend /src/web/dist ./web/dist
+COPY --from=frontend /src/internal/web/dist ./internal/web/dist
 
 ENV CGO_ENABLED=1
 ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
@@ -48,7 +48,7 @@ RUN apk add --no-cache --update \
 COPY --from=builder /app/build/ /app/
 COPY --from=builder /app/DockerEntrypoint.sh /app/
 COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
-COPY --from=builder /app/web/translation /app/web/translation
+COPY --from=builder /app/internal/web/translation /app/internal/web/translation
 
 
 # Configure fail2ban

+ 1 - 1
README.ar_EG.md

@@ -1,4 +1,4 @@
-[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)
+[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>

+ 1 - 1
README.es_ES.md

@@ -1,4 +1,4 @@
-[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)
+[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>

+ 1 - 1
README.fa_IR.md

@@ -1,4 +1,4 @@
-[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)
+[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>

+ 1 - 1
README.md

@@ -33,7 +33,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
 - **Traffic statistics** — per inbound, per client, and per outbound, with reset controls.
 - **Multi-node support** — manage and scale across multiple servers from a single panel.
 - **Outbound & routing** — WARP, NordVPN, custom routing rules, load balancers, and outbound proxy chaining.
-- **Built-in subscription server** with multiple output formats.
+- **Built-in subscription server** with multiple output formats and [custom page templates](docs/custom-subscription-templates.md).
 - **Telegram bot** for remote monitoring and management.
 - **RESTful API** with in-panel Swagger documentation.
 - **Flexible storage** — SQLite (default) or PostgreSQL.

+ 1 - 1
README.ru_RU.md

@@ -1,4 +1,4 @@
-[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)
+[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>

+ 1 - 1
README.zh_CN.md

@@ -1,4 +1,4 @@
-[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)
+[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>

+ 1 - 1
sub_templates/README.md → docs/custom-subscription-templates.md

@@ -1,6 +1,6 @@
 # 3x-ui Custom Subscription Templates
 
-This directory allows you to use custom HTML templates for your users' subscription pages.
+3x-ui can render your users' subscription pages from your own custom HTML templates.
 
 ## How to use a Custom Template
 

+ 5 - 5
frontend/README.md

@@ -3,7 +3,7 @@
 React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles —
 `index.html` (admin panel SPA, all `/panel/*` routes), `login.html`
 (login + 2FA), and `subpage.html` (public subscription viewer). All
-three are built into `../web/dist/` and embedded into the Go binary
+three are built into `../internal/web/dist/` and embedded into the Go binary
 via `embed.FS`.
 
 State is split between local `useState`, TanStack Query for server
@@ -30,7 +30,7 @@ production-style links work without round-tripping through Go.
 | Command | What |
 |---|---|
 | `npm run dev` | Vite dev server with API + WS proxy to Go |
-| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../web/dist/` |
+| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../internal/web/dist/` |
 | `npm run preview` | Serve the built bundle locally |
 | `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
 | `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
@@ -62,11 +62,11 @@ the wall-clock time.
 npm run build
 ```
 
-Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
+Outputs to `../internal/web/dist/` (HTML at the root, hashed JS/CSS under
 `assets/`). `manualChunks` splits AntD, icons, codemirror, and
 react-query into separate vendor bundles to keep the per-page
 initial JS small. The Go binary embeds this directory at compile
-time and `web/controller/dist.go` serves the per-page HTML.
+time and `internal/web/controller/dist.go` serves the per-page HTML.
 
 ## Layout
 
@@ -93,7 +93,7 @@ frontend/
     ├── hooks/           # useClients, useTheme, useWebSocket, …
     ├── api/             # Axios + CSRF interceptor, TanStack Query bridge,
     │                    #   WebSocket client + queryClient.ts
-    ├── i18n/            # react-i18next init (locales in web/translation/)
+    ├── i18n/            # react-i18next init (locales in internal/web/translation/)
     ├── lib/xray/        # Pure functions: link generation, defaults,
     │                    #   form ⇄ wire adapters, protocol capabilities
     ├── schemas/         # Zod source-of-truth (see "Schemas" below)

+ 1 - 1
frontend/eslint.config.js

@@ -4,7 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks';
 import globals from 'globals';
 
 export default [
-  { ignores: ['node_modules/**', '../web/dist/**'] },
+  { ignores: ['node_modules/**', '../internal/web/dist/**'] },
   js.configs.recommended,
   ...tseslint.configs.recommended.map((config) => ({
     ...config,

+ 1 - 1
frontend/eslint.deprecated.config.js

@@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint';
 import reactHooks from 'eslint-plugin-react-hooks';
 
 export default [
-  { ignores: ['node_modules/**', '../web/dist/**', 'src/generated/**'] },
+  { ignores: ['node_modules/**', '../internal/web/dist/**', 'src/generated/**'] },
   {
     files: ['**/*.{ts,tsx}'],
     plugins: {

+ 0 - 305
frontend/public/openapi.json

@@ -1219,49 +1219,6 @@
         ],
         "type": "object"
       },
-      "CustomGeoResource": {
-        "properties": {
-          "alias": {
-            "type": "string"
-          },
-          "createdAt": {
-            "type": "integer"
-          },
-          "id": {
-            "type": "integer"
-          },
-          "lastModified": {
-            "type": "string"
-          },
-          "lastUpdatedAt": {
-            "type": "integer"
-          },
-          "localPath": {
-            "type": "string"
-          },
-          "type": {
-            "type": "string"
-          },
-          "updatedAt": {
-            "type": "integer"
-          },
-          "url": {
-            "type": "string"
-          }
-        },
-        "required": [
-          "alias",
-          "createdAt",
-          "id",
-          "lastModified",
-          "lastUpdatedAt",
-          "localPath",
-          "type",
-          "updatedAt",
-          "url"
-        ],
-        "type": "object"
-      },
       "FallbackParentInfo": {
         "description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
         "properties": {
@@ -1880,10 +1837,6 @@
       "name": "Nodes",
       "description": "Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes."
     },
-    {
-      "name": "Custom Geo",
-      "description": "Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo."
-    },
     {
       "name": "Backup",
       "description": "Operations that interact with the configured Telegram bot."
@@ -6593,264 +6546,6 @@
         }
       }
     },
-    "/panel/api/custom-geo/list": {
-      "get": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.",
-        "operationId": "get_panel_api_custom_geo_list",
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/aliases": {
-      "get": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.",
-        "operationId": "get_panel_api_custom_geo_aliases",
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/add": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.",
-        "operationId": "post_panel_api_custom_geo_add",
-        "requestBody": {
-          "required": true,
-          "content": {
-            "application/json": {
-              "schema": {
-                "type": "object"
-              },
-              "example": {
-                "type": "geoip",
-                "alias": "myips",
-                "url": "https://example.com/geo/my.dat"
-              }
-            }
-          }
-        },
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/update/{id}": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Replace a custom geo source. Same body shape as /add.",
-        "operationId": "post_panel_api_custom_geo_update_id",
-        "parameters": [
-          {
-            "name": "id",
-            "in": "path",
-            "required": true,
-            "description": "Custom geo source ID.",
-            "schema": {
-              "type": "integer"
-            }
-          }
-        ],
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/delete/{id}": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Remove a custom geo source and its cached file.",
-        "operationId": "post_panel_api_custom_geo_delete_id",
-        "parameters": [
-          {
-            "name": "id",
-            "in": "path",
-            "required": true,
-            "description": "Custom geo source ID.",
-            "schema": {
-              "type": "integer"
-            }
-          }
-        ],
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/download/{id}": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Re-download one custom geo source on demand.",
-        "operationId": "post_panel_api_custom_geo_download_id",
-        "parameters": [
-          {
-            "name": "id",
-            "in": "path",
-            "required": true,
-            "description": "Custom geo source ID.",
-            "schema": {
-              "type": "integer"
-            }
-          }
-        ],
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/update-all": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Re-download every configured custom geo source. Errors are reported per-source in the response.",
-        "operationId": "post_panel_api_custom_geo_update_all",
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
     "/panel/api/backuptotgbot": {
       "post": {
         "tags": [

+ 0 - 11
frontend/src/generated/examples.ts

@@ -250,17 +250,6 @@ export const EXAMPLES: Record<string, unknown> = {
     "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": ""

+ 0 - 43
frontend/src/generated/schemas.ts

@@ -1193,49 +1193,6 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
-  "CustomGeoResource": {
-    "properties": {
-      "alias": {
-        "type": "string"
-      },
-      "createdAt": {
-        "type": "integer"
-      },
-      "id": {
-        "type": "integer"
-      },
-      "lastModified": {
-        "type": "string"
-      },
-      "lastUpdatedAt": {
-        "type": "integer"
-      },
-      "localPath": {
-        "type": "string"
-      },
-      "type": {
-        "type": "string"
-      },
-      "updatedAt": {
-        "type": "integer"
-      },
-      "url": {
-        "type": "string"
-      }
-    },
-    "required": [
-      "alias",
-      "createdAt",
-      "id",
-      "lastModified",
-      "lastUpdatedAt",
-      "localPath",
-      "type",
-      "updatedAt",
-      "url"
-    ],
-    "type": "object"
-  },
   "FallbackParentInfo": {
     "description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
     "properties": {

+ 0 - 13
frontend/src/generated/types.ts

@@ -1,5 +1,4 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
-export type LoginStatus = number;
 export type ProcessState = string;
 export type Protocol = string;
 export type SubLinkProvider = unknown;
@@ -264,18 +263,6 @@ export interface ClientTraffic {
   uuid: string;
 }
 
-export interface CustomGeoResource {
-  alias: string;
-  createdAt: number;
-  id: number;
-  lastModified: string;
-  lastUpdatedAt: number;
-  localPath: string;
-  type: string;
-  updatedAt: number;
-  url: string;
-}
-
 export interface FallbackParentInfo {
   masterId: number;
   path?: string;

+ 0 - 16
frontend/src/generated/zod.ts

@@ -1,8 +1,5 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
 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>;
 
@@ -283,19 +280,6 @@ export const ClientTrafficSchema = z.object({
 });
 export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
 
-export const CustomGeoResourceSchema = z.object({
-  alias: z.string(),
-  createdAt: z.number().int(),
-  id: z.number().int(),
-  lastModified: z.string(),
-  lastUpdatedAt: z.number().int(),
-  localPath: z.string(),
-  type: z.string(),
-  updatedAt: z.number().int(),
-  url: z.string(),
-});
-export type CustomGeoResource = z.infer<typeof CustomGeoResourceSchema>;
-
 export const FallbackParentInfoSchema = z.object({
   masterId: z.number().int(),
   path: z.string().optional(),

+ 4 - 4
frontend/src/i18n/react.ts

@@ -2,17 +2,17 @@ import i18next from 'i18next';
 import { initReactI18next } from 'react-i18next';
 
 import { LanguageManager } from '@/utils';
-import enUS from '../../../web/translation/en-US.json';
+import enUS from '../../../internal/web/translation/en-US.json';
 
 const FALLBACK = 'en-US';
 
 const lazyModules = import.meta.glob([
-  '../../../web/translation/*.json',
-  '!../../../web/translation/en-US.json',
+  '../../../internal/web/translation/*.json',
+  '!../../../internal/web/translation/en-US.json',
 ]);
 
 function moduleKeyFor(code: string): string {
-  return `../../../web/translation/${code}.json`;
+  return `../../../internal/web/translation/${code}.json`;
 }
 
 let active: string = LanguageManager.getLanguage();

+ 41 - 8
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -96,8 +96,12 @@ function defaultUdpHop(): Record<string, unknown> {
 export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
   const base = asPath(name);
   const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
-  const showTcp = showAll || TCP_NETWORKS.includes(network);
-  const showUdp = showAll || isHysteria || network === 'kcp';
+  // Wireguard carries no user-selectable transport (always a UDP listener/
+  // dialer), so only the UDP mask section applies — TCP masks would never
+  // wrap anything even though the leftover network value may be 'tcp'.
+  const isWireguard = protocol === 'wireguard';
+  const showTcp = showAll || (!isWireguard && TCP_NETWORKS.includes(network));
+  const showUdp = showAll || isHysteria || isWireguard || network === 'kcp';
   const showQuic = showAll || isHysteria || network === 'xhttp';
   const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
   const hasQuicParams = quicParams != null;
@@ -107,7 +111,7 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
   return (
     <>
       {showTcp && <TcpMasksList base={base} form={form} />}
-      {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} network={network} />}
+      {showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} isWireguard={isWireguard} network={network} />}
       {showQuic && (
         <>
           <Form.Item label="QUIC Params">
@@ -275,6 +279,22 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
   return Promise.resolve();
 }
 
+// randRange bytes must sit in 0-255 — xray rejects the whole config with
+// "invalid randRange" otherwise (reversed ranges like "200-100" are fine,
+// xray reorders them).
+function validateRandRange(_rule: unknown, value: unknown): Promise<void> {
+  const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
+  if (str.length === 0) return Promise.resolve();
+  const m = /^(\d{1,3})(?:-(\d{1,3}))?$/.exec(str);
+  if (!m) return Promise.reject(new Error('Use a byte value or range like 0-255'));
+  const from = Number(m[1]);
+  const to = m[2] !== undefined ? Number(m[2]) : from;
+  if (from > 255 || to > 255) {
+    return Promise.reject(new Error('randRange bytes must be within 0-255'));
+  }
+  return Promise.resolve();
+}
+
 function getDeep(obj: unknown, path: (string | number)[]): unknown {
   let cur: unknown = obj;
   for (const key of path) {
@@ -345,8 +365,8 @@ function HeaderCustomGroups({
 }
 
 function UdpMasksList({
-  base, form, isHysteria, network,
-}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; network: string }) {
+  base, form, isHysteria, isWireguard, network,
+}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) {
   return (
     <Form.List name={[...base, 'udp']}>
       {(fields, { add, remove }) => (
@@ -357,7 +377,7 @@ function UdpMasksList({
               size="small"
               icon={<PlusOutlined />}
               onClick={() => {
-                const def = isHysteria ? 'salamander' : 'mkcp-legacy';
+                const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy';
                 add({ type: def, settings: defaultUdpMaskSettings(def) });
               }}
             />
@@ -370,6 +390,7 @@ function UdpMasksList({
               form={form}
               listPath={[...base, 'udp']}
               isHysteria={isHysteria}
+              isWireguard={isWireguard}
               network={network}
               onRemove={() => remove(field.name)}
             />
@@ -381,13 +402,14 @@ function UdpMasksList({
 }
 
 function UdpMaskItem({
-  fieldName, displayIndex, form, listPath, isHysteria, network, onRemove,
+  fieldName, displayIndex, form, listPath, isHysteria, isWireguard, network, onRemove,
 }: {
   fieldName: number;
   displayIndex: number;
   form: FormInstance;
   listPath: (string | number)[];
   isHysteria: boolean;
+  isWireguard: boolean;
   network: string;
   onRemove: () => void;
 }) {
@@ -404,6 +426,9 @@ function UdpMaskItem({
   const options = isHysteria
     ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
     : [
+      // Salamander is the mask xray-core's own wireguard finalmask example
+      // uses; it stays hysteria-only elsewhere to keep legacy parity.
+      ...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []),
       { value: 'mkcp-legacy', label: 'mKCP Legacy' },
       { value: 'xdns', label: 'xDNS' },
       { value: 'xicmp', label: 'xICMP' },
@@ -674,7 +699,15 @@ function ItemEditor({
                     <InputNumber min={0} />
                   )}
                 </Form.Item>
-                <Form.Item label="Rand Range" name={[fieldName, 'randRange']}>
+                {/* Cleared must become undefined, not '': xray parses an
+                    explicit "" as the range 0-0 (all-zero fill bytes), while
+                    an omitted randRange falls back to the 0-255 default. */}
+                <Form.Item
+                  label="Rand Range"
+                  name={[fieldName, 'randRange']}
+                  normalize={(v) => (v === '' ? undefined : v)}
+                  rules={[{ validator: validateRandRange }]}
+                >
                   <Input placeholder="0-255" />
                 </Form.Item>
               </>

+ 1 - 1
frontend/src/lib/xray/protocol-capabilities.ts

@@ -9,7 +9,7 @@ const TLS_ELIGIBLE_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks'];
 const TLS_NETWORKS = ['tcp', 'ws', 'http', 'grpc', 'httpupgrade', 'xhttp'];
 const REALITY_ELIGIBLE_PROTOCOLS = ['vless', 'trojan'];
 const REALITY_NETWORKS = ['tcp', 'http', 'grpc', 'xhttp'];
-const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria'];
+const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria', 'wireguard'];
 const VISION_FLOW = 'xtls-rprx-vision';
 const SS_2022_PREFIX = '2022';
 const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';

+ 0 - 55
frontend/src/pages/api-docs/endpoints.ts

@@ -860,61 +860,6 @@ export const sections: readonly Section[] = [
     ],
   },
 
-  {
-    id: 'custom-geo',
-    title: 'Custom Geo',
-    description:
-      'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
-    endpoints: [
-      {
-        method: 'GET',
-        path: '/panel/api/custom-geo/list',
-        summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
-      },
-      {
-        method: 'GET',
-        path: '/panel/api/custom-geo/aliases',
-        summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/add',
-        summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
-        body:
-          '{\n  "type": "geoip",\n  "alias": "myips",\n  "url": "https://example.com/geo/my.dat"\n}',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/update/:id',
-        summary: 'Replace a custom geo source. Same body shape as /add.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/delete/:id',
-        summary: 'Remove a custom geo source and its cached file.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/download/:id',
-        summary: 'Re-download one custom geo source on demand.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/update-all',
-        summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
-      },
-    ],
-  },
-
   {
     id: 'backup',
     title: 'Backup',

+ 5 - 0
frontend/src/pages/clients/ClientFormModal.tsx

@@ -32,6 +32,9 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
   'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
 ]);
 
+const CLIENT_FORM_MODAL_Z_INDEX = 1000;
+const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
+
 interface ApiMsg<T = unknown> {
   success?: boolean;
   obj?: T;
@@ -414,6 +417,7 @@ export default function ClientFormModal({
         cancelText={t('cancel')}
         okButtonProps={{ loading: submitting }}
         width={720}
+        zIndex={CLIENT_FORM_MODAL_Z_INDEX}
         style={{ top: 20 }}
         styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
         onOk={onSubmit}
@@ -630,6 +634,7 @@ export default function ClientFormModal({
         open={ipsModalOpen}
         title={`${t('pages.clients.ipLog')}${client?.email ? ` — ${client.email}` : ''}`}
         width={440}
+        zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
         onCancel={() => setIpsModalOpen(false)}
         footer={[
           <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>

+ 1 - 1
frontend/src/pages/clients/FilterDrawer.tsx

@@ -94,7 +94,7 @@ export default function FilterDrawer({
             value={filters.buckets}
             onChange={(v) => patch('buckets', v as string[])}
           >
-            <Space direction="vertical">
+            <Space orientation="vertical">
               {BUCKET_KEYS.map((k) => (
                 <Checkbox key={k} value={k}>
                   {bucketLabel(k, t)}

+ 1 - 1
frontend/src/pages/groups/GroupAddClientsModal.tsx

@@ -122,7 +122,7 @@ export default function GroupAddClientsModal({
         {t('pages.groups.addToGroupDesc')}
       </Typography.Paragraph>
 
-      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+      <Space orientation="vertical" size="small" style={{ width: '100%' }}>
         <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
           <Input.Search
             allowClear

+ 1 - 1
frontend/src/pages/groups/GroupRemoveClientsModal.tsx

@@ -110,7 +110,7 @@ export default function GroupRemoveClientsModal({
         {t('pages.groups.removeFromGroupDesc')}
       </Typography.Paragraph>
 
-      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+      <Space orientation="vertical" size="small" style={{ width: '100%' }}>
         <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
           <Input.Search
             allowClear

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -158,7 +158,7 @@ export default function AttachClientsModal({
         {t('pages.inbounds.attachClientsDesc', { count: clientRows.length })}
       </Typography.Paragraph>
 
-      <Space direction="vertical" size="small" style={{ width: '100%', marginBottom: 12 }}>
+      <Space orientation="vertical" size="small" style={{ width: '100%', marginBottom: 12 }}>
         <Typography.Text strong>{t('pages.inbounds.attachClientsSelectLabel')}</Typography.Text>
         <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
           <Input.Search

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -182,7 +182,7 @@ export default function AttachExistingClientsModal({
         <Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
       ) : (
         <Spin spinning={loading}>
-          <Space direction="vertical" size="small" style={{ width: '100%' }}>
+          <Space orientation="vertical" size="small" style={{ width: '100%' }}>
             <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
               <Space wrap>
                 <Input.Search

+ 1 - 1
frontend/src/pages/inbounds/clients/DetachClientsModal.tsx

@@ -147,7 +147,7 @@ export default function DetachClientsModal({
         {t('pages.inbounds.detachClientsDesc', { count: clientRows.length })}
       </Typography.Paragraph>
 
-      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+      <Space orientation="vertical" size="small" style={{ width: '100%' }}>
         <Typography.Text strong>{t('pages.inbounds.detachClientsSelectLabel')}</Typography.Text>
         <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
           <Input.Search

+ 17 - 4
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -372,9 +372,15 @@ export default function InboundFormModal({
             }],
           },
         });
+      } else if (next === Protocols.WIREGUARD) {
+        // Wireguard has no user-selectable transport: the listener is always
+        // UDP and only finalmask/sockopt from streamSettings apply. Drop the
+        // leftover network/transport slices so the stream tab doesn't render
+        // a TCP sub-form and the wire payload carries no dead tcpSettings.
+        form.setFieldValue('streamSettings', { security: 'none' });
       } else {
         const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
-        if (current?.network === 'hysteria') {
+        if (current?.network === 'hysteria' || !current?.network) {
           form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
         }
       }
@@ -645,7 +651,7 @@ export default function InboundFormModal({
 
   const streamTab = (
     <>
-      {protocol !== Protocols.HYSTERIA && (
+      {protocol !== Protocols.HYSTERIA && protocol !== Protocols.WIREGUARD && (
         <Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
           <Select
             style={{ width: '75%' }}
@@ -683,7 +689,10 @@ export default function InboundFormModal({
 
       {network === 'kcp' && <KcpForm />}
 
-      <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
+      {/* externalProxy only feeds client share links, and wireguard's
+          per-peer .conf fanout resolves its host elsewhere — the section
+          would be dead weight on a wireguard inbound. */}
+      {protocol !== Protocols.WIREGUARD && <ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />}
 
       <SockoptForm toggleSockopt={toggleSockopt} />
 
@@ -897,7 +906,11 @@ export default function InboundFormModal({
             ...(streamEnabled
               ? [
                 { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
-                { key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true },
+                // Wireguard can't do TLS/Reality (canEnableTls is false), so
+                // the security tab would only show a fully disabled radio.
+                ...(protocol !== Protocols.WIREGUARD
+                  ? [{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true }]
+                  : []),
               ]
               : []),
             ...(sniffingSupported

+ 16 - 1
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Divider, Form, Input, InputNumber, Space, Switch } from 'antd';
+import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 
 import { Wireguard } from '@/utils';
@@ -62,6 +62,21 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
       >
         <Switch />
       </Form.Item>
+      <Form.Item name={['settings', 'workers']} label='Workers'>
+        <InputNumber min={1} />
+      </Form.Item>
+      <Form.Item name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
+        <Select
+          allowClear
+          options={[
+            { value: 'ForceIP', label: 'ForceIP' },
+            { value: 'ForceIPv4', label: 'ForceIPv4' },
+            { value: 'ForceIPv4v6', label: 'ForceIPv4v6' },
+            { value: 'ForceIPv6', label: 'ForceIPv6' },
+            { value: 'ForceIPv6v4', label: 'ForceIPv6v4' },
+          ]}
+        />
+      </Form.Item>
       <Form.List name={['settings', 'peers']}>
         {(fields, { add, remove }) => (
           <>

+ 1 - 1
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -159,7 +159,7 @@ export function useInboundColumns({
           if (!cc) return null;
           return (
             <>
-              <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
+              <Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>
                 <TeamOutlined /> {cc.clients}
               </Tag>
               {cc.active.length > 0 && (

+ 0 - 114
frontend/src/pages/index/CustomGeoFormModal.tsx

@@ -1,114 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Form, Input, message, Modal, Select } from 'antd';
-
-import { HttpUtil } from '@/utils';
-import { CustomGeoFormSchema } from '@/schemas/xray';
-
-export interface CustomGeoRecord {
-  id: number;
-  type: 'geosite' | 'geoip';
-  alias: string;
-  url: string;
-}
-
-interface CustomGeoFormModalProps {
-  open: boolean;
-  record: CustomGeoRecord | null;
-  onClose: () => void;
-  onSaved: () => void;
-}
-
-export default function CustomGeoFormModal({
-  open,
-  record,
-  onClose,
-  onSaved,
-}: CustomGeoFormModalProps) {
-  const { t } = useTranslation();
-  const [messageApi, messageContextHolder] = message.useMessage();
-  const [type, setType] = useState<'geosite' | 'geoip'>('geosite');
-  const [alias, setAlias] = useState('');
-  const [url, setUrl] = useState('');
-  const [saving, setSaving] = useState(false);
-
-  const editing = record != null;
-
-  useEffect(() => {
-    if (!open) return;
-    if (record) {
-      setType(record.type);
-      setAlias(record.alias);
-      setUrl(record.url);
-    } else {
-      setType('geosite');
-      setAlias('');
-      setUrl('');
-    }
-  }, [open, record]);
-
-  async function submit() {
-    const validated = CustomGeoFormSchema.safeParse({ type, alias, url });
-    if (!validated.success) {
-      messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
-      return;
-    }
-    setSaving(true);
-    try {
-      const apiUrl = editing
-        ? `/panel/api/custom-geo/update/${record!.id}`
-        : '/panel/api/custom-geo/add';
-      const msg = await HttpUtil.post(apiUrl, validated.data);
-      if (msg?.success) {
-        onSaved();
-        onClose();
-      }
-    } finally {
-      setSaving(false);
-    }
-  }
-
-  return (
-    <>
-      {messageContextHolder}
-      <Modal
-        open={open}
-        title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')}
-      confirmLoading={saving}
-      okText={t('pages.index.customGeoModalSave')}
-      cancelText={t('close')}
-      onOk={submit}
-      onCancel={onClose}
-    >
-      <Form layout="vertical">
-        <Form.Item label={t('pages.index.customGeoType')}>
-          <Select
-            value={type}
-            disabled={editing}
-            onChange={(v) => setType(v)}
-            options={[
-              { value: 'geosite', label: 'geosite' },
-              { value: 'geoip', label: 'geoip' },
-            ]}
-          />
-        </Form.Item>
-        <Form.Item label={t('pages.index.customGeoAlias')}>
-          <Input
-            value={alias}
-            disabled={editing}
-            placeholder={t('pages.index.customGeoAliasPlaceholder')}
-            onChange={(e) => setAlias(e.target.value)}
-          />
-        </Form.Item>
-        <Form.Item label={t('pages.index.customGeoUrl')}>
-          <Input
-            value={url}
-            placeholder="https://"
-            onChange={(e) => setUrl(e.target.value)}
-          />
-        </Form.Item>
-      </Form>
-      </Modal>
-    </>
-  );
-}

+ 0 - 65
frontend/src/pages/index/CustomGeoSection.css

@@ -1,65 +0,0 @@
-.toolbar {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 8px;
-  margin-bottom: 10px;
-}
-
-.custom-geo-count {
-  margin-left: 4px;
-  padding: 2px 8px;
-  border-radius: 10px;
-  background: var(--ant-color-fill-tertiary);
-  font-size: 12px;
-  opacity: 0.75;
-}
-
-.custom-geo-alias-cell {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.custom-geo-alias {
-  font-weight: 500;
-  word-break: break-all;
-}
-
-.custom-geo-type-tag {
-  margin: 0;
-}
-
-.custom-geo-url {
-  word-break: break-all;
-}
-
-.custom-geo-ext-code {
-  cursor: pointer;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-  padding: 2px 6px;
-  border-radius: 4px;
-  background: var(--ant-color-fill-tertiary);
-  user-select: all;
-}
-
-.custom-geo-copyable:hover {
-  background: var(--ant-color-fill-secondary);
-}
-
-.custom-geo-muted {
-  opacity: 0.5;
-}
-
-.custom-geo-empty {
-  text-align: center;
-  padding: 18px 0;
-  opacity: 0.6;
-}
-
-.custom-geo-empty-icon {
-  font-size: 32px;
-  margin-bottom: 6px;
-  display: block;
-}

+ 0 - 283
frontend/src/pages/index/CustomGeoSection.tsx

@@ -1,283 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Alert, Button, message, Modal, Space, Table, Tag, Tooltip } from 'antd';
-import type { ColumnsType } from 'antd/es/table';
-import {
-  PlusOutlined,
-  ReloadOutlined,
-  EditOutlined,
-  DeleteOutlined,
-  InboxOutlined,
-} from '@ant-design/icons';
-
-import { HttpUtil, ClipboardManager } from '@/utils';
-import CustomGeoFormModal from './CustomGeoFormModal';
-import type { CustomGeoRecord } from './CustomGeoFormModal';
-import './CustomGeoSection.css';
-
-interface CustomGeoSectionProps {
-  active: boolean;
-}
-
-interface CustomGeoListRecord extends CustomGeoRecord {
-  lastUpdatedAt?: number;
-}
-
-function formatTime(ts?: number): string {
-  if (!ts) return '';
-  const d = new Date(ts * 1000);
-  if (isNaN(d.getTime())) return String(ts);
-  const pad = (n: number) => String(n).padStart(2, '0');
-  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
-}
-
-function relativeTime(ts?: number): string {
-  if (!ts) return '';
-  const diff = Math.floor(Date.now() / 1000) - ts;
-  if (diff < 60) return 'just now';
-  if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
-  if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
-  if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
-  return formatTime(ts);
-}
-
-function extDisplay(record: CustomGeoListRecord): string {
-  const fn = record.type === 'geoip'
-    ? `geoip_${record.alias}.dat`
-    : `geosite_${record.alias}.dat`;
-  return `ext:${fn}:tag`;
-}
-
-export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
-  const { t } = useTranslation();
-  const [modal, modalContextHolder] = Modal.useModal();
-  const [messageApi, messageContextHolder] = message.useMessage();
-  const [list, setList] = useState<CustomGeoListRecord[]>([]);
-  const [loading, setLoading] = useState(false);
-  const [updatingAll, setUpdatingAll] = useState(false);
-  const [actionId, setActionId] = useState<number | null>(null);
-  const [formOpen, setFormOpen] = useState(false);
-  const [editingRecord, setEditingRecord] = useState<CustomGeoListRecord | null>(null);
-
-  const loadList = useCallback(async () => {
-    setLoading(true);
-    try {
-      const msg = await HttpUtil.get('/panel/api/custom-geo/list');
-      if (msg?.success && Array.isArray(msg.obj)) setList(msg.obj);
-    } finally {
-      setLoading(false);
-    }
-  }, []);
-
-  useEffect(() => {
-    if (active) loadList();
-  }, [active, loadList]);
-
-  function openAdd() {
-    setEditingRecord(null);
-    setFormOpen(true);
-  }
-
-  function openEdit(record: CustomGeoListRecord) {
-    setEditingRecord(record);
-    setFormOpen(true);
-  }
-
-  async function copyExt(record: CustomGeoListRecord) {
-    const text = extDisplay(record);
-    const ok = await ClipboardManager.copyText(text);
-    if (ok) messageApi.success(`${t('copied')}: ${text}`);
-  }
-
-  function confirmDelete(record: CustomGeoListRecord) {
-    modal.confirm({
-      title: t('pages.index.customGeoDelete'),
-      content: t('pages.index.customGeoDeleteConfirm'),
-      okText: t('delete'),
-      okType: 'danger',
-      cancelText: t('cancel'),
-      onOk: async () => {
-        const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
-        if (msg?.success) await loadList();
-      },
-    });
-  }
-
-  async function downloadOne(id: number) {
-    setActionId(id);
-    try {
-      const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
-      if (msg?.success) await loadList();
-    } finally {
-      setActionId(null);
-    }
-  }
-
-  async function updateAll() {
-    setUpdatingAll(true);
-    try {
-      const msg = await HttpUtil.post<{ succeeded?: unknown[]; failed?: unknown[] }>('/panel/api/custom-geo/update-all');
-      const ok = msg?.obj?.succeeded?.length || 0;
-      const failed = msg?.obj?.failed?.length || 0;
-      if (msg?.success || ok > 0) {
-        await loadList();
-        if (failed > 0) messageApi.warning(`Updated ${ok}, failed ${failed}`);
-      }
-    } finally {
-      setUpdatingAll(false);
-    }
-  }
-
-  const columns = useMemo<ColumnsType<CustomGeoListRecord>>(
-    () => [
-      {
-        title: t('pages.index.customGeoAlias'),
-        key: 'alias',
-        width: 200,
-        render: (_v, record) => (
-          <div className="custom-geo-alias-cell">
-            <Tag color={record.type === 'geoip' ? 'cyan' : 'purple'} className="custom-geo-type-tag">
-              {record.type}
-            </Tag>
-            <span className="custom-geo-alias">{record.alias}</span>
-          </div>
-        ),
-      },
-      {
-        title: t('pages.index.customGeoUrl'),
-        key: 'url',
-        ellipsis: true,
-        render: (_v, record) => (
-          <Tooltip placement="topLeft" title={record.url}>
-            <a
-              href={record.url}
-              target="_blank"
-              rel="noopener noreferrer"
-              className="custom-geo-url"
-            >
-              {record.url}
-            </a>
-          </Tooltip>
-        ),
-      },
-      {
-        title: t('pages.index.customGeoExtColumn'),
-        key: 'extDat',
-        width: 220,
-        render: (_v, record) => (
-          <Tooltip title={t('copy')}>
-            <code
-              className="custom-geo-ext-code custom-geo-copyable"
-              onClick={() => copyExt(record)}
-            >
-              {extDisplay(record)}
-            </code>
-          </Tooltip>
-        ),
-      },
-      {
-        title: t('pages.index.customGeoLastUpdated'),
-        key: 'lastUpdatedAt',
-        width: 140,
-        render: (_v, record) =>
-          record.lastUpdatedAt ? (
-            <Tooltip title={formatTime(record.lastUpdatedAt)}>
-              <span>{relativeTime(record.lastUpdatedAt)}</span>
-            </Tooltip>
-          ) : (
-            <span className="custom-geo-muted">—</span>
-          ),
-      },
-      {
-        title: t('pages.index.customGeoActions'),
-        key: 'action',
-        width: 120,
-        render: (_v, record) => (
-          <Space size="small">
-            <Tooltip title={t('pages.index.customGeoEdit')}>
-              <Button
-                type="link"
-                size="small"
-                icon={<EditOutlined />}
-                onClick={() => openEdit(record)}
-              />
-            </Tooltip>
-            <Tooltip title={t('pages.index.customGeoDownload')}>
-              <Button
-                type="link"
-                size="small"
-                loading={actionId === record.id}
-                icon={<ReloadOutlined />}
-                onClick={() => downloadOne(record.id)}
-              />
-            </Tooltip>
-            <Tooltip title={t('pages.index.customGeoDelete')}>
-              <Button
-                type="link"
-                size="small"
-                danger
-                icon={<DeleteOutlined />}
-                onClick={() => confirmDelete(record)}
-              />
-            </Tooltip>
-          </Space>
-        ),
-      },
-    ],
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, actionId],
-  );
-
-  return (
-    <div className="custom-geo-section">
-      {messageContextHolder}
-      {modalContextHolder}
-      <Alert
-        type="info"
-        showIcon
-        className="mb-10"
-        title={t('pages.index.customGeoRoutingHint')}
-      />
-
-      <div className="toolbar">
-        <Button type="primary" loading={loading} onClick={openAdd} icon={<PlusOutlined />}>
-          {t('pages.index.customGeoAdd')}
-        </Button>
-        <Button
-          loading={updatingAll}
-          disabled={list.length === 0}
-          onClick={updateAll}
-          icon={<ReloadOutlined />}
-        >
-          {t('pages.index.geofilesUpdateAll')}
-        </Button>
-        {list.length > 0 && <span className="custom-geo-count">{list.length}</span>}
-      </div>
-
-      <Table
-        columns={columns}
-        dataSource={list}
-        pagination={false}
-        rowKey={(r) => r.id}
-        loading={loading}
-        size="small"
-        scroll={{ x: 760 }}
-        locale={{
-          emptyText: (
-            <div className="custom-geo-empty">
-              <InboxOutlined className="custom-geo-empty-icon" />
-              <div>{t('pages.index.customGeoEmpty')}</div>
-            </div>
-          ),
-        }}
-      />
-
-      <CustomGeoFormModal
-        open={formOpen}
-        record={editingRecord}
-        onClose={() => setFormOpen(false)}
-        onSaved={loadList}
-      />
-    </div>
-  );
-}

+ 219 - 0
frontend/src/pages/index/GeodataSection.tsx

@@ -0,0 +1,219 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
+import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
+
+import { HttpUtil } from '@/utils';
+
+interface GeodataAssetRow {
+  url: string;
+  file: string;
+}
+
+interface GeodataSectionProps {
+  active: boolean;
+  onBusy: (e: { busy: boolean; tip?: string }) => void;
+  onClose: () => void;
+}
+
+const DEFAULT_CRON = '0 4 * * *';
+// Xray resolves `file` inside its asset directory; plain file names only.
+const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
+
+function fileNameFromUrl(url: string): string {
+  try {
+    const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
+    return FILE_NAME_PATTERN.test(seg) ? seg : '';
+  } catch {
+    return '';
+  }
+}
+
+export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
+  const { t } = useTranslation();
+  const [modal, modalContextHolder] = Modal.useModal();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [loading, setLoading] = useState(false);
+  const [cron, setCron] = useState(DEFAULT_CRON);
+  const [outbound, setOutbound] = useState<string | undefined>(undefined);
+  const [rows, setRows] = useState<GeodataAssetRow[]>([]);
+  const [outboundTags, setOutboundTags] = useState<string[]>([]);
+  const templateRef = useRef<Record<string, unknown> | null>(null);
+  const outboundTestUrlRef = useRef('');
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
+      if (!msg?.success || typeof msg.obj !== 'string') return;
+      const payload = JSON.parse(msg.obj) as Record<string, unknown>;
+      const template = (payload.xraySetting || {}) as Record<string, unknown>;
+      templateRef.current = template;
+      outboundTestUrlRef.current =
+        typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
+
+      const geodata = (template.geodata || {}) as Record<string, unknown>;
+      const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
+      setRows(
+        assets
+          .filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
+          .map((a) => ({ url: String(a.url ?? ''), file: String(a.file ?? '') })),
+      );
+      setCron(typeof geodata.cron === 'string' && geodata.cron ? geodata.cron : DEFAULT_CRON);
+      setOutbound(
+        typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
+      );
+
+      // Download outbound candidates: template outbounds + subscription outbounds.
+      const tags = new Set<string>();
+      const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
+      for (const o of outbounds) {
+        const tag = o && typeof o === 'object' ? (o as Record<string, unknown>).tag : undefined;
+        if (typeof tag === 'string' && tag) tags.add(tag);
+      }
+      const subTags = Array.isArray(payload.subscriptionOutboundTags)
+        ? payload.subscriptionOutboundTags
+        : [];
+      for (const tag of subTags) {
+        if (typeof tag === 'string' && tag) tags.add(tag);
+      }
+      setOutboundTags([...tags]);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    if (active) load();
+  }, [active, load]);
+
+  function setRow(index: number, patch: Partial<GeodataAssetRow>) {
+    setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
+  }
+
+  function onUrlBlur(index: number) {
+    setRows((prev) =>
+      prev.map((r, i) => (i === index && !r.file ? { ...r, file: fileNameFromUrl(r.url) } : r)),
+    );
+  }
+
+  function save() {
+    const template = templateRef.current;
+    if (!template) return;
+    const assets = rows
+      .map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
+      .filter((r) => r.url || r.file);
+    for (const a of assets) {
+      // Xray's geodata downloader accepts HTTPS URLs only.
+      if (!/^https:\/\/\S+$/i.test(a.url)) {
+        messageApi.error(t('pages.index.geodataInvalidUrl'));
+        return;
+      }
+      if (!FILE_NAME_PATTERN.test(a.file)) {
+        messageApi.error(t('pages.index.geodataInvalidFile'));
+        return;
+      }
+    }
+    const cronValue = cron.trim();
+    if (assets.length > 0 && cronValue && cronValue.split(/\s+/).length !== 5) {
+      messageApi.error(t('pages.index.geodataInvalidCron'));
+      return;
+    }
+
+    modal.confirm({
+      title: t('pages.index.geodataConfirmTitle'),
+      content: t('pages.index.geodataConfirmContent'),
+      okText: t('confirm'),
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const next: Record<string, unknown> = { ...template };
+        if (assets.length === 0) {
+          delete next.geodata;
+        } else {
+          const geodata: Record<string, unknown> = { assets };
+          if (cronValue) geodata.cron = cronValue;
+          if (outbound) geodata.outbound = outbound;
+          next.geodata = geodata;
+        }
+        onClose();
+        onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
+        try {
+          const msg = await HttpUtil.post('/panel/api/xray/update', {
+            xraySetting: JSON.stringify(next, null, 2),
+            outboundTestUrl: outboundTestUrlRef.current,
+          });
+          if (msg?.success) {
+            await HttpUtil.post('/panel/api/server/restartXrayService');
+          }
+        } finally {
+          onBusy({ busy: false });
+        }
+      },
+    });
+  }
+
+  return (
+    <div>
+      {modalContextHolder}
+      {messageContextHolder}
+      <Spin spinning={loading}>
+        <Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
+        <Form layout="vertical">
+          <Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}>
+            <Input
+              value={cron}
+              placeholder={DEFAULT_CRON}
+              onChange={(e) => setCron(e.target.value)}
+            />
+          </Form.Item>
+          <Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}>
+            <Select
+              style={{ width: '100%' }}
+              allowClear
+              value={outbound}
+              onChange={(v) => setOutbound(v)}
+              options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
+            />
+          </Form.Item>
+        </Form>
+        <Space orientation="vertical" style={{ width: '100%' }} size={8}>
+          {rows.length === 0 && (
+            <Typography.Text type="secondary">{t('pages.index.geodataEmpty')}</Typography.Text>
+          )}
+          {rows.map((row, i) => (
+            <Space.Compact key={i} style={{ width: '100%' }}>
+              <Input
+                style={{ width: '60%' }}
+                placeholder="https://example.com/geosite_custom.dat"
+                value={row.url}
+                onChange={(e) => setRow(i, { url: e.target.value })}
+                onBlur={() => onUrlBlur(i)}
+              />
+              <Input
+                style={{ width: '40%' }}
+                placeholder={t('pages.index.geodataFile')}
+                value={row.file}
+                onChange={(e) => setRow(i, { file: e.target.value })}
+              />
+              <Button
+                icon={<DeleteOutlined />}
+                onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
+              />
+            </Space.Compact>
+          ))}
+          <div className="actions-row">
+            <Button
+              icon={<PlusOutlined />}
+              onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
+            >
+              {t('pages.index.geodataAddFile')}
+            </Button>
+            <Button type="primary" onClick={save} disabled={loading || !templateRef.current}>
+              {t('pages.index.geodataSaveRestart')}
+            </Button>
+          </div>
+        </Space>
+      </Spin>
+    </div>
+  );
+}

+ 9 - 3
frontend/src/pages/index/VersionModal.tsx

@@ -5,7 +5,7 @@ import { ReloadOutlined } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
 import type { Status } from '@/models/status';
-import CustomGeoSection from './CustomGeoSection';
+import GeodataSection from './GeodataSection';
 import './VersionModal.css';
 
 interface BusyEvent {
@@ -161,8 +161,14 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
             },
             {
               key: '3',
-              label: t('pages.index.customGeoTitle'),
-              children: <CustomGeoSection active={activeKeyStr === '3'} />,
+              label: t('pages.index.geodataTitle'),
+              children: (
+                <GeodataSection
+                  active={activeKeyStr === '3'}
+                  onBusy={onBusy}
+                  onClose={onClose}
+                />
+              ),
             },
           ]}
         />

+ 9 - 2
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -156,10 +156,17 @@ export default function OutboundFormModal({
 
   useEffect(() => {
     if (!streamAllowed) return;
+    // Wireguard dials its own UDP — only finalmask/sockopt apply, never a
+    // transport. Don't seed network 'tcp'; clear a leftover one (from a
+    // protocol switch) so the transmission/security blocks stay hidden.
+    if (protocol === 'wireguard') {
+      if (network) form.setFieldValue('streamSettings', { security: 'none' });
+      return;
+    }
     if (network) return;
     form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [streamAllowed, network]);
+  }, [streamAllowed, network, protocol]);
 
   useEffect(() => {
     if (protocol !== 'hysteria') return;
@@ -565,7 +572,7 @@ export default function OutboundFormModal({
 
                     {security === 'reality' && realityAllowed && <RealityForm />}
 
-                    {((streamAllowed && network) || !streamAllowed) && (
+                    {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
                       <SockoptForm form={form} outboundTags={existingTags} />
                     )}
 

+ 19 - 2
frontend/src/schemas/protocols/inbound/wireguard.ts

@@ -1,5 +1,20 @@
 import { z } from 'zod';
 
+export const WireguardDomainStrategySchema = z.enum([
+  'ForceIP',
+  'ForceIPv4',
+  'ForceIPv4v6',
+  'ForceIPv6',
+  'ForceIPv6v4',
+]);
+export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
+
+// AntD InputNumber emits null (not undefined) when the user clears it, and
+// the form store hands that null straight to safeParse on submit — a bare
+// .optional() would reject it and block the save.
+const optionalClearedInt = (schema: z.ZodNumber) =>
+  z.preprocess((v) => (v == null ? undefined : v), schema.optional());
+
 // Wireguard inbound is peer-based (no clients). Each peer is a client device
 // the server accepts; secretKey is the server-side private key and pubKey is
 // derived from it at runtime (not persisted on the wire). Inbound peers
@@ -10,14 +25,16 @@ export const WireguardInboundPeerSchema = z.object({
   publicKey: z.string().min(1),
   preSharedKey: z.string().optional(),
   allowedIPs: z.array(z.string()).default([]),
-  keepAlive: z.number().int().min(0).optional(),
+  keepAlive: optionalClearedInt(z.number().int().min(0)),
 });
 export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
 
 export const WireguardInboundSettingsSchema = z.object({
-  mtu: z.number().int().min(1).optional(),
+  mtu: optionalClearedInt(z.number().int().min(1)),
   secretKey: z.string().min(1),
   peers: z.array(WireguardInboundPeerSchema).default([]),
   noKernelTun: z.boolean().default(false),
+  workers: optionalClearedInt(z.number().int().min(1)),
+  domainStrategy: WireguardDomainStrategySchema.optional(),
 });
 export type WireguardInboundSettings = z.infer<typeof WireguardInboundSettingsSchema>;

+ 0 - 21
frontend/src/schemas/xray.ts

@@ -72,26 +72,6 @@ export const OutboundTestResultSchema = z.object({
     .optional(),
 }).loose();
 
-export const CustomGeoFormSchema = z.object({
-  type: z.enum(['geosite', 'geoip']),
-  alias: z.string().regex(/^[a-z0-9_-]+$/, 'pages.index.customGeoValidationAlias'),
-  url: z
-    .string()
-    .trim()
-    .refine(
-      (u) => {
-        if (!/^https?:\/\//i.test(u)) return false;
-        try {
-          const parsed = new URL(u);
-          return parsed.protocol === 'http:' || parsed.protocol === 'https:';
-        } catch {
-          return false;
-        }
-      },
-      { message: 'pages.index.customGeoValidationUrl' },
-    ),
-});
-
 export const RuleFormSchema = z.object({
   domain: z.string(),
   ip: z.string(),
@@ -123,7 +103,6 @@ export const OutboundTagSchema = z
 
 export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
 export type RuleFormValues = z.infer<typeof RuleFormSchema>;
-export type CustomGeoFormValues = z.infer<typeof CustomGeoFormSchema>;
 export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
 export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
 export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;

+ 14 - 14
frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap

@@ -1683,7 +1683,7 @@ exports[`protocol capability predicates > vmess-basic :: xhttp/tls 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: grpc/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1695,7 +1695,7 @@ exports[`protocol capability predicates > wireguard-basic :: grpc/none 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: grpc/reality 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1707,7 +1707,7 @@ exports[`protocol capability predicates > wireguard-basic :: grpc/reality 1`] =
 exports[`protocol capability predicates > wireguard-basic :: grpc/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1719,7 +1719,7 @@ exports[`protocol capability predicates > wireguard-basic :: grpc/tls 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: httpupgrade/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1731,7 +1731,7 @@ exports[`protocol capability predicates > wireguard-basic :: httpupgrade/none 1`
 exports[`protocol capability predicates > wireguard-basic :: httpupgrade/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1743,7 +1743,7 @@ exports[`protocol capability predicates > wireguard-basic :: httpupgrade/tls 1`]
 exports[`protocol capability predicates > wireguard-basic :: kcp/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1755,7 +1755,7 @@ exports[`protocol capability predicates > wireguard-basic :: kcp/none 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: tcp/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1767,7 +1767,7 @@ exports[`protocol capability predicates > wireguard-basic :: tcp/none 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: tcp/reality 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1779,7 +1779,7 @@ exports[`protocol capability predicates > wireguard-basic :: tcp/reality 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: tcp/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1791,7 +1791,7 @@ exports[`protocol capability predicates > wireguard-basic :: tcp/tls 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: ws/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1803,7 +1803,7 @@ exports[`protocol capability predicates > wireguard-basic :: ws/none 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: ws/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1815,7 +1815,7 @@ exports[`protocol capability predicates > wireguard-basic :: ws/tls 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: xhttp/none 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1827,7 +1827,7 @@ exports[`protocol capability predicates > wireguard-basic :: xhttp/none 1`] = `
 exports[`protocol capability predicates > wireguard-basic :: xhttp/reality 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,
@@ -1839,7 +1839,7 @@ exports[`protocol capability predicates > wireguard-basic :: xhttp/reality 1`] =
 exports[`protocol capability predicates > wireguard-basic :: xhttp/tls 1`] = `
 {
   "canEnableReality": false,
-  "canEnableStream": false,
+  "canEnableStream": true,
   "canEnableTls": false,
   "canEnableTlsFlow": false,
   "canEnableVisionSeed": false,

+ 1 - 1
frontend/src/test/setup.components.ts

@@ -3,7 +3,7 @@ import { cleanup } from '@testing-library/react';
 import i18next from 'i18next';
 import { initReactI18next } from 'react-i18next';
 
-import enUS from '../../../web/translation/en-US.json';
+import enUS from '../../../internal/web/translation/en-US.json';
 
 vi.mock('persian-calendar-suite', () => ({
   PersianDateTimePicker: () => null,

+ 1 - 1
frontend/tsconfig.json

@@ -25,5 +25,5 @@
     }
   },
   "include": ["src/**/*.ts", "src/**/*.tsx"],
-  "exclude": ["node_modules", "../web/dist"]
+  "exclude": ["node_modules", "../internal/web/dist"]
 }

+ 1 - 1
frontend/vite.config.js

@@ -4,7 +4,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { DatabaseSync } from 'node:sqlite';
 
-const outDir = path.resolve(__dirname, '../web/dist');
+const outDir = path.resolve(__dirname, '../internal/web/dist');
 const BACKEND_TARGET = 'http://localhost:2053';
 
 function resolveDBPath() {

+ 7 - 0
config/config.go → internal/config/config.go

@@ -10,6 +10,7 @@ import (
 	"path/filepath"
 	"runtime"
 	"strings"
+	"testing"
 )
 
 //go:embed version
@@ -140,6 +141,12 @@ func GetLogFolder() string {
 	if logFolderPath != "" {
 		return logFolderPath
 	}
+	// Under `go test` the Windows default below is CWD-relative ("./log"), which
+	// scatters a log/ directory through the source tree (one per tested package).
+	// Redirect test runs to a shared temp folder so the source tree stays clean.
+	if testing.Testing() {
+		return filepath.Join(os.TempDir(), "3x-ui-test-log")
+	}
 	if runtime.GOOS == "windows" {
 		return filepath.Join(".", "log")
 	}

+ 0 - 0
config/name → internal/config/name


+ 0 - 0
config/version → internal/config/version


+ 5 - 6
database/db.go → internal/database/db.go

@@ -16,11 +16,11 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/config"
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/util/crypto"
-	"github.com/mhsanaei/3x-ui/v3/util/random"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
@@ -65,7 +65,6 @@ func initModels() error {
 		&model.InboundClientIps{},
 		&xray.ClientTraffic{},
 		&model.HistoryOfSeeders{},
-		&model.CustomGeoResource{},
 		&model.Node{},
 		&model.ApiToken{},
 		&model.ClientRecord{},

+ 1 - 1
database/db_seed_test.go → internal/database/db_seed_test.go

@@ -6,7 +6,7 @@ import (
 	"regexp"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {

+ 0 - 0
database/dialect.go → internal/database/dialect.go


+ 0 - 0
database/dump_sqlite.go → internal/database/dump_sqlite.go


+ 2 - 2
database/dump_sqlite_test.go → internal/database/dump_sqlite_test.go

@@ -5,8 +5,8 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"

+ 3 - 4
database/migrate_data.go → internal/database/migrate_data.go

@@ -11,8 +11,8 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
@@ -25,7 +25,7 @@ import (
 // related tests.
 //
 // Important: When adding a new top-level model (like OutboundSubscription),
-// you must add it here **in addition to** the list in database/db.go:initModels().
+// you must add it here **in addition to** the list in internal/database/db.go:initModels().
 // This list is used for:
 //   - Creating the destination schema during cross-DB migration
 //   - Truncating tables
@@ -39,7 +39,6 @@ func migrationModels() []any {
 		&model.User{},
 		&model.Setting{},
 		&model.HistoryOfSeeders{},
-		&model.CustomGeoResource{},
 		&model.Node{},
 		&model.ApiToken{},
 		&model.Inbound{},

+ 1 - 1
database/migrate_data_test.go → internal/database/migrate_data_test.go

@@ -4,7 +4,7 @@ import (
 	"os"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"

+ 2 - 14
database/model/model.go → internal/database/model/model.go

@@ -9,8 +9,8 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/util/json_util"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 // Protocol represents the protocol type for Xray inbounds.
@@ -525,18 +525,6 @@ type NodeSummary struct {
 	XrayError string `json:"xrayError,omitempty"`
 }
 
-type CustomGeoResource struct {
-	Id            int    `json:"id" gorm:"primaryKey;autoIncrement"`
-	Type          string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
-	Alias         string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
-	Url           string `json:"url" gorm:"not null"`
-	LocalPath     string `json:"localPath" gorm:"column:local_path"`
-	LastUpdatedAt int64  `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
-	LastModified  string `json:"lastModified" gorm:"column:last_modified"`
-	CreatedAt     int64  `json:"createdAt" gorm:"autoCreateTime:milli;column:created_at"`
-	UpdatedAt     int64  `json:"updatedAt" gorm:"autoUpdateTime:milli;column:updated_at"`
-}
-
 type ClientReverse struct {
 	Tag string `json:"tag"`
 }

+ 0 - 0
database/model/model_mtproto_test.go → internal/database/model/model_mtproto_test.go


+ 0 - 0
database/model/model_test.go → internal/database/model/model_test.go


+ 0 - 0
database/model/node_client_traffic.go → internal/database/model/node_client_traffic.go


+ 1 - 1
logger/logger.go → internal/logger/logger.go

@@ -9,7 +9,7 @@ import (
 	"runtime"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/op/go-logging"
 
 	"gopkg.in/natefinch/lumberjack.v2"

+ 2 - 2
mtproto/manager.go → internal/mtproto/manager.go

@@ -12,8 +12,8 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 // Instance is the desired runtime configuration of one mtproto inbound.

+ 1 - 1
mtproto/manager_test.go → internal/mtproto/manager_test.go

@@ -4,7 +4,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestParseMetricLine(t *testing.T) {

+ 0 - 0
mtproto/orphans_linux.go → internal/mtproto/orphans_linux.go


+ 0 - 0
mtproto/orphans_other.go → internal/mtproto/orphans_other.go


+ 2 - 2
mtproto/process.go → internal/mtproto/process.go

@@ -16,8 +16,8 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/config"
-	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 // GetBinaryName returns the mtg binary filename for the current OS and arch,

+ 0 - 0
mtproto/process_other.go → internal/mtproto/process_other.go


+ 1 - 1
mtproto/process_windows.go → internal/mtproto/process_windows.go

@@ -7,7 +7,7 @@ import (
 	"sync"
 	"unsafe"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"golang.org/x/sys/windows"
 )
 

+ 1 - 1
sub/build_urls_test.go → internal/sub/build_urls_test.go

@@ -5,7 +5,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 )
 
 func initSubDB(t *testing.T) {

+ 3 - 3
sub/subClashService.go → internal/sub/clash_service.go

@@ -8,9 +8,9 @@ import (
 	"github.com/goccy/go-json"
 	yaml "github.com/goccy/go-yaml"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 type SubClashService struct {

+ 1 - 1
sub/subClashService_test.go → internal/sub/clash_service_test.go

@@ -4,7 +4,7 @@ import (
 	"reflect"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestEnsureUniqueProxyNames(t *testing.T) {

+ 4 - 4
sub/subController.go → internal/sub/controller.go

@@ -16,8 +16,8 @@ import (
 	"time"
 
 	"github.com/gin-gonic/gin"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 // writeSubError translates a service-layer result into an HTTP response.
@@ -182,14 +182,14 @@ func (a *SUBController) subs(c *gin.Context) {
 	}
 }
 
-// serveSubPage renders web/dist/subpage.html for the current subscription
+// serveSubPage renders internal/web/dist/subpage.html for the current subscription
 // request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
 // we inject that here, along with window.X_UI_BASE_PATH so the
 // page's static asset references resolve correctly when the panel runs
 // behind a URL prefix.
 func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
 	var body []byte
-	if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
+	if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
 		body = diskBody
 	} else {
 		readBody, err := distFS.ReadFile("dist/subpage.html")

+ 0 - 0
sub/subController_test.go → internal/sub/controller_test.go


+ 0 - 0
sub/default.json → internal/sub/default.json


+ 2 - 2
sub/dist.go → internal/sub/dist.go

@@ -4,9 +4,9 @@ import "embed"
 
 // distFS holds the Vite-built frontend filesystem, injected from main at
 // startup. The `web` package owns the //go:embed directive (because dist/
-// is at web/dist/), and hands the FS over via SetDistFS so the sub package
+// is at internal/web/dist/), and hands the FS over via SetDistFS so the sub package
 // doesn't import web — that would create an import cycle once any
-// web/controller handler reuses sub's link-building service.
+// internal/web/controller handler reuses sub's link-building service.
 var distFS embed.FS
 
 // SetDistFS installs the embedded frontend filesystem the sub server uses

+ 5 - 5
sub/subJsonService.go → internal/sub/json_service.go

@@ -7,11 +7,11 @@ import (
 	"maps"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/util/json_util"
-	"github.com/mhsanaei/3x-ui/v3/util/random"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 //go:embed default.json

+ 1 - 1
sub/subJsonService_test.go → internal/sub/json_service_test.go

@@ -4,7 +4,7 @@ import (
 	"encoding/json"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func hasDirectOutOutbound(svc *SubJsonService) bool {

+ 2 - 2
sub/links.go → internal/sub/links.go

@@ -3,8 +3,8 @@ package sub
 import (
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 type LinkProvider struct {

+ 0 - 0
sub/links_test.go → internal/sub/links_test.go


+ 9 - 9
sub/subService.go → internal/sub/service.go

@@ -15,13 +15,13 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/goccy/go-json"
 
-	"github.com/mhsanaei/3x-ui/v3/database"
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/util/common"
-	"github.com/mhsanaei/3x-ui/v3/util/random"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 // SubService provides business logic for generating subscription links and managing subscription data.
@@ -991,7 +991,7 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
 
 // pinnedSha256List extracts tlsSettings.settings.pinnedPeerCertSha256 as a
 // []string. The field is panel-only (stripped before the run-config reaches
-// xray-core via web/service/xray.go) but flows into share links so clients
+// xray-core via internal/web/service/xray.go) but flows into share links so clients
 // can pin the server's certificate hash.
 func pinnedSha256List(tlsClientSettings any) ([]string, bool) {
 	raw, ok := searchKey(tlsClientSettings, "pinnedPeerCertSha256")
@@ -1025,7 +1025,7 @@ func pinnedSha256List(tlsClientSettings any) ([]string, bool) {
 // it. Hysteria2 clients hex-decode pinSHA256 and crash on a base64 value, so
 // each entry is coerced to bare hex here. Anything that is neither a 32-byte
 // hex nor a 32-byte base64 SHA-256 is returned unchanged so unexpected data is
-// not silently dropped. Mirrors decodeCertPin in web/service/node.go.
+// not silently dropped. Mirrors decodeCertPin in internal/web/service/node.go.
 func hysteriaPinHex(pin string) string {
 	pin = strings.TrimSpace(pin)
 	if h := strings.ReplaceAll(pin, ":", ""); len(h) == hex.EncodedLen(sha256.Size) {

+ 1 - 1
sub/subService_test.go → internal/sub/service_test.go

@@ -6,7 +6,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestSubscriptionExpiryFromClient(t *testing.T) {

+ 3 - 3
sub/subService_userinfo_test.go → internal/sub/service_userinfo_test.go

@@ -4,9 +4,9 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database"
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 func TestAggregateTrafficByEmails_FallsBackToClientLimits(t *testing.T) {

+ 8 - 8
sub/sub.go → internal/sub/sub.go

@@ -14,12 +14,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/util/common"
-	"github.com/mhsanaei/3x-ui/v3/web/locale"
-	"github.com/mhsanaei/3x-ui/v3/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/web/network"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/network"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 
 	"github.com/gin-gonic/gin"
 )
@@ -191,8 +191,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	}
 
 	var assetsFS http.FileSystem
-	if _, err := os.Stat("web/dist/assets"); err == nil {
-		assetsFS = http.FS(os.DirFS("web/dist/assets"))
+	if _, err := os.Stat("internal/web/dist/assets"); err == nil {
+		assetsFS = http.FS(os.DirFS("internal/web/dist/assets"))
 	} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
 		assetsFS = http.FS(subFS)
 	} else {

+ 1 - 1
util/common/err.go → internal/util/common/err.go

@@ -5,7 +5,7 @@ import (
 	"errors"
 	"fmt"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 // NewErrorf creates a new error with formatted message.

+ 0 - 0
util/common/format.go → internal/util/common/format.go


+ 0 - 0
util/common/format_test.go → internal/util/common/format_test.go


+ 0 - 0
util/common/multi_error.go → internal/util/common/multi_error.go


+ 0 - 0
util/common/multi_error_test.go → internal/util/common/multi_error_test.go


+ 0 - 0
util/crypto/crypto.go → internal/util/crypto/crypto.go


+ 0 - 0
util/crypto/crypto_test.go → internal/util/crypto/crypto_test.go


+ 0 - 0
util/json_util/json.go → internal/util/json_util/json.go


+ 0 - 0
util/json_util/json_test.go → internal/util/json_util/json_test.go


+ 0 - 0
util/ldap/ldap.go → internal/util/ldap/ldap.go


+ 0 - 0
util/link/outbound.go → internal/util/link/outbound.go


+ 0 - 0
util/link/outbound_test.go → internal/util/link/outbound_test.go


+ 0 - 0
util/netproxy/netproxy.go → internal/util/netproxy/netproxy.go


+ 0 - 0
util/netproxy/netproxy_test.go → internal/util/netproxy/netproxy_test.go


+ 0 - 0
util/netsafe/netsafe.go → internal/util/netsafe/netsafe.go


Некоторые файлы не были показаны из-за большого количества измененных файлов