13 次代碼提交 998fa0dfe1 ... fb311afa6f

作者 SHA1 備註 提交日期
  MHSanaei fb311afa6f fix(sub): keep listen/bind IP out of subscription page URLs 7 小時之前
  MHSanaei eb78b8666f fix(inbound): re-derive auto tags on edit and keep node tags consistent 8 小時之前
  MHSanaei 4a11375f36 fix(tgbot): send login notification asynchronously 10 小時之前
  MHSanaei 8db9729913 fix(model): accept tun protocol in inbound validation 10 小時之前
  MHSanaei 4e4e30d8c1 fix(ci): raise issue-bot max-turns so full triage completes 11 小時之前
  MHSanaei 3f5e37b038 fix(postgres): record client traffic when inbound_id is stale 11 小時之前
  MHSanaei 49c30d6baf fix(frontend): add missing react-hooks/exhaustive-deps 12 小時之前
  MHSanaei 61ba5754ca fix(postgres): commit client traffic backfill in migration 12 小時之前
  MHSanaei c6855d4752 fix(ci): let issue bot run for non-collaborator issue authors 13 小時之前
  MHSanaei e8c6c30982 fix(postgres): resync id sequences so adding clients no longer collides 13 小時之前
  MHSanaei 575355e4f1 fix(inbounds): only reset id sequence when all inbounds are deleted 14 小時之前
  MHSanaei 76dbbfc1f8 feat(inbounds): clearer client validation errors on save 14 小時之前
  MHSanaei 61e8bed3e0 refactor(inbounds): remove column sorter from inbound list 15 小時之前
共有 48 個文件被更改,包括 1109 次插入219 次删除
  1. 8 0
      .dockerignore
  2. 5 0
      .gitattributes
  3. 2 1
      .github/workflows/claude-issue-bot.yml
  4. 6 0
      database/db.go
  5. 24 13
      database/migrate_data.go
  6. 1 1
      database/model/model.go
  7. 91 0
      frontend/src/lib/xray/inbound-tag.ts
  8. 3 3
      frontend/src/pages/inbounds/InboundsPage.tsx
  9. 53 13
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  10. 43 0
      frontend/src/pages/inbounds/form/formatValidationError.ts
  11. 9 28
      frontend/src/pages/inbounds/list/InboundList.tsx
  12. 1 18
      frontend/src/pages/inbounds/list/helpers.ts
  13. 0 13
      frontend/src/pages/inbounds/list/types.ts
  14. 3 23
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  15. 1 1
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  16. 65 0
      frontend/src/test/format-validation-error.test.ts
  17. 65 0
      frontend/src/test/inbound-tag.test.ts
  18. 71 0
      sub/build_urls_test.go
  19. 1 1
      sub/subClashService.go
  20. 1 1
      sub/subController.go
  21. 1 1
      sub/subJsonService.go
  22. 14 48
      sub/subService.go
  23. 38 0
      sub/subService_test.go
  24. 16 2
      web/runtime/remote.go
  25. 31 0
      web/runtime/remote_test.go
  26. 43 16
      web/service/inbound.go
  27. 78 0
      web/service/inbound_client_traffic_test.go
  28. 91 0
      web/service/inbound_migration_test.go
  29. 100 0
      web/service/inbound_update_tag_test.go
  30. 66 0
      web/service/node_tag_sync_test.go
  31. 17 0
      web/service/port_conflict.go
  32. 34 0
      web/service/port_conflict_test.go
  33. 23 22
      web/service/setting.go
  34. 51 0
      web/service/sub_uri_base_test.go
  35. 1 1
      web/service/tgbot.go
  36. 4 1
      web/translation/ar-EG.json
  37. 4 1
      web/translation/en-US.json
  38. 4 1
      web/translation/es-ES.json
  39. 4 1
      web/translation/fa-IR.json
  40. 4 1
      web/translation/id-ID.json
  41. 4 1
      web/translation/ja-JP.json
  42. 4 1
      web/translation/pt-BR.json
  43. 4 1
      web/translation/ru-RU.json
  44. 4 1
      web/translation/tr-TR.json
  45. 4 1
      web/translation/uk-UA.json
  46. 4 1
      web/translation/vi-VN.json
  47. 4 1
      web/translation/zh-CN.json
  48. 4 1
      web/translation/zh-TW.json

+ 8 - 0
.dockerignore

@@ -0,0 +1,8 @@
+.git
+**/node_modules
+web/dist
+build
+db
+cert
+pgdata
+*.db

+ 5 - 0
.gitattributes

@@ -0,0 +1,5 @@
+# Shell scripts must stay LF so the Docker build works when the repo is
+# checked out on Windows (CRLF breaks the script shebang -> exit 127).
+*.sh text eol=lf
+DockerInit.sh text eol=lf
+DockerEntrypoint.sh text eol=lf

+ 2 - 1
.github/workflows/claude-issue-bot.yml

@@ -21,8 +21,9 @@ jobs:
         with:
           github_token: ${{ secrets.GITHUB_TOKEN }}
           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+          allowed_non_write_users: "*"
           claude_args: |
-            --max-turns 25
+            --max-turns 45
             --allowedTools "Bash(gh:*),Read,Glob,Grep"
           prompt: |
             You are the issue assistant for the 3x-ui repository (an Xray-core web panel).

+ 6 - 0
database/db.go

@@ -89,6 +89,12 @@ func initModels() error {
 	if err := pruneOrphanedClientInbounds(); err != nil {
 		return err
 	}
+	if IsPostgres() {
+		if err := resyncPostgresSequences(db, models); err != nil {
+			log.Printf("Error resyncing postgres sequences: %v", err)
+			return err
+		}
+	}
 	return nil
 }
 

+ 24 - 13
database/migrate_data.go

@@ -123,21 +123,32 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
 	return total, err
 }
 
-// resetPostgresSequences advances each table's id sequence past MAX(id),
+// resetPostgresSequences advances each migrated table's id sequence past MAX(id),
 // otherwise the next INSERT-without-id would clash with copied rows.
 func resetPostgresSequences(dst *gorm.DB) error {
-	tables := []string{
-		"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
-		"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
-		"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
-	}
-	for _, t := range tables {
-		// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
-		_ = dst.Exec(fmt.Sprintf(
-			`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
-			 WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
-			t, t, t,
-		)).Error
+	return resyncPostgresSequences(dst, migrationModels())
+}
+
+// resyncPostgresSequences sets each model's id sequence to MAX(id) so the next
+// auto-increment INSERT won't collide with an existing row. Table names are
+// resolved from the models themselves (not hardcoded), so they always match the
+// migrated tables. The statement is a no-op for tables without an id sequence
+// (e.g. composite-PK tables), and idempotent on a healthy DB, so it is safe to
+// run both after migration and on every Postgres startup.
+func resyncPostgresSequences(db *gorm.DB, models []any) error {
+	for _, m := range models {
+		stmt := &gorm.Statement{DB: db}
+		if err := stmt.Parse(m); err != nil {
+			continue
+		}
+		t := stmt.Table
+		// t comes from the trusted model set parsed by GORM, not user input, so
+		// interpolating it as an identifier is safe. We ignore errors per-table.
+		_ = db.Exec(
+			`SELECT setval(pg_get_serial_sequence(?, 'id'), COALESCE((SELECT MAX(id) FROM "`+t+`"), 1), true)
+			 WHERE pg_get_serial_sequence(?, 'id') IS NOT NULL`,
+			t, t,
+		).Error
 	}
 	return nil
 }

+ 1 - 1
database/model/model.go

@@ -56,7 +56,7 @@ type Inbound struct {
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
 	Port           int      `json:"port" form:"port" validate:"gte=1,lte=65535"`
-	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel"`
+	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
 	Settings       string   `json:"settings" form:"settings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`

+ 91 - 0
frontend/src/lib/xray/inbound-tag.ts

@@ -0,0 +1,91 @@
+// Client-side mirror of the backend inbound-tag derivation
+// (web/service/port_conflict.go). Keep in sync; inbound-tag.test.ts guards parity.
+
+type TransportBits = number;
+const TCP: TransportBits = 1;
+const UDP: TransportBits = 2;
+
+function asString(v: unknown): string {
+  return typeof v === 'string' ? v : '';
+}
+
+function inboundTransports(
+  protocol: string,
+  streamSettings: Record<string, unknown> | undefined,
+  settings: Record<string, unknown> | undefined,
+): TransportBits {
+  if (protocol === 'hysteria' || protocol === 'wireguard') return UDP;
+
+  let bits: TransportBits = 0;
+  const network = asString(streamSettings?.network);
+  if (network === 'kcp' || network === 'quic') bits |= UDP;
+  else bits |= TCP;
+
+  if (settings) {
+    if (protocol === 'shadowsocks' || protocol === 'tunnel') {
+      const key = protocol === 'tunnel' ? 'allowedNetwork' : 'network';
+      const n = asString(settings[key]);
+      if (n !== '') {
+        bits = 0;
+        for (const part of n.split(',')) {
+          const p = part.trim();
+          if (p === 'tcp') bits |= TCP;
+          else if (p === 'udp') bits |= UDP;
+        }
+      }
+    } else if (protocol === 'mixed') {
+      if (settings.udp === true) bits |= UDP;
+    }
+  }
+
+  if (bits === 0) bits = TCP;
+  return bits;
+}
+
+function transportTagSuffix(bits: TransportBits): string {
+  if (bits === TCP) return 'tcp';
+  if (bits === UDP) return 'udp';
+  if (bits === (TCP | UDP)) return 'tcpudp';
+  return 'any';
+}
+
+function isAnyListen(listen: string): boolean {
+  return listen === '' || listen === '0.0.0.0' || listen === '::' || listen === '::0';
+}
+
+function baseInboundTag(listen: string, port: number): string {
+  return isAnyListen(listen) ? `in-${port}` : `in-${listen}:${port}`;
+}
+
+function nodeTagPrefix(nodeId: number | null | undefined): string {
+  return nodeId == null ? '' : `n${nodeId}-`;
+}
+
+export interface InboundTagInput {
+  listen: string;
+  port: number;
+  nodeId: number | null | undefined;
+  protocol: string;
+  streamSettings?: Record<string, unknown>;
+  settings?: Record<string, unknown>;
+}
+
+export function composeInboundTag(input: InboundTagInput): string {
+  const bits = inboundTransports(input.protocol, input.streamSettings, input.settings);
+  return (
+    nodeTagPrefix(input.nodeId)
+    + baseInboundTag(input.listen ?? '', input.port ?? 0)
+    + '-'
+    + transportTagSuffix(bits)
+  );
+}
+
+export function isAutoInboundTag(tag: string, input: InboundTagInput): boolean {
+  if (tag === '') return true;
+  const base = composeInboundTag(input);
+  if (tag === base) return true;
+  const prefix = `${base}-`;
+  if (!tag.startsWith(prefix)) return false;
+  const suffix = tag.slice(prefix.length);
+  return suffix !== '' && /^[0-9]+$/.test(suffix);
+}

+ 3 - 3
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -176,7 +176,7 @@ export default function InboundsPage() {
     setPromptInitial(opts.value || '');
     setPromptHandler(() => opts.confirm);
     setPromptOpen(true);
-  }, []);
+  }, [t]);
 
   const onPromptConfirm = useCallback(async (value: string) => {
     if (!promptHandler) {
@@ -329,7 +329,7 @@ export default function InboundsPage() {
         return false;
       },
     });
-  }, [openPrompt, refresh]);
+  }, [openPrompt, refresh, t]);
 
   const onAddInbound = useCallback(() => {
     setFormMode('add');
@@ -476,7 +476,7 @@ export default function InboundsPage() {
       default:
         messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
     }
-  }, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
+  }, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi, t]);
 
   const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
     // Actions that touch per-client secrets (uuid, password, flow, ...) need

+ 53 - 13
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import dayjs from 'dayjs';
 import {
@@ -20,6 +20,7 @@ import {
   formValuesToWirePayload,
 } from '@/lib/xray/inbound-form-adapter';
 import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
+import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
 import {
   canEnableReality,
   canEnableStream,
@@ -48,6 +49,7 @@ import { FinalMaskForm } from '@/lib/xray/forms/transport';
 import './InboundFormModal.css';
 
 import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
+import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
 import {
   HttpFields,
   HysteriaFields,
@@ -157,6 +159,23 @@ export default function InboundFormModal({
   const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
   const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
   const streamEnabled = canEnableStream({ protocol });
+
+  const wListen = Form.useWatch('listen', form) ?? '';
+  const wPort = Form.useWatch('port', form);
+  const wNodeId = Form.useWatch('nodeId', form) ?? null;
+  const wTag = Form.useWatch('tag', form) ?? '';
+  const wSsNetwork = Form.useWatch(['settings', 'network'], form);
+  const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
+  const autoTagRef = useRef(true);
+  const lastWrittenTagRef = useRef('');
+  const currentTagInput = (): InboundTagInput => ({
+    listen: typeof wListen === 'string' ? wListen : '',
+    port: typeof wPort === 'number' ? wPort : 0,
+    nodeId: typeof wNodeId === 'number' ? wNodeId : null,
+    protocol,
+    streamSettings: { network },
+    settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
+  });
   const isFallbackHost =
     (protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
     && network === 'tcp'
@@ -272,6 +291,16 @@ export default function InboundFormModal({
       : buildAddModeValues();
     form.resetFields();
     form.setFieldsValue(initial);
+    const initialTag = (initial.tag ?? '') as string;
+    autoTagRef.current = isAutoInboundTag(initialTag, {
+      listen: initial.listen ?? '',
+      port: initial.port ?? 0,
+      nodeId: initial.nodeId ?? null,
+      protocol: initial.protocol,
+      streamSettings: (initial.streamSettings ?? {}) as Record<string, unknown>,
+      settings: (initial.settings ?? {}) as Record<string, unknown>,
+    });
+    lastWrittenTagRef.current = initialTag;
     if (
       mode === 'edit'
       && dbInbound
@@ -285,6 +314,23 @@ export default function InboundFormModal({
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [open, mode, dbInbound, form]);
 
+  useEffect(() => {
+    if (!open) return;
+    if (wTag === lastWrittenTagRef.current) return;
+    autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open, wTag]);
+
+  useEffect(() => {
+    if (!open || !autoTagRef.current) return;
+    const next = composeInboundTag(currentTagInput());
+    if (next !== (form.getFieldValue('tag') ?? '')) {
+      lastWrittenTagRef.current = next;
+      form.setFieldValue('tag', next);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open, wListen, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
+
   // Why: protocol picker reset cascades through the form — clearing the
   // settings DU branch and dropping a nodeId that no longer applies. The
   // legacy modal did this imperatively in onProtocolChange; here we hook
@@ -360,18 +406,12 @@ export default function InboundFormModal({
     const values = form.getFieldsValue(true) as InboundFormValues;
     const parsed = InboundFormSchema.safeParse(values);
     if (!parsed.success) {
-      const issue = parsed.error.issues[0];
-      const path = Array.isArray(issue?.path) && issue.path.length > 0
-        ? issue.path.join('.')
-        : '';
-      const baseMsg = issue?.message ?? 'somethingWentWrong';
-      const display = path ? `${path}: ${baseMsg}` : baseMsg;
-      messageApi.error(t(baseMsg, { defaultValue: display }));
-      console.error('[InboundFormModal] schema validation failed', {
-        path: issue?.path,
-        message: issue?.message,
-        values,
-      });
+      const issues = parsed.error.issues;
+      messageApi.error(formatInboundValidation(issues, values, t));
+      console.error(
+        '[InboundFormModal] schema validation failed:',
+        issues.map((issue) => formatInboundIssue(issue, values, t)),
+      );
       return;
     }
     setSaving(true);

+ 43 - 0
frontend/src/pages/inbounds/form/formatValidationError.ts

@@ -0,0 +1,43 @@
+import type { TFunction } from 'i18next';
+
+type IssueLike = { path: PropertyKey[]; message: string };
+
+interface ClientLike {
+  email?: unknown;
+}
+
+/**
+ * Turns one Zod issue from the inbound-form schema into a human-readable line.
+ * The schema validates the whole form at once, so a bad client field surfaces
+ * as `settings.clients.<index>.<field>` — useless on its own when an inbound
+ * holds hundreds of clients. We resolve that index back to the client's email
+ * so the operator can find the offending entry. The reason is translated when
+ * it is a custom message key; Zod defaults like "Invalid input" pass through.
+ */
+export function formatInboundIssue(issue: IssueLike, values: unknown, t: TFunction): string {
+  const path = Array.isArray(issue?.path) ? issue.path : [];
+  const reason = t(issue?.message, { defaultValue: issue?.message });
+
+  if (path[0] === 'settings' && path[1] === 'clients' && typeof path[2] === 'number') {
+    const index = path[2];
+    const clients = (values as { settings?: { clients?: ClientLike[] } })?.settings?.clients;
+    const client = Array.isArray(clients) ? clients[index] : undefined;
+    const email = typeof client?.email === 'string' && client.email !== '' ? client.email : '';
+    const who = email ? `"${email}"` : `#${index}`;
+    const field = path.slice(3).map(String).join('.') || t('clients');
+    return t('pages.inbounds.toasts.invalidClientField', { client: who, field, reason });
+  }
+
+  const field = path.map(String).join('.') || 'value';
+  return t('pages.inbounds.toasts.invalidField', { field, reason });
+}
+
+/**
+ * Builds the single-line toast for a failed inbound save: the first issue,
+ * fully described, plus a "(+N more)" tail when several fields failed.
+ */
+export function formatInboundValidation(issues: IssueLike[], values: unknown, t: TFunction): string {
+  const first = formatInboundIssue(issues[0], values, t);
+  if (issues.length <= 1) return first;
+  return t('pages.inbounds.toasts.moreIssues', { message: first, count: issues.length - 1 });
+}

+ 9 - 28
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -25,11 +25,10 @@ import {
 
 import { HttpUtil } from '@/utils';
 
-import { SORT_FNS } from './helpers';
 import { buildRowActionsMenu } from './RowActions';
 import { useInboundColumns } from './useInboundColumns';
 import InboundStatsModal from './InboundStatsModal';
-import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction, SortKey, SortOrder } from './types';
+import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction } from './types';
 import './InboundList.css';
 
 export default function InboundList({
@@ -49,8 +48,6 @@ export default function InboundList({
   onBulkDelete,
 }: InboundListProps) {
   const { t } = useTranslation();
-  const [sortKey, setSortKey] = useState<SortKey | null>(null);
-  const [sortOrder, setSortOrder] = useState<SortOrder>(null);
   const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
   const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
 
@@ -67,14 +64,6 @@ export default function InboundList({
     }
   }, []);
 
-  const sortedInbounds = useMemo(() => {
-    if (!sortKey || !sortOrder) return dbInbounds;
-    const fn = SORT_FNS[sortKey];
-    if (!fn) return dbInbounds;
-    const sorted = [...dbInbounds].sort((a, b) => fn(a, b, { nodesById, clientCount }));
-    return sortOrder === 'descend' ? sorted.reverse() : sorted;
-  }, [dbInbounds, sortKey, sortOrder, nodesById, clientCount]);
-
   const hasAnyRemark = useMemo(
     () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
     [dbInbounds],
@@ -89,11 +78,11 @@ export default function InboundList({
   }, []);
 
   const selectAll = useCallback((checked: boolean) => {
-    setSelectedRowKeys(checked ? sortedInbounds.map((i) => i.id) : []);
-  }, [sortedInbounds]);
+    setSelectedRowKeys(checked ? dbInbounds.map((i) => i.id) : []);
+  }, [dbInbounds]);
 
-  const allSelected = sortedInbounds.length > 0 && selectedRowKeys.length === sortedInbounds.length;
-  const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < sortedInbounds.length;
+  const allSelected = dbInbounds.length > 0 && selectedRowKeys.length === dbInbounds.length;
+  const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < dbInbounds.length;
 
   const handleBulkDelete = useCallback(async () => {
     const ok = await onBulkDelete(selectedRowKeys);
@@ -108,8 +97,6 @@ export default function InboundList({
     subEnable,
     expireDiff,
     trafficDiff,
-    sortKey,
-    sortOrder,
     onRowAction,
     onSwitchEnable,
   });
@@ -160,7 +147,7 @@ export default function InboundList({
       <Space orientation="vertical" style={{ width: '100%' }}>
         {isMobile ? (
           <div className="inbound-cards">
-            {sortedInbounds.length === 0 ? (
+            {dbInbounds.length === 0 ? (
               <div className="card-empty">
                 <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
                 <div>{t('noData')}</div>
@@ -179,7 +166,7 @@ export default function InboundList({
                   <span className="bulk-count">{selectedRowKeys.length}</span>
                 )}
               </div>
-              {sortedInbounds.map((record) => (
+              {dbInbounds.map((record) => (
                 <div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
                   <div className="card-head">
                     <Checkbox
@@ -217,13 +204,13 @@ export default function InboundList({
         ) : (
           <Table
             columns={columns}
-            dataSource={sortedInbounds}
+            dataSource={dbInbounds}
             rowKey={(r) => r.id}
             rowSelection={{
               selectedRowKeys,
               onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
             }}
-            pagination={paginationFor(sortedInbounds)}
+            pagination={paginationFor(dbInbounds)}
             scroll={{ x: 1000 }}
             style={{ marginTop: 10 }}
             size="small"
@@ -235,12 +222,6 @@ export default function InboundList({
                 </div>
               ),
             }}
-            onChange={(_p, _f, sorter) => {
-              const single = Array.isArray(sorter) ? sorter[0] : sorter;
-              const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
-              setSortKey(colKey || null);
-              setSortOrder((single?.order as SortOrder) || null);
-            }}
           />
         )}
       </Space>

+ 1 - 18
frontend/src/pages/inbounds/list/helpers.ts

@@ -1,8 +1,7 @@
-import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
 import { coerceInboundJsonField } from '@/models/dbinbound';
 
-import type { ClientCountEntry, DBInboundRecord, SortKey, StreamHints } from './types';
+import type { DBInboundRecord, StreamHints } from './types';
 
 export function readStreamHints(streamSettings: unknown): StreamHints {
   const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string };
@@ -88,19 +87,3 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
   }
   return false;
 }
-
-export const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
-  id: (a, b) => a.id - b.id,
-  enable: (a, b) => Number(a.enable) - Number(b.enable),
-  remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
-  port: (a, b) => a.port - b.port,
-  protocol: (a, b) => a.protocol.localeCompare(b.protocol),
-  traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
-  expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
-  node: (a, b, ctx) => {
-    const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '￿' : `node #${a.nodeId}`);
-    const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '￿' : `node #${b.nodeId}`);
-    return nameA.localeCompare(nameB);
-  },
-  clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
-};

+ 0 - 13
frontend/src/pages/inbounds/list/types.ts

@@ -74,16 +74,3 @@ export interface InboundListProps {
   onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
   onBulkDelete: (ids: number[]) => Promise<boolean>;
 }
-
-export type SortKey =
-  | 'id'
-  | 'enable'
-  | 'remark'
-  | 'port'
-  | 'protocol'
-  | 'traffic'
-  | 'expiryTime'
-  | 'node'
-  | 'clients';
-
-export type SortOrder = 'ascend' | 'descend' | null;

+ 3 - 23
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useMemo, type ReactElement } from 'react';
+import { useMemo, type ReactElement } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Popover, Switch, Tag, type TableColumnType } from 'antd';
 
@@ -16,7 +16,7 @@ import {
   tunnelNetworkLabel,
   mixedNetworkLabel,
 } from './helpers';
-import type { ClientCountEntry, DBInboundRecord, RowAction, SortKey, SortOrder } from './types';
+import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
 
 interface UseInboundColumnsParams {
   hasAnyRemark: boolean;
@@ -26,8 +26,6 @@ interface UseInboundColumnsParams {
   subEnable: boolean;
   expireDiff: number;
   trafficDiff: number;
-  sortKey: SortKey | null;
-  sortOrder: SortOrder;
   onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
   onSwitchEnable: (dbInbound: DBInboundRecord, next: boolean) => void;
 }
@@ -40,21 +38,12 @@ export function useInboundColumns({
   subEnable,
   expireDiff,
   trafficDiff,
-  sortKey,
-  sortOrder,
   onRowAction,
   onSwitchEnable,
 }: UseInboundColumnsParams): TableColumnType<DBInboundRecord>[] {
   const { t } = useTranslation();
   const { datepicker } = useDatepicker();
 
-  const sorterFor = useCallback((key: SortKey) => ({
-    sorter: true as const,
-    showSorterTooltip: false,
-    sortOrder: sortKey === key ? sortOrder : null,
-    sortDirections: ['ascend' as const, 'descend' as const],
-  }), [sortKey, sortOrder]);
-
   return useMemo(() => {
     const cols: TableColumnType<DBInboundRecord>[] = [
       {
@@ -63,7 +52,6 @@ export function useInboundColumns({
         key: 'id',
         align: 'right',
         width: 30,
-        ...sorterFor('id'),
       },
       {
         title: t('pages.inbounds.operate'),
@@ -84,7 +72,6 @@ export function useInboundColumns({
         key: 'enable',
         align: 'center',
         width: 35,
-        ...sorterFor('enable'),
         render: (_, record) => (
           <Switch
             checked={record.enable}
@@ -101,7 +88,6 @@ export function useInboundColumns({
         key: 'remark',
         align: 'center',
         width: 60,
-        ...sorterFor('remark'),
       });
     }
 
@@ -111,7 +97,6 @@ export function useInboundColumns({
         key: 'node',
         align: 'center',
         width: 60,
-        ...sorterFor('node'),
         render: (_, record) => {
           if (record.nodeId == null) {
             return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
@@ -134,14 +119,12 @@ export function useInboundColumns({
         key: 'port',
         align: 'center',
         width: 40,
-        ...sorterFor('port'),
       },
       {
         title: t('pages.inbounds.protocol'),
         key: 'protocol',
         align: 'left',
         width: 130,
-        ...sorterFor('protocol'),
         render: (_, record) => {
           const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
           if (record.isWireguard || record.isHysteria) {
@@ -170,7 +153,6 @@ export function useInboundColumns({
         key: 'clients',
         align: 'left',
         width: 50,
-        ...sorterFor('clients'),
         render: (_, record) => {
           const cc = clientCount[record.id];
           if (!cc) return null;
@@ -236,7 +218,6 @@ export function useInboundColumns({
         key: 'traffic',
         align: 'center',
         width: 90,
-        ...sorterFor('traffic'),
         render: (_, record) => (
           <Popover
             content={(
@@ -269,7 +250,6 @@ export function useInboundColumns({
         key: 'expiryTime',
         align: 'center',
         width: 40,
-        ...sorterFor('expiryTime'),
         render: (_, record) => {
           if (record.expiryTime > 0) {
             return (
@@ -286,5 +266,5 @@ export function useInboundColumns({
     );
 
     return cols;
-  }, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]);
+  }, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
 }

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

@@ -168,7 +168,7 @@ export default function OutboundFormModal({
     if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
     if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
     form.setFieldValue('streamSettings', slice);
-  }, [protocol, network, security]);
+  }, [protocol, network, security, form]);
 
   const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
   useEffect(() => {

+ 65 - 0
frontend/src/test/format-validation-error.test.ts

@@ -0,0 +1,65 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
+import type { TFunction } from 'i18next';
+
+import { formatInboundIssue, formatInboundValidation } from '@/pages/inbounds/form/formatValidationError';
+
+const templates: Record<string, string> = {
+  'pages.inbounds.toasts.invalidClientField': 'Client {client}: {field} — {reason}',
+  'pages.inbounds.toasts.invalidField': '{field} — {reason}',
+  'pages.inbounds.toasts.moreIssues': '{message}  (+{count} more)',
+  clients: 'clients',
+};
+
+const t = ((key: string, opts?: Record<string, unknown>) => {
+  let out = templates[key] ?? (opts?.defaultValue as string | undefined) ?? key;
+  if (opts) {
+    for (const [k, v] of Object.entries(opts)) {
+      out = out.split(`{${k}}`).join(String(v));
+    }
+  }
+  return out;
+}) as unknown as TFunction;
+
+describe('formatInboundValidation', () => {
+  it('resolves a real client array index back to the client email', () => {
+    const schema = z.object({
+      settings: z.object({
+        clients: z.array(z.object({ email: z.string(), tgId: z.number() })),
+      }),
+    });
+    const values = {
+      settings: {
+        clients: [
+          { email: '[email protected]', tgId: 1 },
+          { email: '[email protected]', tgId: 'oops' },
+        ],
+      },
+    };
+    const parsed = schema.safeParse(values);
+    expect(parsed.success).toBe(false);
+    if (parsed.success) return;
+    expect(formatInboundIssue(parsed.error.issues[0], values, t)).toContain('Client "[email protected]": tgId — ');
+  });
+
+  it('falls back to the index when the client has no email', () => {
+    const issue = { path: ['settings', 'clients', 7, 'tgId'], message: 'Invalid input' };
+    const values = { settings: { clients: [] } };
+    expect(formatInboundIssue(issue, values, t)).toBe('Client #7: tgId — Invalid input');
+  });
+
+  it('formats non-client paths plainly', () => {
+    const issue = { path: ['port'], message: 'Invalid input' };
+    expect(formatInboundIssue(issue, {}, t)).toBe('port — Invalid input');
+  });
+
+  it('appends a count when several fields fail', () => {
+    const issues = [
+      { path: ['settings', 'clients', 0, 'tgId'], message: 'Invalid input' },
+      { path: ['port'], message: 'Invalid input' },
+    ];
+    const values = { settings: { clients: [{ email: '[email protected]' }] } };
+    expect(formatInboundValidation(issues, values, t)).toBe('Client "[email protected]": tgId — Invalid input  (+1 more)');
+  });
+});

+ 65 - 0
frontend/src/test/inbound-tag.test.ts

@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest';
+
+import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
+
+// Parity with web/service/port_conflict.go TestInboundTransports: the L4 suffix
+// the tag encodes must match the Go service so the form preview agrees with the
+// tag the backend re-derives on save.
+describe('composeInboundTag transport suffix parity', () => {
+  const base = (over: Partial<InboundTagInput>): InboundTagInput => ({
+    listen: '0.0.0.0',
+    port: 443,
+    nodeId: null,
+    protocol: 'vless',
+    ...over,
+  });
+
+  const cases: Array<[string, InboundTagInput, string]> = [
+    ['vless tcp', base({ streamSettings: { network: 'tcp' } }), 'in-443-tcp'],
+    ['vless ws (still tcp)', base({ streamSettings: { network: 'ws' } }), 'in-443-tcp'],
+    ['vless kcp is udp', base({ streamSettings: { network: 'kcp' } }), 'in-443-udp'],
+    ['vless quic is udp', base({ streamSettings: { network: 'quic' } }), 'in-443-udp'],
+    ['vless empty stream defaults tcp', base({}), 'in-443-tcp'],
+    ['vmess tcp', base({ protocol: 'vmess', streamSettings: { network: 'tcp' } }), 'in-443-tcp'],
+    ['trojan grpc is tcp', base({ protocol: 'trojan', streamSettings: { network: 'grpc' } }), 'in-443-tcp'],
+    ['hysteria forced udp', base({ protocol: 'hysteria', streamSettings: { network: 'tcp' } }), 'in-443-udp'],
+    ['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'],
+    ['shadowsocks tcp,udp', base({ protocol: 'shadowsocks', settings: { network: 'tcp,udp' } }), 'in-443-tcpudp'],
+    ['shadowsocks udp only', base({ protocol: 'shadowsocks', settings: { network: 'udp' } }), 'in-443-udp'],
+    ['shadowsocks tcp only', base({ protocol: 'shadowsocks', settings: { network: 'tcp' } }), 'in-443-tcp'],
+    ['mixed udp on', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: true } }), 'in-443-tcpudp'],
+    ['mixed udp off', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: false } }), 'in-443-tcp'],
+    ['tunnel allowedNetwork udp', base({ protocol: 'tunnel', settings: { allowedNetwork: 'udp' } }), 'in-443-udp'],
+  ];
+
+  it.each(cases)('%s', (_name, input, want) => {
+    expect(composeInboundTag(input)).toBe(want);
+  });
+
+  it('scopes a non-any listen and node prefix', () => {
+    expect(composeInboundTag(base({ listen: '127.0.0.1', port: 8443, streamSettings: { network: 'tcp' } })))
+      .toBe('in-127.0.0.1:8443-tcp');
+    expect(composeInboundTag(base({ nodeId: 1, port: 443, streamSettings: { network: 'tcp' } })))
+      .toBe('n1-in-443-tcp');
+  });
+});
+
+// Parity with TestIsAutoGeneratedTag.
+describe('isAutoInboundTag', () => {
+  const input: InboundTagInput = {
+    listen: '0.0.0.0', port: 443, nodeId: null, protocol: 'vless', streamSettings: { network: 'tcp' },
+  };
+
+  it('recognises canonical, dedup-suffixed and empty as auto', () => {
+    expect(isAutoInboundTag('in-443-tcp', input)).toBe(true);
+    expect(isAutoInboundTag('in-443-tcp-2', input)).toBe(true);
+    expect(isAutoInboundTag('', input)).toBe(true);
+  });
+
+  it('treats custom / stale / malformed-suffix tags as not auto', () => {
+    expect(isAutoInboundTag('my-custom', input)).toBe(false);
+    expect(isAutoInboundTag('in-8443-tcp', input)).toBe(false);
+    expect(isAutoInboundTag('in-443-tcp-x', input)).toBe(false);
+    expect(isAutoInboundTag('in-443-tcp-', input)).toBe(false);
+  });
+});

+ 71 - 0
sub/build_urls_test.go

@@ -0,0 +1,71 @@
+package sub
+
+import (
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+)
+
+func initSubDB(t *testing.T) {
+	t.Helper()
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	// Close the handle before t.TempDir cleanup so Windows doesn't refuse to
+	// remove the still-open sqlite file.
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+// The subscription page's Copy URL must be built from the same host the
+// subscriber reached the page on (after PrepareForRequest normalizes away a
+// loopback/bind address) — never the raw listen IP. A subscriber that hit a
+// loopback bind should see "localhost", not "127.0.0.1".
+func TestBuildURLs_NormalizesListenIP(t *testing.T) {
+	initSubDB(t)
+	s := &SubService{}
+	s.PrepareForRequest("127.0.0.1")
+
+	subURL, _, _ := s.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
+
+	if strings.Contains(subURL, "127.0.0.1") {
+		t.Fatalf("listen IP leaked into Copy URL: %q", subURL)
+	}
+	if !strings.Contains(subURL, "localhost") {
+		t.Fatalf("Copy URL = %q, want a localhost host", subURL)
+	}
+	if !strings.HasSuffix(subURL, "/sub/ABC") {
+		t.Fatalf("Copy URL = %q, want it to end with /sub/ABC", subURL)
+	}
+}
+
+// A subscriber arriving on a real domain gets that exact domain in the Copy
+// URL, with the configured sub port — matching the Client Information page.
+func TestBuildURLs_UsesSubscriberDomain(t *testing.T) {
+	initSubDB(t)
+	s := &SubService{}
+	s.PrepareForRequest("sub.example.com")
+
+	subURL, jsonURL, clashURL := s.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
+
+	if subURL != "http://sub.example.com:2096/sub/ABC" {
+		t.Fatalf("subURL = %q", subURL)
+	}
+	if jsonURL != "http://sub.example.com:2096/json/ABC" {
+		t.Fatalf("jsonURL = %q", jsonURL)
+	}
+	if clashURL != "http://sub.example.com:2096/clash/ABC" {
+		t.Fatalf("clashURL = %q", clashURL)
+	}
+}
+
+func TestBuildURLs_EmptySubId(t *testing.T) {
+	initSubDB(t)
+	s := &SubService{}
+	s.PrepareForRequest("sub.example.com")
+	a, b, c := s.BuildURLs("/sub/", "/json/", "/clash/", "")
+	if a != "" || b != "" || c != "" {
+		t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c)
+	}
+}

+ 1 - 1
sub/subClashService.go

@@ -97,7 +97,7 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
 	stream := s.streamData(inbound.StreamSettings)
 	// For node-managed inbounds the Clash proxy "server" must be the
 	// node's address, not the request host. resolveInboundAddress handles
-	// the node→listen→request-host fallback chain.
+	// the node→subscriber-host fallback chain.
 	defaultDest := s.SubService.resolveInboundAddress(inbound)
 	if defaultDest == "" {
 		defaultDest = host

+ 1 - 1
sub/subController.go

@@ -130,7 +130,7 @@ func (a *SUBController) subs(c *gin.Context) {
 		// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
 		accept := c.GetHeader("Accept")
 		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
-			subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
+			subURL, subJsonURL, subClashURL := a.subService.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
 			if !a.jsonEnabled {
 				subJsonURL = ""
 			}

+ 1 - 1
sub/subJsonService.go

@@ -147,7 +147,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
 	// synthetic one whose `dest` is the host the client connects to.
 	// For node-managed inbounds we want the node's address — request
 	// host won't reach the right xray. resolveInboundAddress already
-	// implements the node→listen→request-host fallback chain.
+	// implements the node→subscriber-host fallback chain.
 	defaultDest := s.SubService.resolveInboundAddress(inbound)
 	if defaultDest == "" {
 		defaultDest = host

+ 14 - 48
sub/subService.go

@@ -663,24 +663,17 @@ func (s *SubService) loadNodes() {
 	s.nodesByID = m
 }
 
-// resolveInboundAddress picks the host an external client should
-// connect to. Order:
-//  1. If the inbound is node-managed and the node has an address, use
-//     the node's address — central panel's hostname doesn't speak xray
-//     for that inbound.
-//  2. If the inbound binds to a non-wildcard listen address, use it.
-//  3. Otherwise fall back to the request's host (whatever the client
-//     subscribed against).
+// resolveInboundAddress returns the node's address for node-managed inbounds,
+// otherwise the subscriber's host (s.address). The inbound's bind Listen is
+// deliberately ignored: it's a server-side address, not a client-reachable
+// host, so operators advertise a specific endpoint via External Proxy instead.
 func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
 	if inbound.NodeID != nil && s.nodesByID != nil {
 		if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" {
 			return n.Address
 		}
 	}
-	if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
-		return s.address
-	}
-	return inbound.Listen
+	return s.address
 }
 
 func findClientIndex(clients []model.Client, email string) int {
@@ -1866,7 +1859,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
 
 // BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
 // It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
-func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
+func (s *SubService) BuildURLs(subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
 	if subId == "" {
 		return "", "", ""
 	}
@@ -1875,50 +1868,23 @@ func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subCl
 	configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
 	configuredSubClashURI, _ := s.settingService.GetSubClashURI()
 
-	var baseScheme, baseHostWithPort string
-	if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
-		baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
-	}
+	// Same base as the panel's Client Information page; s.address is the
+	// subscriber's host already normalized away from any loopback/bind IP.
+	base := s.settingService.BuildSubURIBase(s.address)
 
-	subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
-	subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
-	subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId)
+	subURL = s.buildSingleURL(configuredSubURI, base, subPath, subId)
+	subJsonURL = s.buildSingleURL(configuredSubJsonURI, base, subJsonPath, subId)
+	subClashURL = s.buildSingleURL(configuredSubClashURI, base, subClashPath, subId)
 
 	return subURL, subJsonURL, subClashURL
 }
 
-// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
-func (s *SubService) getBaseSchemeAndHost(requestScheme, requestHostWithPort string) (string, string) {
-	subDomain, err := s.settingService.GetSubDomain()
-	if err != nil || subDomain == "" {
-		return requestScheme, requestHostWithPort
-	}
-
-	// Get port and TLS settings
-	subPort, _ := s.settingService.GetSubPort()
-	subKeyFile, _ := s.settingService.GetSubKeyFile()
-	subCertFile, _ := s.settingService.GetSubCertFile()
-
-	// Determine scheme from TLS configuration
-	scheme := "http"
-	if subKeyFile != "" && subCertFile != "" {
-		scheme = "https"
-	}
-
-	// Build host:port, always include port for clarity
-	hostWithPort := fmt.Sprintf("%s:%d", subDomain, subPort)
-
-	return scheme, hostWithPort
-}
-
 // buildSingleURL constructs a single URL using configured URI or base components
-func (s *SubService) buildSingleURL(configuredURI, baseScheme, baseHostWithPort, basePath, subId string) string {
+func (s *SubService) buildSingleURL(configuredURI, base, basePath, subId string) string {
 	if configuredURI != "" {
 		return s.joinPathWithID(configuredURI, subId)
 	}
-
-	baseURL := fmt.Sprintf("%s://%s", baseScheme, baseHostWithPort)
-	return s.joinPathWithID(baseURL+basePath, subId)
+	return s.joinPathWithID(base+basePath, subId)
 }
 
 // joinPathWithID safely joins a base path with a subscription ID

+ 38 - 0
sub/subService_test.go

@@ -61,6 +61,44 @@ func TestIsRoutableHost(t *testing.T) {
 	}
 }
 
+func TestResolveInboundAddress(t *testing.T) {
+	const reqHost = "sub.example.com"
+
+	// A subscriber reaches the panel through reqHost; the inbound's own
+	// bind Listen IP (loopback, private, or even a public secondary IP) is
+	// a server-side detail and must never become the link's connect host.
+	t.Run("bind listen IP must not leak into the link host", func(t *testing.T) {
+		s := &SubService{address: reqHost}
+		for _, listen := range []string{"127.0.0.1", "10.0.0.5", "192.168.1.10", "1.2.3.4", "0.0.0.0", "::", "::0", ""} {
+			ib := &model.Inbound{Listen: listen}
+			if got := s.resolveInboundAddress(ib); got != reqHost {
+				t.Fatalf("listen %q: address = %q, want %q (subscriber host, not bind IP)", listen, got, reqHost)
+			}
+		}
+	})
+
+	t.Run("node-managed inbound uses the node address", func(t *testing.T) {
+		id := 7
+		s := &SubService{
+			address:   reqHost,
+			nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
+		}
+		ib := &model.Inbound{NodeID: &id, Listen: "1.2.3.4"}
+		if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
+			t.Fatalf("node-managed address = %q, want node7.example.com", got)
+		}
+	})
+
+	t.Run("node id with no known node falls back to subscriber host", func(t *testing.T) {
+		id := 9
+		s := &SubService{address: reqHost, nodesByID: map[int]*model.Node{}}
+		ib := &model.Inbound{NodeID: &id, Listen: "10.0.0.1"}
+		if got := s.resolveInboundAddress(ib); got != reqHost {
+			t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost)
+		}
+	})
+}
+
 func TestUnmarshalStreamSettings(t *testing.T) {
 	got := unmarshalStreamSettings(`{"network":"ws","wsSettings":{"path":"/api"}}`)
 	if got["network"] != "ws" {

+ 16 - 2
web/runtime/remote.go

@@ -146,18 +146,32 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 }
 
 func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) {
-	if id, ok := r.cacheGet(tag); ok {
+	if id, ok := r.cacheGetTag(tag); ok {
 		return id, nil
 	}
 	if err := r.refreshRemoteIDs(ctx); err != nil {
 		return 0, err
 	}
-	if id, ok := r.cacheGet(tag); ok {
+	if id, ok := r.cacheGetTag(tag); ok {
 		return id, nil
 	}
 	return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name)
 }
 
+// cacheGetTag looks up a remote inbound id by tag, tolerating an n<id>- prefix
+// that lives on only one of the two panels: the node may carry the bare tag
+// while the central panel stores the prefixed form, or vice versa.
+func (r *Remote) cacheGetTag(tag string) (int, bool) {
+	if id, ok := r.cacheGet(tag); ok {
+		return id, true
+	}
+	prefix := fmt.Sprintf("n%d-", r.node.Id)
+	if stripped, found := strings.CutPrefix(tag, prefix); found {
+		return r.cacheGet(stripped)
+	}
+	return r.cacheGet(prefix + tag)
+}
+
 func (r *Remote) cacheGet(tag string) (int, bool) {
 	r.mu.RLock()
 	defer r.mu.RUnlock()

+ 31 - 0
web/runtime/remote_test.go

@@ -3,8 +3,39 @@ package runtime
 import (
 	"encoding/json"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
 )
 
+// cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
+// sits on only one side: the node may store the bare tag while the central
+// panel pushes the prefixed form, or vice versa. Without this a mismatch makes
+// the push create a duplicate inbound on the node.
+func TestCacheGetTag_PrefixAgnostic(t *testing.T) {
+	cases := []struct {
+		name      string
+		cacheTag  string
+		lookup    string
+		wantID    int
+		wantFound bool
+	}{
+		{"exact", "n1-in-443-tcp", "n1-in-443-tcp", 7, true},
+		{"node bare, lookup prefixed", "in-443-tcp", "n1-in-443-tcp", 7, true},
+		{"node prefixed, lookup bare", "n1-in-443-tcp", "in-443-tcp", 7, true},
+		{"unrelated tag", "in-443-tcp", "in-999-tcp", 0, false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			r := NewRemote(&model.Node{Id: 1, Name: "n1"})
+			r.cacheSet(c.cacheTag, 7)
+			id, ok := r.cacheGetTag(c.lookup)
+			if ok != c.wantFound || id != c.wantID {
+				t.Fatalf("cacheGetTag(%q) = (%d, %v), want (%d, %v)", c.lookup, id, ok, c.wantID, c.wantFound)
+			}
+		})
+	}
+}
+
 func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 	tests := []struct {
 		name  string

+ 43 - 16
web/service/inbound.go

@@ -130,7 +130,7 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
 func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
-	err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Find(&inbounds).Error
+	err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error
 	if err != nil && err != gorm.ErrRecordNotFound {
 		return nil, err
 	}
@@ -152,7 +152,7 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
 func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
-	err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Find(&inbounds).Error
+	err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error
 	if err != nil && err != gorm.ErrRecordNotFound {
 		return nil, err
 	}
@@ -618,16 +618,14 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 		return needRestart, err
 	}
 	if !database.IsPostgres() {
-		var maxId int
-		if err := db.Model(&model.Inbound{}).Select("COALESCE(MAX(id), 0)").Scan(&maxId).Error; err != nil {
+		var count int64
+		if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
 			return needRestart, err
 		}
-		if maxId == 0 {
+		if count == 0 {
 			if err := db.Exec("DELETE FROM sqlite_sequence WHERE name = ?", "inbounds").Error; err != nil {
 				return needRestart, err
 			}
-		} else if err := db.Exec("UPDATE sqlite_sequence SET seq = ? WHERE name = ?", maxId, "inbounds").Error; err != nil {
-			return needRestart, err
 		}
 	}
 	return needRestart, nil
@@ -760,8 +758,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err != nil {
 		return inbound, false, err
 	}
+	inbound.NodeID = oldInbound.NodeID
 
 	tag := oldInbound.Tag
+	oldBits := inboundTransports(oldInbound.Protocol, oldInbound.StreamSettings, oldInbound.Settings)
+	oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Listen, oldInbound.Port, oldInbound.NodeID, oldBits)
 
 	db := database.GetDB()
 	tx := db.Begin()
@@ -849,10 +850,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	oldInbound.Settings = inbound.Settings
 	oldInbound.StreamSettings = inbound.StreamSettings
 	oldInbound.Sniffing = inbound.Sniffing
+	if oldTagWasAuto && inbound.Tag == tag {
+		inbound.Tag = ""
+	}
 	oldInbound.Tag, err = s.resolveInboundTag(inbound, inbound.Id)
 	if err != nil {
 		return inbound, false, err
 	}
+	inbound.Tag = oldInbound.Tag
 
 	needRestart := false
 	rt, rterr := s.runtimeFor(oldInbound)
@@ -1269,14 +1274,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		Find(&central).Error; err != nil {
 		return false, err
 	}
-	// Index under both stored tag and the prefix-stripped form so a snap's
-	// bare tag resolves whether or not we rewrote it with n<id>- at create.
+	// Index under the stored tag and its prefix-flipped form so a snap matches
+	// whether the n<id>- prefix lives on the node side, the central side, or
+	// neither — a mismatch must never spawn a duplicate central inbound.
 	tagToCentral := make(map[string]*model.Inbound, len(central)*2)
 	prefix := nodeTagPrefix(&nodeID)
 	for i := range central {
 		tagToCentral[central[i].Tag] = &central[i]
-		if prefix != "" && strings.HasPrefix(central[i].Tag, prefix) {
-			tagToCentral[strings.TrimPrefix(central[i].Tag, prefix)] = &central[i]
+		if prefix != "" {
+			if stripped, found := strings.CutPrefix(central[i].Tag, prefix); found {
+				tagToCentral[stripped] = &central[i]
+			} else {
+				tagToCentral[prefix+central[i].Tag] = &central[i]
+			}
 		}
 	}
 
@@ -1331,6 +1341,15 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			continue
 		}
 		snapTags[snapIb.Tag] = struct{}{}
+		// Record the prefix-flipped form too so the orphan sweep below keeps a
+		// central inbound whether its tag carries the n<id>- prefix or not.
+		if prefix != "" {
+			if stripped, found := strings.CutPrefix(snapIb.Tag, prefix); found {
+				snapTags[stripped] = struct{}{}
+			} else {
+				snapTags[prefix+snapIb.Tag] = struct{}{}
+			}
+		}
 
 		c, ok := tagToCentral[snapIb.Tag]
 		if !ok {
@@ -1756,8 +1775,8 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 	}
 	dbClientTraffics := make([]*xray.ClientTraffic, 0, len(traffics))
 	err = tx.Model(xray.ClientTraffic{}).
-		Where("email IN (?) AND inbound_id IN (?)", emails,
-			tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NULL")).
+		Where("email IN (?) AND inbound_id NOT IN (?)", emails,
+			tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NOT NULL")).
 		Find(&dbClientTraffics).Error
 	if err != nil {
 		return err
@@ -1884,7 +1903,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 
 	err = tx.Model(xray.ClientTraffic{}).
 		Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now).
-		Where("inbound_id IN (?)", tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NULL")).
+		Where("inbound_id NOT IN (?)", tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NOT NULL")).
 		Find(&traffics).Error
 	if err != nil {
 		return false, 0, err
@@ -3124,11 +3143,19 @@ func (s *InboundService) MigrationRequirements() {
 		Port           int
 		StreamSettings []byte
 	}
-	err = tx.Raw(`select id, port, stream_settings
+	externalProxyQuery := `select id, port, stream_settings
 	from inbounds
 	WHERE protocol in ('vmess','vless','trojan')
 	  AND json_extract(stream_settings, '$.security') = 'tls'
-	  AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`).Scan(&externalProxy).Error
+	  AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`
+	if database.IsPostgres() {
+		externalProxyQuery = `select id, port, stream_settings
+	from inbounds
+	WHERE protocol in ('vmess','vless','trojan')
+	  AND NULLIF(stream_settings, '')::jsonb #>> '{security}' = 'tls'
+	  AND NULLIF(stream_settings, '')::jsonb #> '{tlsSettings,settings,domains}' IS NOT NULL`
+	}
+	err = tx.Raw(externalProxyQuery).Scan(&externalProxy).Error
 	if err != nil || len(externalProxy) == 0 {
 		return
 	}

+ 78 - 0
web/service/inbound_client_traffic_test.go

@@ -0,0 +1,78 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+// TestAddClientTraffic_MatchesDespiteStaleInboundId reproduces the production bug where
+// client_traffics rows survive an inbound delete+recreate with a stale inbound_id (the
+// shared-by-email row keeps the deleted inbound's id, and AddClientStat's OnConflict-
+// DoNothing never refreshes it). The old `inbound_id IN (local inbounds)` filter dropped
+// those rows, so local traffic and online status stopped updating. The fix matches by
+// email and only excludes rows owned by a node inbound, so a stale local row is still
+// updated while a genuine node-owned row is left untouched.
+func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const localEmail = "local-user"
+	const nodeEmail = "node-user"
+
+	// A local inbound exists, but the local client's traffic row points at an inbound id
+	// that no longer exists (a deleted earlier incarnation) — the stale-pointer scenario.
+	localInbound := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 40001, Protocol: model.VLESS}
+	if err := db.Create(localInbound).Error; err != nil {
+		t.Fatalf("create local inbound: %v", err)
+	}
+	nodeID := 1
+	nodeInbound := &model.Inbound{UserId: 1, Tag: "node-in", Enable: true, Port: 40002, Protocol: model.VLESS, NodeID: &nodeID}
+	if err := db.Create(nodeInbound).Error; err != nil {
+		t.Fatalf("create node inbound: %v", err)
+	}
+
+	if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: localEmail, Enable: true}).Error; err != nil {
+		t.Fatalf("create stale local client_traffics: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: nodeEmail, Enable: true}).Error; err != nil {
+		t.Fatalf("create node client_traffics: %v", err)
+	}
+
+	svc := InboundService{}
+	err := svc.addClientTraffic(db, []*xray.ClientTraffic{
+		{Email: localEmail, Up: 10, Down: 20},
+		{Email: nodeEmail, Up: 30, Down: 40},
+	})
+	if err != nil {
+		t.Fatalf("addClientTraffic: %v", err)
+	}
+
+	var local xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", localEmail).First(&local).Error; err != nil {
+		t.Fatalf("reload local row: %v", err)
+	}
+	if local.Up != 10 || local.Down != 20 {
+		t.Errorf("stale-pointer local row not updated: up=%d down=%d, want 10/20", local.Up, local.Down)
+	}
+	if local.LastOnline == 0 {
+		t.Errorf("stale-pointer local row LastOnline not set")
+	}
+
+	var node xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", nodeEmail).First(&node).Error; err != nil {
+		t.Fatalf("reload node row: %v", err)
+	}
+	if node.Up != 0 || node.Down != 0 {
+		t.Errorf("node-owned row should not be touched by local traffic: up=%d down=%d, want 0/0", node.Up, node.Down)
+	}
+}

+ 91 - 0
web/service/inbound_migration_test.go

@@ -0,0 +1,91 @@
+package service
+
+import (
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+// TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound guards the
+// PostgreSQL fix where the externalProxy detection query (executed via .Scan) errored on
+// json_extract and rolled back the whole transaction — including the client_traffics
+// backfill at inbound.go:3093-3106, leaving clients with no traffic rows. A MultiDomain
+// inbound is present so that query returns rows and the function runs to completion; both
+// the backfill and the MultiDomain→ExternalProxy migration must then commit.
+func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const backfillEmail = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c010"
+
+	// Inbound A: a client present only in settings.clients, with no client_traffics row.
+	clientInbound := &model.Inbound{
+		UserId:         1,
+		Tag:            "a-tag",
+		Enable:         true,
+		Port:           30001,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"email":"` + backfillEmail + `","id":"` + uid + `","enable":true}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(clientInbound).Error; err != nil {
+		t.Fatalf("create client inbound: %v", err)
+	}
+
+	// Inbound B: a legacy MultiDomain inbound whose tag carries the 0.0.0.0: prefix.
+	// Its presence makes the externalProxy query return rows, so the function does not
+	// early-return and reaches the tag-cleanup statement.
+	multiDomainInbound := &model.Inbound{
+		UserId:         1,
+		Tag:            "inbound-0.0.0.0:30002",
+		Enable:         true,
+		Port:           30002,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[]}`,
+		StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
+	}
+	if err := db.Create(multiDomainInbound).Error; err != nil {
+		t.Fatalf("create multidomain inbound: %v", err)
+	}
+
+	var before int64
+	if err := db.Model(xray.ClientTraffic{}).Count(&before).Error; err != nil {
+		t.Fatalf("count client_traffics before: %v", err)
+	}
+	if before != 0 {
+		t.Fatalf("expected no client_traffics before migration, got %d", before)
+	}
+
+	svc := InboundService{}
+	svc.MigrationRequirements()
+
+	// The backfill must have committed: the settings-only client now owns a row.
+	// Before the fix this was rolled back whenever the externalProxy detection query
+	// errored (it does on Postgres via json_extract), so the MultiDomain inbound below
+	// is deliberately present to make that query return rows and run to completion.
+	var ct xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", backfillEmail).First(&ct).Error; err != nil {
+		t.Fatalf("client_traffics row not backfilled for %s: %v", backfillEmail, err)
+	}
+
+	// The MultiDomain→ExternalProxy migration must have committed too: the detection
+	// query ran (.Scan executes it) and the loop rewrote the inbound's streamSettings.
+	var refreshed model.Inbound
+	if err := db.First(&refreshed, multiDomainInbound.Id).Error; err != nil {
+		t.Fatalf("reload multidomain inbound: %v", err)
+	}
+	if !strings.Contains(refreshed.StreamSettings, "externalProxy") {
+		t.Errorf("MultiDomain migration did not commit; streamSettings = %q", refreshed.StreamSettings)
+	}
+}

+ 100 - 0
web/service/inbound_update_tag_test.go

@@ -0,0 +1,100 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+// changing an inbound's port must re-derive an auto-generated tag, both in
+// the persisted row and in the value returned to the caller (the API
+// response the UI renders). The UI round-trips the old tag in a hidden
+// field, so the update arrives carrying the stale tag.
+func TestUpdateInbound_RegeneratesAutoTagOnPortChange(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "in-22435-tcp", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-22435-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := existing
+	update.Port = 33000
+	update.Tag = "in-22435-tcp"
+	got, _, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.Tag != "in-33000-tcp" {
+		t.Fatalf("persisted tag = %q, want in-33000-tcp", reloaded.Tag)
+	}
+	if got.Tag != "in-33000-tcp" {
+		t.Fatalf("returned tag = %q, want in-33000-tcp", got.Tag)
+	}
+}
+
+// a node-scoped inbound (tag carries the "n1-" prefix) must keep that prefix
+// when its port changes, even if the caller omits nodeId in the update body —
+// the node can't be migrated, so the stored NodeID drives the tag. The runtime
+// manager isn't wired in unit tests, so UpdateInbound returns a runtime error
+// for node inbounds before persisting; we assert on the tag it computed (set on
+// the returned object) which is what the save would use.
+func TestUpdateInbound_NodeTagKeepsPrefixWhenNodeIdOmitted(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflictNode(t, "n1-in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, intPtr(1))
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "n1-in-443-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := existing
+	update.Port = 8443
+	update.Tag = "n1-in-443-tcp"
+	update.NodeID = nil
+	got, _, _ := svc.UpdateInbound(&update)
+	if got.Tag != "n1-in-8443-tcp" {
+		t.Fatalf("node prefix must survive a port change, got %q", got.Tag)
+	}
+}
+
+// a tag the user set by hand (doesn't match the canonical shape) survives a
+// port change untouched.
+func TestUpdateInbound_KeepsCustomTagOnPortChange(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "my-custom-tag", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "my-custom-tag").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := existing
+	update.Port = 33000
+	update.Tag = "my-custom-tag"
+	got, _, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if reloaded.Tag != "my-custom-tag" {
+		t.Fatalf("persisted tag = %q, want my-custom-tag", reloaded.Tag)
+	}
+	if got.Tag != "my-custom-tag" {
+		t.Fatalf("returned tag = %q, want my-custom-tag", got.Tag)
+	}
+}

+ 66 - 0
web/service/node_tag_sync_test.go

@@ -0,0 +1,66 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+)
+
+// A node-backed inbound whose central tag carries the n<id>- prefix must
+// survive a snapshot in which the node reports the bare tag (prefix lives on
+// the central side only). Before the fix the orphan sweep matched snapTags
+// exactly, so it deleted and recreated the inbound on every sync — churning
+// its id and dropping traffic for that cycle.
+func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	const nodeID = 1
+	id := nodeID
+	central := &model.Inbound{
+		UserId:   1,
+		NodeID:   &id,
+		Tag:      "n1-in-443-tcp",
+		Enable:   true,
+		Port:     443,
+		Protocol: model.VLESS,
+		Settings: `{"clients":[]}`,
+	}
+	if err := db.Create(central).Error; err != nil {
+		t.Fatalf("create node inbound: %v", err)
+	}
+	centralID := central.Id
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{
+			Tag:      "in-443-tcp",
+			Enable:   true,
+			Port:     443,
+			Protocol: model.VLESS,
+			Settings: `{"clients":[]}`,
+			Up:       1000,
+			Down:     2000,
+		}},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	var rows []model.Inbound
+	if err := db.Where("node_id = ?", nodeID).Find(&rows).Error; err != nil {
+		t.Fatalf("list node inbounds: %v", err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("expected exactly 1 node inbound (no churn), got %d", len(rows))
+	}
+	if rows[0].Id != centralID {
+		t.Fatalf("inbound was deleted+recreated: id %d -> %d", centralID, rows[0].Id)
+	}
+	if rows[0].Up != 1000 || rows[0].Down != 2000 {
+		t.Fatalf("traffic not attributed across prefix mismatch: up=%d down=%d", rows[0].Up, rows[0].Down)
+	}
+}

+ 17 - 0
web/service/port_conflict.go

@@ -204,6 +204,23 @@ func composeInboundTag(listen string, port int, nodeID *int, bits transportBits)
 	return nodeTagPrefix(nodeID) + baseInboundTag(listen, port) + "-" + transportTagSuffix(bits)
 }
 
+func isAutoGeneratedTag(tag, listen string, port int, nodeID *int, bits transportBits) bool {
+	base := composeInboundTag(listen, port, nodeID, bits)
+	if tag == base {
+		return true
+	}
+	suffix, ok := strings.CutPrefix(tag, base+"-")
+	if !ok || suffix == "" {
+		return false
+	}
+	for _, r := range suffix {
+		if r < '0' || r > '9' {
+			return false
+		}
+	}
+	return true
+}
+
 func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
 	bits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
 	candidate := composeInboundTag(inbound.Listen, inbound.Port, inbound.NodeID, bits)

+ 34 - 0
web/service/port_conflict_test.go

@@ -635,3 +635,37 @@ func TestCheckPortConflict_DetailMessage(t *testing.T) {
 		t.Fatalf("message should mention the port; got %q", msg)
 	}
 }
+
+// isAutoGeneratedTag must recognise the tags generateInboundTag emits (so an
+// edit that changes port/transport re-derives them) while leaving user-typed
+// or cross-panel tags untouched.
+func TestIsAutoGeneratedTag(t *testing.T) {
+	tcp := transportTCP
+	cases := []struct {
+		name   string
+		tag    string
+		listen string
+		port   int
+		nodeID *int
+		bits   transportBits
+		want   bool
+	}{
+		{"canonical", "in-443-tcp", "0.0.0.0", 443, nil, tcp, true},
+		{"canonical udp", "in-443-udp", "0.0.0.0", 443, nil, transportUDP, true},
+		{"dedup suffix", "in-443-tcp-2", "0.0.0.0", 443, nil, tcp, true},
+		{"listen scoped", "in-127.0.0.1:443-tcp", "127.0.0.1", 443, nil, tcp, true},
+		{"node prefixed", "n1-in-443-tcp", "0.0.0.0", 443, intPtr(1), tcp, true},
+		{"custom tag", "my-cool-tag", "0.0.0.0", 443, nil, tcp, false},
+		{"stale port", "in-443-tcp", "0.0.0.0", 8443, nil, tcp, false},
+		{"stale transport", "in-443-tcp", "0.0.0.0", 443, nil, transportUDP, false},
+		{"non-numeric suffix", "in-443-tcp-x", "0.0.0.0", 443, nil, tcp, false},
+		{"empty suffix", "in-443-tcp-", "0.0.0.0", 443, nil, tcp, false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := isAutoGeneratedTag(c.tag, c.listen, c.port, c.nodeID, c.bits); got != c.want {
+				t.Fatalf("isAutoGeneratedTag(%q) = %v, want %v", c.tag, got, c.want)
+			}
+		})
+	}
+}

+ 23 - 22
web/service/setting.go

@@ -908,6 +908,28 @@ func extractHostname(host string) string {
 	return "[" + h + "]"
 }
 
+// BuildSubURIBase is shared by GetDefaultSettings (the panel's Client
+// Information page) and the subscription page so both render subscription
+// URLs identically.
+func (s *SettingService) BuildSubURIBase(host string) string {
+	subPort, _ := s.GetSubPort()
+	subDomain, _ := s.GetSubDomain()
+	subKeyFile, _ := s.GetSubKeyFile()
+	subCertFile, _ := s.GetSubCertFile()
+	subTLS := subKeyFile != "" && subCertFile != ""
+	if subDomain == "" {
+		subDomain = extractHostname(host)
+	}
+	scheme := "http"
+	if subTLS {
+		scheme = "https"
+	}
+	if (subPort == 443 && subTLS) || (subPort == 80 && !subTLS) {
+		return scheme + "://" + subDomain
+	}
+	return fmt.Sprintf("%s://%s:%d", scheme, subDomain, subPort)
+}
+
 func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 	type settingFunc func() (any, error)
 	settings := map[string]settingFunc{
@@ -953,32 +975,11 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 		}
 	}
 	if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
-		subURI := ""
+		subURI := s.BuildSubURIBase(host)
 		subTitle, _ := s.GetSubTitle()
-		subPort, _ := s.GetSubPort()
 		subPath, _ := s.GetSubPath()
 		subJsonPath, _ := s.GetSubJsonPath()
 		subClashPath, _ := s.GetSubClashPath()
-		subDomain, _ := s.GetSubDomain()
-		subKeyFile, _ := s.GetSubKeyFile()
-		subCertFile, _ := s.GetSubCertFile()
-		subTLS := false
-		if subKeyFile != "" && subCertFile != "" {
-			subTLS = true
-		}
-		if subDomain == "" {
-			subDomain = extractHostname(host)
-		}
-		if subTLS {
-			subURI = "https://"
-		} else {
-			subURI = "http://"
-		}
-		if (subPort == 443 && subTLS) || (subPort == 80 && !subTLS) {
-			subURI += subDomain
-		} else {
-			subURI += fmt.Sprintf("%s:%d", subDomain, subPort)
-		}
 		if subEnable && result["subURI"].(string) == "" {
 			result["subURI"] = subURI + subPath
 		}

+ 51 - 0
web/service/sub_uri_base_test.go

@@ -0,0 +1,51 @@
+package service
+
+import "testing"
+
+// BuildSubURIBase is the single source of truth for the scheme://host[:port]
+// prefix shown both on the panel's Client Information page and on the
+// subscription page. The cases pin scheme selection (sub TLS cert/key),
+// Sub Domain preference, standard-port omission, and IPv6 bracketing.
+func TestBuildSubURIBase(t *testing.T) {
+	setupConflictDB(t)
+	s := &SettingService{}
+
+	set := func(subDomain, port, cert, key string) {
+		if err := s.saveSetting("subDomain", subDomain); err != nil {
+			t.Fatalf("set subDomain: %v", err)
+		}
+		if err := s.saveSetting("subPort", port); err != nil {
+			t.Fatalf("set subPort: %v", err)
+		}
+		if err := s.saveSetting("subCertFile", cert); err != nil {
+			t.Fatalf("set subCertFile: %v", err)
+		}
+		if err := s.saveSetting("subKeyFile", key); err != nil {
+			t.Fatalf("set subKeyFile: %v", err)
+		}
+	}
+
+	cases := []struct {
+		name                    string
+		subDomain, port         string
+		cert, key               string
+		host                    string
+		want                    string
+	}{
+		{"no domain, plain, non-standard port", "", "2096", "", "", "panel.example.com", "http://panel.example.com:2096"},
+		{"host carries a port — stripped, sub port applied", "", "2096", "", "", "panel.example.com:9999", "http://panel.example.com:2096"},
+		{"sub domain preferred over host", "sub.cdn.com", "2096", "", "", "panel.example.com", "http://sub.cdn.com:2096"},
+		{"tls + 443 omits the port", "sub.cdn.com", "443", "/c.crt", "/k.key", "panel.example.com", "https://sub.cdn.com"},
+		{"plain + 80 omits the port", "", "80", "", "", "x.com", "http://x.com"},
+		{"tls on a non-standard port keeps it", "", "2096", "/c.crt", "/k.key", "x.com", "https://x.com:2096"},
+		{"ipv6 host is bracketed", "", "2096", "", "", "::1", "http://[::1]:2096"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			set(c.subDomain, c.port, c.cert, c.key)
+			if got := s.BuildSubURIBase(c.host); got != c.want {
+				t.Fatalf("BuildSubURIBase(%q) = %q, want %q", c.host, got, c.want)
+			}
+		})
+	}
+}

+ 1 - 1
web/service/tgbot.go

@@ -2665,7 +2665,7 @@ func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
 	msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username)
 	msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP)
 	msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time)
-	t.SendMsgToTgbotAdmins(msg)
+	go t.SendMsgToTgbotAdmins(msg)
 }
 
 // getInboundUsages retrieves and formats inbound usage information.

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

@@ -442,7 +442,10 @@
         "trafficGetError": "خطأ في الحصول على حركات المرور",
         "getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.",
         "getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.",
-        "getNewVlessEncError": "حدث خطأ أثناء الحصول على VlessEnc."
+        "getNewVlessEncError": "حدث خطأ أثناء الحصول على VlessEnc.",
+        "invalidClientField": "العميل {client}: الحقل {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} أخرى)"
       },
       "form": {
         "moveUp": "أعلى",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Error getting traffic.",
         "getNewX25519CertError": "Error while obtaining the X25519 certificate.",
         "getNewmldsa65Error": "Error while obtaining mldsa65.",
-        "getNewVlessEncError": "Error while obtaining VlessEnc."
+        "getNewVlessEncError": "Error while obtaining VlessEnc.",
+        "invalidClientField": "Client {client}: {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} more)"
       },
       "form": {
         "moveUp": "Move up",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Error al obtener los tráficos",
         "getNewX25519CertError": "Error al obtener el certificado X25519.",
         "getNewmldsa65Error": "Error al obtener el certificado mldsa65.",
-        "getNewVlessEncError": "Error al obtener el certificado VlessEnc."
+        "getNewVlessEncError": "Error al obtener el certificado VlessEnc.",
+        "invalidClientField": "Cliente {client}: campo {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} más)"
       },
       "form": {
         "moveUp": "Subir",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "خطا در دریافت ترافیک‌ها",
         "getNewX25519CertError": "خطا در دریافت گواهی X25519.",
         "getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.",
-        "getNewVlessEncError": "خطا در دریافت گواهی VlessEnc."
+        "getNewVlessEncError": "خطا در دریافت گواهی VlessEnc.",
+        "invalidClientField": "کلاینت {client}: فیلد {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} مورد دیگر)"
       },
       "form": {
         "moveUp": "بالا",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Gagal mendapatkan data lalu lintas",
         "getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.",
         "getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.",
-        "getNewVlessEncError": "Terjadi kesalahan saat mendapatkan sertifikat VlessEnc."
+        "getNewVlessEncError": "Terjadi kesalahan saat mendapatkan sertifikat VlessEnc.",
+        "invalidClientField": "Klien {client}: kolom {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} lainnya)"
       },
       "form": {
         "moveUp": "Naik",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "トラフィックの取得中にエラーが発生しました",
         "getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。",
         "getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。",
-        "getNewVlessEncError": "VlessEnc証明書の取得中にエラーが発生しました。"
+        "getNewVlessEncError": "VlessEnc証明書の取得中にエラーが発生しました。",
+        "invalidClientField": "クライアント {client}: フィールド {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (他 {count} 件)"
       },
       "form": {
         "moveUp": "上へ",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Erro ao obter tráfegos",
         "getNewX25519CertError": "Erro ao obter o certificado X25519.",
         "getNewmldsa65Error": "Erro ao obter o certificado mldsa65.",
-        "getNewVlessEncError": "Erro ao obter o certificado VlessEnc."
+        "getNewVlessEncError": "Erro ao obter o certificado VlessEnc.",
+        "invalidClientField": "Cliente {client}: campo {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} mais)"
       },
       "form": {
         "moveUp": "Mover para cima",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Ошибка получения данных о трафике",
         "getNewX25519CertError": "Ошибка при получении сертификата X25519.",
         "getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.",
-        "getNewVlessEncError": "Ошибка при получении сертификата VlessEnc."
+        "getNewVlessEncError": "Ошибка при получении сертификата VlessEnc.",
+        "invalidClientField": "Клиент {client}: поле {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} ещё)"
       },
       "form": {
         "moveUp": "Вверх",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Trafik bilgisi alınırken hata oluştu",
         "getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.",
         "getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.",
-        "getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu."
+        "getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu.",
+        "invalidClientField": "Müşteri {client}: alan {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} tane daha)"
       },
       "form": {
         "moveUp": "Yukarı",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Помилка отримання даних про трафік",
         "getNewX25519CertError": "Помилка при отриманні сертифіката X25519.",
         "getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.",
-        "getNewVlessEncError": "Помилка при отриманні сертифіката VlessEnc."
+        "getNewVlessEncError": "Помилка при отриманні сертифіката VlessEnc.",
+        "invalidClientField": "Клієнт {client}: поле {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} ще)"
       },
       "form": {
         "moveUp": "Вгору",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Lỗi khi lấy thông tin lưu lượng",
         "getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.",
         "getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.",
-        "getNewVlessEncError": "Lỗi khi lấy chứng chỉ VlessEnc."
+        "getNewVlessEncError": "Lỗi khi lấy chứng chỉ VlessEnc.",
+        "invalidClientField": "Khách hàng {client}: trường {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} lỗi khác)"
       },
       "form": {
         "moveUp": "Lên",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "获取流量数据时出错",
         "getNewX25519CertError": "获取X25519证书时出错。",
         "getNewmldsa65Error": "获取mldsa65证书时出错。",
-        "getNewVlessEncError": "获取VlessEnc证书时出错。"
+        "getNewVlessEncError": "获取VlessEnc证书时出错。",
+        "invalidClientField": "客户端 {client}:字段 {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (另有 {count} 项)"
       },
       "form": {
         "moveUp": "上移",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "取得流量資料時發生錯誤",
         "getNewX25519CertError": "取得X25519憑證時發生錯誤。",
         "getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",
-        "getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。"
+        "getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。",
+        "invalidClientField": "用戶端 {client}:欄位 {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (另有 {count} 項)"
       },
       "form": {
         "moveUp": "上移",