1
0

11 Коммиты 93ff60e568 ... bcd1358032

Автор SHA1 Сообщение Дата
  MHSanaei bcd1358032 fix(nodes): report dev builds as dev+<commit> so updated nodes aren't flagged stale 6 часов назад
  MHSanaei e8878b71a4 feat(nodes): add Dev channel option to node panel updates 6 часов назад
  MHSanaei 11c5b53fac feat(sub): add PROTOCOL, TRANSPORT, SECURITY remark template variables 7 часов назад
  MHSanaei 896016f7f6 fix(web): remove deleted multi-inbound client from runtime regardless of shared email (#5543) 8 часов назад
  MHSanaei e2d25d0ac7 fix(web): show subscription outbounds in dialer proxy dropdown (#5540) 8 часов назад
  Rick Sanchez fe025e8af3 feat(xray): add tunnel health monitor (#5480) 9 часов назад
  FunLay123 3ba43bd86d feat(web): vless encryption new modes (#5517) 9 часов назад
  w3struk ae9bbdf267 fix(web): serve panel SPA routes from NoRoute (#5536) 9 часов назад
  MHSanaei 2830f97f50 feat(x-ui.sh): add Dev channel update option to the management menu 12 часов назад
  MHSanaei 1d1128cf94 fix(update): read setUpdateChannel body as form field, not JSON 12 часов назад
  MHSanaei aad2b3eb1e feat(update): add rolling dev update channel for per-commit builds 13 часов назад
52 измененных файлов с 2526 добавлено и 256 удалено
  1. 13 0
      .env.example
  2. 71 2
      .github/workflows/release.yml
  3. 7 0
      README.md
  4. 43 2
      frontend/public/openapi.json
  5. 3 3
      frontend/src/api/queries/useNodeMutations.ts
  6. 10 2
      frontend/src/lib/remark/remarkVariables.ts
  7. 11 2
      frontend/src/pages/api-docs/endpoints.ts
  8. 19 3
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  9. 31 8
      frontend/src/pages/inbounds/form/protocols/vless.tsx
  10. 25 4
      frontend/src/pages/index/IndexPage.tsx
  11. 60 6
      frontend/src/pages/index/PanelUpdateModal.tsx
  12. 39 8
      frontend/src/pages/nodes/NodesPage.tsx
  13. 1 0
      frontend/src/pages/xray/XrayPage.tsx
  14. 3 1
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  15. 16 0
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  16. 42 0
      internal/config/config.go
  17. 20 0
      internal/config/config_test.go
  18. 89 30
      internal/sub/remark_vars.go
  19. 155 9
      internal/sub/remark_vars_test.go
  20. 271 0
      internal/tunnelmonitor/monitor.go
  21. 454 0
      internal/tunnelmonitor/monitor_test.go
  22. 6 6
      internal/web/controller/dist.go
  23. 2 1
      internal/web/controller/node.go
  24. 27 2
      internal/web/controller/server.go
  25. 81 0
      internal/web/controller/spa.go
  26. 228 0
      internal/web/controller/spa_test.go
  27. 8 3
      internal/web/runtime/remote.go
  28. 8 3
      internal/web/service/client_inbound_apply.go
  29. 34 0
      internal/web/service/del_shared_email_runtime_test.go
  30. 2 2
      internal/web/service/node.go
  31. 138 9
      internal/web/service/panel/panel.go
  32. 69 1
      internal/web/service/panel/panel_test.go
  33. 29 3
      internal/web/service/server.go
  34. 28 0
      internal/web/service/server_vlessenc_test.go
  35. 33 19
      internal/web/service/setting.go
  36. 25 5
      internal/web/translation/ar-EG.json
  37. 25 5
      internal/web/translation/en-US.json
  38. 25 5
      internal/web/translation/es-ES.json
  39. 25 5
      internal/web/translation/fa-IR.json
  40. 25 5
      internal/web/translation/id-ID.json
  41. 25 5
      internal/web/translation/ja-JP.json
  42. 25 5
      internal/web/translation/pt-BR.json
  43. 25 5
      internal/web/translation/ru-RU.json
  44. 25 5
      internal/web/translation/tr-TR.json
  45. 25 5
      internal/web/translation/uk-UA.json
  46. 25 5
      internal/web/translation/vi-VN.json
  47. 25 5
      internal/web/translation/zh-CN.json
  48. 25 5
      internal/web/translation/zh-TW.json
  49. 5 1
      internal/web/web.go
  50. 28 1
      main.go
  51. 10 3
      update.sh
  52. 82 57
      x-ui.sh

+ 13 - 0
.env.example

@@ -4,3 +4,16 @@ XUI_LOG_FOLDER=x-ui
 XUI_BIN_FOLDER=x-ui
 XUI_INIT_WEB_BASE_PATH=/
 # XUI_PORT=8080
+
+# Optional tunnel health monitor (disabled by default). It periodically probes a
+# URL and restarts xray-core after repeated failures. Point XUI_TUNNEL_HEALTH_PROXY
+# at a local xray inbound so the probe tests the tunnel; without it the probe only
+# checks host connectivity and a restart will not fix host network issues. A restart
+# drops every connected client.
+# XUI_TUNNEL_HEALTH_MONITOR=true
+# XUI_TUNNEL_HEALTH_PROXY=socks5://127.0.0.1:1080
+# XUI_TUNNEL_HEALTH_URL=https://www.cloudflare.com/cdn-cgi/trace
+# XUI_TUNNEL_HEALTH_INTERVAL=30s
+# XUI_TUNNEL_HEALTH_TIMEOUT=10s
+# XUI_TUNNEL_HEALTH_FAILURES=3
+# XUI_TUNNEL_HEALTH_COOLDOWN=5m

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

@@ -97,7 +97,13 @@ jobs:
           export CC=$(realpath "$(find "$TOOLCHAIN_DIR/bin" -name '*-gcc.br_real' -type f -executable | head -n1)")
           [ -z "$CC" ] && { echo "No gcc.br_real found in $TOOLCHAIN_DIR/bin" >&2; exit 1; }
           cd -
-          go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
+          # Stamp the commit into per-commit (dev channel) builds only; tagged
+          # stable releases stay unstamped so config.IsDevBuild() returns false.
+          LDFLAGS="-w -s -linkmode external -extldflags '-static'"
+          if [[ "$GITHUB_REF" != refs/tags/* ]]; then
+            LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA::8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+          fi
+          go build -ldflags "$LDFLAGS" -o xui-release -v main.go
           file xui-release
           ldd xui-release || echo "Static binary confirmed"
 
@@ -245,7 +251,12 @@ jobs:
           go version
           gcc --version
 
-          go build -ldflags "-w -s" -o xui-release.exe -v main.go
+          # Stamp the commit into per-commit (dev channel) builds only.
+          LDFLAGS="-w -s"
+          if [[ "$GITHUB_REF" != refs/tags/* ]]; then
+            LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA:0:8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+          fi
+          go build -ldflags "$LDFLAGS" -o xui-release.exe -v main.go
 
       - name: Copy and download resources
         shell: pwsh
@@ -302,3 +313,61 @@ jobs:
           asset_name: x-ui-windows-amd64.zip
           overwrite: true
           prerelease: true
+
+  # =================================
+  #  Rolling dev channel (per-commit)
+  # =================================
+  # Publishes/overwrites the build artifacts to a single fixed-tag pre-release
+  # `dev-latest`, force-moved to the new commit on every push to main. The panel's
+  # "Dev" update channel installs from this tag. `--latest=false` is load-bearing:
+  # it keeps releases/latest pointing at the real stable tag, so the stable
+  # channel is unaffected.
+  publish-dev:
+    name: Publish rolling dev release
+    needs: [build, build-windows]
+    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    # Serialize racing pushes; never cancel an in-flight upload, or the dev
+    # release could be left with a partial asset set.
+    concurrency:
+      group: dev-release
+      cancel-in-progress: false
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v7
+
+      - name: Download all build artifacts
+        uses: actions/download-artifact@v7
+        with:
+          path: dev-artifacts
+          merge-multiple: true
+
+      - name: Publish dev-latest pre-release
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          COMMIT: ${{ github.sha }}
+        run: |
+          set -e
+          short="${COMMIT::8}"
+          notes="Rolling development build — installs via the panel's Dev update channel.
+
+          commit=${COMMIT}
+          built=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+
+          Automated per-commit build from main. Not a stable release."
+
+          # Force-move the dev-latest tag to this commit so the release tracks it.
+          git tag -f dev-latest "${COMMIT}"
+          git push -f origin refs/tags/dev-latest
+
+          if gh release view dev-latest >/dev/null 2>&1; then
+            gh release edit dev-latest --prerelease --latest=false \
+              --title "Dev build ${short}" --notes "${notes}"
+          else
+            gh release create dev-latest --prerelease --latest=false \
+              --target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
+          fi
+
+          gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber

+ 7 - 0
README.md

@@ -146,6 +146,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
 | `XUI_ENABLE_FAIL2BAN` | Enable Fail2ban-based IP-limit enforcement | `true` |
 | `XUI_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warning`, `error`) | `info` |
 | `XUI_DEBUG` | Enable debug mode | `false` |
+| `XUI_TUNNEL_HEALTH_MONITOR` | Enable the tunnel health monitor (probes a URL and restarts xray after repeated failures; a restart drops all clients) | `false` |
+| `XUI_TUNNEL_HEALTH_PROXY` | Proxy the probe is sent through; point it at a local xray inbound so the probe tests the tunnel (e.g. `socks5://127.0.0.1:1080`). Empty means the probe only checks host connectivity | — |
+| `XUI_TUNNEL_HEALTH_URL` | URL probed for tunnel health | `https://www.cloudflare.com/cdn-cgi/trace` |
+| `XUI_TUNNEL_HEALTH_INTERVAL` | Interval between probes | `30s` |
+| `XUI_TUNNEL_HEALTH_TIMEOUT` | Per-probe timeout | `10s` |
+| `XUI_TUNNEL_HEALTH_FAILURES` | Consecutive failures before a restart is triggered | `3` |
+| `XUI_TUNNEL_HEALTH_COOLDOWN` | Minimum delay between consecutive restarts | `5m` |
 
 ## Supported Languages
 

+ 43 - 2
frontend/public/openapi.json

@@ -4240,6 +4240,46 @@
         }
       }
     },
+    "/panel/api/server/setUpdateChannel": {
+      "post": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.",
+        "operationId": "post_panel_api_server_setUpdateChannel",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/updateGeofile": {
       "post": {
         "tags": [
@@ -7400,7 +7440,7 @@
         "tags": [
           "Nodes"
         ],
-        "summary": "Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.",
+        "summary": "Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Set \"dev\": true to move the nodes to the rolling per-commit dev channel instead of the latest stable release. Returns a per-node result list.",
         "operationId": "post_panel_api_nodes_updatePanel",
         "requestBody": {
           "required": true,
@@ -7414,7 +7454,8 @@
                   1,
                   2,
                   3
-                ]
+                ],
+                "dev": false
               }
             }
           }

+ 3 - 3
frontend/src/api/queries/useNodeMutations.ts

@@ -59,8 +59,8 @@ export function useNodeMutations() {
   });
 
   const updatePanelsMut = useMutation({
-    mutationFn: (ids: number[]) =>
-      HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids }, {
+    mutationFn: ({ ids, dev }: { ids: number[]; dev: boolean }) =>
+      HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids, dev }, {
         headers: { 'Content-Type': 'application/json' },
       }),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
@@ -72,7 +72,7 @@ export function useNodeMutations() {
     remove: (id: number) => removeMut.mutateAsync(id),
     setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
     probe: (id: number) => probeMut.mutateAsync(id),
-    updatePanels: (ids: number[]): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync(ids),
+    updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync({ ids, dev }),
     testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
       const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
       return parseMsg(raw, ProbeResultSchema, 'nodes/test');

+ 10 - 2
frontend/src/lib/remark/remarkVariables.ts

@@ -3,7 +3,7 @@
 // per client. This file is the single frontend source of truth for the picker
 // UI and the live preview — keep the token list in sync with remark_vars.go.
 
-export type RemarkVarGroup = 'client' | 'traffic' | 'time';
+export type RemarkVarGroup = 'client' | 'traffic' | 'time' | 'connection';
 
 export interface RemarkVar {
   /** Bare token name, e.g. "TRAFFIC_LEFT" (rendered as {{TRAFFIC_LEFT}}). */
@@ -13,7 +13,7 @@ export interface RemarkVar {
   sample: string;
 }
 
-export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time'];
+export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time', 'connection'];
 
 export const REMARK_VARIABLES: RemarkVar[] = [
   // Client identity
@@ -36,11 +36,19 @@ export const REMARK_VARIABLES: RemarkVar[] = [
   { token: 'DOWN', group: 'traffic', sample: '3.20GB' },
   // Time / status
   { token: 'STATUS', group: 'time', sample: 'active' },
+  { token: 'STATUS_EMOJI', group: 'time', sample: '✅' },
   { token: 'DAYS_LEFT', group: 'time', sample: '12' },
+  { token: 'TIME_LEFT', group: 'time', sample: '12d 4h 30m' },
+  { token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3%' },
   { token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
+  { token: 'JALALI_EXPIRE_DATE', group: 'time', sample: '1405/06/10' },
   { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
   { token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
   { token: 'RESET_DAYS', group: 'time', sample: '30' },
+  // Connection (inbound config descriptors)
+  { token: 'PROTOCOL', group: 'connection', sample: 'VLESS' },
+  { token: 'TRANSPORT', group: 'connection', sample: 'ws' },
+  { token: 'SECURITY', group: 'connection', sample: 'TLS' },
 ];
 
 const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(

+ 11 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -400,6 +400,15 @@ export const sections: readonly Section[] = [
         path: '/panel/api/server/updatePanel',
         summary: 'Self-update the panel to the latest version. The server restarts on success.',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/server/setUpdateChannel',
+        summary: 'Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.',
+        params: [
+          { name: 'dev', in: 'body (form)', type: 'boolean', desc: 'true = dev channel, false = stable.' },
+        ],
+        body: 'dev=true',
+      },
       {
         method: 'POST',
         path: '/panel/api/server/updateGeofile',
@@ -936,8 +945,8 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/nodes/updatePanel',
-        summary: 'Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.',
-        body: '{\n  "ids": [1, 2, 3]\n}',
+        summary: 'Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Set "dev": true to move the nodes to the rolling per-commit dev channel instead of the latest stable release. Returns a per-node result list.',
+        body: '{\n  "ids": [1, 2, 3],\n  "dev": false\n}',
         response: '{\n  "success": true,\n  "obj": [\n    { "id": 1, "name": "de-1", "ok": true },\n    { "id": 2, "name": "fr-1", "ok": false, "error": "node is offline" }\n  ]\n}',
       },
       {

+ 19 - 3
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -285,8 +285,12 @@ export default function InboundFormModal({
   ) => {
     if (block?.id === authId) return true;
     const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
-    if (authId === 'mlkem768') return label.includes('mlkem768');
-    if (authId === 'x25519') return label.includes('x25519');
+    if (authId === 'mlkem768') return label.includes('mlkem768') && !label.includes('xorpub') && !label.includes('random');
+    if (authId === 'x25519') return label.includes('x25519') && !label.includes('xorpub') && !label.includes('random');
+    if (authId === 'mlkem768_xorpub') return label.includes('mlkem768') && label.includes('xorpub');
+    if (authId === 'mlkem768_random') return label.includes('mlkem768') && label.includes('random');
+    if (authId === 'x25519_xorpub') return label.includes('x25519') && label.includes('xorpub');
+    if (authId === 'x25519_random') return label.includes('x25519') && label.includes('random');
     return false;
   };
 
@@ -319,7 +323,19 @@ export default function InboundFormModal({
     const parts = enc.split('.').filter(Boolean);
     const authKey = parts[parts.length - 1] || '';
     if (!authKey) return t('pages.inbounds.vlessAuthCustom');
-    return authKey.length > 300
+    const mode = parts[1] || 'native';
+    const keyType = authKey.length > 300 ? 'mlkem768' : 'x25519';
+    if (mode === 'xorpub') {
+      return keyType === 'mlkem768'
+        ? t('pages.inbounds.vlessAuthMlkem768Xorpub')
+        : t('pages.inbounds.vlessAuthX25519Xorpub');
+    }
+    if (mode === 'random') {
+      return keyType === 'mlkem768'
+        ? t('pages.inbounds.vlessAuthMlkem768Random')
+        : t('pages.inbounds.vlessAuthX25519Random');
+    }
+    return keyType === 'mlkem768'
       ? t('pages.inbounds.vlessAuthMlkem768')
       : t('pages.inbounds.vlessAuthX25519');
   })();

+ 31 - 8
frontend/src/pages/inbounds/form/protocols/vless.tsx

@@ -1,12 +1,21 @@
+import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
+import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
+
+type VlessAuthKind =
+  | 'x25519'
+  | 'x25519_xorpub'
+  | 'x25519_random'
+  | 'mlkem768'
+  | 'mlkem768_xorpub'
+  | 'mlkem768_random';
 
 interface VlessFieldsProps {
   saving: boolean;
   selectedVlessAuth: string;
   network: string;
   security: string;
-  getNewVlessEnc: (kind: 'x25519' | 'mlkem768') => void;
+  getNewVlessEnc: (kind: VlessAuthKind) => void;
   clearVlessEnc: () => void;
 }
 
@@ -19,6 +28,17 @@ export default function VlessFields({
   clearVlessEnc,
 }: VlessFieldsProps) {
   const { t } = useTranslation();
+  const [authKind, setAuthKind] = useState<VlessAuthKind>('x25519');
+
+  const authOptions = [
+    { value: 'x25519', label: t('pages.inbounds.vlessAuthX25519') },
+    { value: 'x25519_xorpub', label: t('pages.inbounds.vlessAuthX25519Xorpub') },
+    { value: 'x25519_random', label: t('pages.inbounds.vlessAuthX25519Random') },
+    { value: 'mlkem768', label: t('pages.inbounds.vlessAuthMlkem768') },
+    { value: 'mlkem768_xorpub', label: t('pages.inbounds.vlessAuthMlkem768Xorpub') },
+    { value: 'mlkem768_random', label: t('pages.inbounds.vlessAuthMlkem768Random') },
+  ];
+
   return (
     <>
       <Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
@@ -27,13 +47,16 @@ export default function VlessFields({
       <Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
         <Input />
       </Form.Item>
-      <Form.Item label=" ">
+      <Form.Item label={t('pages.inbounds.vlessAuthGenerate')}>
         <Space size={8} wrap>
-          <Button type="primary" loading={saving} onClick={() => getNewVlessEnc('x25519')}>
-            {t('pages.inbounds.vlessAuthX25519')}
-          </Button>
-          <Button type="primary" loading={saving} onClick={() => getNewVlessEnc('mlkem768')}>
-            {t('pages.inbounds.vlessAuthMlkem768')}
+          <Select
+            value={authKind}
+            onChange={(v) => setAuthKind(v)}
+            options={authOptions}
+            style={{ width: 240 }}
+          />
+          <Button type="primary" loading={saving} onClick={() => getNewVlessEnc(authKind)}>
+            {t('pages.inbounds.vlessAuthGenerateButton')}
           </Button>
           <Button danger onClick={clearVlessEnc}>{t('clear')}</Button>
         </Space>

+ 25 - 4
frontend/src/pages/index/IndexPage.tsx

@@ -65,6 +65,8 @@ export default function IndexPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
   const [accessLogEnable, setAccessLogEnable] = useState(false);
+  const [isDevBuild, setIsDevBuild] = useState(false);
+  const [devChannelEnable, setDevChannelEnable] = useState(false);
   const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
     currentVersion: '',
     latestVersion: '',
@@ -87,8 +89,14 @@ export default function IndexPage() {
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
   useEffect(() => {
-    HttpUtil.post<{ accessLogEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
-      if (msg?.success && msg.obj) setAccessLogEnable(!!msg.obj.accessLogEnable);
+    HttpUtil.post<{ accessLogEnable?: boolean; isDevBuild?: boolean; devChannelEnable?: boolean }>(
+      '/panel/api/setting/defaultSettings',
+    ).then((msg) => {
+      if (msg?.success && msg.obj) {
+        setAccessLogEnable(!!msg.obj.accessLogEnable);
+        setIsDevBuild(!!msg.obj.isDevBuild);
+        setDevChannelEnable(!!msg.obj.devChannelEnable);
+      }
     });
     HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
       if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
@@ -119,13 +127,21 @@ export default function IndexPage() {
   }, [refresh]);
 
   function openPanelVersion() {
-    if (panelUpdateInfo.updateAvailable) {
+    if (panelUpdateInfo.updateAvailable || isDevBuild) {
       setPanelUpdateOpen(true);
     } else {
       window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
     }
   }
 
+  async function handleChannelChange(dev: boolean) {
+    const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
+    if (!res?.success) return;
+    setDevChannelEnable(dev);
+    const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
+    if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
+  }
+
   function openTelegram() {
     window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
   }
@@ -224,7 +240,9 @@ export default function IndexPage() {
                           {isMobile && displayVersion && (
                             <Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
                               {panelUpdateInfo.updateAvailable
-                                ? `v${panelUpdateInfo.latestVersion}`
+                                ? panelUpdateInfo.channel === 'dev'
+                                  ? panelUpdateInfo.latestVersion
+                                  : `v${panelUpdateInfo.latestVersion}`
                                 : `v${displayVersion}`}
                             </Tag>
                           )}
@@ -446,6 +464,9 @@ export default function IndexPage() {
           <PanelUpdateModal
             open={panelUpdateOpen}
             info={panelUpdateInfo}
+            isDevBuild={isDevBuild}
+            devChannelEnable={devChannelEnable}
+            onChannelChange={handleChannelChange}
             onClose={() => setPanelUpdateOpen(false)}
             onBusy={setBusy}
           />

+ 60 - 6
frontend/src/pages/index/PanelUpdateModal.tsx

@@ -1,5 +1,6 @@
+import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Alert, Button, Modal, Tag } from 'antd';
+import { Alert, Button, Modal, Switch, Tag } from 'antd';
 import { CloudDownloadOutlined } from '@ant-design/icons';
 import axios from 'axios';
 
@@ -7,8 +8,11 @@ import { HttpUtil, PromiseUtil } from '@/utils';
 import './PanelUpdateModal.css';
 
 export interface PanelUpdateInfo {
+  channel?: string;
   currentVersion: string;
   latestVersion: string;
+  currentCommit?: string;
+  latestCommit?: string;
   updateAvailable: boolean;
 }
 
@@ -20,13 +24,27 @@ interface BusyEvent {
 interface PanelUpdateModalProps {
   open: boolean;
   info: PanelUpdateInfo;
+  isDevBuild?: boolean;
+  devChannelEnable?: boolean;
+  onChannelChange?: (dev: boolean) => void | Promise<void>;
   onClose: () => void;
   onBusy: (e: BusyEvent) => void;
 }
 
-export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelUpdateModalProps) {
+export default function PanelUpdateModal({
+  open,
+  info,
+  isDevBuild,
+  devChannelEnable,
+  onChannelChange,
+  onClose,
+  onBusy,
+}: PanelUpdateModalProps) {
   const { t } = useTranslation();
   const [modal, contextHolder] = Modal.useModal();
+  const [channelBusy, setChannelBusy] = useState(false);
+
+  const isDev = info.channel === 'dev';
 
   async function pollUntilBack(): Promise<boolean> {
     await PromiseUtil.sleep(5000);
@@ -43,6 +61,16 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
     return false;
   }
 
+  async function handleChannel(checked: boolean) {
+    if (!onChannelChange) return;
+    setChannelBusy(true);
+    try {
+      await onChannelChange(checked);
+    } finally {
+      setChannelBusy(false);
+    }
+  }
+
   function updatePanel() {
     modal.confirm({
       title: t('pages.index.panelUpdateDialog'),
@@ -84,15 +112,41 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
           />
         )}
 
+        {isDevBuild && (
+          <div className="version-list">
+            <div className="version-list-item">
+              <span>{t('pages.index.devChannel')}</span>
+              <Switch
+                checked={!!devChannelEnable}
+                loading={channelBusy}
+                onChange={handleChannel}
+              />
+            </div>
+          </div>
+        )}
+
+        {devChannelEnable && (
+          <Alert
+            type="info"
+            className="mb-12"
+            title={t('pages.index.devChannelWarning')}
+            showIcon
+          />
+        )}
+
         <div className="version-list">
           <div className="version-list-item">
-            <span>{t('pages.index.currentPanelVersion')}</span>
-            <Tag color="green">v{info.currentVersion || '?'}</Tag>
+            <span>{isDev ? t('pages.index.currentCommit') : t('pages.index.currentPanelVersion')}</span>
+            {isDev ? (
+              <Tag color="green">{info.currentCommit || '?'}</Tag>
+            ) : (
+              <Tag color="green">v{info.currentVersion || '?'}</Tag>
+            )}
           </div>
           {info.updateAvailable ? (
             <div className="version-list-item">
-              <span>{t('pages.index.latestPanelVersion')}</span>
-              <Tag color="purple">{info.latestVersion || '-'}</Tag>
+              <span>{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}</span>
+              <Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
             </div>
           ) : (
             <div className="version-list-item">

+ 39 - 8
frontend/src/pages/nodes/NodesPage.tsx

@@ -1,7 +1,7 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { Button, Card, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
+import { Alert, Button, Card, Checkbox, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
 import {
   CheckCircleOutlined,
   CloseCircleOutlined,
@@ -21,6 +21,33 @@ import { setMessageInstance } from '@/utils/messageBus';
 import { HttpUtil } from '@/utils';
 import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
 
+// Confirm-dialog body that lets the operator pick the stable or dev channel for
+// a node panel update. Reports changes via onChange so the imperative
+// modal.confirm onOk can read the latest choice through a ref.
+function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
+  const { t } = useTranslation();
+  const [dev, setDev] = useState(false);
+  return (
+    <div>
+      <p>{t('pages.nodes.updateConfirmContent')}</p>
+      <Checkbox
+        checked={dev}
+        onChange={(e) => { setDev(e.target.checked); onChange(e.target.checked); }}
+      >
+        {t('pages.nodes.updateDevChannel')}
+      </Checkbox>
+      {dev && (
+        <Alert
+          type="info"
+          showIcon
+          style={{ marginTop: 8 }}
+          message={t('pages.index.devChannelWarning')}
+        />
+      )}
+    </div>
+  );
+}
+
 export default function NodesPage() {
   const { t } = useTranslation();
   const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -136,8 +163,10 @@ export default function NodesPage() {
     await setEnable(node.id, next);
   }, [setEnable]);
 
-  const runUpdate = useCallback(async (ids: number[]) => {
-    const msg = await updatePanels(ids);
+  const devRef = useRef(false);
+
+  const runUpdate = useCallback(async (ids: number[], dev: boolean) => {
+    const msg = await updatePanels(ids, dev);
     if (!msg?.success) {
       messageApi.error(msg?.msg || t('somethingWentWrong'));
       return;
@@ -156,12 +185,13 @@ export default function NodesPage() {
   }, [updatePanels, messageApi, t]);
 
   const onUpdateNode = useCallback((node: NodeRecord) => {
+    devRef.current = false;
     modal.confirm({
       title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
-      content: t('pages.nodes.updateConfirmContent'),
+      content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
       okText: t('update'),
       cancelText: t('cancel'),
-      onOk: () => runUpdate([node.id]),
+      onOk: () => runUpdate([node.id], devRef.current),
     });
   }, [modal, t, runUpdate]);
 
@@ -173,12 +203,13 @@ export default function NodesPage() {
       messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
       return;
     }
+    devRef.current = false;
     modal.confirm({
       title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
-      content: t('pages.nodes.updateConfirmContent'),
+      content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
       okText: t('update'),
       cancelText: t('cancel'),
-      onOk: () => runUpdate(eligible),
+      onOk: () => runUpdate(eligible, devRef.current),
     });
   }, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
 

+ 1 - 0
frontend/src/pages/xray/XrayPage.tsx

@@ -221,6 +221,7 @@ export default function XrayPage() {
             testingAll={testingAll}
             inboundTags={inboundTags}
             subscriptionOutbounds={subscriptionOutbounds}
+            subscriptionOutboundTags={subscriptionOutboundTags}
             isMobile={isMobile}
             onResetTraffic={resetOutboundsTraffic}
             onTest={onTestOutbound}

+ 3 - 1
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -83,6 +83,7 @@ interface OutboundFormModalProps {
   open: boolean;
   outbound: Record<string, unknown> | null;
   existingTags: string[];
+  dialerProxyTags?: string[];
   onClose: () => void;
   onConfirm: (outbound: Record<string, unknown>) => void;
 }
@@ -92,6 +93,7 @@ export default function OutboundFormModal({
   open,
   outbound: outboundProp,
   existingTags,
+  dialerProxyTags,
   onClose,
   onConfirm,
 }: OutboundFormModalProps) {
@@ -514,7 +516,7 @@ export default function OutboundFormModal({
                     {security === 'reality' && realityAllowed && <RealityForm />}
 
                     {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
-                      <SockoptForm form={form} outboundTags={existingTags} />
+                      <SockoptForm form={form} outboundTags={dialerProxyTags ?? existingTags} />
                     )}
 
                     <FinalMaskForm

+ 16 - 0
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -75,6 +75,7 @@ interface OutboundsTabProps {
   testingAll: boolean;
   inboundTags: string[];
   subscriptionOutbounds?: unknown[];
+  subscriptionOutboundTags?: string[];
   isMobile: boolean;
   onResetTraffic: (tag: string) => void;
   onTest: (index: number, mode: string) => void;
@@ -94,6 +95,7 @@ export default function OutboundsTab({
   testingAll,
   inboundTags: _inboundTags,
   subscriptionOutbounds,
+  subscriptionOutboundTags,
   isMobile,
   onResetTraffic,
   onTest,
@@ -140,6 +142,19 @@ export default function OutboundsTab({
 
   const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]);
 
+  const dialerProxyTags = useMemo(() => {
+    const tags = new Set<string>();
+    (templateSettings?.outbounds || []).forEach((o, i) => {
+      if (i === editingIndex) return;
+      if (o?.protocol === 'blackhole') return;
+      if (o?.tag) tags.add(o.tag);
+    });
+    for (const tag of subscriptionOutboundTags || []) {
+      if (tag) tags.add(tag);
+    }
+    return [...tags];
+  }, [templateSettings?.outbounds, editingIndex, subscriptionOutboundTags]);
+
   const mutate = useCallback(
     (mutator: (next: XraySettingsValue) => void) => {
       setTemplateSettings((prev) => {
@@ -521,6 +536,7 @@ export default function OutboundsTab({
           open={modalOpen}
           outbound={editingOutbound}
           existingTags={existingTags}
+          dialerProxyTags={dialerProxyTags}
           onClose={() => setModalOpen(false)}
           onConfirm={onConfirm}
         />

+ 42 - 0
internal/config/config.go

@@ -20,6 +20,15 @@ var version string
 //go:embed name
 var name string
 
+// buildCommit and buildDate are injected at build time via `-ldflags -X` for
+// CI per-commit (dev channel) builds; see .github/workflows/release.yml. They
+// stay empty for a plain `go build` and for stable tagged releases, which is how
+// IsDevBuild tells a rolling dev build apart from a stable/local one.
+var (
+	buildCommit string
+	buildDate   string
+)
+
 // LogLevel represents the logging level for the application.
 type LogLevel string
 
@@ -42,6 +51,39 @@ func GetName() string {
 	return strings.TrimSpace(name)
 }
 
+// GetBuildCommit returns the short git commit this binary was built from, or an
+// empty string for a plain/local build or a stable tagged release.
+func GetBuildCommit() string {
+	return strings.TrimSpace(buildCommit)
+}
+
+// GetBuildDate returns the UTC build timestamp injected at build time, or empty.
+func GetBuildDate() string {
+	return strings.TrimSpace(buildDate)
+}
+
+// IsDevBuild reports whether this binary is a CI per-commit (dev channel) build,
+// detected by the injected commit. Stable releases and local builds return false.
+func IsDevBuild() bool {
+	return GetBuildCommit() != ""
+}
+
+// GetReportedVersion returns the version a panel advertises to a managing master
+// node: the plain version for stable builds, or "dev+<short commit>" for dev
+// builds. The dev form mirrors the master's getPanelUpdateInfo latestVersion so
+// a node on the current dev commit compares as up to date instead of always
+// showing "update available".
+func GetReportedVersion() string {
+	if !IsDevBuild() {
+		return GetVersion()
+	}
+	commit := GetBuildCommit()
+	if len(commit) > 8 {
+		commit = commit[:8]
+	}
+	return "dev+" + commit
+}
+
 // GetLogLevel returns the current logging level based on environment variables or defaults to Info.
 func GetLogLevel() LogLevel {
 	if IsDebug() {

+ 20 - 0
internal/config/config_test.go

@@ -5,6 +5,26 @@ import (
 	"testing"
 )
 
+func TestGetReportedVersion(t *testing.T) {
+	orig := buildCommit
+	t.Cleanup(func() { buildCommit = orig })
+
+	buildCommit = ""
+	if got := GetReportedVersion(); got != GetVersion() {
+		t.Fatalf("stable build: GetReportedVersion = %q, want %q", got, GetVersion())
+	}
+
+	buildCommit = "1d1128cf"
+	if got := GetReportedVersion(); got != "dev+1d1128cf" {
+		t.Fatalf("dev build: GetReportedVersion = %q, want %q", got, "dev+1d1128cf")
+	}
+
+	buildCommit = "1d1128cf945c4615efa05cf41ba7fa766e2ee428"
+	if got := GetReportedVersion(); got != "dev+1d1128cf" {
+		t.Fatalf("dev build (full sha): GetReportedVersion = %q, want %q", got, "dev+1d1128cf")
+	}
+}
+
 func TestGetPortOverride(t *testing.T) {
 	tests := []struct {
 		name       string

+ 89 - 30
internal/sub/remark_vars.go

@@ -6,7 +6,6 @@ import (
 	"strconv"
 	"strings"
 	"time"
-	"unicode"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
@@ -25,6 +24,7 @@ type remarkContext struct {
 	inbound    *model.Inbound
 	hostRemark string
 	transport  string
+	security   string
 }
 
 // configName is the display name for a link: always the inbound's own remark.
@@ -71,6 +71,7 @@ var uiTokenMap = map[string]string{
 	"USAGE_PERCENTAGE":   "USAGE_PERCENTAGE",
 	"PROTOCOL":           "PROTOCOL",
 	"TRANSPORT":          "TRANSPORT",
+	"SECURITY":           "SECURITY",
 }
 
 // translateUISingleBrackets converts user-friendly single-brace tokens to the
@@ -226,6 +227,8 @@ func remarkVarValue(token string, ctx remarkContext) string {
 		return ""
 	case "TRANSPORT":
 		return ctx.transport
+	case "SECURITY":
+		return strings.ToUpper(ctx.security)
 	case "TIME_LEFT":
 		return timeLeftLabel(st.ExpiryTime)
 	case "JALALI_EXPIRE_DATE":
@@ -458,52 +461,107 @@ func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Cl
 	return model.Client{Email: email}
 }
 
-// usageInfoTokens are the per-client status tokens. On every link of a
-// subscription except the client's first, these (and the decoration leading
-// into them) are dropped, so the traffic/expiry info shows once instead of on
-// every server.
-var usageInfoTokens = []string{
-	"TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
-	"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
-	"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
-	"STATUS_EMOJI", "USAGE_PERCENTAGE", "TIME_LEFT", "JALALI_EXPIRE_DATE",
+var usageInfoTokens = map[string]bool{
+	"TRAFFIC_USED": true, "TRAFFIC_LEFT": true, "TRAFFIC_TOTAL": true,
+	"TRAFFIC_USED_BYTES": true, "TRAFFIC_LEFT_BYTES": true, "TRAFFIC_TOTAL_BYTES": true,
+	"UP": true, "DOWN": true, "DAYS_LEFT": true, "EXPIRE_DATE": true, "EXPIRE_UNIX": true,
+	"STATUS": true, "STATUS_EMOJI": true, "USAGE_PERCENTAGE": true, "TIME_LEFT": true,
+	"JALALI_EXPIRE_DATE": true,
 }
 
-// nameOnlyTemplate returns template with the trailing per-client info part
-// removed: everything from the first usage token (and the decoration — emojis,
-// spaces, separators — leading into it) onward is dropped, leaving the config
-// name. Returns "" when the template is info-only.
-func nameOnlyTemplate(template string) string {
-	idx := -1
-	for _, tok := range usageInfoTokens {
-		if i := strings.Index(template, "{{"+tok+"}}"); i >= 0 && (idx < 0 || i < idx) {
-			idx = i
+var connectionTokens = map[string]bool{
+	"PROTOCOL":  true,
+	"TRANSPORT": true,
+	"SECURITY":  true,
+}
+
+var displayRemoveTokens = mergeTokenSets(usageInfoTokens, connectionTokens)
+
+func mergeTokenSets(sets ...map[string]bool) map[string]bool {
+	out := make(map[string]bool)
+	for _, set := range sets {
+		for tok := range set {
+			out[tok] = true
 		}
 	}
-	if idx < 0 {
-		return template
+	return out
+}
+
+func filterRemarkTemplate(template string, remove map[string]bool) string {
+	segments := strings.Split(template, "|")
+	kept := make([]string, 0, len(segments))
+	for _, seg := range segments {
+		if out := filterRemarkSegment(seg, remove); out != "" {
+			kept = append(kept, out)
+		}
 	}
-	return strings.TrimRightFunc(template[:idx], func(r rune) bool {
-		return r != '}' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
-	})
+	return strings.Join(kept, "|")
+}
+
+func filterRemarkSegment(seg string, remove map[string]bool) string {
+	locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
+	hasRemove := false
+	for _, loc := range locs {
+		if remove[seg[loc[2]:loc[3]]] {
+			hasRemove = true
+			break
+		}
+	}
+	if !hasRemove {
+		return strings.TrimSpace(seg)
+	}
+	runs := make([]string, 0, 2)
+	runStart, leftRemoved := 0, false
+	for _, loc := range locs {
+		if !remove[seg[loc[2]:loc[3]]] {
+			continue
+		}
+		runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
+		runStart, leftRemoved = loc[1], true
+	}
+	runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
+	return strings.Join(runs, " ")
+}
+
+func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
+	locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
+	if len(locs) == 0 {
+		return runs
+	}
+	start, end := 0, len(run)
+	if leftRemoved {
+		start = locs[0][0]
+	}
+	if rightRemoved {
+		end = locs[len(locs)-1][1]
+	}
+	if frag := strings.TrimSpace(run[start:end]); frag != "" {
+		runs = append(runs, frag)
+	}
+	return runs
 }
 
-// effectiveTemplate picks which template to expand for one body link: the full
-// template (with the per-client info) for a client's first link, and the
-// name-only template for every link thereafter — so the info shows once. Only
-// called in the subscription-body context (displays render name-only directly).
 func (s *SubService) effectiveTemplate(email string) string {
 	translated := translateUISingleBrackets(s.remarkTemplate)
 	if s.usageShown == nil {
 		s.usageShown = map[string]bool{}
 	}
 	if s.usageShown[email] {
-		return nameOnlyTemplate(translated)
+		return filterRemarkTemplate(translated, usageInfoTokens)
 	}
 	s.usageShown[email] = true
 	return translated
 }
 
+func inboundSecurity(inbound *model.Inbound) string {
+	if inbound == nil {
+		return ""
+	}
+	stream := unmarshalStreamSettings(inbound.StreamSettings)
+	security, _ := stream["security"].(string)
+	return security
+}
+
 // genTemplatedRemark expands the remark template for one client. hostRemark is
 // the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
 // token only and never substitutes the inbound remark as the config name.
@@ -514,12 +572,13 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 		inbound:    inbound,
 		hostRemark: hostRemark,
 		transport:  transport,
+		security:   inboundSecurity(inbound),
 	}
 	var tmpl string
 	if s.subscriptionBody {
 		tmpl = s.effectiveTemplate(client.Email)
 	} else {
-		tmpl = nameOnlyTemplate(translateUISingleBrackets(s.remarkTemplate))
+		tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
 	}
 	if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
 		return out

+ 155 - 9
internal/sub/remark_vars_test.go

@@ -251,22 +251,138 @@ func TestRemarkInDisplayContext(t *testing.T) {
 	}
 }
 
-// nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
-func TestNameOnlyTemplate(t *testing.T) {
+func TestFilterRemarkTemplate_BodyRepeat(t *testing.T) {
 	cases := map[string]string{
-		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",           // usage tail stripped
-		"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}":          "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
-		"{{INBOUND}} | {{STATUS}}":                      "{{INBOUND}}",
-		"{{INBOUND}}-{{EMAIL}}":                         "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged
-		"{{TRAFFIC_LEFT}}":                              "",                      // info only → empty
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}":              "{{INBOUND}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}",
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D":                                      "{{INBOUND}}",
+		"{{INBOUND}} {{PROTOCOL}}|📊{{TRAFFIC_LEFT}}":                                         "{{INBOUND}} {{PROTOCOL}}",
+		"{{INBOUND}}-{{EMAIL}}":                                                              "{{INBOUND}}-{{EMAIL}}",
+		"{{TRAFFIC_LEFT}}|{{SECURITY}}":                                                      "{{SECURITY}}",
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}} {{PROTOCOL}}":                                         "{{INBOUND}}|{{PROTOCOL}}",
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}":                                            "{{INBOUND}}|{{EMAIL}}",
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}",
+		"{{EMAIL}} {{TRAFFIC_USED}}5h":                                                       "{{EMAIL}}",
+		"{{PROTOCOL}} {{TRAFFIC_LEFT}}GB":                                                    "{{PROTOCOL}}",
+		"{{EMAIL}}-{{TRAFFIC_LEFT}}D-{{HOST}}":                                               "{{EMAIL}} {{HOST}}",
+		"{{EMAIL}} 📊{{TRAFFIC_LEFT}} {{PROTOCOL}}":                                           "{{EMAIL}} {{PROTOCOL}}",
 	}
 	for tmpl, want := range cases {
-		if got := nameOnlyTemplate(tmpl); got != want {
-			t.Errorf("nameOnlyTemplate(%q) = %q, want %q", tmpl, got, want)
+		if got := filterRemarkTemplate(tmpl, usageInfoTokens); got != want {
+			t.Errorf("filterRemarkTemplate(%q, usage) = %q, want %q", tmpl, got, want)
 		}
 	}
 }
 
+func TestFilterRemarkTemplate_Display(t *testing.T) {
+	cases := map[string]string{
+		"{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}": "{{INBOUND}}-{{EMAIL}}",
+		"{{INBOUND}} {{PROTOCOL}}":                             "{{INBOUND}}",
+		"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}":                 "{{EMAIL}} {{INBOUND}}",
+		"{{INBOUND}} | {{STATUS}}":                             "{{INBOUND}}",
+		"{{INBOUND}}-{{EMAIL}}":                                "{{INBOUND}}-{{EMAIL}}",
+		"{{TRAFFIC_LEFT}}":                                     "",
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{HOST}}":               "{{INBOUND}}|{{HOST}}",
+		"{{EMAIL}} ⏳{{DAYS_LEFT}}D {{HOST}}":                   "{{EMAIL}} {{HOST}}",
+		"{{INBOUND}} {{TRAFFIC_LEFT}} {{EMAIL}}":               "{{INBOUND}} {{EMAIL}}",
+	}
+	for tmpl, want := range cases {
+		if got := filterRemarkTemplate(tmpl, displayRemoveTokens); got != want {
+			t.Errorf("filterRemarkTemplate(%q, display) = %q, want %q", tmpl, got, want)
+		}
+	}
+}
+
+func TestConnectionTokensOnEveryBodyLink(t *testing.T) {
+	s := &SubService{
+		remarkTemplate:   "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
+		subscriptionBody: true,
+		usageShown:       map[string]bool{},
+	}
+	inbound := &model.Inbound{
+		Remark:         "DE",
+		Protocol:       "vless",
+		StreamSettings: `{"network":"ws","security":"tls"}`,
+		ClientStats:    []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
+	}
+	client := model.Client{Email: "john@x"}
+	first := s.genTemplatedRemark(inbound, client, "", "ws")
+	second := s.genTemplatedRemark(inbound, client, "", "ws")
+	for _, want := range []string{"VLESS", "ws", "TLS"} {
+		if !strings.Contains(first, want) {
+			t.Fatalf("first body link %q missing %q", first, want)
+		}
+		if !strings.Contains(second, want) {
+			t.Fatalf("repeat body link %q missing connection token %q", second, want)
+		}
+	}
+	if strings.ContainsAny(second, "📊") || strings.Contains(second, "GB") {
+		t.Fatalf("repeat body link must drop the usage block: %q", second)
+	}
+}
+
+func TestConnectionTokensMixedIntoUsageSegment(t *testing.T) {
+	s := &SubService{
+		remarkTemplate:   "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D {{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
+		subscriptionBody: true,
+		usageShown:       map[string]bool{},
+	}
+	inbound := &model.Inbound{
+		Remark:         "DE",
+		Protocol:       "vless",
+		StreamSettings: `{"network":"grpc","security":"reality"}`,
+		ClientStats:    []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
+	}
+	client := model.Client{Email: "john@x"}
+	_ = s.genTemplatedRemark(inbound, client, "", "grpc")
+	second := s.genTemplatedRemark(inbound, client, "", "grpc")
+	for _, want := range []string{"VLESS", "grpc", "REALITY"} {
+		if !strings.Contains(second, want) {
+			t.Fatalf("repeat body link %q missing connection token %q", second, want)
+		}
+	}
+	if strings.Contains(second, "GB") || strings.ContainsRune(second, '⏳') {
+		t.Fatalf("repeat body link must drop the usage block: %q", second)
+	}
+}
+
+func TestConnectionTokensDisplayContextUnchanged(t *testing.T) {
+	s := &SubService{
+		remarkTemplate:   "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}",
+		subscriptionBody: false,
+	}
+	inbound := &model.Inbound{
+		Remark:         "DE",
+		Protocol:       "vless",
+		StreamSettings: `{"network":"ws","security":"tls"}`,
+		ClientStats:    []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
+	}
+	if got := s.genTemplatedRemark(inbound, model.Client{Email: "john@x"}, "", "ws"); got != "DE" {
+		t.Fatalf("display remark = %q, want DE (connection after usage stripped outside the body)", got)
+	}
+}
+
+func TestIdentityTokensEverywhere(t *testing.T) {
+	const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}"
+	inbound := &model.Inbound{
+		Remark:         "DE",
+		Protocol:       "vless",
+		StreamSettings: `{"network":"ws","security":"tls"}`,
+		ClientStats:    []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
+	}
+	client := model.Client{Email: "john@x"}
+
+	body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}}
+	_ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block
+	if second := body.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(second, "john@x") {
+		t.Fatalf("repeat body link %q must keep the identity token", second)
+	}
+
+	display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}
+	if got := display.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(got, "john@x") {
+		t.Fatalf("display remark %q must keep the identity token", got)
+	}
+}
+
 // statsForClient resolves usage from the per-request statsByEmail map when the
 // link's own inbound doesn't carry the client's (globally unique) traffic row —
 // the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
@@ -377,6 +493,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
 		stats:     stats,
 		inbound:   inbound,
 		transport: "ws",
+		security:  "reality",
 	}
 
 	cases := []struct{ tmpl, want string }{
@@ -384,6 +501,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
 		{"{{USAGE_PERCENTAGE}}", "50.0%"},
 		{"{{PROTOCOL}}", "VLESS"},
 		{"{{TRANSPORT}}", "ws"},
+		{"{{SECURITY}}", "REALITY"},
 		{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
 	}
 	for _, c := range cases {
@@ -393,6 +511,32 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
 	}
 }
 
+func TestInboundSecurity(t *testing.T) {
+	cases := []struct{ stream, want string }{
+		{`{"network":"ws","security":"tls"}`, "tls"},
+		{`{"network":"tcp","security":"reality"}`, "reality"},
+		{`{"network":"tcp","security":"none"}`, "none"},
+		{`{"network":"tcp"}`, ""},
+		{"", ""},
+	}
+	for _, c := range cases {
+		if got := inboundSecurity(&model.Inbound{StreamSettings: c.stream}); got != c.want {
+			t.Errorf("inboundSecurity(%q) = %q, want %q", c.stream, got, c.want)
+		}
+	}
+	if got := inboundSecurity(nil); got != "" {
+		t.Errorf("inboundSecurity(nil) = %q, want empty", got)
+	}
+}
+
+func TestGenTemplatedRemark_SecurityFromStream(t *testing.T) {
+	s := &SubService{remarkTemplate: "{{INBOUND}} {{SECURITY}}", subscriptionBody: true}
+	inbound := &model.Inbound{Remark: "DE", StreamSettings: `{"network":"tcp","security":"reality"}`}
+	if got := s.genTemplatedRemark(inbound, model.Client{Email: "a@x"}, "", "tcp"); got != "DE REALITY" {
+		t.Fatalf("genTemplatedRemark SECURITY = %q, want %q", got, "DE REALITY")
+	}
+}
+
 func TestTranslateUISingleBrackets(t *testing.T) {
 	cases := []struct{ in, want string }{
 		{"{EMAIL}", "{{EMAIL}}"},
@@ -419,6 +563,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
 		stats:     stats,
 		inbound:   inbound,
 		transport: "ws",
+		security:  "tls",
 	}
 	cases := []struct{ tmpl, want string }{
 		{"{EMAIL}", "[email protected]"},
@@ -429,6 +574,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
 		{"{USAGE_PERCENTAGE}", "50.0%"},
 		{"{PROTOCOL}", "VLESS"},
 		{"{TRANSPORT}", "ws"},
+		{"{SECURITY}", "TLS"},
 	}
 	for _, c := range cases {
 		if got := expandRemarkVars(c.tmpl, ctx); got != c.want {

+ 271 - 0
internal/tunnelmonitor/monitor.go

@@ -0,0 +1,271 @@
+package tunnelmonitor
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
+)
+
+const (
+	defaultHealthURL        = "https://www.cloudflare.com/cdn-cgi/trace"
+	defaultInterval         = 30 * time.Second
+	defaultTimeout          = 10 * time.Second
+	defaultFailureThreshold = 3
+	defaultCooldown         = 5 * time.Minute
+)
+
+// Config controls the optional tunnel health monitor.
+type Config struct {
+	Enabled          bool
+	URL              string
+	ProxyURL         string
+	Interval         time.Duration
+	Timeout          time.Duration
+	FailureThreshold int
+	Cooldown         time.Duration
+}
+
+// RecoveryFunc performs recovery after the monitor reaches the configured
+// failure threshold. The panel wires this to an Xray core restart.
+type RecoveryFunc func(context.Context) error
+
+// Monitor periodically probes a URL and triggers recovery after repeated
+// failures. It is intentionally independent from panel settings/UI so it can be
+// enabled safely through service environment variables first.
+type Monitor struct {
+	cfg          Config
+	client       *http.Client
+	recover      RecoveryFunc
+	failures     int
+	lastRecovery time.Time
+	now          func() time.Time
+}
+
+// DefaultConfig returns disabled-by-default monitor settings.
+func DefaultConfig() Config {
+	return Config{
+		Enabled:          false,
+		URL:              defaultHealthURL,
+		Interval:         defaultInterval,
+		Timeout:          defaultTimeout,
+		FailureThreshold: defaultFailureThreshold,
+		Cooldown:         defaultCooldown,
+	}
+}
+
+// ConfigFromEnv builds Config from XUI_TUNNEL_HEALTH_* environment variables.
+//
+// Supported variables:
+//   - XUI_TUNNEL_HEALTH_MONITOR=true
+//   - XUI_TUNNEL_HEALTH_URL=https://www.cloudflare.com/cdn-cgi/trace
+//   - XUI_TUNNEL_HEALTH_PROXY=socks5://127.0.0.1:1080
+//   - XUI_TUNNEL_HEALTH_INTERVAL=30s
+//   - XUI_TUNNEL_HEALTH_TIMEOUT=10s
+//   - XUI_TUNNEL_HEALTH_FAILURES=3
+//   - XUI_TUNNEL_HEALTH_COOLDOWN=5m
+func ConfigFromEnv() Config {
+	cfg := DefaultConfig()
+
+	cfg.Enabled = parseBool(os.Getenv("XUI_TUNNEL_HEALTH_MONITOR"))
+	cfg.URL = firstNonEmpty(os.Getenv("XUI_TUNNEL_HEALTH_URL"), cfg.URL)
+	cfg.ProxyURL = strings.TrimSpace(os.Getenv("XUI_TUNNEL_HEALTH_PROXY"))
+	cfg.Interval = parseDurationEnv("XUI_TUNNEL_HEALTH_INTERVAL", cfg.Interval)
+	cfg.Timeout = parseDurationEnv("XUI_TUNNEL_HEALTH_TIMEOUT", cfg.Timeout)
+	cfg.Cooldown = parseDurationEnv("XUI_TUNNEL_HEALTH_COOLDOWN", cfg.Cooldown)
+	cfg.FailureThreshold = parseIntEnv("XUI_TUNNEL_HEALTH_FAILURES", cfg.FailureThreshold)
+
+	return cfg.Normalize()
+}
+
+// Normalize applies safe bounds and defaults.
+func (c Config) Normalize() Config {
+	if strings.TrimSpace(c.URL) == "" {
+		c.URL = defaultHealthURL
+	}
+	c.URL = strings.TrimSpace(c.URL)
+	c.ProxyURL = strings.TrimSpace(c.ProxyURL)
+
+	if c.Interval < time.Second {
+		c.Interval = defaultInterval
+	}
+	if c.Timeout < time.Second {
+		c.Timeout = defaultTimeout
+	}
+	if c.FailureThreshold < 1 {
+		c.FailureThreshold = defaultFailureThreshold
+	}
+	if c.Cooldown < time.Second {
+		c.Cooldown = defaultCooldown
+	}
+
+	return c
+}
+
+// New creates a monitor with an HTTP client based on cfg.
+func New(cfg Config, recover RecoveryFunc) (*Monitor, error) {
+	cfg = cfg.Normalize()
+
+	client, err := netproxy.NewHTTPClient(cfg.ProxyURL, cfg.Timeout)
+	if err != nil {
+		return nil, err
+	}
+
+	return newWithClient(cfg, client, recover), nil
+}
+
+func newWithClient(cfg Config, client *http.Client, recover RecoveryFunc) *Monitor {
+	cfg = cfg.Normalize()
+	if client == nil {
+		client = &http.Client{Timeout: cfg.Timeout}
+	}
+
+	return &Monitor{
+		cfg:     cfg,
+		client:  client,
+		recover: recover,
+		now:     time.Now,
+	}
+}
+
+// Run starts the monitor loop until ctx is cancelled.
+func (m *Monitor) Run(ctx context.Context) {
+	if m == nil || !m.cfg.Enabled {
+		return
+	}
+
+	logger.Info("Tunnel health monitor enabled: ", m.cfg.URL)
+
+	ticker := time.NewTicker(m.cfg.Interval)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			logger.Info("Tunnel health monitor stopped")
+			return
+		case <-ticker.C:
+			recovered, err := m.Step(ctx)
+			if err != nil {
+				logger.Warning("Tunnel health monitor check failed: ", err)
+			}
+			if recovered {
+				logger.Warning("Tunnel health monitor triggered Xray restart")
+			}
+		}
+	}
+}
+
+// Step performs one probe and maybe triggers recovery.
+func (m *Monitor) Step(ctx context.Context) (bool, error) {
+	if m == nil {
+		return false, errors.New("nil monitor")
+	}
+
+	if err := m.probe(ctx); err != nil {
+		m.failures++
+
+		if m.failures < m.cfg.FailureThreshold {
+			return false, fmt.Errorf("probe failed %d/%d: %w", m.failures, m.cfg.FailureThreshold, err)
+		}
+
+		now := m.now()
+		if !m.lastRecovery.IsZero() && now.Sub(m.lastRecovery) < m.cfg.Cooldown {
+			m.failures = m.cfg.FailureThreshold
+			return false, fmt.Errorf("probe failed %d/%d; recovery cooldown active: %w", m.failures, m.cfg.FailureThreshold, err)
+		}
+
+		if m.recover == nil {
+			m.failures = m.cfg.FailureThreshold
+			return false, errors.New("recovery function is not configured")
+		}
+
+		if recErr := m.recover(ctx); recErr != nil {
+			return false, fmt.Errorf("recovery failed after probe error %v: %w", err, recErr)
+		}
+
+		m.lastRecovery = now
+		m.failures = 0
+		return true, err
+	}
+
+	if m.failures > 0 {
+		logger.Info("Tunnel health monitor recovered after successful probe")
+	}
+	m.failures = 0
+	return false, nil
+}
+
+func (m *Monitor) probe(ctx context.Context) error {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.URL, nil)
+	if err != nil {
+		return err
+	}
+
+	resp, err := m.client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+
+	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
+		return fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
+	}
+
+	return nil
+}
+
+func parseBool(value string) bool {
+	switch strings.ToLower(strings.TrimSpace(value)) {
+	case "1", "true", "yes", "y", "on", "enable", "enabled":
+		return true
+	default:
+		return false
+	}
+}
+
+func parseDurationEnv(name string, fallback time.Duration) time.Duration {
+	value := strings.TrimSpace(os.Getenv(name))
+	if value == "" {
+		return fallback
+	}
+
+	d, err := time.ParseDuration(value)
+	if err != nil {
+		return fallback
+	}
+
+	return d
+}
+
+func parseIntEnv(name string, fallback int) int {
+	value := strings.TrimSpace(os.Getenv(name))
+	if value == "" {
+		return fallback
+	}
+
+	n, err := strconv.Atoi(value)
+	if err != nil {
+		return fallback
+	}
+
+	return n
+}
+
+func firstNonEmpty(value string, fallback string) string {
+	value = strings.TrimSpace(value)
+	if value == "" {
+		return fallback
+	}
+	return value
+}

+ 454 - 0
internal/tunnelmonitor/monitor_test.go

@@ -0,0 +1,454 @@
+package tunnelmonitor
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/op/go-logging"
+)
+
+func TestMain(m *testing.M) {
+	logger.InitLogger(logging.ERROR)
+	m.Run()
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+	return f(req)
+}
+
+func TestMonitorRestartsAfterFailureThreshold(t *testing.T) {
+	cfg := Config{
+		Enabled:          true,
+		URL:              "http://example.test",
+		Interval:         time.Minute,
+		Timeout:          time.Second,
+		FailureThreshold: 2,
+		Cooldown:         time.Minute,
+	}
+
+	client := &http.Client{
+		Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+			return nil, errors.New("tunnel down")
+		}),
+	}
+
+	restarts := 0
+	monitor := newWithClient(cfg, client, func(ctx context.Context) error {
+		restarts++
+		return nil
+	})
+
+	monitor.now = func() time.Time {
+		return time.Unix(100, 0)
+	}
+
+	if recovered, _ := monitor.Step(context.Background()); recovered {
+		t.Fatal("first failure must not trigger recovery")
+	}
+
+	if recovered, _ := monitor.Step(context.Background()); !recovered {
+		t.Fatal("second consecutive failure should trigger recovery")
+	}
+
+	if restarts != 1 {
+		t.Fatalf("expected 1 recovery, got %d", restarts)
+	}
+}
+
+func TestMonitorRespectsRecoveryCooldown(t *testing.T) {
+	cfg := Config{
+		Enabled:          true,
+		URL:              "http://example.test",
+		Interval:         time.Minute,
+		Timeout:          time.Second,
+		FailureThreshold: 1,
+		Cooldown:         time.Minute,
+	}
+
+	client := &http.Client{
+		Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+			return nil, errors.New("tunnel down")
+		}),
+	}
+
+	now := time.Unix(100, 0)
+	restarts := 0
+
+	monitor := newWithClient(cfg, client, func(ctx context.Context) error {
+		restarts++
+		return nil
+	})
+
+	monitor.now = func() time.Time {
+		return now
+	}
+
+	recovered, _ := monitor.Step(context.Background())
+	if !recovered {
+		t.Fatal("first failure should trigger recovery when threshold is 1")
+	}
+
+	recovered, _ = monitor.Step(context.Background())
+	if recovered {
+		t.Fatal("cooldown should suppress immediate second recovery")
+	}
+
+	if restarts != 1 {
+		t.Fatalf("expected 1 recovery during cooldown, got %d", restarts)
+	}
+
+	now = now.Add(time.Minute + time.Second)
+
+	recovered, _ = monitor.Step(context.Background())
+	if !recovered {
+		t.Fatal("recovery should be allowed after cooldown")
+	}
+
+	if restarts != 2 {
+		t.Fatalf("expected 2 recoveries after cooldown, got %d", restarts)
+	}
+}
+
+func TestMonitorSuccessResetsFailures(t *testing.T) {
+	cfg := Config{
+		Enabled:          true,
+		URL:              "http://example.test",
+		Interval:         time.Minute,
+		Timeout:          time.Second,
+		FailureThreshold: 2,
+		Cooldown:         time.Minute,
+	}
+
+	fail := true
+	client := &http.Client{
+		Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+			if fail {
+				return nil, errors.New("tunnel down")
+			}
+
+			return &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       http.NoBody,
+			}, nil
+		}),
+	}
+
+	restarts := 0
+	monitor := newWithClient(cfg, client, func(ctx context.Context) error {
+		restarts++
+		return nil
+	})
+
+	_, _ = monitor.Step(context.Background())
+
+	fail = false
+	if recovered, err := monitor.Step(context.Background()); recovered || err != nil {
+		t.Fatalf("successful probe should not recover or fail, recovered=%v err=%v", recovered, err)
+	}
+
+	fail = true
+	if recovered, _ := monitor.Step(context.Background()); recovered {
+		t.Fatal("failure after success should be counted as first failure again")
+	}
+
+	if restarts != 0 {
+		t.Fatalf("expected no recovery, got %d", restarts)
+	}
+}
+
+func TestConfigFromEnvParsesValues(t *testing.T) {
+	t.Setenv("XUI_TUNNEL_HEALTH_MONITOR", "true")
+	t.Setenv("XUI_TUNNEL_HEALTH_URL", "https://example.com/health")
+	t.Setenv("XUI_TUNNEL_HEALTH_PROXY", "socks5://127.0.0.1:1080")
+	t.Setenv("XUI_TUNNEL_HEALTH_INTERVAL", "15s")
+	t.Setenv("XUI_TUNNEL_HEALTH_TIMEOUT", "3s")
+	t.Setenv("XUI_TUNNEL_HEALTH_FAILURES", "4")
+	t.Setenv("XUI_TUNNEL_HEALTH_COOLDOWN", "2m")
+
+	cfg := ConfigFromEnv()
+
+	if !cfg.Enabled {
+		t.Fatal("expected monitor to be enabled")
+	}
+
+	if cfg.URL != "https://example.com/health" {
+		t.Fatalf("unexpected URL: %s", cfg.URL)
+	}
+
+	if !strings.HasPrefix(cfg.ProxyURL, "socks5://") {
+		t.Fatalf("unexpected proxy URL: %s", cfg.ProxyURL)
+	}
+
+	if cfg.Interval != 15*time.Second {
+		t.Fatalf("unexpected interval: %s", cfg.Interval)
+	}
+
+	if cfg.Timeout != 3*time.Second {
+		t.Fatalf("unexpected timeout: %s", cfg.Timeout)
+	}
+
+	if cfg.FailureThreshold != 4 {
+		t.Fatalf("unexpected threshold: %d", cfg.FailureThreshold)
+	}
+
+	if cfg.Cooldown != 2*time.Minute {
+		t.Fatalf("unexpected cooldown: %s", cfg.Cooldown)
+	}
+}
+
+func failingClient() *http.Client {
+	return &http.Client{
+		Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+			return nil, errors.New("tunnel down")
+		}),
+	}
+}
+
+func statusClient(code int) *http.Client {
+	return &http.Client{
+		Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+			return &http.Response{StatusCode: code, Body: http.NoBody}, nil
+		}),
+	}
+}
+
+func TestProbeStatusCodeClassification(t *testing.T) {
+	cases := []struct {
+		status  int
+		healthy bool
+	}{
+		{199, false},
+		{200, true},
+		{204, true},
+		{301, true},
+		{399, true},
+		{400, false},
+		{404, false},
+		{500, false},
+	}
+
+	for _, tc := range cases {
+		cfg := Config{
+			Enabled:          true,
+			URL:              "http://example.test",
+			Interval:         time.Minute,
+			Timeout:          time.Second,
+			FailureThreshold: 100,
+			Cooldown:         time.Minute,
+		}
+
+		monitor := newWithClient(cfg, statusClient(tc.status), func(ctx context.Context) error {
+			return nil
+		})
+
+		recovered, err := monitor.Step(context.Background())
+		if recovered {
+			t.Fatalf("status %d: unexpected recovery", tc.status)
+		}
+		if tc.healthy && err != nil {
+			t.Fatalf("status %d: expected healthy probe, got error %v", tc.status, err)
+		}
+		if !tc.healthy && err == nil {
+			t.Fatalf("status %d: expected failure, got nil error", tc.status)
+		}
+	}
+}
+
+func TestNormalizeClampsBounds(t *testing.T) {
+	got := Config{
+		URL:              "   ",
+		Interval:         0,
+		Timeout:          500 * time.Millisecond,
+		FailureThreshold: 0,
+		Cooldown:         0,
+	}.Normalize()
+
+	if got.URL != defaultHealthURL {
+		t.Fatalf("URL not defaulted: %q", got.URL)
+	}
+	if got.Interval != defaultInterval {
+		t.Fatalf("Interval not clamped: %s", got.Interval)
+	}
+	if got.Timeout != defaultTimeout {
+		t.Fatalf("Timeout not clamped: %s", got.Timeout)
+	}
+	if got.FailureThreshold != defaultFailureThreshold {
+		t.Fatalf("FailureThreshold not clamped: %d", got.FailureThreshold)
+	}
+	if got.Cooldown != defaultCooldown {
+		t.Fatalf("Cooldown not clamped: %s", got.Cooldown)
+	}
+
+	valid := Config{
+		URL:              "https://example.com/health",
+		Interval:         15 * time.Second,
+		Timeout:          3 * time.Second,
+		FailureThreshold: 5,
+		Cooldown:         2 * time.Minute,
+	}.Normalize()
+
+	if valid.URL != "https://example.com/health" ||
+		valid.Interval != 15*time.Second ||
+		valid.Timeout != 3*time.Second ||
+		valid.FailureThreshold != 5 ||
+		valid.Cooldown != 2*time.Minute {
+		t.Fatalf("valid config was mutated: %+v", valid)
+	}
+}
+
+func TestNewRejectsUnsupportedProxyScheme(t *testing.T) {
+	m, err := New(Config{ProxyURL: "ftp://127.0.0.1:21"}, func(ctx context.Context) error {
+		return nil
+	})
+	if err == nil || m != nil {
+		t.Fatalf("expected error and nil monitor for bad scheme, got m=%v err=%v", m, err)
+	}
+
+	m, err = New(Config{}, func(ctx context.Context) error {
+		return nil
+	})
+	if err != nil || m == nil {
+		t.Fatalf("expected a valid monitor for empty proxy, got m=%v err=%v", m, err)
+	}
+}
+
+func TestMonitorRecoveryErrorDoesNotArmCooldown(t *testing.T) {
+	cfg := Config{
+		Enabled:          true,
+		URL:              "http://example.test",
+		Interval:         time.Minute,
+		Timeout:          time.Second,
+		FailureThreshold: 1,
+		Cooldown:         time.Minute,
+	}
+
+	attempts := 0
+	monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
+		attempts++
+		return errors.New("restart failed")
+	})
+	monitor.now = func() time.Time {
+		return time.Unix(100, 0)
+	}
+
+	recovered, err := monitor.Step(context.Background())
+	if recovered || err == nil {
+		t.Fatalf("failed recovery must report recovered=false with an error, got recovered=%v err=%v", recovered, err)
+	}
+	if !monitor.lastRecovery.IsZero() {
+		t.Fatal("a failed recovery must not arm the cooldown")
+	}
+
+	if _, err := monitor.Step(context.Background()); err == nil {
+		t.Fatal("expected error on the second failing step")
+	}
+	if attempts != 2 {
+		t.Fatalf("recovery should be retried (no cooldown) after a failure, attempts=%d", attempts)
+	}
+}
+
+func TestMonitorNilRecoverStaysBounded(t *testing.T) {
+	cfg := Config{
+		Enabled:          true,
+		URL:              "http://example.test",
+		Interval:         time.Minute,
+		Timeout:          time.Second,
+		FailureThreshold: 2,
+		Cooldown:         time.Minute,
+	}
+
+	monitor := newWithClient(cfg, failingClient(), nil)
+
+	for i := 0; i < 5; i++ {
+		recovered, _ := monitor.Step(context.Background())
+		if recovered {
+			t.Fatal("a nil recovery func must never report recovery")
+		}
+		if monitor.failures > cfg.FailureThreshold {
+			t.Fatalf("failures must stay capped at threshold %d, got %d", cfg.FailureThreshold, monitor.failures)
+		}
+	}
+}
+
+func TestMonitorFailuresCappedDuringCooldown(t *testing.T) {
+	cfg := Config{
+		Enabled:          true,
+		URL:              "http://example.test",
+		Interval:         time.Minute,
+		Timeout:          time.Second,
+		FailureThreshold: 2,
+		Cooldown:         time.Minute,
+	}
+
+	restarts := 0
+	monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
+		restarts++
+		return nil
+	})
+	monitor.now = func() time.Time {
+		return time.Unix(100, 0)
+	}
+
+	monitor.Step(context.Background())
+	if recovered, _ := monitor.Step(context.Background()); !recovered {
+		t.Fatal("expected recovery once the threshold is reached")
+	}
+
+	for i := 0; i < 6; i++ {
+		monitor.Step(context.Background())
+		if monitor.failures > cfg.FailureThreshold {
+			t.Fatalf("failures must never exceed threshold %d during cooldown, got %d", cfg.FailureThreshold, monitor.failures)
+		}
+	}
+
+	if restarts != 1 {
+		t.Fatalf("cooldown should suppress further recoveries, restarts=%d", restarts)
+	}
+}
+
+func TestMonitorRunStopsOnContextCancel(t *testing.T) {
+	cfg := Config{
+		Enabled:          true,
+		URL:              "http://example.test",
+		Timeout:          time.Second,
+		FailureThreshold: 1,
+		Cooldown:         time.Hour,
+	}
+
+	recovered := make(chan struct{})
+	var once sync.Once
+	monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
+		once.Do(func() { close(recovered) })
+		return nil
+	})
+	monitor.cfg.Interval = 5 * time.Millisecond
+
+	ctx, cancel := context.WithCancel(context.Background())
+	done := make(chan struct{})
+	go func() {
+		monitor.Run(ctx)
+		close(done)
+	}()
+
+	select {
+	case <-recovered:
+	case <-time.After(2 * time.Second):
+		cancel()
+		t.Fatal("Run did not trigger recovery within the deadline")
+	}
+
+	cancel()
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("Run did not return after context cancellation")
+	}
+}

+ 6 - 6
internal/web/controller/dist.go

@@ -2,9 +2,9 @@ package controller
 
 import (
 	"bytes"
-	"embed"
 	"encoding/json"
 	htmlpkg "html"
+	"io/fs"
 	"net/http"
 	"strings"
 	"time"
@@ -16,10 +16,10 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 )
 
-var distFS embed.FS
+var distFS fs.FS
 
-func SetDistFS(fs embed.FS) {
-	distFS = fs
+func SetDistFS(fsys fs.FS) {
+	distFS = fsys
 }
 
 var distPageBuildTime = time.Now()
@@ -30,7 +30,7 @@ var distPageBuildTime = time.Now()
 // produced at frontend build time by scripts/build-openapi.mjs and
 // embedded into the binary via the dist FS.
 func ServeOpenAPISpec(c *gin.Context) {
-	body, err := distFS.ReadFile("dist/openapi.json")
+	body, err := fs.ReadFile(distFS, "dist/openapi.json")
 	if err != nil {
 		c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
 		return
@@ -72,7 +72,7 @@ func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
 }
 
 func serveDistPage(c *gin.Context, name string) {
-	body, err := distFS.ReadFile("dist/" + name)
+	body, err := fs.ReadFile(distFS, "dist/"+name)
 	if err != nil {
 		c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
 		return

+ 2 - 1
internal/web/controller/node.go

@@ -318,6 +318,7 @@ func (a *NodeController) probe(c *gin.Context) {
 func (a *NodeController) updatePanel(c *gin.Context) {
 	var req struct {
 		Ids []int `json:"ids"`
+		Dev bool  `json:"dev"`
 	}
 	if err := c.ShouldBindJSON(&req); err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
@@ -327,7 +328,7 @@ func (a *NodeController) updatePanel(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
 		return
 	}
-	results, err := a.nodeService.UpdatePanels(req.Ids)
+	results, err := a.nodeService.UpdatePanels(req.Ids, req.Dev)
 	jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
 }
 

+ 27 - 2
internal/web/controller/server.go

@@ -69,6 +69,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.POST("/restartXrayService", a.restartXrayService)
 	g.POST("/installXray/:version", a.installXray)
 	g.POST("/updatePanel", a.updatePanel)
+	g.POST("/setUpdateChannel", a.setUpdateChannel)
 	g.POST("/updateGeofile", a.updateGeofile)
 	g.POST("/updateGeofile/:fileName", a.updateGeofile)
 	g.POST("/logs/:count", a.getLogs)
@@ -205,12 +206,36 @@ func (a *ServerController) installXray(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
 }
 
-// updatePanel starts a panel self-update to the latest release.
+// updatePanel starts a panel self-update. With no "dev" form value it follows
+// this panel's own channel setting; an explicit "dev" (sent by the master node
+// updater) overrides it for this run.
 func (a *ServerController) updatePanel(c *gin.Context) {
-	err := a.panelService.StartUpdate()
+	devParam := c.PostForm("dev")
+	var err error
+	if devParam == "" {
+		err = a.panelService.StartUpdate()
+	} else {
+		dev, perr := strconv.ParseBool(devParam)
+		if perr != nil {
+			jsonMsg(c, "invalid data", perr)
+			return
+		}
+		err = a.panelService.StartUpdateChannel(dev)
+	}
 	jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
 }
 
+// setUpdateChannel toggles whether self-update tracks the rolling dev release.
+func (a *ServerController) setUpdateChannel(c *gin.Context) {
+	dev, err := strconv.ParseBool(c.PostForm("dev"))
+	if err != nil {
+		jsonMsg(c, "invalid data", err)
+		return
+	}
+	err = a.settingService.SetDevChannelEnable(dev)
+	jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
+}
+
 // updateGeofile updates the specified geo file for Xray.
 func (a *ServerController) updateGeofile(c *gin.Context) {
 	fileName := c.Param("fileName")

+ 81 - 0
internal/web/controller/spa.go

@@ -2,6 +2,8 @@ package controller
 
 import (
 	"net/http"
+	"path"
+	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
@@ -57,6 +59,85 @@ func (a *XUIController) panelSPA(c *gin.Context) {
 	serveDistPage(c, "index.html")
 }
 
+// HandleNoRoutePanelSPA serves the React shell for client-side routes that were
+// not explicitly registered in Gin. It intentionally runs from engine.NoRoute
+// instead of a /panel/*path wildcard so explicit JSON/API routes keep their
+// normal routing semantics.
+func (a *XUIController) HandleNoRoutePanelSPA(c *gin.Context) bool {
+	if !isPanelSPAFallbackRequest(c) {
+		return false
+	}
+
+	if !session.IsLogin(c) {
+		if isAjax(c) {
+			pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
+		} else {
+			c.Header("Cache-Control", "no-store")
+			c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
+		}
+		c.Abort()
+		return true
+	}
+
+	a.panelSPA(c)
+	return true
+}
+
+func isPanelSPAFallbackRequest(c *gin.Context) bool {
+	if c.Request.Method != http.MethodGet {
+		return false
+	}
+	if !acceptsHTML(c.GetHeader("Accept")) {
+		return false
+	}
+
+	basePath := c.GetString("base_path")
+	if basePath == "" {
+		basePath = "/"
+	}
+	panelPath := strings.TrimRight(basePath, "/") + "/panel"
+
+	reqPath := c.Request.URL.Path
+	if reqPath != panelPath && !strings.HasPrefix(reqPath, panelPath+"/") {
+		return false
+	}
+
+	if reqPath == panelPath+"/csrf-token" || strings.HasPrefix(reqPath, panelPath+"/csrf-token/") {
+		return false
+	}
+	if reqPath == panelPath+"/api" || strings.HasPrefix(reqPath, panelPath+"/api/") {
+		return false
+	}
+	if isStaticAssetPath(reqPath) {
+		return false
+	}
+	return true
+}
+
+var staticAssetExts = map[string]struct{}{
+	".js": {}, ".mjs": {}, ".cjs": {}, ".css": {}, ".map": {}, ".json": {},
+	".png": {}, ".jpg": {}, ".jpeg": {}, ".gif": {}, ".svg": {}, ".ico": {},
+	".webp": {}, ".avif": {}, ".woff": {}, ".woff2": {}, ".ttf": {}, ".eot": {},
+	".otf": {}, ".wasm": {}, ".txt": {}, ".xml": {}, ".webmanifest": {},
+}
+
+func isStaticAssetPath(reqPath string) bool {
+	ext := strings.ToLower(path.Ext(reqPath))
+	if ext == "" {
+		return false
+	}
+	_, ok := staticAssetExts[ext]
+	return ok
+}
+
+func acceptsHTML(accept string) bool {
+	if accept == "" {
+		return true
+	}
+	accept = strings.ToLower(accept)
+	return strings.Contains(accept, "text/html") || strings.Contains(accept, "*/*")
+}
+
 // csrfToken returns the session CSRF token to authenticated SPA clients.
 // The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
 // but checkLogin still gates the response — anonymous callers get 401/redirect.

+ 228 - 0
internal/web/controller/spa_test.go

@@ -0,0 +1,228 @@
+package controller
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"testing/fstest"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-contrib/sessions/cookie"
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
+)
+
+func newSPAFallbackTestEngine(t *testing.T) *gin.Engine {
+	return newSPAFallbackTestEngineWithBasePath(t, "/admin-random/")
+}
+
+func newSPAFallbackTestEngineWithBasePath(t *testing.T, basePath string) *gin.Engine {
+	t.Helper()
+	gin.SetMode(gin.TestMode)
+
+	oldDistFS := distFS
+	SetDistFS(fstest.MapFS{
+		"dist/index.html": {Data: []byte(`<!doctype html><html><head></head><body>spa shell</body></html>`)},
+	})
+	t.Cleanup(func() { SetDistFS(oldDistFS) })
+
+	engine := gin.New()
+	engine.Use(sessions.Sessions("3x-ui", cookie.NewStore([]byte("spa-fallback-test-secret"))))
+	engine.Use(func(c *gin.Context) {
+		c.Set("base_path", basePath)
+		c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
+		if c.GetHeader("X-Test-Login") == "1" {
+			session.SetAPIAuthUser(c, &model.User{Id: 1, Username: "test"})
+		}
+		c.Next()
+	})
+
+	ctrl := NewXUIController(engine.Group(basePath))
+	engine.NoRoute(func(c *gin.Context) {
+		if ctrl.HandleNoRoutePanelSPA(c) {
+			return
+		}
+		c.AbortWithStatus(http.StatusNotFound)
+	})
+	return engine
+}
+
+func TestPanelSPAFallbackServesRootBasePath(t *testing.T) {
+	engine := newSPAFallbackTestEngineWithBasePath(t, "/")
+	req := httptest.NewRequest(http.MethodGet, "/panel/hosts", nil)
+	req.Header.Set("Accept", "text/html")
+	req.Header.Set("X-Test-Login", "1")
+	w := httptest.NewRecorder()
+
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+	}
+	if !strings.Contains(w.Body.String(), "spa shell") {
+		t.Fatalf("body does not contain SPA shell: %s", w.Body.String())
+	}
+}
+
+func TestPanelSPAFallbackServesAuthenticatedClientRoutes(t *testing.T) {
+	engine := newSPAFallbackTestEngine(t)
+
+	for _, target := range []string{
+		"/admin-random/panel/hosts",
+		"/admin-random/panel/some/future/route",
+	} {
+		t.Run(target, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, target, nil)
+			req.Header.Set("Accept", "text/html")
+			req.Header.Set("X-Test-Login", "1")
+			w := httptest.NewRecorder()
+
+			engine.ServeHTTP(w, req)
+
+			if w.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+			}
+			if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
+				t.Fatalf("Content-Type = %q, want text/html", ct)
+			}
+			if !strings.Contains(w.Body.String(), "spa shell") {
+				t.Fatalf("body does not contain SPA shell: %s", w.Body.String())
+			}
+		})
+	}
+}
+
+func TestPanelSPAFallbackPreservesAuthSemantics(t *testing.T) {
+	engine := newSPAFallbackTestEngine(t)
+
+	t.Run("browser redirects to login", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/hosts", nil)
+		req.Header.Set("Accept", "text/html")
+		w := httptest.NewRecorder()
+
+		engine.ServeHTTP(w, req)
+
+		if w.Code != http.StatusTemporaryRedirect {
+			t.Fatalf("status = %d, want 307", w.Code)
+		}
+		if loc := w.Header().Get("Location"); loc != "/admin-random/" {
+			t.Fatalf("Location = %q, want /admin-random/", loc)
+		}
+	})
+
+	t.Run("ajax gets json unauthorized", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/hosts", nil)
+		req.Header.Set("Accept", "text/html")
+		req.Header.Set("X-Requested-With", "XMLHttpRequest")
+		w := httptest.NewRecorder()
+
+		engine.ServeHTTP(w, req)
+
+		if w.Code != http.StatusUnauthorized {
+			t.Fatalf("status = %d, want 401", w.Code)
+		}
+		if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
+			t.Fatalf("Content-Type = %q, want application/json", ct)
+		}
+	})
+}
+
+func TestPanelSPAFallbackExclusions(t *testing.T) {
+	engine := newSPAFallbackTestEngine(t)
+
+	for _, tc := range []struct {
+		target string
+		want   int
+	}{
+		{target: "/admin-random/panel/api", want: http.StatusNotFound},
+		{target: "/admin-random/panel/api/unknown", want: http.StatusNotFound},
+		{target: "/admin-random/panel/csrf-token/", want: http.StatusMovedPermanently},
+		{target: "/admin-random/panel/missing.js", want: http.StatusNotFound},
+	} {
+		t.Run(tc.target, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, tc.target, nil)
+			req.Header.Set("Accept", "text/html")
+			req.Header.Set("X-Test-Login", "1")
+			w := httptest.NewRecorder()
+
+			engine.ServeHTTP(w, req)
+
+			if w.Code != tc.want {
+				t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
+			}
+			if strings.Contains(w.Body.String(), "spa shell") {
+				t.Fatalf("excluded route was served by SPA fallback: %s", w.Body.String())
+			}
+		})
+	}
+}
+
+func TestPanelCSRFTokenRemainsExplicit(t *testing.T) {
+	engine := newSPAFallbackTestEngine(t)
+
+	req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/csrf-token", nil)
+	req.Header.Set("Accept", "text/html")
+	req.Header.Set("X-Test-Login", "1")
+	w := httptest.NewRecorder()
+
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+	}
+	if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
+		t.Fatalf("Content-Type = %q, want application/json", ct)
+	}
+	if strings.Contains(w.Body.String(), "spa shell") {
+		t.Fatalf("csrf-token was served by SPA fallback: %s", w.Body.String())
+	}
+}
+
+func TestPanelSPAFallbackPredicate(t *testing.T) {
+	oldDistFS := distFS
+	SetDistFS(fstest.MapFS{})
+	t.Cleanup(func() { SetDistFS(oldDistFS) })
+
+	cases := []struct {
+		name   string
+		method string
+		path   string
+		accept string
+		want   bool
+	}{
+		{name: "panel root", method: http.MethodGet, path: "/admin-random/panel", accept: "text/html", want: true},
+		{name: "panel descendant", method: http.MethodGet, path: "/admin-random/panel/hosts", accept: "*/*", want: true},
+		{name: "empty accept", method: http.MethodGet, path: "/admin-random/panel/future", want: true},
+		{name: "post excluded", method: http.MethodPost, path: "/admin-random/panel/hosts", accept: "text/html"},
+		{name: "json accept excluded", method: http.MethodGet, path: "/admin-random/panel/hosts", accept: "application/json"},
+		{name: "api root excluded", method: http.MethodGet, path: "/admin-random/panel/api", accept: "text/html"},
+		{name: "api descendant excluded", method: http.MethodGet, path: "/admin-random/panel/api/unknown", accept: "text/html"},
+		{name: "csrf excluded", method: http.MethodGet, path: "/admin-random/panel/csrf-token", accept: "text/html"},
+		{name: "csrf descendant excluded", method: http.MethodGet, path: "/admin-random/panel/csrf-token/", accept: "text/html"},
+		{name: "file excluded", method: http.MethodGet, path: "/admin-random/panel/missing.css", accept: "text/html"},
+		{name: "outside panel excluded", method: http.MethodGet, path: "/admin-random/hosts", accept: "text/html"},
+		{name: "dotted email route param served", method: http.MethodGet, path: "/admin-random/panel/clients/[email protected]", accept: "text/html", want: true},
+		{name: "dotted version route param served", method: http.MethodGet, path: "/admin-random/panel/sub/1.2.3", accept: "text/html", want: true},
+		{name: "uppercase asset extension excluded", method: http.MethodGet, path: "/admin-random/panel/app.JS", accept: "text/html"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			c, _ := gin.CreateTestContext(httptest.NewRecorder())
+			c.Set("base_path", "/admin-random/")
+			req := httptest.NewRequest(tc.method, tc.path, nil)
+			if tc.accept != "" {
+				req.Header.Set("Accept", tc.accept)
+			}
+			c.Request = req
+
+			if got := isPanelSPAFallbackRequest(c); got != tc.want {
+				t.Fatalf("isPanelSPAFallbackRequest() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}

+ 8 - 3
internal/web/runtime/remote.go

@@ -538,9 +538,14 @@ func (r *Remote) RestartXray(ctx context.Context) error {
 
 // UpdatePanel asks the node to run its own official self-updater (update.sh)
 // and restart onto the latest release. The node returns as soon as the job is
-// launched; the new version surfaces on the next heartbeat.
-func (r *Remote) UpdatePanel(ctx context.Context) error {
-	_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", nil)
+// launched; the new version surfaces on the next heartbeat. When dev is true the
+// node is moved to the rolling dev channel instead of the latest stable release.
+func (r *Remote) UpdatePanel(ctx context.Context, dev bool) error {
+	var body any
+	if dev {
+		body = url.Values{"dev": {"true"}}
+	}
+	_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", body)
 	return err
 }
 

+ 8 - 3
internal/web/service/client_inbound_apply.go

@@ -787,9 +787,12 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 		delStat = traffic != nil
 	}
 
+	// The runtime user is scoped to this inbound's tag + email, so the push plan
+	// is resolved independently of emailShared — a sibling inbound still carrying
+	// the email must not suppress removing the user from this inbound's Xray.
 	var rt runtime.Runtime
 	var push bool
-	if len(email) > 0 && !emailShared && (oldInbound.NodeID != nil || needApiDel) {
+	if len(email) > 0 && (oldInbound.NodeID != nil || needApiDel) {
 		r, p, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
 		if perr != nil {
 			return false, perr
@@ -828,8 +831,10 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 	}
 
 	// Apply the runtime delete after commit — outside the serialized writer so a
-	// slow node call can't stall traffic accounting.
-	if len(email) > 0 && !emailShared {
+	// slow node call can't stall traffic accounting. Independent of emailShared:
+	// Xray users are keyed by inbound tag, so the user must be removed from this
+	// inbound's runtime even when the same email survives in another inbound.
+	if len(email) > 0 {
 		if oldInbound.NodeID == nil {
 			// Local inbound: a disabled client isn't in the running Xray, so only
 			// a live one (needApiDel) needs an API removal.

+ 34 - 0
internal/web/service/del_shared_email_runtime_test.go

@@ -0,0 +1,34 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Deleting a client that is attached to more than one inbound must still remove
+// the user from the running runtime of the inbound being deleted from. The
+// runtime user is keyed by inbound tag, so a sibling inbound still carrying the
+// same email (emailShared) must not suppress the per-inbound runtime removal —
+// otherwise the deleted user keeps connecting on that inbound until Xray
+// restart (#5543).
+func TestDelInboundClientByEmail_SharedEmailStillRemovesFromRuntime(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+
+	shared := []model.Client{{ID: uuid.NewString(), Email: "shared@x", Enable: true}}
+	ibA := nodeInbound(t, nodeID, 31001, shared)
+	nodeInbound(t, nodeID, 31002, shared)
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	if _, err := svc.DelInboundClientByEmail(inboundSvc, ibA.Id, "shared@x", false); err != nil {
+		t.Fatalf("DelInboundClientByEmail: %v", err)
+	}
+
+	if got := fake.deleteUser.Load(); got != 1 {
+		t.Fatalf("shared-email delete dispatched %d DeleteUser RPCs, want 1 (must remove from the deleted inbound's runtime despite the sibling inbound) (#5543)", got)
+	}
+}

+ 2 - 2
internal/web/service/node.go

@@ -637,7 +637,7 @@ type NodeUpdateResult struct {
 // UpdatePanels triggers the official self-updater on each given node. Only
 // enabled, online nodes are eligible — an offline node can't be reached, so it
 // is reported as skipped rather than silently dropped.
-func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
+func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, error) {
 	mgr := runtime.GetManager()
 	if mgr == nil {
 		return nil, fmt.Errorf("runtime manager unavailable")
@@ -662,7 +662,7 @@ func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
 				break
 			}
 			ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
-			updErr := remote.UpdatePanel(ctx)
+			updErr := remote.UpdatePanel(ctx, dev)
 			cancel()
 			if updErr != nil {
 				res.Error = updErr.Error()

+ 138 - 9
internal/web/service/panel/panel.go

@@ -8,6 +8,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"regexp"
 	"runtime"
 	"strconv"
 	"strings"
@@ -25,17 +26,27 @@ import (
 type PanelService struct{}
 
 // PanelUpdateInfo contains the current and latest available panel versions.
+// On the dev channel the version fields carry a "dev+<sha>" label and the commit
+// fields hold the short SHAs that drive the update-available decision.
 type PanelUpdateInfo struct {
+	Channel         string `json:"channel"`
 	CurrentVersion  string `json:"currentVersion"`
 	LatestVersion   string `json:"latestVersion"`
+	CurrentCommit   string `json:"currentCommit,omitempty"`
+	LatestCommit    string `json:"latestCommit,omitempty"`
 	UpdateAvailable bool   `json:"updateAvailable"`
 }
 
 const (
 	panelUpdaterURL      = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh"
 	maxPanelUpdaterBytes = 2 << 20
+	// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
+	// newest main commit; the dev update channel installs from it.
+	devReleaseTag = "dev-latest"
 )
 
+var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`)
+
 func (s *PanelService) RestartPanel(delay time.Duration) error {
 	go func() {
 		time.Sleep(delay)
@@ -58,22 +69,72 @@ func (s *PanelService) RestartPanel(delay time.Duration) error {
 	return nil
 }
 
-// GetUpdateInfo checks GitHub for the latest 3x-ui release.
+// GetUpdateInfo checks GitHub for the latest 3x-ui release. When the dev channel
+// is enabled on a dev build it compares commits against the rolling dev release;
+// otherwise it compares versions against the latest stable tag.
 func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
+	if devChannelActive() {
+		return getDevUpdateInfo()
+	}
 	latest, err := fetchLatestPanelVersion()
 	if err != nil {
 		return nil, err
 	}
 	current := config.GetVersion()
 	return &PanelUpdateInfo{
+		Channel:         "stable",
 		CurrentVersion:  current,
 		LatestVersion:   latest,
 		UpdateAvailable: isNewerVersion(latest, current),
 	}, nil
 }
 
-// StartUpdate starts the official updater outside of the current web request.
+// devChannelActive reports whether self-update should track the rolling dev
+// release. It requires both the opt-in setting and a dev build, so a stable
+// binary with the toggle left on never cross-grades to the dev channel.
+func devChannelActive() bool {
+	if !config.IsDevBuild() {
+		return false
+	}
+	enabled, err := (&service.SettingService{}).GetDevChannelEnable()
+	return err == nil && enabled
+}
+
+// getDevUpdateInfo compares the running commit against the commit recorded in the
+// rolling dev release.
+func getDevUpdateInfo() (*PanelUpdateInfo, error) {
+	release, err := fetchPanelRelease(devReleaseTag)
+	if err != nil {
+		return nil, err
+	}
+	latestCommit := extractReleaseCommit(release)
+	if latestCommit == "" {
+		return nil, fmt.Errorf("dev release commit is unknown")
+	}
+	currentCommit := config.GetBuildCommit()
+	return &PanelUpdateInfo{
+		Channel:         "dev",
+		CurrentVersion:  config.GetVersion(),
+		CurrentCommit:   shortCommit(currentCommit),
+		LatestCommit:    shortCommit(latestCommit),
+		LatestVersion:   "dev+" + shortCommit(latestCommit),
+		UpdateAvailable: !commitsEqual(currentCommit, latestCommit),
+	}, nil
+}
+
+// StartUpdate starts the official updater using this panel's own channel setting.
 func (s *PanelService) StartUpdate() error {
+	return s.startUpdate(devChannelActive())
+}
+
+// StartUpdateChannel runs the updater against an explicitly chosen channel,
+// overriding the local dev-channel setting. Used by the master node updater so
+// a node can be moved to the dev channel from the central panel.
+func (s *PanelService) StartUpdateChannel(dev bool) error {
+	return s.startUpdate(dev)
+}
+
+func (s *PanelService) startUpdate(useDev bool) error {
 	if runtime.GOOS != "linux" {
 		return fmt.Errorf("panel web update is supported only on Linux installations")
 	}
@@ -89,6 +150,10 @@ func (s *PanelService) StartUpdate() error {
 	}
 
 	mainFolder, serviceFolder := resolveUpdateFolders()
+	updateTag := ""
+	if useDev {
+		updateTag = devReleaseTag
+	}
 	updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
 
 	if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
@@ -97,6 +162,7 @@ func (s *PanelService) StartUpdate() error {
 			"--unit", unitName,
 			"--setenv", "XUI_MAIN_FOLDER="+mainFolder,
 			"--setenv", "XUI_SERVICE="+serviceFolder,
+			"--setenv", "XUI_UPDATE_TAG="+updateTag,
 			bash, "-lc", updateScript,
 		)
 		out, err := cmd.CombinedOutput()
@@ -118,6 +184,7 @@ func (s *PanelService) StartUpdate() error {
 	cmd.Env = append(os.Environ(),
 		"XUI_MAIN_FOLDER="+mainFolder,
 		"XUI_SERVICE="+serviceFolder,
+		"XUI_UPDATE_TAG="+updateTag,
 	)
 	setDetachedProcess(cmd)
 	if err := cmd.Start(); err != nil {
@@ -170,24 +237,86 @@ func downloadPanelUpdater() (string, error) {
 }
 
 func fetchLatestPanelVersion() (string, error) {
-	client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
-	resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")
+	release, err := fetchPanelRelease("")
 	if err != nil {
 		return "", err
 	}
+	if release.TagName == "" {
+		return "", fmt.Errorf("latest panel release tag is empty")
+	}
+	return release.TagName, nil
+}
+
+// fetchPanelRelease fetches a release from GitHub. An empty tag resolves the
+// latest stable release; a non-empty tag (e.g. dev-latest) resolves that tag.
+func fetchPanelRelease(tag string) (*service.Release, error) {
+	url := "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest"
+	if tag != "" {
+		url = "https://api.github.com/repos/MHSanaei/3x-ui/releases/tags/" + tag
+	}
+	client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
+	resp, err := client.Get(url)
+	if err != nil {
+		return nil, err
+	}
 	defer resp.Body.Close()
 	if resp.StatusCode != http.StatusOK {
-		return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
+		return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
 	}
 
 	var release service.Release
 	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
-		return "", err
+		return nil, err
 	}
-	if release.TagName == "" {
-		return "", fmt.Errorf("latest panel release tag is empty")
+	return &release, nil
+}
+
+// extractReleaseCommit reads the build commit recorded in the dev release: first
+// the `commit=<sha>` marker the CI writes into the body, falling back to the
+// tag's target commit.
+func extractReleaseCommit(release *service.Release) string {
+	if m := releaseCommitRegex.FindStringSubmatch(release.Body); m != nil {
+		return strings.ToLower(m[1])
 	}
-	return release.TagName, nil
+	if isCommitSHA(release.TargetCommitish) {
+		return strings.ToLower(release.TargetCommitish)
+	}
+	return ""
+}
+
+func isCommitSHA(s string) bool {
+	s = strings.TrimSpace(s)
+	if len(s) < 7 || len(s) > 40 {
+		return false
+	}
+	for _, r := range s {
+		if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
+			return false
+		}
+	}
+	return true
+}
+
+func shortCommit(sha string) string {
+	sha = strings.TrimSpace(sha)
+	if len(sha) > 8 {
+		return sha[:8]
+	}
+	return sha
+}
+
+// commitsEqual compares a short (injected) commit against a full release commit
+// by prefix, so an 8-char build stamp matches the 40-char release SHA.
+func commitsEqual(a, b string) bool {
+	a = strings.ToLower(strings.TrimSpace(a))
+	b = strings.ToLower(strings.TrimSpace(b))
+	if a == "" || b == "" {
+		return false
+	}
+	if len(a) > len(b) {
+		a, b = b, a
+	}
+	return strings.HasPrefix(b, a)
 }
 
 func resolveUpdateFolders() (string, string) {

+ 69 - 1
internal/web/service/panel/panel_test.go

@@ -1,6 +1,10 @@
 package panel
 
-import "testing"
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
 
 func TestIsNewerVersion(t *testing.T) {
 	cases := []struct {
@@ -39,3 +43,67 @@ func TestShellQuote(t *testing.T) {
 		t.Fatalf("unexpected quote result with single quote: %s", got)
 	}
 }
+
+func TestExtractReleaseCommit(t *testing.T) {
+	full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
+	cases := []struct {
+		name    string
+		release service.Release
+		want    string
+	}{
+		{
+			name:    "from body marker",
+			release: service.Release{Body: "Rolling build\n\ncommit=" + full + "\nbuilt=2026-06-24T00:00:00Z"},
+			want:    full,
+		},
+		{
+			name:    "body marker is case-insensitive and wins over target",
+			release: service.Release{Body: "COMMIT=" + full, TargetCommitish: "deadbeef"},
+			want:    full,
+		},
+		{
+			name:    "fallback to target commit sha",
+			release: service.Release{Body: "no marker here", TargetCommitish: full},
+			want:    full,
+		},
+		{
+			name:    "branch target is not a commit",
+			release: service.Release{Body: "no marker", TargetCommitish: "main"},
+			want:    "",
+		},
+	}
+	for _, tc := range cases {
+		if got := extractReleaseCommit(&tc.release); got != tc.want {
+			t.Fatalf("%s: extractReleaseCommit = %q, want %q", tc.name, got, tc.want)
+		}
+	}
+}
+
+func TestCommitsEqual(t *testing.T) {
+	full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
+	cases := []struct {
+		a, b string
+		want bool
+	}{
+		{"1a2b3c4d", full, true},  // injected 8-char prefix matches full release sha
+		{full, "1a2b3c4d", true},  // order independent
+		{"1A2B3C4D", full, true},  // case insensitive
+		{"deadbeef", full, false}, // different commit
+		{"", full, false},         // empty current never matches
+		{"1a2b3c4d", "", false},   // empty latest never matches
+	}
+	for _, tc := range cases {
+		if got := commitsEqual(tc.a, tc.b); got != tc.want {
+			t.Fatalf("commitsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
+		}
+	}
+}
+
+func TestShortCommit(t *testing.T) {
+	if got := shortCommit("1a2b3c4d5e6f7a8b"); got != "1a2b3c4d" {
+		t.Fatalf("shortCommit truncation = %q, want %q", got, "1a2b3c4d")
+	}
+	if got := shortCommit("abc"); got != "abc" {
+		t.Fatalf("shortCommit short input = %q, want %q", got, "abc")
+	}
+}

+ 29 - 3
internal/web/service/server.go

@@ -117,7 +117,10 @@ type Status struct {
 
 // Release represents information about a software release from GitHub.
 type Release struct {
-	TagName string `json:"tag_name"` // The tag name of the release
+	TagName         string `json:"tag_name"`         // The tag name of the release
+	Body            string `json:"body"`             // The release notes; the dev channel reads its commit from here
+	TargetCommitish string `json:"target_commitish"` // The branch/commit the tag points at
+	Prerelease      bool   `json:"prerelease"`       // Whether this is a pre-release
 }
 
 // ServerService provides business logic for server monitoring and management.
@@ -601,7 +604,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 		status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
 	}
 	status.Xray.Version = s.xrayService.GetXrayVersion()
-	status.PanelVersion = config.GetVersion()
+	status.PanelVersion = config.GetReportedVersion()
 	if guid, err := s.settingService.GetPanelGuid(); err == nil {
 		status.PanelGuid = guid
 	}
@@ -2061,11 +2064,34 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
 		return nil, err
 	}
 
+	auths := parseVlessEncAuths(out.String())
+	auths = append(auths, deriveVlessEncModes(auths)...)
+
 	return map[string]any{
-		"auths": parseVlessEncAuths(out.String()),
+		"auths": auths,
 	}, nil
 }
 
+func deriveVlessEncModes(auths []map[string]string) []map[string]string {
+	var extra []map[string]string
+	for _, a := range auths {
+		for _, mode := range []string{"xorpub", "random"} {
+			dec := strings.Replace(a["decryption"], ".native.", "."+mode+".", 1)
+			enc := strings.Replace(a["encryption"], ".native.", "."+mode+".", 1)
+			if dec == a["decryption"] && enc == a["encryption"] {
+				continue
+			}
+			extra = append(extra, map[string]string{
+				"id":         a["id"] + "_" + mode,
+				"label":      a["label"] + " (" + mode + ")",
+				"decryption": dec,
+				"encryption": enc,
+			})
+		}
+	}
+	return extra
+}
+
 func parseVlessEncAuths(output string) []map[string]string {
 	lines := strings.Split(output, "\n")
 	var auths []map[string]string

+ 28 - 0
internal/web/service/server_vlessenc_test.go

@@ -80,3 +80,31 @@ Authentication: X25519, not Post-Quantum
 		t.Fatalf("encryption = %q, want client", auths[0]["encryption"])
 	}
 }
+
+func TestDeriveVlessEncModes(t *testing.T) {
+	base := []map[string]string{
+		{
+			"id":         "x25519",
+			"label":      "X25519, not Post-Quantum",
+			"decryption": "mlkem768x25519plus.native.600s.server-key",
+			"encryption": "mlkem768x25519plus.native.0rtt.client-key",
+		},
+	}
+
+	derived := deriveVlessEncModes(base)
+	if len(derived) != 2 {
+		t.Fatalf("expected 2 derived blocks, got %d", len(derived))
+	}
+	if derived[0]["id"] != "x25519_xorpub" {
+		t.Errorf("id = %q, want x25519_xorpub", derived[0]["id"])
+	}
+	if derived[0]["decryption"] != "mlkem768x25519plus.xorpub.600s.server-key" {
+		t.Errorf("decryption = %q", derived[0]["decryption"])
+	}
+	if derived[0]["encryption"] != "mlkem768x25519plus.xorpub.0rtt.client-key" {
+		t.Errorf("encryption = %q", derived[0]["encryption"])
+	}
+	if derived[1]["id"] != "x25519_random" {
+		t.Errorf("id = %q, want x25519_random", derived[1]["id"])
+	}
+}

+ 33 - 19
internal/web/service/setting.go

@@ -14,6 +14,7 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"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"
@@ -109,6 +110,7 @@ var defaultValueMap = map[string]string{
 	"restartXrayOnClientDisable":  "true",
 	"xrayOutboundTestUrl":         "https://www.google.com/generate_204",
 	"panelOutbound":               "",
+	"devChannelEnable":            "false",
 
 	// LDAP defaults
 	"ldapEnable":            "false",
@@ -855,6 +857,16 @@ func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error {
 	return s.setBool("restartXrayOnClientDisable", value)
 }
 
+// GetDevChannelEnable reports whether the panel self-update tracks the rolling
+// per-commit dev release instead of the latest stable tag.
+func (s *SettingService) GetDevChannelEnable() (bool, error) {
+	return s.getBool("devChannelEnable")
+}
+
+func (s *SettingService) SetDevChannelEnable(value bool) error {
+	return s.setBool("devChannelEnable", value)
+}
+
 // GetIpLimitEnable reports whether the IP-limit feature is available. Always
 // true since the panel enforces limits via the core's online-stats API; on an
 // older core the job falls back to access-log parsing and warns there when the
@@ -1209,25 +1221,27 @@ func (s *SettingService) BuildSubURIBase(host string) string {
 func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 	type settingFunc func() (any, error)
 	settings := map[string]settingFunc{
-		"expireDiff":      func() (any, error) { return s.GetExpireDiff() },
-		"trafficDiff":     func() (any, error) { return s.GetTrafficDiff() },
-		"pageSize":        func() (any, error) { return s.GetPageSize() },
-		"defaultCert":     func() (any, error) { return s.GetCertFile() },
-		"defaultKey":      func() (any, error) { return s.GetKeyFile() },
-		"tgBotEnable":     func() (any, error) { return s.GetTgbotEnabled() },
-		"subThemeDir":     func() (any, error) { return s.GetSubThemeDir() },
-		"subEnable":       func() (any, error) { return s.GetSubEnable() },
-		"subJsonEnable":   func() (any, error) { return s.GetSubJsonEnable() },
-		"subClashEnable":  func() (any, error) { return s.GetSubClashEnable() },
-		"subTitle":        func() (any, error) { return s.GetSubTitle() },
-		"subURI":          func() (any, error) { return s.GetSubURI() },
-		"subJsonURI":      func() (any, error) { return s.GetSubJsonURI() },
-		"subClashURI":     func() (any, error) { return s.GetSubClashURI() },
-		"datepicker":      func() (any, error) { return s.GetDatepicker() },
-		"ipLimitEnable":   func() (any, error) { return s.GetIpLimitEnable() },
-		"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
-		"webDomain":       func() (any, error) { return s.GetWebDomain() },
-		"subDomain":       func() (any, error) { return s.GetSubDomain() },
+		"expireDiff":       func() (any, error) { return s.GetExpireDiff() },
+		"trafficDiff":      func() (any, error) { return s.GetTrafficDiff() },
+		"pageSize":         func() (any, error) { return s.GetPageSize() },
+		"defaultCert":      func() (any, error) { return s.GetCertFile() },
+		"defaultKey":       func() (any, error) { return s.GetKeyFile() },
+		"tgBotEnable":      func() (any, error) { return s.GetTgbotEnabled() },
+		"subThemeDir":      func() (any, error) { return s.GetSubThemeDir() },
+		"subEnable":        func() (any, error) { return s.GetSubEnable() },
+		"subJsonEnable":    func() (any, error) { return s.GetSubJsonEnable() },
+		"subClashEnable":   func() (any, error) { return s.GetSubClashEnable() },
+		"subTitle":         func() (any, error) { return s.GetSubTitle() },
+		"subURI":           func() (any, error) { return s.GetSubURI() },
+		"subJsonURI":       func() (any, error) { return s.GetSubJsonURI() },
+		"subClashURI":      func() (any, error) { return s.GetSubClashURI() },
+		"datepicker":       func() (any, error) { return s.GetDatepicker() },
+		"ipLimitEnable":    func() (any, error) { return s.GetIpLimitEnable() },
+		"accessLogEnable":  func() (any, error) { return s.GetAccessLogEnable() },
+		"webDomain":        func() (any, error) { return s.GetWebDomain() },
+		"subDomain":        func() (any, error) { return s.GetSubDomain() },
+		"devChannelEnable": func() (any, error) { return s.GetDevChannelEnable() },
+		"isDevBuild":       func() (any, error) { return config.IsDevBuild(), nil },
 	}
 
 	result := make(map[string]any)

+ 25 - 5
internal/web/translation/ar-EG.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "إصدار البانل الحالي",
       "latestPanelVersion": "أحدث إصدار للبانل",
       "panelUpToDate": "البانل محدث لآخر إصدار",
+      "devChannel": "قناة التطوير",
+      "devChannelWarning": "تتابع نسخ التطوير كل كومِت على main وليست إصدارات مستقرة — لا يوجد رجوع تلقائي لإصدار أقدم.",
+      "currentCommit": "الكومِت الحالي",
+      "latestCommit": "أحدث كومِت",
+      "updateChannelChanged": "تم تغيير قناة التحديث",
       "upToDate": "محدث",
       "xrayStatusUnknown": "مش معروف",
       "xrayStatusRunning": "شغالة",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "النطاقات المستثناة",
       "decryption": "فك التشفير",
       "encryption": "التشفير",
-      "vlessAuthX25519": "مصادقة X25519",
-      "vlessAuthMlkem768": "مصادقة ML-KEM-768",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "مخصص",
       "vlessAuthSelected": "المحدد: {auth}",
+      "vlessAuthGenerate": "إنشاء المفاتيح",
+      "vlessAuthGenerateButton": "إنشاء",
       "advanced": {
         "title": "أقسام JSON للاتصال الوارد",
         "subtitle": "JSON الكامل للاتصال الوارد ومحررات مخصصة لـ settings و sniffing و streamSettings.",
@@ -968,6 +979,7 @@
       "upToDate": "محدّث",
       "updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟",
       "updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.",
+      "updateDevChannel": "التحديث إلى قناة التطوير (أحدث كومِت)",
       "testConnection": "اختبار الاتصال",
       "connectionOk": "الاتصال شغال ({ms} ms)",
       "connectionFailed": "فشل الاتصال",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "العميل",
           "traffic": "حركة المرور",
-          "time": "الوقت والحالة"
+          "time": "الوقت والحالة",
+          "connection": "الاتصال"
         },
         "descEMAIL": "بريد العميل",
         "descINBOUND": "ملاحظة الوارد نفسه (اسم الإعداد)",
@@ -1829,11 +1842,18 @@
         "descUP": "حركة مرور الرفع",
         "descDOWN": "حركة مرور التنزيل",
         "descSTATUS": "نشط / منتهٍ / معطّل / مستنفد",
+        "descSTATUS_EMOJI": "الحالة كرمز تعبيري (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "الأيام حتى الانتهاء (مخفية إذا كانت غير محدودة)",
+        "descTIME_LEFT": "الوقت المتبقي (مثال: 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "حركة المرور المستخدمة كنسبة مئوية (مخفية إذا كانت غير محدودة)",
         "descEXPIRE_DATE": "تاريخ الانتهاء (YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "تاريخ الانتهاء بالتقويم الجلالي (YYYY/MM/DD)",
         "descEXPIRE_UNIX": "الانتهاء كطابع زمني Unix (بالثواني)",
         "descCREATED_UNIX": "وقت الإنشاء كطابع زمني Unix (بالثواني)",
-        "descRESET_DAYS": "فترة إعادة تعيين حركة المرور بالأيام"
+        "descRESET_DAYS": "فترة إعادة تعيين حركة المرور بالأيام",
+        "descPROTOCOL": "بروتوكول الوارد (VLESS، VMess، Trojan، …)",
+        "descTRANSPORT": "شبكة النقل (tcp، ws، grpc، …)",
+        "descSECURITY": "أمان النقل (TLS، REALITY، NONE)"
       },
       "toasts": {
         "list": "فشل تحميل المضيفات",
@@ -2066,4 +2086,4 @@
     "statusDown": "غير متصل",
     "statusUp": "متصل"
   }
-}
+}

+ 25 - 5
internal/web/translation/en-US.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "Current panel version",
       "latestPanelVersion": "Latest panel version",
       "panelUpToDate": "Panel is up to date",
+      "devChannel": "Dev channel",
+      "devChannelWarning": "Dev builds track every commit on main and aren't stable releases — there is no automatic downgrade.",
+      "currentCommit": "Current commit",
+      "latestCommit": "Latest commit",
+      "updateChannelChanged": "Update channel changed",
       "upToDate": "Up to date",
       "xrayStatusUnknown": "Unknown",
       "xrayStatusRunning": "Running",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "Domains excluded",
       "decryption": "Decryption",
       "encryption": "Encryption",
-      "vlessAuthX25519": "X25519 auth",
-      "vlessAuthMlkem768": "ML-KEM-768 auth",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "Custom",
       "vlessAuthSelected": "Selected: {auth}",
+      "vlessAuthGenerate": "Generate keys",
+      "vlessAuthGenerateButton": "Generate",
       "advanced": {
         "title": "Inbound JSON sections",
         "subtitle": "Full inbound JSON and focused editors for settings, sniffing, and streamSettings.",
@@ -989,7 +1000,8 @@
         "groups": {
           "client": "Client",
           "traffic": "Traffic",
-          "time": "Time & status"
+          "time": "Time & status",
+          "connection": "Connection"
         },
         "descEMAIL": "Client email",
         "descINBOUND": "Inbound's own remark (the config name)",
@@ -1008,11 +1020,18 @@
         "descUP": "Upload traffic",
         "descDOWN": "Download traffic",
         "descSTATUS": "active / expired / disabled / depleted",
+        "descSTATUS_EMOJI": "Status as an emoji (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "Days until expiry (hidden if unlimited)",
+        "descTIME_LEFT": "Remaining time (e.g. 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "Used traffic as a percentage (hidden if unlimited)",
         "descEXPIRE_DATE": "Expiry date (YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "Expiry date in the Jalali calendar (YYYY/MM/DD)",
         "descEXPIRE_UNIX": "Expiry as a Unix timestamp (seconds)",
         "descCREATED_UNIX": "Creation time as a Unix timestamp (seconds)",
-        "descRESET_DAYS": "Traffic reset period in days"
+        "descRESET_DAYS": "Traffic reset period in days",
+        "descPROTOCOL": "Inbound protocol (VLESS, VMess, Trojan, …)",
+        "descTRANSPORT": "Transport network (tcp, ws, grpc, …)",
+        "descSECURITY": "Transport security (TLS, REALITY, NONE)"
       },
       "toasts": {
         "list": "Failed to load hosts",
@@ -1079,6 +1098,7 @@
       "upToDate": "Up to date",
       "updateConfirmTitle": "Update {count} node(s) to the latest version?",
       "updateConfirmContent": "Each selected node downloads the latest release and restarts onto it. Only enabled, online nodes are updated.",
+      "updateDevChannel": "Update to Dev channel (latest commit)",
       "testConnection": "Test Connection",
       "connectionOk": "Connection OK ({ms} ms)",
       "connectionFailed": "Connection failed",
@@ -2069,4 +2089,4 @@
     "statusDown": "DOWN",
     "statusUp": "UP"
   }
-}
+}

+ 25 - 5
internal/web/translation/es-ES.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "Versión actual del panel",
       "latestPanelVersion": "Última versión del panel",
       "panelUpToDate": "El panel está actualizado",
+      "devChannel": "Canal de desarrollo",
+      "devChannelWarning": "Las compilaciones de desarrollo siguen cada commit en main y no son versiones estables; no hay reversión automática.",
+      "currentCommit": "Commit actual",
+      "latestCommit": "Último commit",
+      "updateChannelChanged": "Canal de actualización cambiado",
       "upToDate": "Actualizado",
       "xrayStatusUnknown": "Desconocido",
       "xrayStatusRunning": "En ejecución",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "Dominios excluidos",
       "decryption": "Descifrado",
       "encryption": "Cifrado",
-      "vlessAuthX25519": "Autenticación X25519",
-      "vlessAuthMlkem768": "Autenticación ML-KEM-768",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "Personalizado",
       "vlessAuthSelected": "Seleccionado: {auth}",
+      "vlessAuthGenerate": "Generar claves",
+      "vlessAuthGenerateButton": "Generar",
       "advanced": {
         "title": "Secciones JSON del inbound",
         "subtitle": "JSON completo del inbound y editores específicos para settings, sniffing y streamSettings.",
@@ -968,6 +979,7 @@
       "upToDate": "Actualizado",
       "updateConfirmTitle": "¿Actualizar {count} nodo(s) a la última versión?",
       "updateConfirmContent": "Cada nodo seleccionado descarga la última versión y se reinicia con ella. Solo se actualizan los nodos habilitados y en línea.",
+      "updateDevChannel": "Actualizar al canal de desarrollo (último commit)",
       "testConnection": "Probar conexión",
       "connectionOk": "Conexión correcta ({ms} ms)",
       "connectionFailed": "Conexión fallida",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "Cliente",
           "traffic": "Tráfico",
-          "time": "Tiempo y estado"
+          "time": "Tiempo y estado",
+          "connection": "Conexión"
         },
         "descEMAIL": "Email del cliente",
         "descINBOUND": "Notas del propio inbound (nombre de la configuración)",
@@ -1829,11 +1842,18 @@
         "descUP": "Tráfico de subida",
         "descDOWN": "Tráfico de bajada",
         "descSTATUS": "activo / expirado / deshabilitado / agotado",
+        "descSTATUS_EMOJI": "Estado como emoji (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "Días hasta la expiración (oculto si es ilimitado)",
+        "descTIME_LEFT": "Tiempo restante (p. ej. 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "Tráfico usado en porcentaje (oculto si es ilimitado)",
         "descEXPIRE_DATE": "Fecha de expiración (AAAA-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "Fecha de expiración en el calendario Jalali (AAAA/MM/DD)",
         "descEXPIRE_UNIX": "Expiración como marca de tiempo Unix (segundos)",
         "descCREATED_UNIX": "Hora de creación como marca de tiempo Unix (segundos)",
-        "descRESET_DAYS": "Periodo de reinicio de tráfico en días"
+        "descRESET_DAYS": "Periodo de reinicio de tráfico en días",
+        "descPROTOCOL": "Protocolo del inbound (VLESS, VMess, Trojan, …)",
+        "descTRANSPORT": "Red de transporte (tcp, ws, grpc, …)",
+        "descSECURITY": "Seguridad del transporte (TLS, REALITY, NONE)"
       },
       "toasts": {
         "list": "Error al cargar los hosts",
@@ -2066,4 +2086,4 @@
     "statusDown": "CAÍDO",
     "statusUp": "ACTIVO"
   }
-}
+}

+ 25 - 5
internal/web/translation/fa-IR.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "نسخه فعلی پنل",
       "latestPanelVersion": "آخرین نسخه پنل",
       "panelUpToDate": "پنل به‌روز است",
+      "devChannel": "کانال توسعه (Dev)",
+      "devChannelWarning": "بیلدهای توسعه هر کامیت روی main را دنبال می‌کنند و نسخهٔ پایدار نیستند — بازگشت خودکار به نسخهٔ قبلی وجود ندارد.",
+      "currentCommit": "کامیت فعلی",
+      "latestCommit": "آخرین کامیت",
+      "updateChannelChanged": "کانال به‌روزرسانی تغییر کرد",
       "upToDate": "به‌روز",
       "xrayStatusUnknown": "ناشناخته",
       "xrayStatusRunning": "در حال اجرا",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "دامنه‌های مستثنا",
       "decryption": "رمزگشایی",
       "encryption": "رمزنگاری",
-      "vlessAuthX25519": "احراز X25519",
-      "vlessAuthMlkem768": "احراز ML-KEM-768",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "سفارشی",
       "vlessAuthSelected": "انتخاب‌شده: {auth}",
+      "vlessAuthGenerate": "تولید کلیدها",
+      "vlessAuthGenerateButton": "تولید",
       "advanced": {
         "title": "بخش‌های JSON اینباند",
         "subtitle": "JSON کامل اینباند و ویرایشگرهای جداگانه برای settings، sniffing و streamSettings.",
@@ -968,6 +979,7 @@
       "upToDate": "به‌روز",
       "updateConfirmTitle": "{count} نود به آخرین نسخه به‌روزرسانی شوند؟",
       "updateConfirmContent": "هر نود انتخاب‌شده آخرین نسخه را دانلود و روی آن ری‌استارت می‌شود. فقط نودهای فعال و آنلاین به‌روزرسانی می‌شوند.",
+      "updateDevChannel": "به‌روزرسانی به کانال دِو (آخرین کامیت)",
       "testConnection": "تست اتصال",
       "connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)",
       "connectionFailed": "اتصال ناموفق",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "کاربر",
           "traffic": "ترافیک",
-          "time": "زمان و وضعیت"
+          "time": "زمان و وضعیت",
+          "connection": "اتصال"
         },
         "descEMAIL": "ایمیل کاربر",
         "descINBOUND": "نام خود اینباند (نام کانفیگ)",
@@ -1829,11 +1842,18 @@
         "descUP": "ترافیک آپلود",
         "descDOWN": "ترافیک دانلود",
         "descSTATUS": "فعال / منقضی‌شده / غیرفعال / مصرف‌شده",
+        "descSTATUS_EMOJI": "وضعیت به‌صورت ایموجی (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "روزهای باقی‌مانده تا انقضا (در صورت نامحدود بودن پنهان می‌شود)",
+        "descTIME_LEFT": "زمان باقی‌مانده (مثلاً 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "ترافیک مصرف‌شده به درصد (در صورت نامحدود بودن پنهان می‌شود)",
         "descEXPIRE_DATE": "تاریخ انقضا (YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "تاریخ انقضا در تقویم جلالی (YYYY/MM/DD)",
         "descEXPIRE_UNIX": "انقضا به‌صورت مهر زمانی Unix (ثانیه)",
         "descCREATED_UNIX": "زمان ایجاد به‌صورت مهر زمانی Unix (ثانیه)",
-        "descRESET_DAYS": "دورهٔ بازنشانی ترافیک به روز"
+        "descRESET_DAYS": "دورهٔ بازنشانی ترافیک به روز",
+        "descPROTOCOL": "پروتکل اینباند (VLESS، VMess، Trojan، …)",
+        "descTRANSPORT": "شبکهٔ انتقال (tcp، ws، grpc، …)",
+        "descSECURITY": "امنیت انتقال (TLS، REALITY، NONE)"
       },
       "toasts": {
         "list": "بارگذاری میزبان‌ها ناموفق",
@@ -2066,4 +2086,4 @@
     "statusDown": "قطع",
     "statusUp": "وصل"
   }
-}
+}

+ 25 - 5
internal/web/translation/id-ID.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "Versi panel saat ini",
       "latestPanelVersion": "Versi panel terbaru",
       "panelUpToDate": "Panel sudah terbaru",
+      "devChannel": "Kanal dev",
+      "devChannelWarning": "Build dev mengikuti setiap commit di main dan bukan rilis stabil — tidak ada penurunan versi otomatis.",
+      "currentCommit": "Commit saat ini",
+      "latestCommit": "Commit terbaru",
+      "updateChannelChanged": "Kanal pembaruan diubah",
       "upToDate": "Terbaru",
       "xrayStatusUnknown": "Tidak diketahui",
       "xrayStatusRunning": "Berjalan",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "Domain yang dikecualikan",
       "decryption": "Dekripsi",
       "encryption": "Enkripsi",
-      "vlessAuthX25519": "Auth X25519",
-      "vlessAuthMlkem768": "Auth ML-KEM-768",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "Khusus",
       "vlessAuthSelected": "Dipilih: {auth}",
+      "vlessAuthGenerate": "Buat kunci",
+      "vlessAuthGenerateButton": "Buat",
       "advanced": {
         "title": "Bagian JSON inbound",
         "subtitle": "JSON inbound lengkap dan editor fokus untuk settings, sniffing, dan streamSettings.",
@@ -968,6 +979,7 @@
       "upToDate": "Terbaru",
       "updateConfirmTitle": "Perbarui {count} node ke versi terbaru?",
       "updateConfirmContent": "Setiap node terpilih mengunduh rilis terbaru dan memulai ulang. Hanya node aktif dan online yang diperbarui.",
+      "updateDevChannel": "Perbarui ke kanal dev (commit terbaru)",
       "testConnection": "Tes Koneksi",
       "connectionOk": "Koneksi OK ({ms} ms)",
       "connectionFailed": "Koneksi gagal",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "Klien",
           "traffic": "Trafik",
-          "time": "Waktu & status"
+          "time": "Waktu & status",
+          "connection": "Koneksi"
         },
         "descEMAIL": "Email klien",
         "descINBOUND": "Catatan inbound itu sendiri (nama konfigurasi)",
@@ -1829,11 +1842,18 @@
         "descUP": "Trafik unggah",
         "descDOWN": "Trafik unduh",
         "descSTATUS": "aktif / kedaluwarsa / nonaktif / habis",
+        "descSTATUS_EMOJI": "Status sebagai emoji (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "Hari hingga kedaluwarsa (disembunyikan jika tanpa batas)",
+        "descTIME_LEFT": "Waktu tersisa (mis. 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "Trafik terpakai dalam persentase (disembunyikan jika tanpa batas)",
         "descEXPIRE_DATE": "Tanggal kedaluwarsa (YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "Tanggal kedaluwarsa dalam kalender Jalali (YYYY/MM/DD)",
         "descEXPIRE_UNIX": "Kedaluwarsa sebagai timestamp Unix (detik)",
         "descCREATED_UNIX": "Waktu pembuatan sebagai timestamp Unix (detik)",
-        "descRESET_DAYS": "Periode reset trafik dalam hari"
+        "descRESET_DAYS": "Periode reset trafik dalam hari",
+        "descPROTOCOL": "Protokol inbound (VLESS, VMess, Trojan, …)",
+        "descTRANSPORT": "Jaringan transport (tcp, ws, grpc, …)",
+        "descSECURITY": "Keamanan transport (TLS, REALITY, NONE)"
       },
       "toasts": {
         "list": "Gagal memuat host",
@@ -2066,4 +2086,4 @@
     "statusDown": "MATI",
     "statusUp": "AKTIF"
   }
-}
+}

+ 25 - 5
internal/web/translation/ja-JP.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "現在のパネルバージョン",
       "latestPanelVersion": "最新のパネルバージョン",
       "panelUpToDate": "パネルは最新です",
+      "devChannel": "開発チャンネル",
+      "devChannelWarning": "開発ビルドは main の各コミットを追跡し、安定版ではありません。自動ダウングレードはありません。",
+      "currentCommit": "現在のコミット",
+      "latestCommit": "最新のコミット",
+      "updateChannelChanged": "更新チャンネルを変更しました",
       "upToDate": "最新",
       "xrayStatusUnknown": "不明",
       "xrayStatusRunning": "実行中",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "除外するドメイン",
       "decryption": "復号",
       "encryption": "暗号化",
-      "vlessAuthX25519": "X25519 認証",
-      "vlessAuthMlkem768": "ML-KEM-768 認証",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "カスタム",
       "vlessAuthSelected": "選択中: {auth}",
+      "vlessAuthGenerate": "鍵を生成",
+      "vlessAuthGenerateButton": "生成",
       "advanced": {
         "title": "インバウンド JSON セクション",
         "subtitle": "インバウンド全体の JSON と、settings、sniffing、streamSettings 用の専用エディター。",
@@ -968,6 +979,7 @@
       "upToDate": "最新",
       "updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?",
       "updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。",
+      "updateDevChannel": "開発チャンネルに更新(最新コミット)",
       "testConnection": "接続テスト",
       "connectionOk": "接続OK ({ms} ms)",
       "connectionFailed": "接続に失敗しました",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "クライアント",
           "traffic": "トラフィック",
-          "time": "時刻とステータス"
+          "time": "時刻とステータス",
+          "connection": "接続"
         },
         "descEMAIL": "クライアントのメール",
         "descINBOUND": "インバウンド自身の備考(設定名)",
@@ -1829,11 +1842,18 @@
         "descUP": "アップロードトラフィック",
         "descDOWN": "ダウンロードトラフィック",
         "descSTATUS": "active / expired / disabled / depleted",
+        "descSTATUS_EMOJI": "絵文字で表したステータス(✅ ⏳ 🚫)",
         "descDAYS_LEFT": "有効期限までの日数(無制限の場合は非表示)",
+        "descTIME_LEFT": "残り時間(例:12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "使用済みトラフィックの割合(無制限の場合は非表示)",
         "descEXPIRE_DATE": "有効期限(YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "ジャラーリー暦の有効期限(YYYY/MM/DD)",
         "descEXPIRE_UNIX": "有効期限の Unix タイムスタンプ(秒)",
         "descCREATED_UNIX": "作成時刻の Unix タイムスタンプ(秒)",
-        "descRESET_DAYS": "トラフィックリセット周期(日数)"
+        "descRESET_DAYS": "トラフィックリセット周期(日数)",
+        "descPROTOCOL": "インバウンドのプロトコル(VLESS、VMess、Trojan など)",
+        "descTRANSPORT": "トランスポートネットワーク(tcp、ws、grpc など)",
+        "descSECURITY": "トランスポートのセキュリティ(TLS、REALITY、NONE)"
       },
       "toasts": {
         "list": "ホストの読み込みに失敗しました",
@@ -2066,4 +2086,4 @@
     "statusDown": "ダウン",
     "statusUp": "アップ"
   }
-}
+}

+ 25 - 5
internal/web/translation/pt-BR.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "Versão atual do painel",
       "latestPanelVersion": "Última versão do painel",
       "panelUpToDate": "O painel está atualizado",
+      "devChannel": "Canal de desenvolvimento",
+      "devChannelWarning": "As builds de desenvolvimento acompanham cada commit na main e não são versões estáveis — não há downgrade automático.",
+      "currentCommit": "Commit atual",
+      "latestCommit": "Último commit",
+      "updateChannelChanged": "Canal de atualização alterado",
       "upToDate": "Atualizado",
       "xrayStatusUnknown": "Desconhecido",
       "xrayStatusRunning": "Em execução",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "Domínios excluídos",
       "decryption": "Descriptografia",
       "encryption": "Criptografia",
-      "vlessAuthX25519": "Autenticação X25519",
-      "vlessAuthMlkem768": "Autenticação ML-KEM-768",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "Personalizado",
       "vlessAuthSelected": "Selecionado: {auth}",
+      "vlessAuthGenerate": "Gerar chaves",
+      "vlessAuthGenerateButton": "Gerar",
       "advanced": {
         "title": "Seções JSON do inbound",
         "subtitle": "JSON completo do inbound e editores específicos para settings, sniffing e streamSettings.",
@@ -968,6 +979,7 @@
       "upToDate": "Atualizado",
       "updateConfirmTitle": "Atualizar {count} nó(s) para a versão mais recente?",
       "updateConfirmContent": "Cada nó selecionado baixa a versão mais recente e reinicia nela. Apenas nós ativos e online são atualizados.",
+      "updateDevChannel": "Atualizar para o canal de desenvolvimento (último commit)",
       "testConnection": "Testar conexão",
       "connectionOk": "Conexão OK ({ms} ms)",
       "connectionFailed": "Falha na conexão",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "Cliente",
           "traffic": "Tráfego",
-          "time": "Tempo e status"
+          "time": "Tempo e status",
+          "connection": "Conexão"
         },
         "descEMAIL": "Email do cliente",
         "descINBOUND": "Observação da própria entrada (nome da configuração)",
@@ -1829,11 +1842,18 @@
         "descUP": "Tráfego de upload",
         "descDOWN": "Tráfego de download",
         "descSTATUS": "ativo / expirado / desativado / esgotado",
+        "descSTATUS_EMOJI": "Status como emoji (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "Dias até a expiração (oculto se ilimitado)",
+        "descTIME_LEFT": "Tempo restante (ex.: 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "Tráfego usado como porcentagem (oculto se ilimitado)",
         "descEXPIRE_DATE": "Data de expiração (AAAA-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "Data de expiração no calendário Jalali (AAAA/MM/DD)",
         "descEXPIRE_UNIX": "Expiração como timestamp Unix (segundos)",
         "descCREATED_UNIX": "Data de criação como timestamp Unix (segundos)",
-        "descRESET_DAYS": "Período de redefinição de tráfego em dias"
+        "descRESET_DAYS": "Período de redefinição de tráfego em dias",
+        "descPROTOCOL": "Protocolo da entrada (VLESS, VMess, Trojan, …)",
+        "descTRANSPORT": "Rede de transporte (tcp, ws, grpc, …)",
+        "descSECURITY": "Segurança do transporte (TLS, REALITY, NONE)"
       },
       "toasts": {
         "list": "Falha ao carregar os hosts",
@@ -2066,4 +2086,4 @@
     "statusDown": "INATIVO",
     "statusUp": "ATIVO"
   }
-}
+}

+ 25 - 5
internal/web/translation/ru-RU.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "Текущая версия панели",
       "latestPanelVersion": "Последняя версия панели",
       "panelUpToDate": "Панель обновлена",
+      "devChannel": "Канал разработки",
+      "devChannelWarning": "Сборки разработки отслеживают каждый коммит в main и не являются стабильными релизами — автоматического отката нет.",
+      "currentCommit": "Текущий коммит",
+      "latestCommit": "Последний коммит",
+      "updateChannelChanged": "Канал обновления изменён",
       "upToDate": "Обновлено",
       "xrayStatusUnknown": "Неизвестно",
       "xrayStatusRunning": "Запущен",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "Исключённые домены",
       "decryption": "Расшифрование",
       "encryption": "Шифрование",
-      "vlessAuthX25519": "Аутентификация X25519",
-      "vlessAuthMlkem768": "Аутентификация ML-KEM-768",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "Свой",
       "vlessAuthSelected": "Выбрано: {auth}",
+      "vlessAuthGenerate": "Генерация ключей",
+      "vlessAuthGenerateButton": "Сгенерировать",
       "advanced": {
         "title": "Разделы JSON входящего",
         "subtitle": "Полный JSON входящего и отдельные редакторы для settings, sniffing и streamSettings.",
@@ -968,6 +979,7 @@
       "upToDate": "Актуально",
       "updateConfirmTitle": "Обновить {count} узлов до последней версии?",
       "updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.",
+      "updateDevChannel": "Обновить до канала разработки (последний коммит)",
       "testConnection": "Проверить соединение",
       "connectionOk": "Соединение в порядке ({ms} мс)",
       "connectionFailed": "Не удалось подключиться",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "Клиент",
           "traffic": "Трафик",
-          "time": "Время и статус"
+          "time": "Время и статус",
+          "connection": "Подключение"
         },
         "descEMAIL": "Email клиента",
         "descINBOUND": "Собственное примечание входящего (имя конфигурации)",
@@ -1829,11 +1842,18 @@
         "descUP": "Исходящий трафик",
         "descDOWN": "Входящий трафик",
         "descSTATUS": "активен / истёк / отключён / исчерпан",
+        "descSTATUS_EMOJI": "Статус в виде эмодзи (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "Дней до окончания (скрыто при безлимите)",
+        "descTIME_LEFT": "Оставшееся время (например, 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "Использованный трафик в процентах (скрыт при безлимите)",
         "descEXPIRE_DATE": "Дата окончания (ГГГГ-ММ-ДД)",
+        "descJALALI_EXPIRE_DATE": "Дата окончания по календарю Jalali (ГГГГ/ММ/ДД)",
         "descEXPIRE_UNIX": "Окончание в виде Unix-метки времени (секунды)",
         "descCREATED_UNIX": "Время создания в виде Unix-метки времени (секунды)",
-        "descRESET_DAYS": "Период сброса трафика в днях"
+        "descRESET_DAYS": "Период сброса трафика в днях",
+        "descPROTOCOL": "Протокол входящего (VLESS, VMess, Trojan, …)",
+        "descTRANSPORT": "Транспортная сеть (tcp, ws, grpc, …)",
+        "descSECURITY": "Безопасность транспорта (TLS, REALITY, NONE)"
       },
       "toasts": {
         "list": "Не удалось загрузить хосты",
@@ -2066,4 +2086,4 @@
     "statusDown": "НЕДОСТУПЕН",
     "statusUp": "РАБОТАЕТ"
   }
-}
+}

+ 25 - 5
internal/web/translation/tr-TR.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "Mevcut panel sürümü",
       "latestPanelVersion": "Panelin en son sürümü",
       "panelUpToDate": "Panel güncel",
+      "devChannel": "Geliştirme kanalı",
+      "devChannelWarning": "Geliştirme yapıları main üzerindeki her commit'i izler ve kararlı sürüm değildir — otomatik geri alma yoktur.",
+      "currentCommit": "Geçerli commit",
+      "latestCommit": "Son commit",
+      "updateChannelChanged": "Güncelleme kanalı değiştirildi",
       "upToDate": "Güncel",
       "xrayStatusUnknown": "Bilinmiyor",
       "xrayStatusRunning": "Çalışıyor",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "Hariç tutulan alan adları",
       "decryption": "Şifre Çözme",
       "encryption": "Şifreleme",
-      "vlessAuthX25519": "X25519 Kimlik Doğrulama",
-      "vlessAuthMlkem768": "ML-KEM-768 Kimlik Doğrulama",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "Özel",
       "vlessAuthSelected": "Seçili: {auth}",
+      "vlessAuthGenerate": "Anahtar oluştur",
+      "vlessAuthGenerateButton": "Oluştur",
       "advanced": {
         "title": "Gelen Bağlantı JSON Bölümleri",
         "subtitle": "Tam gelen bağlantı JSON'u ve settings, sniffing, streamSettings için odaklanmış düzenleyiciler.",
@@ -968,6 +979,7 @@
       "upToDate": "Güncel",
       "updateConfirmTitle": "{count} düğüm en son sürüme güncellensin mi?",
       "updateConfirmContent": "Seçilen her düğüm en son sürümü indirir ve yeniden başlatılır. Yalnızca etkin ve çevrimiçi düğümler güncellenir.",
+      "updateDevChannel": "Dev kanalına güncelle (son commit)",
       "testConnection": "Bağlantıyı Test Et",
       "connectionOk": "Bağlantı tamam ({ms} ms)",
       "connectionFailed": "Bağlantı başarısız",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "Kullanıcı",
           "traffic": "Trafik",
-          "time": "Zaman ve durum"
+          "time": "Zaman ve durum",
+          "connection": "Bağlantı"
         },
         "descEMAIL": "Kullanıcı e-postası",
         "descINBOUND": "Gelen bağlantının kendi açıklaması (yapılandırma adı)",
@@ -1829,11 +1842,18 @@
         "descUP": "Yükleme trafiği",
         "descDOWN": "İndirme trafiği",
         "descSTATUS": "aktif / süresi dolmuş / devre dışı / tükenmiş",
+        "descSTATUS_EMOJI": "Emoji olarak durum (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "Süre dolana kadar kalan gün (sınırsızsa gizlenir)",
+        "descTIME_LEFT": "Kalan süre (örn. 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "Kullanılan trafik yüzde olarak (sınırsızsa gizlenir)",
         "descEXPIRE_DATE": "Son kullanma tarihi (YYYY-AA-GG)",
+        "descJALALI_EXPIRE_DATE": "Celali takvimine göre son kullanma tarihi (YYYY/MM/DD)",
         "descEXPIRE_UNIX": "Son kullanma Unix zaman damgası olarak (saniye)",
         "descCREATED_UNIX": "Oluşturulma zamanı Unix zaman damgası olarak (saniye)",
-        "descRESET_DAYS": "Trafik sıfırlama periyodu (gün)"
+        "descRESET_DAYS": "Trafik sıfırlama periyodu (gün)",
+        "descPROTOCOL": "Gelen bağlantı protokolü (VLESS, VMess, Trojan, …)",
+        "descTRANSPORT": "Taşıma ağı (tcp, ws, grpc, …)",
+        "descSECURITY": "Taşıma güvenliği (TLS, REALITY, NONE)"
       },
       "toasts": {
         "list": "Host'lar yüklenemedi",
@@ -2066,4 +2086,4 @@
     "statusDown": "ÇEVRİMDIŞI",
     "statusUp": "ÇEVRİMİÇİ"
   }
-}
+}

+ 25 - 5
internal/web/translation/uk-UA.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "Поточна версія панелі",
       "latestPanelVersion": "Остання версія панелі",
       "panelUpToDate": "Панель оновлено",
+      "devChannel": "Канал розробки",
+      "devChannelWarning": "Збірки розробки відстежують кожен коміт у main і не є стабільними релізами — автоматичного відкату немає.",
+      "currentCommit": "Поточний коміт",
+      "latestCommit": "Останній коміт",
+      "updateChannelChanged": "Канал оновлення змінено",
       "upToDate": "Оновлено",
       "xrayStatusUnknown": "Невідомо",
       "xrayStatusRunning": "Запущено",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "Виключені домени",
       "decryption": "Розшифрування",
       "encryption": "Шифрування",
-      "vlessAuthX25519": "Автентифікація X25519",
-      "vlessAuthMlkem768": "Автентифікація ML-KEM-768",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "Користувацький",
       "vlessAuthSelected": "Вибрано: {auth}",
+      "vlessAuthGenerate": "Генерація ключів",
+      "vlessAuthGenerateButton": "Згенерувати",
       "advanced": {
         "title": "Розділи JSON вхідного",
         "subtitle": "Повний JSON вхідного та окремі редактори для settings, sniffing і streamSettings.",
@@ -968,6 +979,7 @@
       "upToDate": "Актуально",
       "updateConfirmTitle": "Оновити {count} вузлів до останньої версії?",
       "updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.",
+      "updateDevChannel": "Оновити до каналу розробки (останній коміт)",
       "testConnection": "Перевірити з'єднання",
       "connectionOk": "З'єднання в порядку ({ms} мс)",
       "connectionFailed": "Помилка з'єднання",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "Клієнт",
           "traffic": "Трафік",
-          "time": "Час і статус"
+          "time": "Час і статус",
+          "connection": "З'єднання"
         },
         "descEMAIL": "Email клієнта",
         "descINBOUND": "Власна примітка вхідного (назва конфігурації)",
@@ -1829,11 +1842,18 @@
         "descUP": "Вихідний трафік",
         "descDOWN": "Вхідний трафік",
         "descSTATUS": "active / expired / disabled / depleted",
+        "descSTATUS_EMOJI": "Статус у вигляді емодзі (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "Днів до закінчення (прихований, якщо безлімітний)",
+        "descTIME_LEFT": "Залишок часу (напр. 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "Використаний трафік у відсотках (прихований, якщо безлімітний)",
         "descEXPIRE_DATE": "Дата закінчення (YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "Дата закінчення за календарем Jalali (YYYY/MM/DD)",
         "descEXPIRE_UNIX": "Закінчення як мітка часу Unix (секунди)",
         "descCREATED_UNIX": "Час створення як мітка часу Unix (секунди)",
-        "descRESET_DAYS": "Період скидання трафіку в днях"
+        "descRESET_DAYS": "Період скидання трафіку в днях",
+        "descPROTOCOL": "Протокол вхідного (VLESS, VMess, Trojan, …)",
+        "descTRANSPORT": "Транспортна мережа (tcp, ws, grpc, …)",
+        "descSECURITY": "Безпека транспорту (TLS, REALITY, NONE)"
       },
       "toasts": {
         "list": "Не вдалося завантажити хости",
@@ -2066,4 +2086,4 @@
     "statusDown": "НЕДОСТУПНО",
     "statusUp": "ДОСТУПНО"
   }
-}
+}

+ 25 - 5
internal/web/translation/vi-VN.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "Phiên bản panel hiện tại",
       "latestPanelVersion": "Phiên bản panel mới nhất",
       "panelUpToDate": "Panel đã được cập nhật",
+      "devChannel": "Kênh phát triển",
+      "devChannelWarning": "Bản dev bám theo từng commit trên main và không phải bản ổn định — không có hạ cấp tự động.",
+      "currentCommit": "Commit hiện tại",
+      "latestCommit": "Commit mới nhất",
+      "updateChannelChanged": "Đã đổi kênh cập nhật",
       "upToDate": "Đã cập nhật",
       "xrayStatusUnknown": "Không xác định",
       "xrayStatusRunning": "Đang chạy",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "Tên miền bị loại trừ",
       "decryption": "Giải mã",
       "encryption": "Mã hóa",
-      "vlessAuthX25519": "Xác thực X25519",
-      "vlessAuthMlkem768": "Xác thực ML-KEM-768",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "Tùy chỉnh",
       "vlessAuthSelected": "Đã chọn: {auth}",
+      "vlessAuthGenerate": "Tạo khóa",
+      "vlessAuthGenerateButton": "Tạo",
       "advanced": {
         "title": "Các phần JSON của inbound",
         "subtitle": "JSON inbound đầy đủ và các trình chỉnh sửa riêng cho settings, sniffing và streamSettings.",
@@ -968,6 +979,7 @@
       "upToDate": "Mới nhất",
       "updateConfirmTitle": "Cập nhật {count} node lên phiên bản mới nhất?",
       "updateConfirmContent": "Mỗi node đã chọn sẽ tải bản phát hành mới nhất và khởi động lại. Chỉ các node đang bật và trực tuyến được cập nhật.",
+      "updateDevChannel": "Cập nhật lên kênh phát triển (commit mới nhất)",
       "testConnection": "Kiểm tra kết nối",
       "connectionOk": "Kết nối OK ({ms} ms)",
       "connectionFailed": "Kết nối thất bại",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "Khách hàng",
           "traffic": "Lưu lượng",
-          "time": "Thời gian & trạng thái"
+          "time": "Thời gian & trạng thái",
+          "connection": "Kết nối"
         },
         "descEMAIL": "Email khách hàng",
         "descINBOUND": "Ghi chú của chính inbound (tên cấu hình)",
@@ -1829,11 +1842,18 @@
         "descUP": "Lưu lượng tải lên",
         "descDOWN": "Lưu lượng tải xuống",
         "descSTATUS": "active / expired / disabled / depleted",
+        "descSTATUS_EMOJI": "Trạng thái dạng biểu tượng cảm xúc (✅ ⏳ 🚫)",
         "descDAYS_LEFT": "Số ngày đến khi hết hạn (ẩn nếu không giới hạn)",
+        "descTIME_LEFT": "Thời gian còn lại (ví dụ 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "Lưu lượng đã dùng tính theo phần trăm (ẩn nếu không giới hạn)",
         "descEXPIRE_DATE": "Ngày hết hạn (YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "Ngày hết hạn theo lịch Jalali (YYYY/MM/DD)",
         "descEXPIRE_UNIX": "Hết hạn dạng dấu thời gian Unix (giây)",
         "descCREATED_UNIX": "Thời điểm tạo dạng dấu thời gian Unix (giây)",
-        "descRESET_DAYS": "Chu kỳ đặt lại lưu lượng tính theo ngày"
+        "descRESET_DAYS": "Chu kỳ đặt lại lưu lượng tính theo ngày",
+        "descPROTOCOL": "Giao thức inbound (VLESS, VMess, Trojan, …)",
+        "descTRANSPORT": "Mạng truyền tải (tcp, ws, grpc, …)",
+        "descSECURITY": "Bảo mật truyền tải (TLS, REALITY, NONE)"
       },
       "toasts": {
         "list": "Không tải được danh sách host",
@@ -2066,4 +2086,4 @@
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusUp": "HOẠT ĐỘNG"
   }
-}
+}

+ 25 - 5
internal/web/translation/zh-CN.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "当前面板版本",
       "latestPanelVersion": "最新面板版本",
       "panelUpToDate": "面板已是最新",
+      "devChannel": "开发通道",
+      "devChannelWarning": "开发版会跟踪 main 的每次提交,并非稳定版本,且无法自动降级。",
+      "currentCommit": "当前提交",
+      "latestCommit": "最新提交",
+      "updateChannelChanged": "更新通道已切换",
       "upToDate": "已是最新",
       "xrayStatusUnknown": "未知",
       "xrayStatusRunning": "运行中",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "排除的域名",
       "decryption": "解密",
       "encryption": "加密",
-      "vlessAuthX25519": "X25519 认证",
-      "vlessAuthMlkem768": "ML-KEM-768 认证",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "自定义",
       "vlessAuthSelected": "已选择:{auth}",
+      "vlessAuthGenerate": "生成密钥",
+      "vlessAuthGenerateButton": "生成",
       "advanced": {
         "title": "入站 JSON 部分",
         "subtitle": "完整入站 JSON 以及针对 settings、sniffing 和 streamSettings 的专用编辑器。",
@@ -968,6 +979,7 @@
       "upToDate": "已是最新",
       "updateConfirmTitle": "将 {count} 个节点更新到最新版本?",
       "updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。",
+      "updateDevChannel": "更新到开发通道(最新提交)",
       "testConnection": "测试连接",
       "connectionOk": "连接正常 ({ms} ms)",
       "connectionFailed": "连接失败",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "客户端",
           "traffic": "流量",
-          "time": "时间与状态"
+          "time": "时间与状态",
+          "connection": "连接"
         },
         "descEMAIL": "客户端邮箱",
         "descINBOUND": "入站本身的备注(配置名称)",
@@ -1829,11 +1842,18 @@
         "descUP": "上传流量",
         "descDOWN": "下载流量",
         "descSTATUS": "active / expired / disabled / depleted",
+        "descSTATUS_EMOJI": "以表情符号显示状态(✅ ⏳ 🚫)",
         "descDAYS_LEFT": "距到期天数(无限制时隐藏)",
+        "descTIME_LEFT": "剩余时间(例如 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "已用流量百分比(无限制时隐藏)",
         "descEXPIRE_DATE": "到期日期(YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "Jalali(波斯)历的到期日期(YYYY/MM/DD)",
         "descEXPIRE_UNIX": "到期时间的 Unix 时间戳(秒)",
         "descCREATED_UNIX": "创建时间的 Unix 时间戳(秒)",
-        "descRESET_DAYS": "流量重置周期(天)"
+        "descRESET_DAYS": "流量重置周期(天)",
+        "descPROTOCOL": "入站协议(VLESS、VMess、Trojan……)",
+        "descTRANSPORT": "传输网络(tcp、ws、grpc……)",
+        "descSECURITY": "传输安全(TLS、REALITY、NONE)"
       },
       "toasts": {
         "list": "加载主机失败",
@@ -2066,4 +2086,4 @@
     "statusDown": "断开",
     "statusUp": "恢复"
   }
-}
+}

+ 25 - 5
internal/web/translation/zh-TW.json

@@ -153,6 +153,11 @@
       "currentPanelVersion": "目前面板版本",
       "latestPanelVersion": "最新面板版本",
       "panelUpToDate": "面板已是最新",
+      "devChannel": "開發通道",
+      "devChannelWarning": "開發版會追蹤 main 的每次提交,並非穩定版本,且無法自動降級。",
+      "currentCommit": "目前提交",
+      "latestCommit": "最新提交",
+      "updateChannelChanged": "更新通道已切換",
       "upToDate": "已是最新",
       "xrayStatusUnknown": "未知",
       "xrayStatusRunning": "運行中",
@@ -402,10 +407,16 @@
       "sniffingDomainsExcluded": "排除的網域",
       "decryption": "解密",
       "encryption": "加密",
-      "vlessAuthX25519": "X25519 認證",
-      "vlessAuthMlkem768": "ML-KEM-768 認證",
+      "vlessAuthX25519": "X25519 (native)",
+      "vlessAuthMlkem768": "ML-KEM-768 (native)",
+      "vlessAuthX25519Xorpub": "X25519 (xorpub)",
+      "vlessAuthX25519Random": "X25519 (random)",
+      "vlessAuthMlkem768Xorpub": "ML-KEM-768 (xorpub)",
+      "vlessAuthMlkem768Random": "ML-KEM-768 (random)",
       "vlessAuthCustom": "自訂",
       "vlessAuthSelected": "已選擇:{auth}",
+      "vlessAuthGenerate": "生成密鑰",
+      "vlessAuthGenerateButton": "生成",
       "advanced": {
         "title": "入站 JSON 部分",
         "subtitle": "完整入站 JSON 以及針對 settings、sniffing 和 streamSettings 的專用編輯器。",
@@ -968,6 +979,7 @@
       "upToDate": "已是最新",
       "updateConfirmTitle": "將 {count} 個節點更新到最新版本?",
       "updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。",
+      "updateDevChannel": "更新到開發通道(最新提交)",
       "testConnection": "測試連線",
       "connectionOk": "連線正常 ({ms} ms)",
       "connectionFailed": "連線失敗",
@@ -1810,7 +1822,8 @@
         "groups": {
           "client": "客戶端",
           "traffic": "流量",
-          "time": "時間與狀態"
+          "time": "時間與狀態",
+          "connection": "連線"
         },
         "descEMAIL": "客戶端電子郵件",
         "descINBOUND": "入站本身的備註(配置名稱)",
@@ -1829,11 +1842,18 @@
         "descUP": "上傳流量",
         "descDOWN": "下載流量",
         "descSTATUS": "active / expired / disabled / depleted",
+        "descSTATUS_EMOJI": "以表情符號表示的狀態(✅ ⏳ 🚫)",
         "descDAYS_LEFT": "距到期天數(無限制時隱藏)",
+        "descTIME_LEFT": "剩餘時間(例如 12d 4h 30m)",
+        "descUSAGE_PERCENTAGE": "已用流量百分比(無限制時隱藏)",
         "descEXPIRE_DATE": "到期日期(YYYY-MM-DD)",
+        "descJALALI_EXPIRE_DATE": "Jalali 曆的到期日期(YYYY/MM/DD)",
         "descEXPIRE_UNIX": "到期時間(Unix 時間戳記,秒)",
         "descCREATED_UNIX": "建立時間(Unix 時間戳記,秒)",
-        "descRESET_DAYS": "流量重置週期(天)"
+        "descRESET_DAYS": "流量重置週期(天)",
+        "descPROTOCOL": "入站協定(VLESS、VMess、Trojan…)",
+        "descTRANSPORT": "傳輸網路(tcp、ws、grpc…)",
+        "descSECURITY": "傳輸安全(TLS、REALITY、NONE)"
       },
       "toasts": {
         "list": "載入 Host 失敗",
@@ -2066,4 +2086,4 @@
     "statusDown": "中斷",
     "statusUp": "恢復"
   }
-}
+}

+ 5 - 1
internal/web/web.go

@@ -264,8 +264,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		c.JSON(http.StatusOK, gin.H{})
 	})
 
-	// Add a catch-all route to handle undefined paths and return 404
+	// Let unknown panel document routes fall back to the SPA shell, while every
+	// non-SPA miss still returns a hard 404.
 	engine.NoRoute(func(c *gin.Context) {
+		if s.panel.HandleNoRoutePanelSPA(c) {
+			return
+		}
 		c.AbortWithStatus(http.StatusNotFound)
 	})
 

+ 28 - 1
main.go

@@ -3,6 +3,7 @@
 package main
 
 import (
+	"context"
 	"flag"
 	"fmt"
 	"log"
@@ -18,6 +19,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/sub"
+	"github.com/mhsanaei/3x-ui/v3/internal/tunnelmonitor"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
 	"github.com/mhsanaei/3x-ui/v3/internal/web"
@@ -91,7 +93,7 @@ func runWebServer() {
 		return
 	}
 
-	sigCh := make(chan os.Signal, 1)
+	sigCh := make(chan os.Signal, 8)
 	// Trap shutdown signals
 	signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1, os.Interrupt)
 	global.SetRestartHook(func() {
@@ -100,6 +102,27 @@ func runWebServer() {
 		default:
 		}
 	})
+
+	var stopTunnelHealthMonitor context.CancelFunc
+	monitorCfg := tunnelmonitor.ConfigFromEnv()
+	if monitorCfg.Enabled {
+		if monitorCfg.ProxyURL == "" {
+			logger.Warning("Tunnel health monitor enabled without XUI_TUNNEL_HEALTH_PROXY: the probe measures host connectivity, not the xray tunnel, so failures will restart xray without fixing host network issues")
+		}
+
+		monitorCtx, cancel := context.WithCancel(context.Background())
+		stopTunnelHealthMonitor = cancel
+
+		monitor, err := tunnelmonitor.New(monitorCfg, func(_ context.Context) error {
+			logger.Warning("Tunnel health monitor threshold reached, restarting xray-core")
+			return server.RestartXray()
+		})
+		if err != nil {
+			logger.Warning("Tunnel health monitor disabled: ", err)
+		} else {
+			go monitor.Run(monitorCtx)
+		}
+	}
 	for {
 		sig := <-sigCh
 
@@ -142,6 +165,10 @@ func runWebServer() {
 			}
 
 		default:
+			if stopTunnelHealthMonitor != nil {
+				stopTunnelHealthMonitor()
+			}
+
 			// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
 			tgbot.StopBot()
 			// ------------------------------------------------------------

+ 10 - 3
update.sh

@@ -895,9 +895,16 @@ update_x-ui() {
 
     echo -e "${green}Downloading new x-ui version...${plain}"
 
-    tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
-    if [[ ! -n "$tag_version" ]]; then
-        _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
+    # XUI_UPDATE_TAG lets the panel target a specific release tag (e.g. the
+    # rolling dev-latest pre-release). Empty keeps the default latest-stable flow.
+    if [[ -n "${XUI_UPDATE_TAG}" ]]; then
+        tag_version="${XUI_UPDATE_TAG}"
+        echo -e "${green}Using update tag: ${tag_version}${plain}"
+    else
+        tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+        if [[ ! -n "$tag_version" ]]; then
+            _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
+        fi
     fi
     echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
     ${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null

+ 82 - 57
x-ui.sh

@@ -155,6 +155,24 @@ update() {
     fi
 }
 
+update_dev() {
+    confirm "This will update x-ui to the latest DEV commit (the rolling 'dev-latest' build, not a stable release). Your data is preserved. Continue?" "y"
+    if [[ $? != 0 ]]; then
+        LOGE "Cancelled"
+        if [[ $# == 0 ]]; then
+            before_show_menu
+        fi
+        return 0
+    fi
+    # XUI_UPDATE_TAG tells update.sh to install the dev-latest pre-release
+    # instead of the latest stable tag.
+    XUI_UPDATE_TAG="dev-latest" bash <(curl -Ls https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh)
+    if [[ $? == 0 ]]; then
+        LOGI "Dev update is complete, Panel has automatically restarted "
+        before_show_menu
+    fi
+}
+
 update_menu() {
     echo -e "${yellow}Updating Menu${plain}"
     confirm "This function will update the menu to the latest changes." "y"
@@ -3104,6 +3122,7 @@ show_usage() {
 │  ${blue}x-ui log${plain}                   - Check logs                       │
 │  ${blue}x-ui banlog${plain}                - Check Fail2ban ban logs          │
 │  ${blue}x-ui update${plain}                - Update                           │
+│  ${blue}x-ui update-dev${plain}            - Update to Dev channel (latest)   │
 │  ${blue}x-ui update-all-geofiles${plain}   - Update all geo files             │
 │  ${blue}x-ui migrateDB [file]${plain}      - Convert .db <-> .dump (SQLite)   │
 │  ${blue}x-ui legacy${plain}                - Legacy version                   │
@@ -3115,46 +3134,46 @@ show_usage() {
 show_menu() {
     echo -e "
 ╔────────────────────────────────────────────────╗
-│   ${green}3X-UI Panel Management Script${plain}                │
-│   ${green}0.${plain} Exit Script                               │
-│────────────────────────────────────────────────│
-│   ${green}1.${plain} Install                                   │
-│   ${green}2.${plain} Update                                    │
-│   ${green}3.${plain} Update Menu                               │
-│   ${green}4.${plain} Legacy Version                            │
-│   ${green}5.${plain} Uninstall                                 │
+│  ${green}3X-UI Panel Management Script${plain}                │
+│  ${green}0.${plain} Exit Script                               │
 │────────────────────────────────────────────────│
-│   ${green}6.${plain} Reset Username & Password                 │
-│   ${green}7.${plain} Reset Web Base Path                       │
-│   ${green}8.${plain} Reset Settings                            │
-│   ${green}9.${plain} Change Port                               │
-│  ${green}10.${plain} View Current Settings                     │
+│  ${green}1.${plain} Install                                   │
+│  ${green}2.${plain} Update                                    │
+│  ${green}3.${plain} Update to Dev Channel (latest commit)     │
+│  ${green}4.${plain} Update Menu                               │
+│  ${green}5.${plain} Legacy Version                            │
+│  ${green}6.${plain} Uninstall                                 │
 │────────────────────────────────────────────────│
-│  ${green}11.${plain} Start                                     │
-│  ${green}12.${plain} Stop                                      │
-│  ${green}13.${plain} Restart                                   │
-|  ${green}14.${plain} Restart Xray                              │
-│  ${green}15.${plain} Check Status                              │
-│  ${green}16.${plain} Logs Management                           │
+│  ${green}7.${plain} Reset Username & Password                 │
+│  ${green}8.${plain} Reset Web Base Path                       │
+│  ${green}9.${plain} Reset Settings                            │
+│  ${green}10.${plain} Change Port                              │
+│  ${green}11.${plain} View Current Settings                    │
 │────────────────────────────────────────────────│
-│  ${green}17.${plain} Enable Autostart                          │
-│  ${green}18.${plain} Disable Autostart                         │
+│  ${green}12.${plain} Start                                    │
+│  ${green}13.${plain} Stop                                     │
+│  ${green}14.${plain} Restart                                  │
+|  ${green}15.${plain} Restart Xray                             │
+│  ${green}16.${plain} Check Status                             │
+│  ${green}17.${plain} Logs Management                          │
 │────────────────────────────────────────────────│
-│  ${green}19.${plain} SSL Certificate Management                │
-│  ${green}20.${plain} Cloudflare SSL Certificate                │
-│  ${green}21.${plain} IP Limit Management                       │
-│  ${green}22.${plain} Firewall Management                       │
-│  ${green}23.${plain} SSH Port Forwarding Management            │
+│  ${green}18.${plain} Enable Autostart                         │
+│  ${green}19.${plain} Disable Autostart                        │
 │────────────────────────────────────────────────│
-│  ${green}24.${plain} Enable BBR                                │
-│  ${green}25.${plain} Update Geo Files                          │
-│  ${green}26.${plain} Speedtest by Ookla                        │
+│  ${green}20.${plain} SSL Certificate Management               │
+│  ${green}21.${plain} Cloudflare SSL Certificate               │
+│  ${green}22.${plain} IP Limit Management                      │
+│  ${green}23.${plain} Firewall Management                      │
+│  ${green}24.${plain} SSH Port Forwarding Management           │
+│  ${green}25.${plain} PostgreSQL Management                    │
 │────────────────────────────────────────────────│
-│  ${green}27.${plain} PostgreSQL Management                     │
+│  ${green}26.${plain} Enable BBR                               │
+│  ${green}27.${plain} Update Geo Files                         │
+│  ${green}28.${plain} Speedtest by Ookla                       │
 ╚────────────────────────────────────────────────╝
 "
     show_status
-    echo && read -rp "Please enter your selection [0-27]: " num
+    echo && read -rp "Please enter your selection [0-28]: " num
 
     case "${num}" in
         0)
@@ -3167,82 +3186,85 @@ show_menu() {
             check_install && update
             ;;
         3)
-            check_install && update_menu
+            check_install && update_dev
             ;;
         4)
-            check_install && legacy_version
+            check_install && update_menu
             ;;
         5)
-            check_install && uninstall
+            check_install && legacy_version
             ;;
         6)
-            check_install && reset_user
+            check_install && uninstall
             ;;
         7)
-            check_install && reset_webbasepath
+            check_install && reset_user
             ;;
         8)
-            check_install && reset_config
+            check_install && reset_webbasepath
             ;;
         9)
-            check_install && set_port
+            check_install && reset_config
             ;;
         10)
-            check_install && check_config
+            check_install && set_port
             ;;
         11)
-            check_install && start
+            check_install && check_config
             ;;
         12)
-            check_install && stop
+            check_install && start
             ;;
         13)
-            check_install && restart
+            check_install && stop
             ;;
         14)
-            check_install && restart_xray
+            check_install && restart
             ;;
         15)
-            check_install && status
+            check_install && restart_xray
             ;;
         16)
-            check_install && show_log
+            check_install && status
             ;;
         17)
-            check_install && enable
+            check_install && show_log
             ;;
         18)
-            check_install && disable
+            check_install && enable
             ;;
         19)
-            ssl_cert_issue_main
+            check_install && disable
             ;;
         20)
-            ssl_cert_issue_CF
+            ssl_cert_issue_main
             ;;
         21)
-            iplimit_main
+            ssl_cert_issue_CF
             ;;
         22)
-            firewall_menu
+            iplimit_main
             ;;
         23)
-            SSH_port_forwarding
+            firewall_menu
             ;;
         24)
-            bbr_menu
+            SSH_port_forwarding
             ;;
         25)
-            update_geo
+            postgresql_menu
             ;;
         26)
-            run_speedtest
+            bbr_menu
             ;;
         27)
-            postgresql_menu
+            update_geo
+            ;;
+        28)
+            run_speedtest
             ;;
         *)
-            LOGE "Please enter the correct number [0-27]"
+            LOGE "Please enter the correct number [0-28]"
             ;;
     esac
 }
@@ -3285,6 +3307,9 @@ if [[ $# > 0 ]]; then
         "update")
             check_install 0 && update 0
             ;;
+        "update-dev")
+            check_install 0 && update_dev 0
+            ;;
         "legacy")
             check_install 0 && legacy_version 0
             ;;