7 Achegas b5479f3f30 ... 8f3202f431

Autor SHA1 Mensaxe Data
  MHSanaei 8f3202f431 fix(traffic-writer): replace sync.Once with Start/Stop cycle so SIGHUP restart works hai 11 horas
  MHSanaei 0cb6568fd5 v3.0.1 hai 12 horas
  MHSanaei 6a90f98412 feat(inbounds): add sub/client link endpoints; hide panel version on login hai 12 horas
  Farhad H. P. Shirvan 9318c2105f fix(xray): implement graceful shutdown for xray process and add tests (#4259) hai 13 horas
  MHSanaei e642f7324e feat(panel): in-panel API documentation page hai 13 horas
  MHSanaei 7214ffafc5 fix(inbounds): scope port check to node and preserve caller tag hai 14 horas
  MHSanaei 88061bac10 fix(theme): default to dark, polish theme cycle visibility and hover hai 14 horas
Modificáronse 41 ficheiros con 1866 adicións e 179 borrados
  1. 1 1
      config/version
  2. 13 0
      frontend/api-docs.html
  3. 21 89
      frontend/src/components/AppSidebar.vue
  4. 1 1
      frontend/src/composables/useTheme.js
  5. 17 0
      frontend/src/entries/api-docs.js
  6. 339 0
      frontend/src/pages/api-docs/ApiDocsPage.vue
  7. 128 0
      frontend/src/pages/api-docs/EndpointRow.vue
  8. 65 0
      frontend/src/pages/api-docs/EndpointSection.vue
  9. 548 0
      frontend/src/pages/api-docs/endpoints.js
  10. 41 21
      frontend/src/pages/login/LoginPage.vue
  11. 5 2
      frontend/src/pages/sub/SubPage.vue
  12. 3 0
      frontend/vite.config.js
  13. 3 0
      main.go
  14. 16 0
      sub/dist.go
  15. 59 0
      sub/links.go
  16. 5 3
      sub/sub.go
  17. 1 3
      sub/subController.go
  18. 6 2
      sub/subService.go
  19. 7 3
      web/controller/dist.go
  20. 56 0
      web/controller/inbound.go
  21. 6 0
      web/controller/xui.go
  22. 30 9
      web/service/inbound.go
  23. 49 4
      web/service/port_conflict.go
  24. 125 0
      web/service/port_conflict_test.go
  25. 47 18
      web/service/traffic_writer.go
  26. 1 0
      web/translation/ar-EG.json
  27. 1 0
      web/translation/en-US.json
  28. 1 0
      web/translation/es-ES.json
  29. 1 0
      web/translation/fa-IR.json
  30. 1 0
      web/translation/id-ID.json
  31. 1 0
      web/translation/ja-JP.json
  32. 1 0
      web/translation/pt-BR.json
  33. 1 0
      web/translation/ru-RU.json
  34. 1 0
      web/translation/tr-TR.json
  35. 1 0
      web/translation/uk-UA.json
  36. 1 0
      web/translation/vi-VN.json
  37. 1 0
      web/translation/zh-CN.json
  38. 1 0
      web/translation/zh-TW.json
  39. 3 1
      web/web.go
  40. 96 22
      xray/process.go
  41. 162 0
      xray/process_test.go

+ 1 - 1
config/version

@@ -1 +1 @@
-3.0.0
+3.0.1

+ 13 - 0
frontend/api-docs.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>3x-ui · API Docs</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/api-docs.js"></script>
+  </body>
+</html>

+ 21 - 89
frontend/src/components/AppSidebar.vue

@@ -10,6 +10,7 @@ import {
   LogoutOutlined,
   CloseOutlined,
   MenuOutlined,
+  ApiOutlined,
 } from '@ant-design/icons-vue';
 
 import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
@@ -19,17 +20,12 @@ const { t } = useI18n();
 const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
 
 const props = defineProps({
-  // Path prefix (e.g. /custom-base/) the panel is served under. Defaults
-  // to '' which means tab keys end up as '/panel/...'. Pages pass the
-  // value the Go backend gave them (in production via a meta tag).
   basePath: { type: String, default: '' },
   // Current request URI so the matching menu item highlights.
   requestUri: { type: String, default: '' },
 });
 
-// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
-// imports — keep a small name-to-component map so tab definitions stay
-// declarative.
+
 const iconByName = {
   dashboard: DashboardOutlined,
   user: UserOutlined,
@@ -37,41 +33,26 @@ const iconByName = {
   tool: ToolOutlined,
   cluster: ClusterOutlined,
   logout: LogoutOutlined,
+  apidocs: ApiOutlined,
 };
 
-// basePath comes from Go (`/` by default, `/myprefix/` when configured) so
-// these concatenations land on absolute paths. In dev we synthesize the prop
-// from a window global which can be empty — force a leading slash so the
-// browser doesn't resolve the link relative to the current pathname (which
-// would turn /panel/settings + 'panel/...' into /panel/panel/...).
 const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
 
-// Labels are i18n-driven so the sidebar matches the locale picked
-// in panel settings without a page reload of the sidebar component.
 const tabs = computed(() => [
   { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
   { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
   { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
   { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
   { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
+  { key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
   { key: `${prefix}logout`, icon: 'logout', title: t('logout') },
 ]);
 
-// Logout sits in its own pinned-to-bottom block on the drawer; the
-// remaining items are the navigation proper. The full-height sider on
-// desktop still uses `tabs` as-is so the desktop look is unchanged.
 const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
 const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
-
 const activeTab = ref([props.requestUri]);
-
 const drawerOpen = ref(false);
 const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
-
-// Drawer width is capped against the viewport — AD-Vue's default 378px
-// overflows on narrow phones (e.g. 360px portrait), leaving the page
-// hidden behind the mask. `min()` keeps it sane on both phones and
-// tablets while never exceeding 320px on larger displays.
 const drawerWidth = 'min(82vw, 320px)';
 
 function openLink(key) {
@@ -98,12 +79,6 @@ function closeDrawer() {
   drawerOpen.value = false;
 }
 
-/* 3-state theme cycle driven by the brand-row icon button.
- *   Light  → Dark   (turn dark on, ensure ultra off)
- *   Dark   → Ultra  (turn ultra on)
- *   Ultra  → Light  (turn ultra off, turn dark off)
- * Using a single button keeps the sider header clean — the old
- * ThemeSwitch a-sub-menu plus its expandable items lived here. */
 function cycleTheme() {
   pauseAnimationsUntilLeave('theme-cycle');
   if (!theme.isDark) {
@@ -128,7 +103,8 @@ function cycleTheme() {
           <svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
             stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
             <circle cx="12" cy="12" r="4" />
-            <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+            <path
+              d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
           </svg>
           <svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
             stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -169,7 +145,8 @@ function cycleTheme() {
             <svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
               stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
               <circle cx="12" cy="12" r="4" />
-              <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+              <path
+                d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
             </svg>
             <svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
               stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -210,13 +187,6 @@ function cycleTheme() {
 </template>
 
 <style scoped>
-/* Pin the desktop sider to the viewport. Without this, AD-Vue's
- * `<a-layout-sider>` stretches to match the flex row's height — which
- * equals the page height on tall dashboards (cards stack into one
- * column below `lg` = 992px), so the bottom-anchored
- * `.ant-layout-sider-trigger` (and Logout right above it) slide off
- * the screen. Sticky + 100vh keeps the sider exactly viewport-tall;
- * `align-self: flex-start` stops the flex row from re-stretching it. */
 .ant-sidebar>.ant-layout-sider {
   position: sticky;
   top: 0;
@@ -224,12 +194,6 @@ function cycleTheme() {
   align-self: flex-start;
 }
 
-/* `.sider-brand` and `.drawer-brand` share the same light-theme colour
- * but differ in layout — the sider one is centered with its own
- * top-of-sidebar padding + border, the drawer one sits inside a flex
- * header next to the close button. Dark/ultra colour overrides live
- * in the non-scoped block at the bottom (theme classes attach to
- * body / html). */
 .sider-brand,
 .drawer-brand {
   font-weight: 600;
@@ -275,16 +239,18 @@ function cycleTheme() {
   align-items: center;
   justify-content: center;
   cursor: pointer;
-  color: inherit;
+  color: rgba(0, 0, 0, 0.75);
   padding: 0;
   flex-shrink: 0;
-  transition: background-color 0.2s, transform 0.15s;
+  transition: background-color 0.2s, transform 0.15s, color 0.2s;
 }
 
 .theme-cycle:hover,
 .theme-cycle:focus-visible {
-  background: rgba(128, 128, 128, 0.18);
+  background-color: rgba(64, 150, 255, 0.1);
+  color: #4096ff;
   transform: scale(1.08);
+  outline: none;
 }
 
 .theme-cycle svg {
@@ -355,31 +321,15 @@ function cycleTheme() {
   font-size: 16px;
 }
 
-/* Push the utility (Logout) block to the bottom of the flex-column
- * drawer body and separate it from the nav block with a hairline. The
- * border colour is theme-neutral so it reads on both light and dark. */
 .drawer-utility {
   margin-top: auto;
   border-top: 1px solid rgba(128, 128, 128, 0.15);
 }
 
-/* Pin Logout exactly above AD-Vue's `.ant-layout-sider-trigger` (the
- * collapse bar at the bottom, position: absolute; height: 48px). The
- * old `margin-top: auto` approach only pushed the utility down when the
- * content was shorter than the container — on short viewports the
- * Logout got hidden behind the trigger. Switching to a flex layout
- * where `.sider-nav` consumes all spare space (flex: 1) and
- * `.sider-utility` stays at content height pins it consistently. The
- * padding-bottom: 48px on the parent reserves the trigger's strip so
- * Logout sits directly above it.
- *
- * The mobile @media rule below still hides the whole sider on phones;
- * this block only kicks in once that override no longer matches. */
 .ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
   display: flex;
   flex-direction: column;
   height: 100%;
-  padding-bottom: 48px;
 }
 
 .sider-brand {
@@ -403,9 +353,6 @@ function cycleTheme() {
     display: inline-flex;
   }
 
-  /* On mobile the drawer is the menu — hide the inline sider's content
-   * + the collapse trigger so the sider stops taking layout space and
-   * leaves no remnant button next to the page. */
   .ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
   .ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
     display: none;
@@ -421,13 +368,6 @@ function cycleTheme() {
 </style>
 
 <style>
-/* Non-scoped so the rules survive AD-Vue teleporting the drawer body
- * outside the AppSidebar element's scope id. Without this the Vue
- * `:global(body.dark) .drawer-brand` form did not produce the expected
- * `body.dark .drawer-brand[data-v-xxx]` selector reliably, and the
- * drawer brand stayed at the light-theme dark colour on the navy
- * drawer surface. Class names are specific enough that no collision is
- * expected; AppSidebar owns the only drawer in the app. */
 body.dark .drawer-brand,
 body.dark .sider-brand {
   color: rgba(255, 255, 255, 0.92);
@@ -446,14 +386,14 @@ html[data-theme='ultra-dark'] .drawer-close {
   color: rgba(255, 255, 255, 0.85);
 }
 
-/* Pin the drawer surface to the same colour the desktop sider uses
- * (Layout.colorBgHeader / Menu.colorItemBg from useTheme.js) so the
- * header, empty body region, and menu items read as one continuous
- * panel. AD-Vue's CSS-in-JS tokens otherwise leave the drawer at
- * colorBgElevated (#2d2d30 in dark) which clashes with the #252526
- * menu rows. `!important` is required to beat the CSS-in-JS rule
- * specificity; AppSidebar owns the only drawer in the app so this
- * doesn't collide with anything else. */
+body.dark .theme-cycle {
+  color: rgba(255, 255, 255, 0.85);
+}
+
+html[data-theme='ultra-dark'] .theme-cycle {
+  color: rgba(255, 255, 255, 0.92);
+}
+
 body.dark .ant-drawer .ant-drawer-content,
 body.dark .ant-drawer .ant-drawer-body {
   background: #252526 !important;
@@ -464,14 +404,6 @@ html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
   background: #0a0a0a !important;
 }
 
-/* Force the same light-blue tint on selected + hover/active across
- * all three themes. AD-Vue's defaults read too subtle on the dark
- * sider, and the light-theme variant looked inconsistent vs. dark —
- * applying the same RGBA tint over all backgrounds gives the active
- * page the same visual weight everywhere. `!important` is required to
- * beat AD-Vue's CSS-in-JS specificity; scoped to .sider-nav /
- * .sider-utility / .drawer-menu so only the navigation menus pick up
- * the override (other a-menu instances keep AD-Vue defaults). */
 .sider-nav .ant-menu-item-selected,
 .sider-utility .ant-menu-item-selected,
 .drawer-menu .ant-menu-item-selected {

+ 1 - 1
frontend/src/composables/useTheme.js

@@ -16,7 +16,7 @@ function readBool(key, fallback) {
 }
 
 const isDark = readBool(STORAGE_DARK, true);
-const isUltra = readBool(STORAGE_ULTRA, true);
+const isUltra = readBool(STORAGE_ULTRA, false);
 
 export const theme = reactive({
   isDark,

+ 17 - 0
frontend/src/entries/api-docs.js

@@ -0,0 +1,17 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
+
+setupAxios();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');

+ 339 - 0
frontend/src/pages/api-docs/ApiDocsPage.vue

@@ -0,0 +1,339 @@
+<script setup>
+import { ref, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+import {
+  KeyOutlined,
+  ReloadOutlined,
+  CopyOutlined,
+  EyeOutlined,
+  EyeInvisibleOutlined,
+} from '@ant-design/icons-vue';
+
+import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
+import AppSidebar from '@/components/AppSidebar.vue';
+import { HttpUtil, ClipboardManager } from '@/utils/index.js';
+import { sections } from './endpoints.js';
+import EndpointSection from './EndpointSection.vue';
+
+const { t } = useI18n();
+
+const basePath = window.X_UI_BASE_PATH || '';
+const requestUri = window.location.pathname;
+
+const apiToken = ref('');
+const tokenLoading = ref(false);
+const tokenRotating = ref(false);
+const tokenVisible = ref(false);
+
+const curlExample = `curl -X GET \\
+  -H "Authorization: Bearer YOUR_API_TOKEN" \\
+  -H "Accept: application/json" \\
+  https://your-panel.example.com/panel/api/inbounds/list`;
+
+async function loadApiToken() {
+  tokenLoading.value = true;
+  try {
+    const msg = await HttpUtil.get('/panel/setting/getApiToken');
+    if (msg?.success) apiToken.value = msg.obj || '';
+  } finally {
+    tokenLoading.value = false;
+  }
+}
+
+function regenerateApiToken() {
+  Modal.confirm({
+    title: t('pages.nodes.regenerateConfirm'),
+    okText: t('confirm'),
+    cancelText: t('cancel'),
+    okType: 'danger',
+    onOk: async () => {
+      tokenRotating.value = true;
+      try {
+        const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
+        if (msg?.success) {
+          apiToken.value = msg.obj || '';
+          message.success(t('success'));
+        }
+      } finally {
+        tokenRotating.value = false;
+      }
+    },
+  });
+}
+
+async function copyApiToken() {
+  if (!apiToken.value) return;
+  const ok = await ClipboardManager.copy(apiToken.value);
+  if (ok) message.success(t('success'));
+}
+
+function scrollToSection(id) {
+  const el = document.getElementById(id);
+  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+}
+
+onMounted(() => {
+  loadApiToken();
+});
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
+      <AppSidebar :base-path="basePath" :request-uri="requestUri" />
+
+      <a-layout class="content-shell">
+        <a-layout-content class="content-area">
+          <div class="docs-wrapper">
+            <header class="docs-header">
+              <h1 class="docs-title">API Documentation</h1>
+              <p class="docs-lead">
+                The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
+                cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
+                returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
+              </p>
+            </header>
+
+            <a-card class="token-card" size="small">
+              <div class="token-card-head">
+                <div class="token-card-title">
+                  <KeyOutlined />
+                  <span>API Token</span>
+                </div>
+                <a-space size="small" wrap>
+                  <a-button size="small" @click="tokenVisible = !tokenVisible">
+                    <template #icon>
+                      <EyeInvisibleOutlined v-if="tokenVisible" />
+                      <EyeOutlined v-else />
+                    </template>
+                    {{ tokenVisible ? 'Hide' : 'Show' }}
+                  </a-button>
+                  <a-button size="small" :disabled="!apiToken" @click="copyApiToken">
+                    <template #icon>
+                      <CopyOutlined />
+                    </template>
+                    Copy
+                  </a-button>
+                  <a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
+                    <template #icon>
+                      <ReloadOutlined />
+                    </template>
+                    Regenerate
+                  </a-button>
+                </a-space>
+              </div>
+              <a-spin :spinning="tokenLoading" size="small">
+                <pre
+                  class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
+              </a-spin>
+              <p class="token-hint">
+                Send it on every request as <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated
+                callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
+                running bots will need the new value.
+              </p>
+            </a-card>
+
+            <a-card class="curl-card" size="small" title="Quick example">
+              <pre class="code-block">{{ curlExample }}</pre>
+            </a-card>
+
+            <nav class="toc-nav">
+              <span class="toc-label">On this page:</span>
+              <a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
+                @click.prevent="scrollToSection(s.id)">
+                {{ s.title }}
+              </a>
+            </nav>
+
+            <EndpointSection v-for="s in sections" :key="s.id" :section="s" />
+          </div>
+        </a-layout-content>
+      </a-layout>
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.api-docs-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.api-docs-page.is-dark {
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
+}
+
+.api-docs-page.is-dark.is-ultra {
+  --bg-page: #000;
+  --bg-card: #0a0a0a;
+}
+
+.content-shell {
+  background: var(--bg-page);
+}
+
+.content-area {
+  padding: 24px;
+  max-width: 100%;
+}
+
+@media (max-width: 768px) {
+  .content-area {
+    padding: 16px 12px 12px;
+    padding-top: 64px;
+  }
+}
+
+.docs-wrapper {
+  max-width: 1100px;
+  margin: 0 auto;
+}
+
+.docs-header {
+  margin-bottom: 18px;
+}
+
+.docs-title {
+  font-size: 26px;
+  font-weight: 700;
+  margin: 0 0 8px;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.docs-lead {
+  margin: 0;
+  color: rgba(0, 0, 0, 0.65);
+  line-height: 1.6;
+  font-size: 14px;
+}
+
+.docs-lead code,
+.token-hint code {
+  background: rgba(128, 128, 128, 0.12);
+  padding: 1px 6px;
+  border-radius: 4px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 12.5px;
+}
+
+.token-card,
+.curl-card {
+  margin-bottom: 16px;
+}
+
+.token-card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-bottom: 8px;
+}
+
+.token-card-title {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  font-weight: 600;
+  font-size: 14px;
+}
+
+.token-value {
+  background: rgba(128, 128, 128, 0.08);
+  border: 1px solid rgba(128, 128, 128, 0.15);
+  border-radius: 6px;
+  padding: 10px 12px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 13px;
+  margin: 0;
+  word-break: break-all;
+  white-space: pre-wrap;
+}
+
+.token-hint {
+  margin: 10px 0 0;
+  color: rgba(0, 0, 0, 0.55);
+  font-size: 12.5px;
+  line-height: 1.55;
+}
+
+.code-block {
+  background: rgba(128, 128, 128, 0.08);
+  border: 1px solid rgba(128, 128, 128, 0.15);
+  border-radius: 6px;
+  padding: 10px 12px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 12.5px;
+  line-height: 1.55;
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  overflow-x: auto;
+}
+
+.toc-nav {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px 14px;
+  padding: 12px 16px;
+  background: rgba(128, 128, 128, 0.08);
+  border-radius: 6px;
+  margin-bottom: 16px;
+}
+
+.toc-label {
+  font-size: 12px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+  color: rgba(0, 0, 0, 0.5);
+}
+
+.toc-link {
+  color: #1677ff;
+  text-decoration: none;
+  cursor: pointer;
+  font-size: 13px;
+}
+
+.toc-link:hover {
+  color: #4096ff;
+  text-decoration: underline;
+}
+</style>
+
+<style>
+body.dark .docs-title {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+body.dark .docs-lead,
+body.dark .token-hint {
+  color: rgba(255, 255, 255, 0.7);
+}
+
+body.dark .docs-lead code,
+body.dark .token-hint code {
+  background: rgba(255, 255, 255, 0.1);
+}
+
+body.dark .token-value,
+body.dark .code-block {
+  background: rgba(255, 255, 255, 0.04);
+  border-color: rgba(255, 255, 255, 0.1);
+  color: rgba(255, 255, 255, 0.88);
+}
+
+body.dark .toc-nav {
+  background: rgba(255, 255, 255, 0.04);
+}
+
+body.dark .toc-label {
+  color: rgba(255, 255, 255, 0.55);
+}
+</style>

+ 128 - 0
frontend/src/pages/api-docs/EndpointRow.vue

@@ -0,0 +1,128 @@
+<script setup>
+import { computed } from 'vue';
+import { methodColors } from './endpoints.js';
+
+const props = defineProps({
+  endpoint: { type: Object, required: true },
+});
+
+const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
+const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
+
+const paramColumns = [
+  { title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
+  { title: 'In', dataIndex: 'in', key: 'in', width: 100 },
+  { title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
+  { title: 'Description', dataIndex: 'desc', key: 'desc' },
+];
+</script>
+
+<template>
+  <div class="endpoint-row">
+    <div class="endpoint-header">
+      <a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
+      <code class="endpoint-path">{{ endpoint.path }}</code>
+    </div>
+
+    <p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p>
+
+    <div v-if="hasParams" class="endpoint-block">
+      <div class="block-label">Parameters</div>
+      <a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
+    </div>
+
+    <div v-if="endpoint.body" class="endpoint-block">
+      <div class="block-label">Request body</div>
+      <a-typography-paragraph :copyable="{ text: endpoint.body }">
+        <pre class="code-block">{{ endpoint.body }}</pre>
+      </a-typography-paragraph>
+    </div>
+
+    <div v-if="endpoint.response" class="endpoint-block">
+      <div class="block-label">Response</div>
+      <a-typography-paragraph :copyable="{ text: endpoint.response }">
+        <pre class="code-block">{{ endpoint.response }}</pre>
+      </a-typography-paragraph>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.endpoint-row {
+  padding: 12px 0;
+}
+
+.endpoint-row + .endpoint-row {
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.endpoint-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.method-tag {
+  font-weight: 600;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  letter-spacing: 0.5px;
+  min-width: 60px;
+  text-align: center;
+}
+
+.endpoint-path {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 13px;
+  word-break: break-all;
+}
+
+.endpoint-summary {
+  margin: 8px 0 0;
+  color: rgba(0, 0, 0, 0.65);
+  line-height: 1.55;
+}
+
+.endpoint-block {
+  margin-top: 12px;
+}
+
+.block-label {
+  font-size: 12px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+  color: rgba(0, 0, 0, 0.5);
+  margin-bottom: 6px;
+}
+
+.code-block {
+  background: rgba(128, 128, 128, 0.08);
+  border: 1px solid rgba(128, 128, 128, 0.15);
+  border-radius: 6px;
+  padding: 10px 12px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 12.5px;
+  line-height: 1.55;
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  overflow-x: auto;
+}
+</style>
+
+<style>
+body.dark .endpoint-summary {
+  color: rgba(255, 255, 255, 0.7);
+}
+
+body.dark .block-label {
+  color: rgba(255, 255, 255, 0.55);
+}
+
+body.dark .code-block {
+  background: rgba(255, 255, 255, 0.04);
+  border-color: rgba(255, 255, 255, 0.1);
+  color: rgba(255, 255, 255, 0.88);
+}
+</style>

+ 65 - 0
frontend/src/pages/api-docs/EndpointSection.vue

@@ -0,0 +1,65 @@
+<script setup>
+import EndpointRow from './EndpointRow.vue';
+
+defineProps({
+  section: { type: Object, required: true },
+});
+</script>
+
+<template>
+  <section :id="section.id" class="api-section">
+    <h2 class="section-title">{{ section.title }}</h2>
+    <p v-if="section.description" class="section-description">{{ section.description }}</p>
+    <div class="endpoints">
+      <EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.api-section {
+  background: #fff;
+  border: 1px solid rgba(128, 128, 128, 0.15);
+  border-radius: 8px;
+  padding: 20px 24px;
+  margin-bottom: 20px;
+  scroll-margin-top: 16px;
+}
+
+.section-title {
+  font-size: 20px;
+  font-weight: 600;
+  margin: 0;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.section-description {
+  margin: 6px 0 14px;
+  color: rgba(0, 0, 0, 0.65);
+  line-height: 1.55;
+}
+
+.endpoints > :first-child {
+  padding-top: 0;
+}
+</style>
+
+<style>
+body.dark .api-section {
+  background: #252526;
+  border-color: rgba(255, 255, 255, 0.1);
+}
+
+html[data-theme='ultra-dark'] .api-section {
+  background: #0a0a0a;
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
+body.dark .section-title {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+body.dark .section-description {
+  color: rgba(255, 255, 255, 0.7);
+}
+</style>

+ 548 - 0
frontend/src/pages/api-docs/endpoints.js

@@ -0,0 +1,548 @@
+export const sections = [
+  {
+    id: 'auth',
+    title: 'Authentication',
+    description:
+      'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
+    endpoints: [
+      {
+        method: 'POST',
+        path: '/login',
+        summary: 'Authenticate with username + password and receive a session cookie. Required before any cookie-based API call.',
+        params: [
+          { name: 'username', in: 'body', type: 'string', desc: 'Panel admin username.' },
+          { name: 'password', in: 'body', type: 'string', desc: 'Panel admin password.' },
+          { name: 'twoFactorCode', in: 'body', type: 'string', desc: 'OTP code when 2FA is enabled. Omit otherwise.' },
+        ],
+        body: '{\n  "username": "admin",\n  "password": "admin",\n  "twoFactorCode": "123456"\n}',
+        response:
+          '{\n  "success": true,\n  "msg": "Logged in successfully"\n}',
+      },
+      {
+        method: 'GET',
+        path: '/logout',
+        summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
+      },
+      {
+        method: 'GET',
+        path: '/csrf-token',
+        summary: 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
+        response:
+          '{\n  "success": true,\n  "obj": "csrf-token-string"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/getTwoFactorEnable',
+        summary: 'Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field.',
+        response: '{\n  "success": true,\n  "obj": false\n}',
+      },
+    ],
+  },
+
+  {
+    id: 'inbounds',
+    title: 'Inbounds API',
+    description:
+      'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/list',
+        summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
+        response:
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "userId": 1,\n      "up": 0,\n      "down": 0,\n      "total": 0,\n      "remark": "VLESS-443",\n      "enable": true,\n      "expiryTime": 0,\n      "listen": "",\n      "port": 443,\n      "protocol": "vless",\n      "settings": "{\\"clients\\":[...]}",\n      "streamSettings": "{...}",\n      "tag": "inbound-443",\n      "sniffing": "{...}",\n      "clientStats": [...]\n    }\n  ]\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/get/:id',
+        summary: 'Fetch a single inbound by numeric ID.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+        ],
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/getClientTraffics/:email',
+        summary: 'Traffic counters for a client identified by email.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
+        ],
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/getClientTrafficsById/:id',
+        summary: 'Traffic counters for a client identified by its UUID/password.',
+        params: [
+          { name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/add',
+        summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
+        body:
+          '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n  "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n  "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/del/:id',
+        summary: 'Delete an inbound by ID. Also removes its associated client stats rows.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/update/:id',
+        summary: 'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/setEnable/:id',
+        summary: 'Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+        ],
+        body: '{\n  "enable": false\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/clientIps/:email',
+        summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/clearClientIps/:email',
+        summary: 'Reset the recorded IP list for a client.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/addClient',
+        summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
+        body:
+          '{\n  "id": 1,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/:id/copyClients',
+        summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
+          { name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
+          { name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
+          { name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/:id/delClient/:clientId',
+        summary: 'Delete a client by its UUID/password from a specific inbound.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+          { name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/updateClient/:clientId',
+        summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
+        params: [
+          { name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
+        ],
+        body:
+          '{\n  "id": 1,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
+        summary: 'Zero out upload + download counters for one client.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/resetAllTraffics',
+        summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/resetAllClientTraffics/:id',
+        summary: 'Reset traffic for every client in one inbound.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/delDepletedClients/:id',
+        summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/import',
+        summary: 'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
+        params: [
+          { name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/onlines',
+        summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
+        response: '{\n  "success": true,\n  "obj": ["user1", "user2"]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/lastOnline',
+        summary: 'Map of client email → last-seen unix timestamp.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/getSubLinks/:subId',
+        summary:
+          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
+        params: [
+          { name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?security=reality&...#user1",\n    "vmess://eyJ2IjoyLC..."\n  ]\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/getClientLinks/:id/:email',
+        summary:
+          "Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?...#user1"\n  ]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/updateClientTraffic/:email',
+        summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+        body: '{\n  "upload": 1073741824,\n  "download": 5368709120\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/:id/delClientByEmail/:email',
+        summary: 'Delete a client identified by email rather than UUID.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+    ],
+  },
+
+  {
+    id: 'server',
+    title: 'Server API',
+    description:
+      'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/server/status',
+        summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/cpuHistory/:bucket',
+        summary: 'Legacy: aggregated CPU history. Use /history/cpu/:bucket instead — same data with a uniform {t, v} shape.',
+        params: [
+          { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
+        ],
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/history/:metric/:bucket',
+        summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
+        params: [
+          { name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' },
+          { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
+        ],
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getXrayVersion',
+        summary: 'List Xray binary versions available for install on this host.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getPanelUpdateInfo',
+        summary: 'Check whether a newer 3x-ui release is available on GitHub.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getConfigJson',
+        summary: 'Return the assembled Xray config that’s currently running on this host.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getDb',
+        summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getNewUUID',
+        summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getNewX25519Cert',
+        summary: 'Generate a new X25519 keypair for Reality.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getNewmldsa65',
+        summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getNewmlkem768',
+        summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getNewVlessEnc',
+        summary: 'Generate a new VLESS encryption keypair.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/stopXrayService',
+        summary: 'Stop the Xray binary. All proxies go offline immediately.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/restartXrayService',
+        summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/installXray/:version',
+        summary: 'Download and install the specified Xray version. Pass "latest" for the newest release.',
+        params: [
+          { name: 'version', in: 'path', type: 'string', desc: 'Xray tag (e.g. v25.10.31) or "latest".' },
+        ],
+      },
+      {
+        method: 'POST',
+        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/updateGeofile',
+        summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/updateGeofile/:fileName',
+        summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
+        params: [
+          { name: 'fileName', in: 'path', type: 'string', desc: 'Filename of the data file to refresh.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/logs/:count',
+        summary: 'Return the last N lines of the panel’s own log.',
+        params: [
+          { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
+        ],
+        body: '{\n  "level": "info",\n  "syslog": false\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/xraylogs/:count',
+        summary: 'Return the last N lines of the Xray process log.',
+        params: [
+          { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/importDB',
+        summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/server/getNewEchCert',
+        summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.',
+      },
+    ],
+  },
+
+  {
+    id: 'nodes',
+    title: 'Nodes API',
+    description:
+      'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/nodes/list',
+        summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/nodes/get/:id',
+        summary: 'Fetch a single node by ID.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/add',
+        summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
+        body:
+          '{\n  "name": "de-fra-1",\n  "scheme": "https",\n  "host": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/update/:id',
+        summary: 'Replace a node’s connection details. Same body shape as /add.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/del/:id',
+        summary: 'Delete a node. Inbounds bound to it are not auto-migrated.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/setEnable/:id',
+        summary: 'Pause or resume traffic sync with this node.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+        ],
+        body: '{\n  "enable": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/test',
+        summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/probe/:id',
+        summary: 'Probe an existing node, updating its cached health state.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+        ],
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/nodes/history/:id/:metric/:bucket',
+        summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+          { name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
+          { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
+        ],
+      },
+    ],
+  },
+
+  {
+    id: 'customGeo',
+    title: 'Custom Geo API',
+    description:
+      'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/custom-geo/list',
+        summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/custom-geo/aliases',
+        summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/custom-geo/add',
+        summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
+        body:
+          '{\n  "type": "geoip",\n  "alias": "myips",\n  "url": "https://example.com/geo/my.dat"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/custom-geo/update/:id',
+        summary: 'Replace a custom geo source. Same body shape as /add.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/custom-geo/delete/:id',
+        summary: 'Remove a custom geo source and its cached file.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/custom-geo/download/:id',
+        summary: 'Re-download one custom geo source on demand.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/custom-geo/update-all',
+        summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
+      },
+    ],
+  },
+
+  {
+    id: 'backup',
+    title: 'Backup',
+    description: 'Operations that interact with the configured Telegram bot.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/backuptotgbot',
+        summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
+      },
+    ],
+  },
+];
+
+export const methodColors = {
+  GET: 'blue',
+  POST: 'green',
+  PUT: 'orange',
+  PATCH: 'orange',
+  DELETE: 'red',
+};

+ 41 - 21
frontend/src/pages/login/LoginPage.vue

@@ -18,7 +18,6 @@ const { t } = useI18n();
 const fetched = ref(false);
 const submitting = ref(false);
 const twoFactorEnable = ref(false);
-const version = computed(() => window.X_UI_CUR_VER || '');
 
 const user = reactive({
   username: '',
@@ -93,7 +92,8 @@ function cycleTheme() {
             <svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
               stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
               <circle cx="12" cy="12" r="4" />
-              <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+              <path
+                d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
             </svg>
             <svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
               stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -177,7 +177,6 @@ function cycleTheme() {
               </a-form-item>
             </a-form>
 
-            <div v-if="version" class="version">v{{ version }}</div>
           </div>
         </div>
       </a-layout-content>
@@ -277,24 +276,49 @@ function cycleTheme() {
 }
 
 @keyframes blob-drift-a {
-  0%   { transform: translate(0, 0) scale(1); }
-  50%  { transform: translate(18vw, 10vh) scale(1.15); }
-  100% { transform: translate(34vw, 22vh) scale(1.25); }
+  0% {
+    transform: translate(0, 0) scale(1);
+  }
+
+  50% {
+    transform: translate(18vw, 10vh) scale(1.15);
+  }
+
+  100% {
+    transform: translate(34vw, 22vh) scale(1.25);
+  }
 }
 
 @keyframes blob-drift-b {
-  0%   { transform: translate(0, 0) scale(1); }
-  50%  { transform: translate(-16vw, -10vh) scale(1.12); }
-  100% { transform: translate(-30vw, -22vh) scale(1.2); }
+  0% {
+    transform: translate(0, 0) scale(1);
+  }
+
+  50% {
+    transform: translate(-16vw, -10vh) scale(1.12);
+  }
+
+  100% {
+    transform: translate(-30vw, -22vh) scale(1.2);
+  }
 }
 
 @keyframes blob-drift-c {
-  0%   { transform: translate(-50%, -50%) scale(1); }
-  50%  { transform: translate(-20%, -20%) scale(1.1); }
-  100% { transform: translate(-80%, -10%) scale(1.05); }
+  0% {
+    transform: translate(-50%, -50%) scale(1);
+  }
+
+  50% {
+    transform: translate(-20%, -20%) scale(1.1);
+  }
+
+  100% {
+    transform: translate(-80%, -10%) scale(1.05);
+  }
 }
 
 @media (prefers-reduced-motion: reduce) {
+
   .login-app::before,
   .login-app::after,
   .login-content::before {
@@ -310,7 +334,7 @@ function cycleTheme() {
   position: relative;
 }
 
-.login-content > * {
+.login-content>* {
   position: relative;
   z-index: 1;
 }
@@ -347,7 +371,8 @@ function cycleTheme() {
 
 .theme-cycle:hover,
 .theme-cycle:focus-visible {
-  color: var(--color-accent);
+  background-color: rgba(64, 150, 255, 0.1);
+  color: #4096ff;
   transform: scale(1.05);
   outline: none;
 }
@@ -428,10 +453,12 @@ function cycleTheme() {
 .headline-leave-active {
   transition: opacity 280ms ease, transform 280ms ease;
 }
+
 .headline-enter-from {
   opacity: 0;
   transform: translateY(6px);
 }
+
 .headline-leave-to {
   opacity: 0;
   transform: translateY(-6px);
@@ -446,13 +473,6 @@ function cycleTheme() {
   margin-bottom: 0;
 }
 
-.version {
-  text-align: center;
-  font-size: 12px;
-  color: var(--color-text-subtle);
-  margin-top: 16px;
-}
-
 .settings-popover {
   min-width: 220px;
 }

+ 5 - 2
frontend/src/pages/sub/SubPage.vue

@@ -164,7 +164,8 @@ const themeClass = computed(() => ({
                     <svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                       stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                       <circle cx="12" cy="12" r="4" />
-                      <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+                      <path
+                        d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
                     </svg>
                     <svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                       stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -503,7 +504,8 @@ const themeClass = computed(() => ({
 
 .theme-cycle:hover,
 .theme-cycle:focus-visible {
-  color: #1677ff;
+  background-color: rgba(64, 150, 255, 0.1);
+  color: #4096ff;
   transform: scale(1.05);
   outline: none;
 }
@@ -520,6 +522,7 @@ const themeClass = computed(() => ({
 
 .is-dark .theme-cycle:hover,
 .is-dark .theme-cycle:focus-visible {
+  background-color: rgba(64, 150, 255, 0.1);
   color: #4096ff;
 }
 

+ 3 - 0
frontend/vite.config.js

@@ -26,6 +26,8 @@ const BASE_MIGRATED_ROUTES = {
   'panel/xray/': '/xray.html',
   'panel/nodes': '/nodes.html',
   'panel/nodes/': '/nodes.html',
+  'panel/api-docs': '/api-docs.html',
+  'panel/api-docs/': '/api-docs.html',
 };
 
 let cachedBasePath = '/';
@@ -150,6 +152,7 @@ export default defineConfig({
         inbounds: path.resolve(__dirname, 'inbounds.html'),
         xray: path.resolve(__dirname, 'xray.html'),
         nodes: path.resolve(__dirname, 'nodes.html'),
+        apiDocs: path.resolve(__dirname, 'api-docs.html'),
         subpage: path.resolve(__dirname, 'subpage.html'),
       },
       output: {

+ 3 - 0
main.go

@@ -61,6 +61,8 @@ func runWebServer() {
 	}
 
 	var subServer *sub.Server
+	sub.SetDistFS(web.EmbeddedDist())
+	service.RegisterSubLinkProvider(sub.NewLinkProvider())
 	subServer = sub.NewServer()
 	global.SetSubServer(subServer)
 	err = subServer.Start()
@@ -101,6 +103,7 @@ func runWebServer() {
 			}
 			log.Println("Web server restarted successfully.")
 
+			sub.SetDistFS(web.EmbeddedDist())
 			subServer = sub.NewServer()
 			global.SetSubServer(subServer)
 			err = subServer.Start()

+ 16 - 0
sub/dist.go

@@ -0,0 +1,16 @@
+package sub
+
+import "embed"
+
+// distFS holds the Vite-built frontend filesystem, injected from main at
+// startup. The `web` package owns the //go:embed directive (because dist/
+// is at web/dist/), and hands the FS over via SetDistFS so the sub package
+// doesn't import web — that would create an import cycle once any
+// web/controller handler reuses sub's link-building service.
+var distFS embed.FS
+
+// SetDistFS installs the embedded frontend filesystem the sub server uses
+// for its info page assets. Must be called before NewServer().Start().
+func SetDistFS(fs embed.FS) {
+	distFS = fs
+}

+ 59 - 0
sub/links.go

@@ -0,0 +1,59 @@
+package sub
+
+import (
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+)
+
+type LinkProvider struct {
+	settingService service.SettingService
+}
+
+func NewLinkProvider() *LinkProvider {
+	return &LinkProvider{}
+}
+
+func (p *LinkProvider) build(host string) *SubService {
+	showInfo, _ := p.settingService.GetSubShowInfo()
+	rModel, err := p.settingService.GetRemarkModel()
+	if err != nil {
+		rModel = "-ieo"
+	}
+	svc := NewSubService(showInfo, rModel)
+	svc.PrepareForRequest(host)
+	return svc
+}
+
+func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
+	svc := p.build(host)
+	links, _, _, err := svc.GetSubs(subId, host)
+	if err != nil {
+		return nil, err
+	}
+	out := make([]string, 0, len(links))
+	for _, l := range links {
+		out = append(out, splitLinkLines(l)...)
+	}
+	return out, nil
+}
+
+func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
+	svc := p.build(host)
+	return splitLinkLines(svc.GetLink(inbound, email))
+}
+
+func splitLinkLines(raw string) []string {
+	if raw == "" {
+		return nil
+	}
+	parts := strings.Split(raw, "\n")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		if p = strings.TrimSpace(p); p != "" {
+			out = append(out, p)
+		}
+	}
+	return out
+}

+ 5 - 3
sub/sub.go

@@ -12,10 +12,10 @@ import (
 	"os"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
-	webpkg "github.com/mhsanaei/3x-ui/v3/web"
 	"github.com/mhsanaei/3x-ui/v3/web/locale"
 	"github.com/mhsanaei/3x-ui/v3/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/web/network"
@@ -188,7 +188,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	var assetsFS http.FileSystem
 	if _, err := os.Stat("web/dist/assets"); err == nil {
 		assetsFS = http.FS(os.DirFS("web/dist/assets"))
-	} else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil {
+	} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
 		assetsFS = http.FS(subFS)
 	} else {
 		logger.Error("sub: failed to mount embedded dist assets:", err)
@@ -313,7 +313,9 @@ func (s *Server) Stop() error {
 	var err1 error
 	var err2 error
 	if s.httpServer != nil {
-		err1 = s.httpServer.Shutdown(s.ctx)
+		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer shutdownCancel()
+		err1 = s.httpServer.Shutdown(shutdownCtx)
 	}
 	if s.listener != nil {
 		err2 = s.listener.Close()

+ 1 - 3
sub/subController.go

@@ -10,7 +10,6 @@ import (
 	"strconv"
 	"strings"
 
-	webpkg "github.com/mhsanaei/3x-ui/v3/web"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
@@ -159,8 +158,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
 	if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
 		body = diskBody
 	} else {
-		dist := webpkg.EmbeddedDist()
-		readBody, err := dist.ReadFile("dist/subpage.html")
+		readBody, err := distFS.ReadFile("dist/subpage.html")
 		if err != nil {
 			c.String(http.StatusInternalServerError, "missing embedded subpage")
 			return

+ 6 - 2
sub/subService.go

@@ -98,7 +98,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 				if client.Enable {
 					hasEnabledClient = true
 				}
-				result = append(result, s.getLink(inbound, client.Email))
+				result = append(result, s.GetLink(inbound, client.Email))
 				var ct xray.ClientTraffic
 				ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
 				if ct.LastOnline > lastOnline {
@@ -198,7 +198,11 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
 	return inbound.Listen, inbound.Port, string(modifiedStream), nil
 }
 
-func (s *SubService) getLink(inbound *model.Inbound, email string) string {
+// GetLink dispatches to the protocol-specific generator for one (inbound, client)
+// pair. Returns "" when the inbound's protocol doesn't produce a subscription URL
+// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may
+// contain multiple `\n`-separated URLs when the inbound has externalProxy set.
+func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
 	switch inbound.Protocol {
 	case "vmess":
 		return s.genVmessLink(inbound, email)

+ 7 - 3
web/controller/dist.go

@@ -50,7 +50,6 @@ func serveDistPage(c *gin.Context, name string) {
 		"&", `&`,
 	)
 	escapedBase := jsEscape.Replace(basePath)
-	escapedVer := jsEscape.Replace(config.GetVersion())
 	csrfToken, err := session.EnsureCSRFToken(c)
 	if err != nil {
 		logger.Warning("Unable to mint CSRF token for", name+":", err)
@@ -58,8 +57,13 @@ func serveDistPage(c *gin.Context, name string) {
 	}
 	csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
 
-	inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase +
-		`";window.X_UI_CUR_VER="` + escapedVer + `";</script>`)
+	script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
+	if name != "login.html" {
+		escapedVer := jsEscape.Replace(config.GetVersion())
+		script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
+	}
+	script += `;</script>`
+	inject := []byte(script)
 	inject = append(inject, csrfMeta...)
 	inject = append(inject, []byte(`</head>`)...)
 	out := bytes.Replace(body, []byte("</head>"), inject, 1)

+ 56 - 0
web/controller/inbound.go

@@ -3,7 +3,9 @@ package controller
 import (
 	"encoding/json"
 	"fmt"
+	"net"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -62,6 +64,8 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.GET("/get/:id", a.getInbound)
 	g.GET("/getClientTraffics/:email", a.getClientTraffics)
 	g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
+	g.GET("/getSubLinks/:subId", a.getSubLinks)
+	g.GET("/getClientLinks/:id/:email", a.getClientLinks)
 
 	g.POST("/add", a.addInbound)
 	g.POST("/del/:id", a.delInbound)
@@ -571,3 +575,55 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
 		a.xrayService.SetToNeedRestart()
 	}
 }
+
+// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
+// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
+// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
+// controller layer means the service interface stays HTTP-agnostic — service
+// methods receive a plain host string instead of a *gin.Context.
+func resolveHost(c *gin.Context) string {
+	if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
+		if i := strings.Index(h, ","); i >= 0 {
+			h = strings.TrimSpace(h[:i])
+		}
+		if hp, _, err := net.SplitHostPort(h); err == nil {
+			return hp
+		}
+		return h
+	}
+	if h := c.GetHeader("X-Real-IP"); h != "" {
+		return h
+	}
+	if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
+		return h
+	}
+	return c.Request.Host
+}
+
+// getSubLinks returns every protocol URL produced for the given subscription
+// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
+func (a *InboundController) getSubLinks(c *gin.Context) {
+	links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}
+
+// getClientLinks returns the URL(s) for one client on one inbound — the same
+// string the Copy URL button copies in the panel UI. Empty array when the
+// protocol has no URL form, or when the email isn't found on the inbound.
+func (a *InboundController) getClientLinks(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}

+ 6 - 0
web/controller/xui.go

@@ -36,6 +36,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
 	g.GET("/nodes", a.nodes)
 	g.GET("/settings", a.settings)
 	g.GET("/xray", a.xraySettings)
+	g.GET("/api-docs", a.apiDocs)
 
 	// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
 	// so they fetch the session token via this endpoint at startup and replay it
@@ -76,6 +77,11 @@ func (a *XUIController) xraySettings(c *gin.Context) {
 	serveDistPage(c, "xray.html")
 }
 
+// apiDocs renders the in-panel API documentation page.
+func (a *XUIController) apiDocs(c *gin.Context) {
+	serveDistPage(c, "api-docs.html")
+}
+
 // 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.

+ 30 - 9
web/service/inbound.go

@@ -291,12 +291,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		return inbound, false, common.NewError("Port already exists:", inbound.Port)
 	}
 
-	// pick a tag that won't collide with an existing row. for the common
-	// case this is the same "inbound-<port>" string the controller already
-	// set; only when this port already has another inbound on a different
-	// transport (now possible after the transport-aware port check) does
-	// this disambiguate with a -tcp/-udp suffix. see #4103.
-	inbound.Tag, err = s.generateInboundTag(inbound, 0)
+	inbound.Tag, err = s.resolveInboundTag(inbound, 0)
 	if err != nil {
 		return inbound, false, err
 	}
@@ -636,9 +631,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	oldInbound.Settings = inbound.Settings
 	oldInbound.StreamSettings = inbound.StreamSettings
 	oldInbound.Sniffing = inbound.Sniffing
-	// regenerate tag with collision-aware logic. for this row we pass
-	// inbound.Id as ignoreId so it doesn't see its own old tag in the db.
-	oldInbound.Tag, err = s.generateInboundTag(inbound, inbound.Id)
+	oldInbound.Tag, err = s.resolveInboundTag(inbound, inbound.Id)
 	if err != nil {
 		return inbound, false, err
 	}
@@ -3873,3 +3866,31 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
 
 	return needRestart, db.Save(oldInbound).Error
 }
+
+type SubLinkProvider interface {
+	SubLinksForSubId(host, subId string) ([]string, error)
+	LinksForClient(host string, inbound *model.Inbound, email string) []string
+}
+
+var registeredSubLinkProvider SubLinkProvider
+
+func RegisterSubLinkProvider(p SubLinkProvider) {
+	registeredSubLinkProvider = p
+}
+
+func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) {
+	if registeredSubLinkProvider == nil {
+		return nil, common.NewError("sub link provider not registered")
+	}
+	return registeredSubLinkProvider.SubLinksForSubId(host, subId)
+}
+func (s *InboundService) GetClientLinks(host string, id int, email string) ([]string, error) {
+	inbound, err := s.GetInbound(id)
+	if err != nil {
+		return nil, err
+	}
+	if registeredSubLinkProvider == nil {
+		return nil, common.NewError("sub link provider not registered")
+	}
+	return registeredSubLinkProvider.LinksForClient(host, inbound, email), nil
+}

+ 49 - 4
web/service/port_conflict.go

@@ -118,15 +118,16 @@ func isAnyListen(s string) bool {
 // port-only check, this one understands that tcp/443 and udp/443 are
 // independent sockets in linux and may coexist on the same address.
 //
+// node scope: inbounds with different NodeID run on different physical
+// machines (local panel xray vs a remote node, or two remote nodes),
+// so their sockets can't collide. only candidates with the same NodeID
+// participate in the listen/transport overlap check.
+//
 // the listen-overlap rule (specific addr conflicts with any-addr on the
 // same port, both directions) is preserved from the previous check.
 func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (bool, error) {
 	db := database.GetDB()
 
-	// pull every candidate on this port; we filter by listen-overlap and
-	// transport in go to keep the sql plain. the port column is indexed
-	// in practice by the existing port check, and the candidate set is
-	// tiny (one per coexisting socket family at most).
 	var candidates []*model.Inbound
 	q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
 	if ignoreId > 0 {
@@ -138,6 +139,9 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
 
 	newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
 	for _, c := range candidates {
+		if !sameNode(c.NodeID, inbound.NodeID) {
+			continue
+		}
 		if !listenOverlaps(c.Listen, inbound.Listen) {
 			continue
 		}
@@ -148,6 +152,21 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
 	return false, nil
 }
 
+// sameNode reports whether two NodeID pointers refer to the same xray
+// process. nil/nil means both inbounds run on the local panel; non-nil
+// with equal value means they share the same remote node. any mix
+// (local vs remote, remote-A vs remote-B) is "different node" and
+// can't produce a real socket collision.
+func sameNode(a, b *int) bool {
+	if a == nil && b == nil {
+		return true
+	}
+	if a == nil || b == nil {
+		return false
+	}
+	return *a == *b
+}
+
 // baseInboundTag is the historical "inbound-<port>" / "inbound-<listen>:<port>"
 // shape. kept exactly so existing routing rules that reference these tags
 // keep working after the upgrade.
@@ -220,6 +239,32 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
 	return "", common.NewError("could not pick a unique inbound tag for port:", inbound.Port)
 }
 
+// resolveInboundTag chooses a tag for an Add or Update. when the caller
+// supplied a non-empty Tag (e.g. the central panel pushed its picked
+// tag to a node during a multi-node sync) and that tag is free in the
+// local DB, it's used verbatim so the two panels stay in agreement —
+// otherwise the node would regenerate (often back to bare
+// "inbound-<port>") and the eventual traffic sync-back would try to
+// INSERT a row whose tag already exists, hitting the UNIQUE constraint
+// on inbounds.tag and rolling the node-side row right back out.
+// when Tag is empty (the common UI path) or collides, fall back to the
+// transport-aware generateInboundTag.
+//
+// ignoreId mirrors generateInboundTag: pass 0 on add, the inbound's
+// own id on update so a row doesn't see its own current tag as taken.
+func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
+	if inbound.Tag != "" {
+		taken, err := s.tagExists(inbound.Tag, ignoreId)
+		if err != nil {
+			return "", err
+		}
+		if !taken {
+			return inbound.Tag, nil
+		}
+	}
+	return s.generateInboundTag(inbound, ignoreId)
+}
+
 func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
 	db := database.GetDB()
 	q := db.Model(model.Inbound{}).Where("tag = ?", tag)

+ 125 - 0
web/service/port_conflict_test.go

@@ -35,6 +35,11 @@ func setupConflictDB(t *testing.T) {
 }
 
 func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string) {
+	t.Helper()
+	seedInboundConflictNode(t, tag, listen, port, protocol, streamSettings, settings, nil)
+}
+
+func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string, nodeID *int) {
 	t.Helper()
 	in := &model.Inbound{
 		Tag:            tag,
@@ -44,12 +49,15 @@ func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol mo
 		Protocol:       protocol,
 		StreamSettings: streamSettings,
 		Settings:       settings,
+		NodeID:         nodeID,
 	}
 	if err := database.GetDB().Create(in).Error; err != nil {
 		t.Fatalf("seed inbound %s: %v", tag, err)
 	}
 }
 
+func intPtr(v int) *int { return &v }
+
 func TestInboundTransports(t *testing.T) {
 	cases := []struct {
 		name           string
@@ -345,6 +353,123 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
 	}
 }
 
+// inbounds bound to different nodes run on different physical machines,
+// so the same port + transport must be allowed across nodes. covers
+// local-vs-remote, remote-A-vs-remote-B, and the still-clashing
+// same-node case.
+func TestCheckPortConflict_NodeScope(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
+	seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, intPtr(1))
+
+	svc := &InboundService{}
+
+	cases := []struct {
+		name   string
+		nodeID *int
+		want   bool
+	}{
+		{"new local same port + tcp clashes with local", nil, true},
+		{"new remote on different node from local is fine", intPtr(2), false},
+		{"new remote on existing node 1 clashes", intPtr(1), true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			candidate := &model.Inbound{
+				Listen:         "0.0.0.0",
+				Port:           443,
+				Protocol:       model.VLESS,
+				StreamSettings: `{"network":"tcp"}`,
+				NodeID:         c.nodeID,
+			}
+			got, err := svc.checkPortConflict(candidate, 0)
+			if err != nil {
+				t.Fatalf("checkPortConflict: %v", err)
+			}
+			if got != c.want {
+				t.Fatalf("got conflict=%v, want %v", got, c.want)
+			}
+		})
+	}
+}
+
+// when the caller passes an explicit non-empty Tag that doesn't collide,
+// resolveInboundTag returns it verbatim. this is the cross-panel path:
+// the central panel picks a tag, pushes the inbound to a node, and the
+// node must keep that exact tag so the eventual traffic sync-back can
+// match the row by tag. previously the node regenerated and the two
+// panels diverged, causing a UNIQUE constraint failure on sync.
+func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflictNode(t, "inbound-5000", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
+	seedInboundConflictNode(t, "inbound-5000-udp", "0.0.0.0", 5000, model.Hysteria2, ``, ``, nil)
+
+	svc := &InboundService{}
+	pushed := &model.Inbound{
+		Tag:            "inbound-5000-tcp",
+		Listen:         "0.0.0.0",
+		Port:           5000,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+		NodeID:         intPtr(1),
+	}
+	got, err := svc.resolveInboundTag(pushed, 0)
+	if err != nil {
+		t.Fatalf("resolveInboundTag: %v", err)
+	}
+	if got != "inbound-5000-tcp" {
+		t.Fatalf("caller tag must be preserved when free, got %q", got)
+	}
+}
+
+// when the caller leaves Tag empty (the local UI path) resolveInboundTag
+// falls back to generateInboundTag, which keeps the historical
+// "inbound-<port>" shape so existing routing rules don't change.
+func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	in := &model.Inbound{
+		Listen:   "0.0.0.0",
+		Port:     8443,
+		Protocol: model.VLESS,
+	}
+	got, err := svc.resolveInboundTag(in, 0)
+	if err != nil {
+		t.Fatalf("resolveInboundTag: %v", err)
+	}
+	if got != "inbound-8443" {
+		t.Fatalf("expected generated inbound-8443, got %q", got)
+	}
+}
+
+// when the caller's Tag collides (e.g. a node that was used standalone
+// happens to already own the tag the central panel picked),
+// resolveInboundTag falls back to generateInboundTag rather than
+// failing — the inbound still lands, just under a slightly different
+// tag that the central will pick up via the AddInbound response.
+func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflictNode(t, "inbound-5000-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
+
+	svc := &InboundService{}
+	pushed := &model.Inbound{
+		Tag:            "inbound-5000-tcp",
+		Listen:         "0.0.0.0",
+		Port:           5000,
+		Protocol:       model.Hysteria2,
+		StreamSettings: ``,
+		Settings:       ``,
+	}
+	got, err := svc.resolveInboundTag(pushed, 0)
+	if err != nil {
+		t.Fatalf("resolveInboundTag: %v", err)
+	}
+	if got == "inbound-5000-tcp" {
+		t.Fatalf("colliding caller tag must be replaced, but resolver kept %q", got)
+	}
+}
+
 // updating an inbound must not see itself as a conflict, that's what
 // ignoreId is for.
 func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {

+ 47 - 18
web/service/traffic_writer.go

@@ -21,39 +21,64 @@ type trafficWriteRequest struct {
 }
 
 var (
+	twMu     sync.Mutex
 	twQueue  chan *trafficWriteRequest
-	twCtx    context.Context
 	twCancel context.CancelFunc
 	twDone   chan struct{}
-	twOnce   sync.Once
 )
 
+// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
+// after StopTrafficWriter — each Start/Stop cycle gets fresh channels. The
+// previous sync.Once-based implementation deadlocked after a SIGHUP-driven
+// panel restart: Stop killed the consumer goroutine but Once prevented Start
+// from spawning a new one, so every later submitTrafficWrite blocked forever
+// on <-req.done with no consumer (including the AddTraffic call inside
+// XrayService.GetXrayConfig that runs from startTask).
 func StartTrafficWriter() {
-	twOnce.Do(func() {
-		twQueue = make(chan *trafficWriteRequest, trafficWriterQueueSize)
-		twCtx, twCancel = context.WithCancel(context.Background())
-		twDone = make(chan struct{})
-		go runTrafficWriter()
-	})
+	twMu.Lock()
+	defer twMu.Unlock()
+	if twQueue != nil {
+		return
+	}
+	queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
+	ctx, cancel := context.WithCancel(context.Background())
+	done := make(chan struct{})
+	twQueue = queue
+	twCancel = cancel
+	twDone = done
+	go runTrafficWriter(queue, ctx, done)
 }
 
+// StopTrafficWriter cancels the writer context and waits for the goroutine to
+// drain any pending requests before returning. Resets the package state so a
+// subsequent StartTrafficWriter can spawn a fresh consumer.
 func StopTrafficWriter() {
-	if twCancel != nil {
-		twCancel()
-		<-twDone
+	twMu.Lock()
+	cancel := twCancel
+	done := twDone
+	twQueue = nil
+	twCancel = nil
+	twDone = nil
+	twMu.Unlock()
+
+	if cancel != nil {
+		cancel()
+	}
+	if done != nil {
+		<-done
 	}
 }
 
-func runTrafficWriter() {
-	defer close(twDone)
+func runTrafficWriter(queue chan *trafficWriteRequest, ctx context.Context, done chan struct{}) {
+	defer close(done)
 	for {
 		select {
-		case req := <-twQueue:
+		case req := <-queue:
 			req.done <- safeApply(req.apply)
-		case <-twCtx.Done():
+		case <-ctx.Done():
 			for {
 				select {
-				case req := <-twQueue:
+				case req := <-queue:
 					req.done <- safeApply(req.apply)
 				default:
 					return
@@ -74,12 +99,16 @@ func safeApply(fn func() error) (err error) {
 }
 
 func submitTrafficWrite(fn func() error) error {
-	if twQueue == nil {
+	twMu.Lock()
+	queue := twQueue
+	twMu.Unlock()
+
+	if queue == nil {
 		return safeApply(fn)
 	}
 	req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
 	select {
-	case twQueue <- req:
+	case queue <- req:
 	case <-time.After(trafficWriterSubmitTimeout):
 		return errors.New("traffic writer queue full")
 	}

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

@@ -97,6 +97,7 @@
     "nodes": "النودز",
     "settings": "إعدادات البانل",
     "xray": "إعدادات Xray",
+    "apiDocs": "API Docs",
     "logout": "تسجيل خروج",
     "link": "إدارة"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "Nodes",
     "settings": "Panel Settings",
     "xray": "Xray Configs",
+    "apiDocs": "API Docs",
     "logout": "Log Out",
     "link": "Manage"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "Nodos",
     "settings": "Configuraciones",
     "xray": "Ajustes Xray",
+    "apiDocs": "API Docs",
     "logout": "Cerrar Sesión",
     "link": "Gestionar"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "نودها",
     "settings": "تنظیمات پنل",
     "xray": "پیکربندی ایکس‌ری",
+    "apiDocs": "API Docs",
     "logout": "خروج",
     "link": "مدیریت"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "Node",
     "settings": "Pengaturan Panel",
     "xray": "Konfigurasi Xray",
+    "apiDocs": "API Docs",
     "logout": "Keluar",
     "link": "Kelola"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "ノード",
     "settings": "パネル設定",
     "xray": "Xray設定",
+    "apiDocs": "API Docs",
     "logout": "ログアウト",
     "link": "リンク管理"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "Nós",
     "settings": "Panel Settings",
     "xray": "Xray Configs",
+    "apiDocs": "API Docs",
     "logout": "Sair",
     "link": "Gerenciar"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "Узлы",
     "settings": "Настройки",
     "xray": "Настройки Xray",
+    "apiDocs": "API Docs",
     "logout": "Выход",
     "link": "Управление"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "Düğümler",
     "settings": "Panel Ayarları",
     "xray": "Xray Yapılandırmaları",
+    "apiDocs": "API Docs",
     "logout": "Çıkış Yap",
     "link": "Yönet"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "Вузли",
     "settings": "Параметри панелі",
     "xray": "Конфігурації Xray",
+    "apiDocs": "API Docs",
     "logout": "Вийти",
     "link": "Керувати"
   },

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

@@ -98,6 +98,7 @@
     "settings": "Cài đặt bảng điều khiển",
     "logout": "Đăng xuất",
     "xray": "Cài đặt Xray",
+    "apiDocs": "API Docs",
     "link": "Quản lý"
   },
   "pages": {

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

@@ -97,6 +97,7 @@
     "nodes": "节点",
     "settings": "面板设置",
     "xray": "Xray 设置",
+    "apiDocs": "API Docs",
     "logout": "退出登录",
     "link": "管理"
   },

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

@@ -97,6 +97,7 @@
     "nodes": "節點",
     "settings": "面板設定",
     "xray": "Xray 設定",
+    "apiDocs": "API Docs",
     "logout": "退出登入",
     "link": "管理"
   },

+ 3 - 1
web/web.go

@@ -456,7 +456,9 @@ func (s *Server) Stop() error {
 	var err1 error
 	var err2 error
 	if s.httpServer != nil {
-		err1 = s.httpServer.Shutdown(s.ctx)
+		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer shutdownCancel()
+		err1 = s.httpServer.Shutdown(shutdownCtx)
 	}
 	if s.listener != nil {
 		err2 = s.listener.Close()

+ 96 - 22
xray/process.go

@@ -10,6 +10,7 @@ import (
 	"runtime"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"syscall"
 	"time"
 
@@ -120,7 +121,8 @@ func NewTestProcess(xrayConfig *Config, configPath string) *Process {
 }
 
 type process struct {
-	cmd *exec.Cmd
+	cmd  *exec.Cmd
+	done chan struct{}
 
 	version string
 	apiPort int
@@ -139,8 +141,15 @@ type process struct {
 	logWriter  *LogWriter
 	exitErr    error
 	startTime  time.Time
+
+	intentionalStop atomic.Bool
 }
 
+var (
+	xrayGracefulStopTimeout = 5 * time.Second
+	xrayForceStopTimeout    = 2 * time.Second
+)
+
 // newProcess creates a new internal process struct for Xray.
 func newProcess(config *Config) *process {
 	return &process{
@@ -163,6 +172,13 @@ func (p *process) IsRunning() bool {
 	if p.cmd == nil || p.cmd.Process == nil {
 		return false
 	}
+	if p.done != nil {
+		select {
+		case <-p.done:
+			return false
+		default:
+		}
+	}
 	if p.cmd.ProcessState == nil {
 		return true
 	}
@@ -326,27 +342,13 @@ func (p *process) Start() (err error) {
 	}
 
 	cmd := exec.Command(GetBinaryPath(), "-c", configPath)
-	p.cmd = cmd
-
 	cmd.Stdout = p.logWriter
 	cmd.Stderr = p.logWriter
 
-	go func() {
-		err := cmd.Run()
-		if err != nil {
-			// On Windows, killing the process results in "exit status 1" which isn't an error for us
-			if runtime.GOOS == "windows" {
-				errStr := strings.ToLower(err.Error())
-				if strings.Contains(errStr, "exit status 1") {
-					// Suppress noisy log on graceful stop
-					p.exitErr = err
-					return
-				}
-			}
-			logger.Error("Failure in running xray-core:", err)
-			p.exitErr = err
-		}
-	}()
+	err = p.startCommand(cmd)
+	if err != nil {
+		return err
+	}
 
 	p.refreshVersion()
 	p.refreshAPIPort()
@@ -354,11 +356,49 @@ func (p *process) Start() (err error) {
 	return nil
 }
 
+func (p *process) startCommand(cmd *exec.Cmd) error {
+	p.cmd = cmd
+	p.done = make(chan struct{})
+	p.exitErr = nil
+	p.intentionalStop.Store(false)
+
+	if err := cmd.Start(); err != nil {
+		close(p.done)
+		p.cmd = nil
+		return err
+	}
+
+	go p.waitForCommand(cmd)
+	return nil
+}
+
+func (p *process) waitForCommand(cmd *exec.Cmd) {
+	defer close(p.done)
+
+	err := cmd.Wait()
+	if err == nil || p.intentionalStop.Load() {
+		return
+	}
+
+	// On Windows, killing the process results in "exit status 1" which isn't an error for us.
+	if runtime.GOOS == "windows" {
+		errStr := strings.ToLower(err.Error())
+		if strings.Contains(errStr, "exit status 1") {
+			p.exitErr = err
+			return
+		}
+	}
+
+	logger.Error("Failure in running xray-core:", err)
+	p.exitErr = err
+}
+
 // Stop terminates the running Xray process.
 func (p *process) Stop() error {
 	if !p.IsRunning() {
 		return errors.New("xray is not running")
 	}
+	p.intentionalStop.Store(true)
 
 	// Remove temporary config file used for test runs so main config is never touched
 	if p.configPath != "" {
@@ -371,9 +411,43 @@ func (p *process) Stop() error {
 	}
 
 	if runtime.GOOS == "windows" {
-		return p.cmd.Process.Kill()
-	} else {
-		return p.cmd.Process.Signal(syscall.SIGTERM)
+		if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+			return err
+		}
+		return p.waitForExit(xrayForceStopTimeout)
+	}
+
+	if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
+		if errors.Is(err, os.ErrProcessDone) {
+			return p.waitForExit(xrayForceStopTimeout)
+		}
+		return err
+	}
+
+	if err := p.waitForExit(xrayGracefulStopTimeout); err == nil {
+		return nil
+	}
+
+	logger.Warning("xray-core did not stop after SIGTERM, killing process")
+	if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+		return err
+	}
+	return p.waitForExit(xrayForceStopTimeout)
+}
+
+func (p *process) waitForExit(timeout time.Duration) error {
+	if p.done == nil {
+		return nil
+	}
+
+	timer := time.NewTimer(timeout)
+	defer timer.Stop()
+
+	select {
+	case <-p.done:
+		return nil
+	case <-timer.C:
+		return common.NewErrorf("timed out waiting for xray-core process to stop after %s", timeout)
 	}
 }
 

+ 162 - 0
xray/process_test.go

@@ -0,0 +1,162 @@
+//go:build !windows
+
+package xray
+
+import (
+	"os"
+	"os/exec"
+	"os/signal"
+	"path/filepath"
+	"syscall"
+	"testing"
+	"time"
+
+	xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/op/go-logging"
+)
+
+func TestStopWaitsForGracefulExit(t *testing.T) {
+	initProcessTestLogger(t)
+
+	p := startProcessHelper(t, "delayed-term")
+
+	start := time.Now()
+	if err := p.Stop(); err != nil {
+		t.Fatalf("Stop: %v", err)
+	}
+	if elapsed := time.Since(start); elapsed < 150*time.Millisecond {
+		t.Fatalf("Stop returned before child exited; elapsed=%s", elapsed)
+	}
+	if p.IsRunning() {
+		t.Fatal("process still reports running after Stop")
+	}
+}
+
+func TestIntentionalStopDoesNotRecordExitError(t *testing.T) {
+	initProcessTestLogger(t)
+
+	p := startProcessHelper(t, "default-term")
+
+	if err := p.Stop(); err != nil {
+		t.Fatalf("Stop: %v", err)
+	}
+	if err := p.GetErr(); err != nil {
+		t.Fatalf("GetErr after intentional stop = %v, want nil", err)
+	}
+	if result := p.GetResult(); result != "" {
+		t.Fatalf("GetResult after intentional stop = %q, want empty", result)
+	}
+}
+
+func TestStopKillsProcessThatIgnoresSIGTERM(t *testing.T) {
+	initProcessTestLogger(t)
+
+	oldGraceful := xrayGracefulStopTimeout
+	oldForce := xrayForceStopTimeout
+	xrayGracefulStopTimeout = 100 * time.Millisecond
+	xrayForceStopTimeout = 2 * time.Second
+	t.Cleanup(func() {
+		xrayGracefulStopTimeout = oldGraceful
+		xrayForceStopTimeout = oldForce
+	})
+
+	p := startProcessHelper(t, "ignore-term")
+
+	if err := p.Stop(); err != nil {
+		t.Fatalf("Stop: %v", err)
+	}
+	if p.IsRunning() {
+		t.Fatal("process still reports running after forced stop")
+	}
+}
+
+func initProcessTestLogger(t *testing.T) {
+	t.Helper()
+	t.Setenv("XUI_LOG_FOLDER", t.TempDir())
+	xuilogger.InitLogger(logging.ERROR)
+}
+
+func startProcessHelper(t *testing.T, mode string) *process {
+	t.Helper()
+
+	readyPath := filepath.Join(t.TempDir(), "ready")
+	cmd := exec.Command(os.Args[0], "-test.run=TestXrayProcessHelper", "--", mode)
+	cmd.Env = append(os.Environ(),
+		"XRAY_PROCESS_HELPER=1",
+		"XRAY_PROCESS_READY="+readyPath,
+	)
+
+	p := newProcess(nil)
+	if err := p.startCommand(cmd); err != nil {
+		t.Fatalf("start helper process: %v", err)
+	}
+	waitForProcessHelperReady(t, readyPath)
+
+	t.Cleanup(func() {
+		if p.IsRunning() {
+			p.intentionalStop.Store(true)
+			_ = p.cmd.Process.Kill()
+			_ = p.waitForExit(2 * time.Second)
+		}
+	})
+
+	return p
+}
+
+func waitForProcessHelperReady(t *testing.T, readyPath string) {
+	t.Helper()
+
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) {
+		if _, err := os.Stat(readyPath); err == nil {
+			return
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	t.Fatalf("helper process did not become ready")
+}
+
+func TestXrayProcessHelper(t *testing.T) {
+	if os.Getenv("XRAY_PROCESS_HELPER") != "1" {
+		return
+	}
+
+	mode := ""
+	for i, arg := range os.Args {
+		if arg == "--" && i+1 < len(os.Args) {
+			mode = os.Args[i+1]
+			break
+		}
+	}
+
+	switch mode {
+	case "delayed-term":
+		sigCh := make(chan os.Signal, 1)
+		signal.Notify(sigCh, syscall.SIGTERM)
+		markProcessHelperReady(t)
+		<-sigCh
+		time.Sleep(200 * time.Millisecond)
+		os.Exit(0)
+	case "default-term":
+		markProcessHelperReady(t)
+		select {}
+	case "ignore-term":
+		signal.Ignore(syscall.SIGTERM)
+		markProcessHelperReady(t)
+		select {}
+	default:
+		t.Fatalf("unknown helper mode %q", mode)
+	}
+}
+
+func markProcessHelperReady(t *testing.T) {
+	t.Helper()
+
+	readyPath := os.Getenv("XRAY_PROCESS_READY")
+	if readyPath == "" {
+		t.Fatal("XRAY_PROCESS_READY is not set")
+	}
+	if err := os.WriteFile(readyPath, []byte("ready"), 0644); err != nil {
+		t.Fatalf("write helper ready file: %v", err)
+	}
+}