1
0

11 Коммиты acf3603dc8 ... d0edbcec81

Автор SHA1 Сообщение Дата
  Sanaei d0edbcec81 feat(xray): update xray-core to v26.9.9 and follow the udpHop move 7 часов назад
  Sanaei cfd596a489 fix(amneziawg): let a cleared header protection key reach a running device 8 часов назад
  Sanaei efc603f59c fix(settings): show the SMTP failure reason instead of a raw i18n key 8 часов назад
  Sanaei c392f367e1 fix(dns): stop offering a port field that DoH entries discard 8 часов назад
  Sanaei 705b291d34 fix(amneziawg): stop losing an inbound and its server keys on the API path 8 часов назад
  Sanaei 4e423fa452 fix(sub): drop Reality parameters when a host forces plain TLS 8 часов назад
  Sanaei 65c5580e7d fix(clients): keep per-peer keys when a client spans several tunnel inbounds 8 часов назад
  Sanaei 2004340d1d fix(inbounds): let a node-adopted inbound keep its own protocol on edit 8 часов назад
  Sanaei bc57548a35 fix(clients): withdraw the delete tombstone when the email is re-created 8 часов назад
  Sanaei 246d9207a5 fix(sub): emit a bare host in Clash proxies 8 часов назад
  Sanaei 20d7f91c65 refactor(ci): add an adversarial pass and name the analyst briefing 11 часов назад
50 измененных файлов с 926 добавлено и 131 удалено
  1. 1 1
      .github/claude/issue-analyst-context.md
  2. 1 1
      .github/workflows/claude-issue-analyst.yml
  3. 2 2
      .github/workflows/release.yml
  4. 1 1
      DockerInit.sh
  5. 32 0
      REVIEW.md
  6. 24 24
      bot_context_test.go
  7. 13 1
      frontend/src/lib/xray/inbound-link.ts
  8. 9 11
      frontend/src/lib/xray/outbound-link-parser.ts
  9. 16 8
      frontend/src/pages/xray/dns/DnsServerModal.tsx
  10. 6 3
      frontend/src/schemas/protocols/stream/finalmask.ts
  11. 19 0
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  12. 47 0
      frontend/src/test/dns-server-port-field.test.tsx
  13. 13 0
      frontend/src/test/golden/fixtures/finalmask/udp-hop.json
  14. 31 0
      frontend/src/test/inbound-link.test.ts
  15. 23 13
      frontend/src/test/outbound-link-parser.test.ts
  16. 1 1
      go.mod
  17. 2 2
      go.sum
  18. 6 2
      internal/amneziawgnet/device.go
  19. 46 1
      internal/amneziawgnet/device_test.go
  20. 4 1
      internal/sub/clash_service.go
  21. 25 0
      internal/sub/clash_service_test.go
  22. 14 0
      internal/sub/endpoint.go
  23. 24 0
      internal/sub/host_sub_test.go
  24. 26 4
      internal/sub/service.go
  25. 33 0
      internal/sub/service_test.go
  26. 16 7
      internal/util/link/outbound.go
  27. 48 7
      internal/util/link/outbound_test.go
  28. 5 0
      internal/web/service/client_bulk.go
  29. 28 0
      internal/web/service/client_crud.go
  30. 62 0
      internal/web/service/client_delete_tombstone_test.go
  31. 98 0
      internal/web/service/client_update_tunnel_peers_test.go
  32. 11 9
      internal/web/service/email/email.go
  33. 31 0
      internal/web/service/email/email_test.go
  34. 10 8
      internal/web/service/inbound.go
  35. 42 5
      internal/web/service/inbound_amneziawg.go
  36. 76 6
      internal/web/service/inbound_amneziawg_test.go
  37. 67 0
      internal/web/service/inbound_node_protocol_update_test.go
  38. 1 1
      internal/web/translation/ar-EG.json
  39. 1 1
      internal/web/translation/en-US.json
  40. 1 1
      internal/web/translation/es-ES.json
  41. 1 1
      internal/web/translation/fa-IR.json
  42. 1 1
      internal/web/translation/id-ID.json
  43. 1 1
      internal/web/translation/ja-JP.json
  44. 1 1
      internal/web/translation/pt-BR.json
  45. 1 1
      internal/web/translation/ru-RU.json
  46. 1 1
      internal/web/translation/tr-TR.json
  47. 1 1
      internal/web/translation/uk-UA.json
  48. 1 1
      internal/web/translation/vi-VN.json
  49. 1 1
      internal/web/translation/zh-CN.json
  50. 1 1
      internal/web/translation/zh-TW.json

+ 1 - 1
.github/claude/repo-context.md → .github/claude/issue-analyst-context.md

@@ -1,4 +1,4 @@
-# Repository context for the Claude bot
+# Repository context for the issue analyst
 
 
 Briefing for the issue analyst in `.github/workflows/claude-issue-analyst.yml`.
 Briefing for the issue analyst in `.github/workflows/claude-issue-analyst.yml`.
 It exists so these facts live in ONE place next to the code instead of being
 It exists so these facts live in ONE place next to the code instead of being

+ 1 - 1
.github/workflows/claude-issue-analyst.yml

@@ -79,7 +79,7 @@ jobs:
             needs, and no deeper.
             needs, and no deeper.
 
 
             REPOSITORY CONTEXT
             REPOSITORY CONTEXT
-            Read `.github/claude/repo-context.md` in the checkout before you answer
+            Read `.github/claude/issue-analyst-context.md` in the checkout before you answer
             anything. It carries the stack, the repository map, the hard rules, what CI
             anything. It carries the stack, the repository map, the hard rules, what CI
             runs, and the support facts reporters most often get wrong - the random
             runs, and the support facts reporters most often get wrong - the random
             generated credentials, the distro-dependent service environment file, the
             generated credentials, the distro-dependent service environment file, the

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

@@ -124,7 +124,7 @@ jobs:
           cd x-ui/bin
           cd x-ui/bin
 
 
           # Download dependencies
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.8/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.9/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             fetch ${Xray_URL}Xray-linux-64.zip
             fetch ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -287,7 +287,7 @@ jobs:
           cd x-ui\bin
           cd x-ui\bin
 
 
           # Download Xray for Windows
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.8/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/"
           Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
 fi
 fi
 mkdir -p build/bin
 mkdir -p build/bin
 cd build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.8/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"
 mv xray "xray-linux-${FNAME}"

+ 32 - 0
REVIEW.md

@@ -79,6 +79,38 @@ surface — still pre-existing, but open the summary with it.
   (never testify), the panel is Ant Design (never Tailwind or shadcn). Neither
   (never testify), the panel is Ant Design (never Tailwind or shadcn). Neither
   golangci-lint nor oxlint forbids the import, so it passes CI clean.
   golangci-lint nor oxlint forbids the import, so it passes CI clean.
 
 
+## Try to break it
+
+The question behind every finding is how this change fails in production, so
+read the changed code under the conditions this panel actually meets rather
+than the happy path the author had in mind:
+
+- **An upgrade over an operator's existing database.** Rows written before
+  this change: a column added with its zero value, a field the old writer
+  never set, a settings blob in the older shape. And the way back, because
+  there are no down-migrations — an operator who rolls the binary back reads
+  the same rows.
+- **A restart.** Anything held only in memory is gone when the panel or the
+  Xray child restarts, and the cron jobs in `internal/web/job/` then fire
+  against whatever survived.
+- **A second actor at the same instant.** Two panel requests, a request racing
+  a cron job, or a sub-node syncing while the master writes. Read-modify-write
+  on the same row is where this surfaces.
+- **The same operation twice.** A retried request, a re-sent sync, a job that
+  ran late and then again on schedule. Traffic and quota resets and Xray API
+  calls have to survive being applied a second time.
+- **Absent, empty and extreme input.** An inbound with no clients, a client
+  with no traffic, an expired or disabled one, a nil settings blob — and the
+  other end, the operator with thousands of clients whose loop or query this
+  change sits inside.
+- **A dependency that is down.** The Xray gRPC API refusing a call, the
+  mtg-multi management API unreachable, a sub-node offline, PIA or LDAP
+  timing out. What the caller sees, and what state is left behind.
+
+Running a case is not reporting it. Each one still has to clear the
+verification bar below — the code path that mishandles it, cited — and a case
+the code already handles is not a finding at all.
+
 ## Do not report
 ## Do not report
 
 
 - Anything CI already enforces: golangci-lint and gofumpt, oxlint, format
 - Anything CI already enforces: golangci-lint and gofumpt, oxlint, format

+ 24 - 24
bot_context_test.go

@@ -1,7 +1,7 @@
 package main
 package main
 
 
-// The bot prompts under .github/workflows/ read .github/claude/repo-context.md
-// instead of restating repo facts; a stale claim there is invisible, so pin it.
+// The issue analyst prompt reads .github/claude/issue-analyst-context.md instead
+// of restating repo facts; a stale claim there is invisible, so pin it.
 
 
 import (
 import (
 	"os"
 	"os"
@@ -12,9 +12,9 @@ import (
 )
 )
 
 
 const (
 const (
-	botContextPath = ".github/claude/repo-context.md"
-	reviewPath     = "REVIEW.md"
-	ciWorkflowPath = ".github/workflows/ci.yml"
+	analystContextPath = ".github/claude/issue-analyst-context.md"
+	reviewPath         = "REVIEW.md"
+	ciWorkflowPath     = ".github/workflows/ci.yml"
 )
 )
 
 
 func readRepoFile(t *testing.T, path string) string {
 func readRepoFile(t *testing.T, path string) string {
@@ -32,7 +32,7 @@ func section(t *testing.T, doc, from, to string) string {
 	t.Helper()
 	t.Helper()
 	i := strings.Index(doc, from)
 	i := strings.Index(doc, from)
 	if i < 0 {
 	if i < 0 {
-		t.Fatalf("%s no longer contains the heading %q", botContextPath, from)
+		t.Fatalf("%s no longer contains the heading %q", analystContextPath, from)
 	}
 	}
 	rest := doc[i+len(from):]
 	rest := doc[i+len(from):]
 	if before, _, ok := strings.Cut(rest, to); ok {
 	if before, _, ok := strings.Cut(rest, to); ok {
@@ -41,18 +41,18 @@ func section(t *testing.T, doc, from, to string) string {
 	return rest
 	return rest
 }
 }
 
 
-func TestBotContextLocaleFileCount(t *testing.T) {
-	doc := readRepoFile(t, botContextPath)
+func TestAnalystContextLocaleFileCount(t *testing.T) {
+	doc := readRepoFile(t, analystContextPath)
 	m := regexp.MustCompile("`internal/web/translation/` \\((\\d+) files\\)").FindStringSubmatch(doc)
 	m := regexp.MustCompile("`internal/web/translation/` \\((\\d+) files\\)").FindStringSubmatch(doc)
 	if m == nil {
 	if m == nil {
-		t.Fatalf("%s no longer states the locale file count as \"`internal/web/translation/` (N files)\"", botContextPath)
+		t.Fatalf("%s no longer states the locale file count as \"`internal/web/translation/` (N files)\"", analystContextPath)
 	}
 	}
 	files, err := filepath.Glob("internal/web/translation/*.json")
 	files, err := filepath.Glob("internal/web/translation/*.json")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("glob locales: %v", err)
 		t.Fatalf("glob locales: %v", err)
 	}
 	}
 	if got := len(files); m[1] != itoa(got) {
 	if got := len(files); m[1] != itoa(got) {
-		t.Errorf("%s claims %s locale files, internal/web/translation/ holds %d; update the claim and every prompt that relies on it", botContextPath, m[1], got)
+		t.Errorf("%s claims %s locale files, internal/web/translation/ holds %d; update the claim and every prompt that relies on it", analystContextPath, m[1], got)
 	}
 	}
 }
 }
 
 
@@ -68,26 +68,26 @@ func itoa(n int) string {
 	return string(b)
 	return string(b)
 }
 }
 
 
-func TestBotContextNamesRealCIJobs(t *testing.T) {
-	doc := readRepoFile(t, botContextPath)
+func TestAnalystContextNamesRealCIJobs(t *testing.T) {
+	doc := readRepoFile(t, analystContextPath)
 	ci := readRepoFile(t, ciWorkflowPath)
 	ci := readRepoFile(t, ciWorkflowPath)
 	table := section(t, doc, "## What CI runs", "**What CI does NOT prove.**")
 	table := section(t, doc, "## What CI runs", "**What CI does NOT prove.**")
 	rows := regexp.MustCompile("(?m)^\\| `([a-z0-9-]+)` \\|").FindAllStringSubmatch(table, -1)
 	rows := regexp.MustCompile("(?m)^\\| `([a-z0-9-]+)` \\|").FindAllStringSubmatch(table, -1)
 	if len(rows) < 5 {
 	if len(rows) < 5 {
-		t.Fatalf("expected the CI table in %s to list at least 5 jobs, found %d", botContextPath, len(rows))
+		t.Fatalf("expected the CI table in %s to list at least 5 jobs, found %d", analystContextPath, len(rows))
 	}
 	}
 	for _, r := range rows {
 	for _, r := range rows {
 		t.Run(r[1], func(t *testing.T) {
 		t.Run(r[1], func(t *testing.T) {
 			if !strings.Contains(ci, "\n  "+r[1]+":\n") {
 			if !strings.Contains(ci, "\n  "+r[1]+":\n") {
-				t.Errorf("%s describes a CI job %q that %s does not define", botContextPath, r[1], ciWorkflowPath)
+				t.Errorf("%s describes a CI job %q that %s does not define", analystContextPath, r[1], ciWorkflowPath)
 			}
 			}
 		})
 		})
 	}
 	}
 }
 }
 
 
-func TestBotContextNamesRealPaths(t *testing.T) {
-	// REVIEW.md briefs the review job the way repo-context.md briefs the
-	// issue bot, so both get their paths pinned.
+func TestAnalystContextNamesRealPaths(t *testing.T) {
+	// REVIEW.md briefs the review job the way issue-analyst-context.md briefs
+	// the analyst, so both get their paths pinned.
 	// internal/web/dist and frontend/node_modules are build output: absent from a
 	// internal/web/dist and frontend/node_modules are build output: absent from a
 	// fresh clone, created by `make dist-stub` and `npm ci`.
 	// fresh clone, created by `make dist-stub` and `npm ci`.
 	generated := map[string]bool{
 	generated := map[string]bool{
@@ -97,7 +97,7 @@ func TestBotContextNamesRealPaths(t *testing.T) {
 	}
 	}
 	seen := map[string]bool{}
 	seen := map[string]bool{}
 	counts := map[string]int{}
 	counts := map[string]int{}
-	for _, src := range []string{botContextPath, reviewPath} {
+	for _, src := range []string{analystContextPath, reviewPath} {
 		for _, m := range regexp.MustCompile("`([^`]+)`").FindAllStringSubmatch(readRepoFile(t, src), -1) {
 		for _, m := range regexp.MustCompile("`([^`]+)`").FindAllStringSubmatch(readRepoFile(t, src), -1) {
 			p := m[1]
 			p := m[1]
 			if !regexp.MustCompile(`^(internal|frontend|docs|tools|\.github)/`).MatchString(p) ||
 			if !regexp.MustCompile(`^(internal|frontend|docs|tools|\.github)/`).MatchString(p) ||
@@ -113,19 +113,19 @@ func TestBotContextNamesRealPaths(t *testing.T) {
 			})
 			})
 		}
 		}
 	}
 	}
-	if counts[botContextPath] < 20 {
-		t.Errorf("expected the bot context to name at least 20 repository paths, found %d - has the file been gutted?", counts[botContextPath])
+	if counts[analystContextPath] < 20 {
+		t.Errorf("expected the bot context to name at least 20 repository paths, found %d - has the file been gutted?", counts[analystContextPath])
 	}
 	}
 }
 }
 
 
-func TestBotContextSkipGatesExist(t *testing.T) {
-	doc := readRepoFile(t, botContextPath)
+func TestAnalystContextSkipGatesExist(t *testing.T) {
+	doc := readRepoFile(t, analystContextPath)
 	table := section(t, doc, "**What CI does NOT prove.**", "Mutation testing")
 	table := section(t, doc, "**What CI does NOT prove.**", "Mutation testing")
 	// [A-Z0-9_] and not [A-Z_]: XRAY_E2E_BINARY carries a digit, and excluding it
 	// [A-Z0-9_] and not [A-Z_]: XRAY_E2E_BINARY carries a digit, and excluding it
 	// silently dropped that gate from the check instead of failing.
 	// silently dropped that gate from the check instead of failing.
 	gates := regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(table, -1)
 	gates := regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(table, -1)
 	if len(gates) < 5 {
 	if len(gates) < 5 {
-		t.Fatalf("expected at least 5 skip-gate variables in %s, found %d", botContextPath, len(gates))
+		t.Fatalf("expected at least 5 skip-gate variables in %s, found %d", analystContextPath, len(gates))
 	}
 	}
 	var sources []string
 	var sources []string
 	err := filepath.WalkDir("internal", func(path string, d os.DirEntry, err error) error {
 	err := filepath.WalkDir("internal", func(path string, d os.DirEntry, err error) error {
@@ -147,7 +147,7 @@ func TestBotContextSkipGatesExist(t *testing.T) {
 					return
 					return
 				}
 				}
 			}
 			}
-			t.Errorf("%s lists %s as a test skip gate, but no .go file under internal/ reads it", botContextPath, g[1])
+			t.Errorf("%s lists %s as a test skip gate, but no .go file under internal/ reads it", analystContextPath, g[1])
 		})
 		})
 	}
 	}
 }
 }

+ 13 - 1
frontend/src/lib/xray/inbound-link.ts

@@ -751,6 +751,18 @@ function hysteriaPinHex(pin: string): string {
   }
   }
 }
 }
 
 
+// Hysteria2 hop range advertised as `mport`. xray-core 26.9.9 moved hopping
+// from finalmask.quicParams.udpHop to a 'udphop' UDP mask; inbounds stored
+// before the upgrade still carry the old key.
+function udpHopPorts(stream: NonNullable<Inbound['streamSettings']>): string {
+  for (const mask of stream.finalmask?.udp ?? []) {
+    if (mask.type !== 'udphop') continue;
+    const ports = mask.settings?.remotePorts;
+    if (typeof ports === 'string' && ports.trim().length > 0) return ports.trim();
+  }
+  return stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
+}
+
 // Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
 // Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
 // The scheme is always hysteria2 — xray-core builds version 2 only, so the
 // The scheme is always hysteria2 — xray-core builds version 2 only, so the
 // settings schema pins it there and the subscription server emits the same
 // settings schema pins it there and the subscription server emits the same
@@ -817,7 +829,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
     }
     }
   }
   }
 
 
-  const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
+  const hopPorts = udpHopPorts(stream);
   if (hopPorts.length > 0) {
   if (hopPorts.length > 0) {
     params.set('mport', hopPorts);
     params.set('mport', hopPorts);
   }
   }

+ 9 - 11
frontend/src/lib/xray/outbound-link-parser.ts

@@ -311,21 +311,19 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
   finalmask.udp = [...udp, { type: 'salamander', settings }];
   finalmask.udp = [...udp, { type: 'salamander', settings }];
 }
 }
 
 
-// Rebuild the UDP port-hopping range from the standard mport param, which the
-// generator emits as finalmask.quicParams.udpHop.ports. A range already supplied
-// via fm= wins; the client-side interval falls back to the panel's default.
+// Rebuild the UDP port-hopping range from the standard mport param. xray-core
+// 26.9.9 replaced finalmask.quicParams.udpHop with a 'udphop' UDP mask, whose
+// intervalremote mode is what the old key used to do; an fm= mask wins.
 function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
 function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
   const ports = firstParam(params, 'mport');
   const ports = firstParam(params, 'mport');
   if (!ports) return;
   if (!ports) return;
   const finalmask = ensureFinalMask(stream);
   const finalmask = ensureFinalMask(stream);
-  const quicParams = (
-    finalmask.quicParams && typeof finalmask.quicParams === 'object'
-      ? finalmask.quicParams
-      : (finalmask.quicParams = {})
-  ) as Raw;
-  const existingHop = quicParams.udpHop as Raw | undefined;
-  if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return;
-  quicParams.udpHop = { ports, interval: '5-10' };
+  const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
+  if (udp.some((mask) => (mask as Raw | undefined)?.type === 'udphop')) return;
+  finalmask.udp = [
+    ...udp,
+    { type: 'udphop', settings: { mode: 'intervalremote', interval: '5-10', remotePorts: ports } },
+  ];
 }
 }
 
 
 const QUIC_PARAMS_NUMERIC_KEYS = [
 const QUIC_PARAMS_NUMERIC_KEYS = [

+ 16 - 8
frontend/src/pages/xray/dns/DnsServerModal.tsx

@@ -137,7 +137,13 @@ export default function DnsServerModal({
   onConfirm,
   onConfirm,
 }: DnsServerModalProps) {
 }: DnsServerModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const methods = useForm<DnsServerForm>({ defaultValues: defaultFormValues() });
+  const methods = useForm<DnsServerForm>({
+    defaultValues: defaultFormValues(),
+  });
+  const address = useWatch({ control: methods.control, name: 'address' }) ?? '';
+  // Xray ignores port for DoH/DoHL/DoQL, so valuesToWire never stores one:
+  // offering the field there discards whatever is typed into it.
+  const portApplies = !isEncryptedDnsAddress(address);
   const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
   const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
   const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
   const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
   const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
   const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
@@ -168,13 +174,15 @@ export default function DnsServerModal({
           >
           >
             <Input />
             <Input />
           </FormField>
           </FormField>
-          <FormField
-            label={t('pages.inbounds.port')}
-            name="port"
-            rules={{ validate: rhfZodValidate(shape.port) }}
-          >
-            <InputNumber min={1} max={65535} />
-          </FormField>
+          {portApplies && (
+            <FormField
+              label={t('pages.inbounds.port')}
+              name="port"
+              rules={{ validate: rhfZodValidate(shape.port) }}
+            >
+              <InputNumber min={1} max={65535} />
+            </FormField>
+          )}
           <FormField label={t('pages.xray.dns.tag')} name="tag">
           <FormField label={t('pages.xray.dns.tag')} name="tag">
             <Input />
             <Input />
           </FormField>
           </FormField>

+ 6 - 3
frontend/src/schemas/protocols/stream/finalmask.ts

@@ -19,6 +19,8 @@ export const TcpMaskSchema = z.object({
 });
 });
 export type TcpMask = z.infer<typeof TcpMaskSchema>;
 export type TcpMask = z.infer<typeof TcpMaskSchema>;
 
 
+// 'udphop' is client-only in xray-core (it refuses to wrap a server socket),
+// so it round-trips here but is deliberately absent from the mask dropdown.
 export const UdpMaskTypeSchema = z.enum([
 export const UdpMaskTypeSchema = z.enum([
   'salamander',
   'salamander',
   'mkcp-legacy',
   'mkcp-legacy',
@@ -28,6 +30,7 @@ export const UdpMaskTypeSchema = z.enum([
   'noise',
   'noise',
   'sudoku',
   'sudoku',
   'realm',
   'realm',
+  'udphop',
 ]);
 ]);
 export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
 export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
 
 
@@ -43,9 +46,9 @@ export type QuicCongestion = z.infer<typeof QuicCongestionSchema>;
 export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']);
 export const BbrProfileSchema = z.enum(['conservative', 'standard', 'aggressive']);
 export type BbrProfile = z.infer<typeof BbrProfileSchema>;
 export type BbrProfile = z.infer<typeof BbrProfileSchema>;
 
 
-// udpHop randomizes the QUIC port between a range every `interval` seconds
-// to dodge port-based blocking. Both fields are dash-range strings on the
-// wire (e.g. '20000-50000', '5-10'). preprocess coerces legacy DB rows
+// udpHop declares the hop range advertised to clients as `mport`. xray-core
+// 26.9.9 moved actual hopping to the 'udphop' UDP mask and ignores this key,
+// which was always inert server-side. preprocess coerces legacy DB rows
 // where interval was stored as a number (UI bug — see B19 in commit history).
 // where interval was stored as a number (UI bug — see B19 in commit history).
 const StringRangeSchema = z.preprocess((v) => (typeof v === 'number' ? String(v) : v), z.string());
 const StringRangeSchema = z.preprocess((v) => (typeof v === 'number' ? String(v) : v), z.string());
 
 

+ 19 - 0
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -201,6 +201,25 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
 }
 }
 `;
 `;
 
 
+exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-hop byte-stably 1`] = `
+{
+  "tcp": [],
+  "udp": [
+    {
+      "settings": {
+        "interval": "5-10",
+        "mode": "intervalremote",
+        "remoteIPs": [
+          "203.0.113.0/24",
+        ],
+        "remotePorts": "20000-50000",
+      },
+      "type": "udphop",
+    },
+  ],
+}
+`;
+
 exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`] = `
 exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`] = `
 {
 {
   "tcp": [],
   "tcp": [],

+ 47 - 0
frontend/src/test/dns-server-port-field.test.tsx

@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+
+import DnsServerModal from '@/pages/xray/dns/DnsServerModal';
+import { renderWithProviders } from './test-utils';
+
+describe('DnsServerModal port field', () => {
+  it('hides the port for an encrypted address, whose port lives in the URL', () => {
+    renderWithProviders(
+      <DnsServerModal
+        open
+        server="https://dns.example.com/dns-query"
+        isEdit
+        onClose={() => {}}
+        onConfirm={() => {}}
+      />,
+    );
+
+    expect(screen.queryByLabelText('Port')).toBeNull();
+  });
+
+  it('offers the port for a plain address and for DoT', () => {
+    renderWithProviders(
+      <DnsServerModal
+        open
+        server="tls://dns.example.com"
+        isEdit
+        onClose={() => {}}
+        onConfirm={() => {}}
+      />,
+    );
+
+    expect(screen.getByLabelText('Port')).toBeTruthy();
+  });
+
+  it('drops the port field as soon as the address becomes a DoH URL', () => {
+    renderWithProviders(
+      <DnsServerModal open server="1.1.1.1" isEdit onClose={() => {}} onConfirm={() => {}} />,
+    );
+
+    expect(screen.getByLabelText('Port')).toBeTruthy();
+    fireEvent.change(screen.getByLabelText('Address'), {
+      target: { value: 'https://dns.example.com/dns-query' },
+    });
+    expect(screen.queryByLabelText('Port')).toBeNull();
+  });
+});

+ 13 - 0
frontend/src/test/golden/fixtures/finalmask/udp-hop.json

@@ -0,0 +1,13 @@
+{
+  "udp": [
+    {
+      "type": "udphop",
+      "settings": {
+        "mode": "intervalremote",
+        "interval": "5-10",
+        "remotePorts": "20000-50000",
+        "remoteIPs": ["203.0.113.0/24"]
+      }
+    }
+  ]
+}

+ 31 - 0
frontend/src/test/inbound-link.test.ts

@@ -233,6 +233,37 @@ describe('genHysteriaLink', () => {
     expect(link.endsWith('#hop-test')).toBe(true);
     expect(link.endsWith('#hop-test')).toBe(true);
   });
   });
 
 
+  it('emits mport from the udphop mask xray-core 26.9.9 moved hopping to', () => {
+    const [, raw] = fixtures[0];
+    const withHop = {
+      ...raw,
+      settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
+      streamSettings: {
+        ...(raw.streamSettings as Record<string, unknown>),
+        finalmask: {
+          udp: [
+            {
+              type: 'udphop',
+              settings: { mode: 'intervalremote', interval: '5-10', remotePorts: '30000-40000' },
+            },
+          ],
+        },
+      },
+    };
+    const typed = InboundSchema.parse(withHop);
+    const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
+
+    const link = genHysteriaLink({
+      inbound: typed,
+      address: 'example.test',
+      port: typed.port,
+      remark: 'hop-mask',
+      clientAuth: client.auth,
+    });
+
+    expect(link).toContain('mport=30000-40000');
+  });
+
   it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
   it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
     const [, raw] = fixtures[0];
     const [, raw] = fixtures[0];
     const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';
     const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';

+ 23 - 13
frontend/src/test/outbound-link-parser.test.ts

@@ -443,7 +443,9 @@ describe('parseHysteria2Link', () => {
     expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
     expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
   });
   });
 
 
-  it('reconstructs udpHop from the standard mport param', () => {
+  // xray-core 26.9.9 ignores quicParams.udpHop; hopping is a 'udphop' UDP mask
+  // and its mode must be one the core's UDPHop.Build() accepts.
+  it('reconstructs a udphop mask from the standard mport param', () => {
     const out = parseHysteria2Link(
     const out = parseHysteria2Link(
       'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
       'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
     );
     );
@@ -451,16 +453,25 @@ describe('parseHysteria2Link', () => {
       string,
       string,
       unknown
       unknown
     >;
     >;
-    const quic = finalmask.quicParams as Record<string, unknown>;
-    const udpHop = quic.udpHop as Record<string, unknown>;
-    expect(udpHop.ports).toBe('20000-50000');
-    expect(udpHop.interval).toBe('5-10');
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    const hop = udp.find((mask) => mask.type === 'udphop');
+    expect(hop).toBeDefined();
+    const settings = hop!.settings as Record<string, unknown>;
+    expect(settings.remotePorts).toBe('20000-50000');
+    expect(settings.interval).toBe('5-10');
+    expect(settings.mode).toBe('intervalremote');
+    expect((finalmask.quicParams as Record<string, unknown> | undefined)?.udpHop).toBeUndefined();
   });
   });
 
 
-  it('lets an fm= udpHop win over mport', () => {
+  it('lets an fm= udphop mask win over mport', () => {
     const fm = encodeURIComponent(
     const fm = encodeURIComponent(
       JSON.stringify({
       JSON.stringify({
-        quicParams: { udpHop: { ports: '30000-40000', interval: '7-9' } },
+        udp: [
+          {
+            type: 'udphop',
+            settings: { mode: 'intervalremote', interval: '7-9', remotePorts: '30000-40000' },
+          },
+        ],
       }),
       }),
     );
     );
     const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
     const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
@@ -469,12 +480,11 @@ describe('parseHysteria2Link', () => {
       string,
       string,
       unknown
       unknown
     >;
     >;
-    const udpHop = (finalmask.quicParams as Record<string, unknown>).udpHop as Record<
-      string,
-      unknown
-    >;
-    expect(udpHop.ports).toBe('30000-40000');
-    expect(udpHop.interval).toBe('7-9');
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    expect(udp).toHaveLength(1);
+    const settings = udp[0].settings as Record<string, unknown>;
+    expect(settings.remotePorts).toBe('30000-40000');
+    expect(settings.interval).toBe('7-9');
   });
   });
 
 
   it('round-trips the salamander packetSize (Gecko) under fm', () => {
   it('round-trips the salamander packetSize (Gecko) under fm', () => {

+ 1 - 1
go.mod

@@ -25,7 +25,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.74.0
 	github.com/valyala/fasthttp v1.74.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e
+	github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5
 	go.uber.org/atomic v1.11.0
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.56.0
 	golang.org/x/crypto v0.56.0
 	golang.org/x/net v0.58.0
 	golang.org/x/net v0.58.0

+ 2 - 2
go.sum

@@ -231,8 +231,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws=
 github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws=
 github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
 github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e h1:yQS0pPlOBi8y6bHNXbqx9AYito2pAXgItwKK7Waqaw8=
-github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e/go.mod h1:G9OqFEPNkwNoxywvEkdBncyfeytCzC31CBazgy3d9ic=
+github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 h1:BsUC2sCXcdVCb09SUh1iWku0ci779t4bUIlKUor1ZRI=
+github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5/go.mod h1:obbr2WDmr/cpQ/YLe1k0HTULnFXCO0rTWIeSrHoFk3o=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=

+ 6 - 2
internal/amneziawgnet/device.go

@@ -205,13 +205,17 @@ func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error
 	writeOptionalLine(&b, "i4", o.I4)
 	writeOptionalLine(&b, "i4", o.I4)
 	writeOptionalLine(&b, "i5", o.I5)
 	writeOptionalLine(&b, "i5", o.I5)
 
 
+	// An omitted line means "unchanged" to amneziawg-go, so a cleared key can
+	// only reach a live device as the all-zero one that disables the feature.
+	hpHex := strings.Repeat("0", 64)
 	if opts.HeaderProtectionKey != "" {
 	if opts.HeaderProtectionKey != "" {
-		hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey)
+		var err error
+		hpHex, err = wireguard.KeyToHex(opts.HeaderProtectionKey)
 		if err != nil {
 		if err != nil {
 			return "", fmt.Errorf("invalid header protection key: %w", err)
 			return "", fmt.Errorf("invalid header protection key: %w", err)
 		}
 		}
-		fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
 	}
 	}
+	fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
 	if opts.ContentPaddingAddition != "" {
 	if opts.ContentPaddingAddition != "" {
 		fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
 		fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
 	}
 	}

+ 46 - 1
internal/amneziawgnet/device_test.go

@@ -191,7 +191,12 @@ func TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines(t *testing.T) {
 	if err != nil {
 	if err != nil {
 		t.Fatalf("buildUAPIConfig with empty options: %v", err)
 		t.Fatalf("buildUAPIConfig with empty options: %v", err)
 	}
 	}
-	if strings.Contains(conf, "header_protection_key=") || strings.Contains(conf, "content_padding_addition=") {
+	// header_protection_key is the exception: an omitted line reads as
+	// "unchanged", so clearing the key has to be sent as the all-zero one.
+	if !strings.Contains(conf, "header_protection_key="+strings.Repeat("0", 64)+"\n") {
+		t.Fatalf("an unset key must be emitted as the all-zero key, got:\n%s", conf)
+	}
+	if strings.Contains(conf, "content_padding_addition=") {
 		t.Fatalf("empty DeviceOptions must not emit AWG 3.0 lines, got:\n%s", conf)
 		t.Fatalf("empty DeviceOptions must not emit AWG 3.0 lines, got:\n%s", conf)
 	}
 	}
 
 
@@ -624,3 +629,43 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+// Clearing HeaderProtectionKey on a running inbound must actually reach the
+// device: amneziawg-go treats an absent UAPI line as "keep the current value",
+// so an omitted key leaves header protection permanently on. Worse, the stale
+// key keeps the S1-S4 minimum alive, so lowering them then fails IpcSet with
+// -22 on every reconcile after the peers were already replaced.
+func TestBuildUAPIConfigClearedHeaderProtectionKeyIsSentAsZero(t *testing.T) {
+	priv, _, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate keypair: %v", err)
+	}
+	inst := amneziawg.Instance{
+		PrivateKey:  priv,
+		Obfuscation: amneziawg.Obfuscation31{S1: 20, S2: 20, S3: 20, S4: 20},
+	}
+
+	key, err := wireguard.GenerateWireguardPSK()
+	if err != nil {
+		t.Fatalf("generate header protection key: %v", err)
+	}
+	withKey, err := buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key})
+	if err != nil {
+		t.Fatalf("buildUAPIConfig with a key: %v", err)
+	}
+	cleared, err := buildUAPIConfig(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatalf("buildUAPIConfig with the key cleared: %v", err)
+	}
+	if withKey == cleared {
+		t.Fatal("clearing the key produced an identical UAPI config, so the device would never see the change")
+	}
+
+	zero := "header_protection_key=" + strings.Repeat("0", 64) + "\n"
+	if !strings.Contains(cleared, zero) {
+		t.Fatalf("cleared config must carry the all-zero key, got:\n%s", cleared)
+	}
+	if strings.Contains(withKey, zero) {
+		t.Fatalf("a configured key must not be emitted as zero, got:\n%s", withKey)
+	}
+}

+ 4 - 1
internal/sub/clash_service.go

@@ -234,7 +234,10 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
 		// the synthetic/legacy entry) before it becomes the proxy name.
 		// the synthetic/legacy entry) before it becomes the proxy name.
 		subReq.renderHostRemark(inbound, client, extPrxy, network)
 		subReq.renderHostRemark(inbound, client, extPrxy, network)
 		workingInbound := *inbound
 		workingInbound := *inbound
-		workingInbound.Listen, _ = extPrxy["dest"].(string)
+		// A Clash "server" is a bare host, not a URI authority, and the custom
+		// share address stores IPv6 literals bracketed.
+		dest, _ := extPrxy["dest"].(string)
+		workingInbound.Listen = strings.Trim(dest, "[]")
 		if port, ok := extPrxy["port"].(float64); ok {
 		if port, ok := extPrxy["port"].(float64); ok {
 			workingInbound.Port = int(port)
 			workingInbound.Port = int(port)
 		}
 		}

+ 25 - 0
internal/sub/clash_service_test.go

@@ -883,3 +883,28 @@ func TestBuildWireguardProxyForClashNoKey(t *testing.T) {
 		t.Fatalf("buildProxy = %v, want nil for a keyless wireguard client", proxy)
 		t.Fatalf("buildProxy = %v, want nil for a keyless wireguard client", proxy)
 	}
 	}
 }
 }
+
+// TestGetProxies_CustomIPv6ShareAddrIsUnbracketed pins that a Clash "server" is a
+// bare host: the custom share address stores IPv6 literals bracketed, and mihomo
+// rejects "[2001:db8::1]" there.
+func TestGetProxies_CustomIPv6ShareAddrIsUnbracketed(t *testing.T) {
+	svc := &SubClashService{SubService: &SubService{}}
+	inbound := &model.Inbound{
+		Protocol:          model.VLESS,
+		Port:              443,
+		Remark:            "r",
+		Settings:          `{"encryption":"none"}`,
+		StreamSettings:    `{"network":"tcp","security":"none"}`,
+		ShareAddrStrategy: "custom",
+		ShareAddr:         "[2001:db8::1]",
+	}
+	client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Email: "[email protected]"}
+
+	proxies := svc.getProxies(svc.SubService, inbound, client, "panel.example.com")
+	if len(proxies) != 1 {
+		t.Fatalf("getProxies returned %d proxies, want 1", len(proxies))
+	}
+	if got := proxies[0]["server"]; got != "2001:db8::1" {
+		t.Fatalf("server = %v, want 2001:db8::1", got)
+	}
+}

+ 14 - 0
internal/sub/endpoint.go

@@ -69,6 +69,19 @@ func applyEndpointTLSObj(e ShareEndpoint, obj map[string]any, security string) {
 	}
 	}
 }
 }
 
 
+// dropBaseRealityParams removes the parameters that only mean something on a
+// reality link once a host forces the endpoint to plain TLS or no TLS.
+func dropBaseRealityParams(params map[string]string, baseSecurity, securityToApply string) {
+	if baseSecurity != "reality" || securityToApply == "reality" {
+		return
+	}
+	// sni and fp name the master's reality dest, not this endpoint's own
+	// certificate; the host's values are re-applied right after this.
+	for _, k := range []string{"pbk", "sid", "spx", "pqv", "sni", "fp"} {
+		delete(params, k)
+	}
+}
+
 // buildEndpointLinks renders one URL-param link per endpoint (vless/trojan/ss).
 // buildEndpointLinks renders one URL-param link per endpoint (vless/trojan/ss).
 // securityToApply mirrors the legacy externalProxy loop: "same" keeps the base
 // securityToApply mirrors the legacy externalProxy loop: "same" keeps the base
 // security, otherwise the endpoint's forceTls wins; "none" strips TLS hint
 // security, otherwise the endpoint's forceTls wins; "none" strips TLS hint
@@ -87,6 +100,7 @@ func (s *SubService) buildEndpointLinks(
 			securityToApply = e.ForceTls
 			securityToApply = e.ForceTls
 		}
 		}
 		nextParams := cloneStringMap(params)
 		nextParams := cloneStringMap(params)
+		dropBaseRealityParams(nextParams, baseSecurity, securityToApply)
 		applyEndpointTLSParams(e, nextParams, securityToApply)
 		applyEndpointTLSParams(e, nextParams, securityToApply)
 		applyEndpointRealityParams(e, nextParams, securityToApply)
 		applyEndpointRealityParams(e, nextParams, securityToApply)
 		applyEndpointHostPath(e, nextParams)
 		applyEndpointHostPath(e, nextParams)

+ 24 - 0
internal/sub/host_sub_test.go

@@ -418,3 +418,27 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) {
 		t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
 		t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
 	}
 	}
 }
 }
+
+// A host that forces plain TLS over a Reality inbound must not leave the
+// Reality identity behind: pbk/sid/spx and the Reality dest sni describe a
+// handshake the endpoint no longer performs.
+func TestSub_HostTlsOverRealityDropsRealityParams(t *testing.T) {
+	seedSubDB(t)
+	reality := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["master-dest.example.com"],"publicKey":"MASTERPBK","shortIds":["ab12"],"fingerprint":"chrome"}}`
+	ib := seedSubInbound(t, "s1", "reality-in", 4461, 1, reality)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "edge.example.com", Port: 443, Security: "tls"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	if !strings.Contains(joined, "security=tls") {
+		t.Fatalf("host forces tls, link must say so: %s", joined)
+	}
+	for _, leaked := range []string{"pbk=", "sid=", "spx=", "sni=master-dest.example.com"} {
+		if strings.Contains(joined, leaked) {
+			t.Fatalf("reality parameter %q survived a tls host override: %s", leaked, joined)
+		}
+	}
+}

+ 26 - 4
internal/sub/service.go

@@ -1368,18 +1368,39 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
 }
 }
 
 
-// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range
-// (finalmask.quicParams.udpHop.ports), or "" when port hopping is off. The
-// range is emitted as the v2rayN-compatible `mport` query param; the URL port
-// field stays numeric so .NET-Uri-based importers (v2rayN) can parse the link.
+// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range, or
+// "" when port hopping is off. The range is emitted as the v2rayN-compatible
+// `mport` query param; the URL port field stays numeric so .NET-Uri-based
+// importers (v2rayN) can parse the link.
 func hysteriaHopPorts(stream map[string]any) string {
 func hysteriaHopPorts(stream map[string]any) string {
 	finalmask, _ := stream["finalmask"].(map[string]any)
 	finalmask, _ := stream["finalmask"].(map[string]any)
+	if ports := udpHopMaskPorts(finalmask); ports != "" {
+		return ports
+	}
 	quicParams, _ := finalmask["quicParams"].(map[string]any)
 	quicParams, _ := finalmask["quicParams"].(map[string]any)
 	udpHop, _ := quicParams["udpHop"].(map[string]any)
 	udpHop, _ := quicParams["udpHop"].(map[string]any)
 	ports, _ := udpHop["ports"].(string)
 	ports, _ := udpHop["ports"].(string)
 	return strings.TrimSpace(ports)
 	return strings.TrimSpace(ports)
 }
 }
 
 
+// udpHopMaskPorts reads remotePorts off the first "udphop" UDP mask. xray-core
+// 26.9.9 moved hopping here from finalmask.quicParams.udpHop, which it now ignores.
+func udpHopMaskPorts(finalmask map[string]any) string {
+	masks, _ := finalmask["udp"].([]any)
+	for _, rawMask := range masks {
+		mask, _ := rawMask.(map[string]any)
+		if maskType, _ := mask["type"].(string); maskType != "udphop" {
+			continue
+		}
+		settings, _ := mask["settings"].(map[string]any)
+		ports, _ := settings["remotePorts"].(string)
+		if ports = strings.TrimSpace(ports); ports != "" {
+			return ports
+		}
+	}
+	return ""
+}
+
 // gecko packetSize bounds mirror xray-core's salamander buffer cap and the
 // gecko packetSize bounds mirror xray-core's salamander buffer cap and the
 // frontend editor, so both link generators emit identical URIs.
 // frontend editor, so both link generators emit identical URIs.
 const (
 const (
@@ -2469,6 +2490,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{
 	"noise":         {},
 	"noise":         {},
 	"header-custom": {},
 	"header-custom": {},
 	"realm":         {},
 	"realm":         {},
+	"udphop":        {},
 }
 }
 
 
 var validFinalMaskTCPTypes = map[string]struct{}{
 var validFinalMaskTCPTypes = map[string]struct{}{

+ 33 - 0
internal/sub/service_test.go

@@ -1081,6 +1081,19 @@ func TestMarshalFinalMask_KeepsXmcTcpMask(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestMarshalFinalMask_KeepsUdpHopMask(t *testing.T) {
+	fm := map[string]any{
+		"udp": []any{udpHopMask("20000-50000")},
+	}
+	out, ok := marshalFinalMask(fm)
+	if !ok {
+		t.Fatal("expected ok=true for a udphop udp mask")
+	}
+	if !strings.Contains(out, "udphop") || !strings.Contains(out, "20000-50000") {
+		t.Fatalf("marshaled finalmask dropped the udphop mask: %s", out)
+	}
+}
+
 func TestHasFinalMaskContent(t *testing.T) {
 func TestHasFinalMaskContent(t *testing.T) {
 	if hasFinalMaskContent(nil) {
 	if hasFinalMaskContent(nil) {
 		t.Fatal("nil should not count as content")
 		t.Fatal("nil should not count as content")
@@ -1127,6 +1140,13 @@ func TestHysteriaPinHex(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func udpHopMask(ports string) map[string]any {
+	return map[string]any{
+		"type":     "udphop",
+		"settings": map[string]any{"mode": "intervalremote", "interval": "5-10", "remotePorts": ports},
+	}
+}
+
 func TestHysteriaHopPorts(t *testing.T) {
 func TestHysteriaHopPorts(t *testing.T) {
 	withHop := func(ports any) map[string]any {
 	withHop := func(ports any) map[string]any {
 		return map[string]any{
 		return map[string]any{
@@ -1137,6 +1157,11 @@ func TestHysteriaHopPorts(t *testing.T) {
 			},
 			},
 		}
 		}
 	}
 	}
+	withHopMask := func(ports string) map[string]any {
+		return map[string]any{
+			"finalmask": map[string]any{"udp": []any{udpHopMask(ports)}},
+		}
+	}
 
 
 	cases := []struct {
 	cases := []struct {
 		name   string
 		name   string
@@ -1144,6 +1169,14 @@ func TestHysteriaHopPorts(t *testing.T) {
 		want   string
 		want   string
 	}{
 	}{
 		{"range", withHop("20000-50000"), "20000-50000"},
 		{"range", withHop("20000-50000"), "20000-50000"},
+		{"udphop mask", withHopMask("20000-50000"), "20000-50000"},
+		{"udphop mask wins over legacy key", map[string]any{
+			"finalmask": map[string]any{
+				"udp":        []any{udpHopMask("30000-40000")},
+				"quicParams": map[string]any{"udpHop": map[string]any{"ports": "20000-50000"}},
+			},
+		}, "30000-40000"},
+		{"udphop mask without remotePorts", withHopMask(""), ""},
 		{"trimmed", withHop("  443,20000-50000  "), "443,20000-50000"},
 		{"trimmed", withHop("  443,20000-50000  "), "443,20000-50000"},
 		{"empty string", withHop(""), ""},
 		{"empty string", withHop(""), ""},
 		{"non-string", withHop(float64(443)), ""},
 		{"non-string", withHop(float64(443)), ""},

+ 16 - 7
internal/util/link/outbound.go

@@ -760,21 +760,30 @@ func applyHysteria2Obfs(stream map[string]any, p url.Values) {
 }
 }
 
 
 // applyHysteria2Hop rebuilds the UDP port-hopping range from the standard mport
 // applyHysteria2Hop rebuilds the UDP port-hopping range from the standard mport
-// param, which the generator emits as finalmask.quicParams.udpHop.ports. A range
-// already supplied via fm= wins; the client-side interval falls back to the same
-// default the panel writes.
+// param. xray-core 26.9.9 replaced finalmask.quicParams.udpHop with a "udphop"
+// UDP mask, whose intervalremote mode is what the old key used to do; a mask
+// already supplied via fm= wins.
 func applyHysteria2Hop(stream map[string]any, p url.Values) {
 func applyHysteria2Hop(stream map[string]any, p url.Values) {
 	ports := firstParam(p, "mport")
 	ports := firstParam(p, "mport")
 	if ports == "" {
 	if ports == "" {
 		return
 		return
 	}
 	}
-	quicParams := ensureChildMap(ensureChildMap(stream, "finalmask"), "quicParams")
-	if udpHop, ok := quicParams["udpHop"].(map[string]any); ok {
-		if existing, _ := udpHop["ports"].(string); existing != "" {
+	finalmask := ensureChildMap(stream, "finalmask")
+	masks, _ := finalmask["udp"].([]any)
+	for _, rawMask := range masks {
+		mask, _ := rawMask.(map[string]any)
+		if maskType, _ := mask["type"].(string); maskType == "udphop" {
 			return
 			return
 		}
 		}
 	}
 	}
-	quicParams["udpHop"] = map[string]any{"ports": ports, "interval": "5-10"}
+	finalmask["udp"] = append(masks, map[string]any{
+		"type": "udphop",
+		"settings": map[string]any{
+			"mode":        "intervalremote",
+			"interval":    "5-10",
+			"remotePorts": ports,
+		},
+	})
 }
 }
 
 
 func ensureChildMap(parent map[string]any, key string) map[string]any {
 func ensureChildMap(parent map[string]any, key string) map[string]any {

+ 48 - 7
internal/util/link/outbound_test.go

@@ -148,16 +148,25 @@ func finalmaskUDP(t *testing.T, res *ParseResult) []any {
 	return udp
 	return udp
 }
 }
 
 
+func hopMask(t *testing.T, res *ParseResult) (map[string]any, bool) {
+	t.Helper()
+	for _, rawMask := range finalmaskUDP(t, res) {
+		mask, _ := rawMask.(map[string]any)
+		if maskType, _ := mask["type"].(string); maskType == "udphop" {
+			settings, _ := mask["settings"].(map[string]any)
+			return settings, true
+		}
+	}
+	return nil, false
+}
+
 func hopPorts(t *testing.T, res *ParseResult) (string, bool) {
 func hopPorts(t *testing.T, res *ParseResult) (string, bool) {
 	t.Helper()
 	t.Helper()
-	stream, _ := res.Outbound["streamSettings"].(map[string]any)
-	finalmask, _ := stream["finalmask"].(map[string]any)
-	quicParams, _ := finalmask["quicParams"].(map[string]any)
-	udpHop, ok := quicParams["udpHop"].(map[string]any)
+	settings, ok := hopMask(t, res)
 	if !ok {
 	if !ok {
 		return "", false
 		return "", false
 	}
 	}
-	ports, _ := udpHop["ports"].(string)
+	ports, _ := settings["remotePorts"].(string)
 	return ports, true
 	return ports, true
 }
 }
 
 
@@ -258,11 +267,17 @@ func TestParseHysteria2_Mport(t *testing.T) {
 		{"standard mport", "mport=20000-50000", "20000-50000", true},
 		{"standard mport", "mport=20000-50000", "20000-50000", true},
 		{"no mport", "sni=ex.com", "", false},
 		{"no mport", "sni=ex.com", "", false},
 		{
 		{
-			name:      "fm udpHop wins over mport",
-			query:     "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`),
+			name:      "fm udphop mask wins over mport",
+			query:     "mport=1-2&fm=" + url.QueryEscape(`{"udp":[{"type":"udphop","settings":{"mode":"intervalremote","interval":"7-9","remotePorts":"30000-40000"}}]}`),
 			wantPorts: "30000-40000",
 			wantPorts: "30000-40000",
 			wantHop:   true,
 			wantHop:   true,
 		},
 		},
+		{
+			name:      "legacy fm quicParams.udpHop no longer suppresses mport",
+			query:     "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`),
+			wantPorts: "1-2",
+			wantHop:   true,
+		},
 	}
 	}
 	for _, c := range cases {
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
 		t.Run(c.name, func(t *testing.T) {
@@ -281,6 +296,32 @@ func TestParseHysteria2_Mport(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// xray-core 26.9.9 rejects a udphop mask whose mode is empty or unknown, so
+// the mport importer must emit a mode the core's UDPHop.Build() accepts.
+func TestParseHysteria2_MportEmitsCoreAcceptedMask(t *testing.T) {
+	res, err := ParseLink("hysteria2://[email protected]:443?security=tls&mport=20000-50000#node")
+	if err != nil {
+		t.Fatalf("parse hysteria2: %v", err)
+	}
+	settings, ok := hopMask(t, res)
+	if !ok {
+		t.Fatalf("no udphop mask (stream: %v)", res.Outbound["streamSettings"])
+	}
+	if got, _ := settings["mode"].(string); got != "intervalremote" {
+		t.Errorf("mode = %q, want %q", got, "intervalremote")
+	}
+	if got, _ := settings["interval"].(string); got != "5-10" {
+		t.Errorf("interval = %q, want %q", got, "5-10")
+	}
+	stream, _ := res.Outbound["streamSettings"].(map[string]any)
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	if quicParams, ok := finalmask["quicParams"].(map[string]any); ok {
+		if _, dead := quicParams["udpHop"]; dead {
+			t.Error("importer still writes the quicParams.udpHop key the core ignores")
+		}
+	}
+}
+
 func TestParseShadowsocks(t *testing.T) {
 func TestParseShadowsocks(t *testing.T) {
 	modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
 	modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
 	legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:[email protected]:8388"))
 	legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:[email protected]:8388"))

+ 5 - 0
internal/web/service/client_bulk.go

@@ -1417,6 +1417,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		}
 		}
 	}
 	}
 
 
+	createdEmails := make([]string, 0, len(prep))
 	for idx := range prep {
 	for idx := range prep {
 		if failed[idx] {
 		if failed[idx] {
 			skip(prep[idx].client.Email, reason[idx])
 			skip(prep[idx].client.Email, reason[idx])
@@ -1426,8 +1427,12 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 			skip(prep[idx].client.Email, err.Error())
 			skip(prep[idx].client.Email, err.Error())
 			continue
 			continue
 		}
 		}
+		createdEmails = append(createdEmails, prep[idx].client.Email)
 		result.Created++
 		result.Created++
 	}
 	}
+	// A re-created email is a live identity again: a delete tombstone left
+	// standing makes the next node merge prune the new client's inbound links.
+	withdrawClientTombstones(createdEmails...)
 	return result, needRestart, nil
 	return result, needRestart, nil
 }
 }
 
 

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

@@ -236,6 +236,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 		// already existed, and a create the panel reported as failed must not.
 		// already existed, and a create the panel reported as failed must not.
 		return needRestart, fanoutErr
 		return needRestart, fanoutErr
 	}
 	}
+	// A re-created email is a live identity again: a delete tombstone left
+	// standing makes the next node merge prune the new client's inbound links.
+	withdrawClientTombstones(client.Email)
 	return needRestart, s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid)
 	return needRestart, s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid)
 }
 }
 
 
@@ -645,6 +648,11 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		}
 		}
 	}
 	}
 
 
+	tunnelCount, tcErr := tunnelInboundCount(inboundIds)
+	if tcErr != nil {
+		return false, tcErr
+	}
+
 	// Built before any inbound is written, as in Create: fillProtocolDefaults
 	// Built before any inbound is written, as in Create: fillProtocolDefaults
 	// mints the shared credentials on the first inbound, later ones reuse them.
 	// mints the shared credentials on the first inbound, later ones reuse them.
 	applies := make([]inboundApply, 0, len(inboundIds))
 	applies := make([]inboundApply, 0, len(inboundIds))
@@ -670,6 +678,13 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		clientForInbound := updated
 		clientForInbound := updated
 		if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
 		if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
 			clientForInbound.AllowedIPs = ips
 			clientForInbound.AllowedIPs = ips
+		} else if tunnelCount > 1 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) {
+			// One shared peer field set cannot describe several peers: broadcast
+			// it and they all end up with the same keys and tunnel address.
+			clientForInbound.AllowedIPs = nil
+			clientForInbound.PrivateKey = ""
+			clientForInbound.PublicKey = ""
+			clientForInbound.PreSharedKey = ""
 		} else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
 		} else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
 			// A single shared AllowedIPs field (the common case for a caller
 			// A single shared AllowedIPs field (the common case for a caller
 			// that never sends AllowedIPsByInbound) must never overwrite an
 			// that never sends AllowedIPsByInbound) must never overwrite an
@@ -903,6 +918,19 @@ func (s *ClientService) hasTunnelAttachment(inboundSvc *InboundService, inboundI
 	return false
 	return false
 }
 }
 
 
+// tunnelInboundCount reports how many of inboundIds are WireGuard/AmneziaWG,
+// i.e. how many independent peers one shared field set would be written to.
+func tunnelInboundCount(inboundIds []int) (int64, error) {
+	if len(inboundIds) == 0 {
+		return 0, nil
+	}
+	var n int64
+	err := database.GetDB().Model(&model.Inbound{}).
+		Where("id IN ? AND protocol IN ?", inboundIds, []model.Protocol{model.WireGuard, model.AmneziaWG}).
+		Count(&n).Error
+	return n, err
+}
+
 // addressesFitAmneziaWGInbound reports whether every entry in addrs falls
 // addressesFitAmneziaWGInbound reports whether every entry in addrs falls
 // inside ib's own configured subnet(s). AmneziaWG only: its kernel interface
 // inside ib's own configured subnet(s). AmneziaWG only: its kernel interface
 // Address is exactly that subnet, so an address inherited from elsewhere (an
 // Address is exactly that subnet, so an address inherited from elsewhere (an

+ 62 - 0
internal/web/service/client_delete_tombstone_test.go

@@ -1,10 +1,13 @@
 package service
 package service
 
 
 import (
 import (
+	"fmt"
 	"testing"
 	"testing"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 )
 
 
 // Delete tombstones up front and keeps the record when an inbound fails. A
 // Delete tombstones up front and keeps the record when an inbound fails. A
@@ -42,3 +45,62 @@ func TestFailedDeleteWithdrawsTombstone(t *testing.T) {
 		t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused")
 		t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused")
 	}
 	}
 }
 }
+
+// A client re-created under a just-deleted email is a live identity again. If
+// the tombstone outlives it, the node merge prunes the new client's link.
+func TestRecreatedClientSurvivesNodeMerge(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const email = "reborn@x"
+	seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41501, email)
+	nodeSettings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+	syncNodeWithSettings(t, inboundSvc, 1, "n1-in", nodeSettings, xray.ClientTraffic{Email: email, Enable: true})
+
+	var ib model.Inbound
+	if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
+		t.Fatalf("load inbound: %v", err)
+	}
+
+	rec := &model.ClientRecord{}
+	if err := db.Where("email = ?", email).First(rec).Error; err != nil {
+		t.Fatalf("load adopted client record: %v", err)
+	}
+	t.Cleanup(func() { withdrawClientTombstones(email) })
+	if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
+		t.Fatalf("delete client: %v", err)
+	}
+
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client:     model.Client{Email: email, Enable: true, ID: "44444444-4444-4444-4444-444444444444"},
+		InboundIds: []int{ib.Id},
+	}); err != nil {
+		t.Fatalf("re-create client: %v", err)
+	}
+
+	// The create marks the node config-dirty, which parks the client merge; the
+	// reconcile clears it a tick later, well inside the 90s tombstone window.
+	if err := db.Model(&model.Node{}).Where("id = ?", 1).Update("config_dirty", false).Error; err != nil {
+		t.Fatalf("clear config_dirty: %v", err)
+	}
+	snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
+		Tag: "n1-in", Protocol: model.VLESS, Settings: nodeSettings,
+		ClientStats: []xray.ClientTraffic{{Email: email, Enable: true}},
+	}}}
+	if _, err := inboundSvc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
+		t.Fatalf("node merge: %v", err)
+	}
+
+	var links int64
+	if err := db.Model(&model.ClientInbound{}).
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Where("clients.email = ? AND client_inbounds.inbound_id = ?", email, ib.Id).
+		Count(&links).Error; err != nil {
+		t.Fatalf("count client links: %v", err)
+	}
+	if links != 1 {
+		t.Fatalf("re-created client has %d inbound links after the node merge, want 1: the delete tombstone outlived the email and the merge pruned it", links)
+	}
+}

+ 98 - 0
internal/web/service/client_update_tunnel_peers_test.go

@@ -0,0 +1,98 @@
+package service
+
+import (
+	"encoding/base64"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+func wgTestKeypair(t *testing.T, seed byte) (priv, pub string) {
+	t.Helper()
+	raw := make([]byte, 32)
+	for i := range raw {
+		raw[i] = seed
+	}
+	priv = base64.StdEncoding.EncodeToString(raw)
+	pub, err := wgutil.PublicKeyFromPrivate(priv)
+	if err != nil {
+		t.Fatalf("derive public key: %v", err)
+	}
+	return priv, pub
+}
+
+func inboundPeer(t *testing.T, inboundSvc *InboundService, ibId int, email string) model.Client {
+	t.Helper()
+	ib, err := inboundSvc.GetInbound(ibId)
+	if err != nil {
+		t.Fatalf("GetInbound %d: %v", ibId, err)
+	}
+	clients, err := inboundSvc.GetClients(ib)
+	if err != nil {
+		t.Fatalf("GetClients %d: %v", ibId, err)
+	}
+	for i := range clients {
+		if clients[i].Email == email {
+			return clients[i]
+		}
+	}
+	t.Fatalf("email %q not found on inbound %d", email, ibId)
+	return model.Client{}
+}
+
+// A client on several WireGuard inbounds is several independent peers, each
+// with its own keypair and tunnel address. The edit form can only carry one
+// field set, so a save that broadcasts it leaves every peer but one with keys
+// and an address belonging to a different node, breaking those tunnels.
+func TestUpdateDoesNotBroadcastPeerCredentialsAcrossTunnelInbounds(t *testing.T) {
+	setupBulkDB(t)
+	inboundSvc := &InboundService{}
+	svc := &ClientService{}
+
+	const email = "multi@wg"
+	privA, pubA := wgTestKeypair(t, 0x11)
+	privB, pubB := wgTestKeypair(t, 0x22)
+
+	peerA := model.Client{
+		Email: email, SubID: "sub-multi", Enable: true,
+		PrivateKey: privA, PublicKey: pubA, AllowedIPs: []string{"10.10.151.5/32"},
+	}
+	peerB := model.Client{
+		Email: email, SubID: "sub-multi", Enable: true,
+		PrivateKey: privB, PublicKey: pubB, AllowedIPs: []string{"10.10.152.5/32"},
+	}
+
+	ibA := mkInbound(t, 51821, model.WireGuard, clientsSettings(t, []model.Client{peerA}))
+	if err := svc.SyncInbound(nil, ibA.Id, []model.Client{peerA}); err != nil {
+		t.Fatalf("seed inbound A linkage: %v", err)
+	}
+	ibB := mkInbound(t, 51822, model.WireGuard, clientsSettings(t, []model.Client{peerB}))
+	if err := svc.SyncInbound(nil, ibB.Id, []model.Client{peerB}); err != nil {
+		t.Fatalf("seed inbound B linkage: %v", err)
+	}
+	recId := lookupClientRecord(t, email).Id
+
+	// What the client edit form sends: inbound A's peer fields, once, for
+	// a save that only meant to change an unrelated field.
+	updated := model.Client{
+		Email: email, Enable: true, Comment: "renamed",
+		PrivateKey: privA, PublicKey: pubA, AllowedIPs: []string{"10.10.151.5/32"},
+	}
+	if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	gotB := inboundPeer(t, inboundSvc, ibB.Id, email)
+	if gotB.PrivateKey != privB || gotB.PublicKey != pubB {
+		t.Fatalf("inbound B peer keys were overwritten with inbound A's: private=%q public=%q", gotB.PrivateKey, gotB.PublicKey)
+	}
+	if len(gotB.AllowedIPs) != 1 || gotB.AllowedIPs[0] != "10.10.152.5/32" {
+		t.Fatalf("inbound B AllowedIPs = %v, want unchanged [10.10.152.5/32]", gotB.AllowedIPs)
+	}
+
+	gotA := inboundPeer(t, inboundSvc, ibA.Id, email)
+	if gotA.PrivateKey != privA || len(gotA.AllowedIPs) != 1 || gotA.AllowedIPs[0] != "10.10.151.5/32" {
+		t.Fatalf("inbound A peer must keep its own values, got %+v", gotA)
+	}
+}

+ 11 - 9
internal/web/service/email/email.go

@@ -321,28 +321,30 @@ func (s *EmailService) SendTest() error {
 	)
 	)
 }
 }
 
 
-// classifySMTPError maps raw SMTP errors to human-readable messages.
+// classifySMTPError maps a raw SMTP error to an i18n key. The key is returned
+// unprefixed: the caller renders it under "pages.settings.", as does every
+// other Message this file produces.
 func classifySMTPError(err error) string {
 func classifySMTPError(err error) string {
 	msg := err.Error()
 	msg := err.Error()
 	msgLower := strings.ToLower(msg)
 	msgLower := strings.ToLower(msg)
 
 
 	switch {
 	switch {
 	case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
 	case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
-		return "pages.settings.smtpErrorAuth"
+		return "smtpErrorAuth"
 	case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
 	case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
-		return "pages.settings.smtpErrorStarttls"
+		return "smtpErrorStarttls"
 	case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"):
 	case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"):
-		return "pages.settings.smtpErrorTls"
+		return "smtpErrorTls"
 	case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"):
 	case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"):
-		return "pages.settings.smtpErrorRefused"
+		return "smtpErrorRefused"
 	case strings.Contains(msgLower, "timeout"):
 	case strings.Contains(msgLower, "timeout"):
-		return "pages.settings.smtpErrorTimeout"
+		return "smtpErrorTimeout"
 	case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
 	case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
-		return "pages.settings.smtpErrorRelay"
+		return "smtpErrorRelay"
 	case strings.Contains(msgLower, "eof"):
 	case strings.Contains(msgLower, "eof"):
-		return "pages.settings.smtpErrorEof"
+		return "smtpErrorEof"
 	default:
 	default:
-		return fmt.Sprintf("pages.settings.smtpErrorUnknown: %s", msg)
+		return "smtpErrorUnknown"
 	}
 	}
 }
 }
 
 

+ 31 - 0
internal/web/service/email/email_test.go

@@ -2,6 +2,7 @@ package email
 
 
 import (
 import (
 	"bufio"
 	"bufio"
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"mime"
 	"mime"
@@ -222,3 +223,33 @@ func TestBuildMessageStripsHeaderInjection(t *testing.T) {
 		t.Errorf("injected X-Evil header leaked: %q", got)
 		t.Errorf("injected X-Evil header leaked: %q", got)
 	}
 	}
 }
 }
+
+// Every Message this file emits is rendered by the frontend under a single
+// "pages.settings." prefix, so a key that carries its own resolves to nothing
+// and the alert shows the raw key instead of the classified reason.
+func TestClassifySMTPErrorReturnsUnprefixedKeys(t *testing.T) {
+	cases := []struct {
+		name string
+		err  error
+		want string
+	}{
+		{"auth", errors.New("535 5.7.8 Authentication credentials invalid"), "smtpErrorAuth"},
+		{"starttls", errors.New("534 must issue a STARTTLS command first"), "smtpErrorStarttls"},
+		{"refused", errors.New("dial tcp 127.0.0.1:25: connection refused"), "smtpErrorRefused"},
+		{"timeout", errors.New("i/o timeout"), "smtpErrorTimeout"},
+		{"relay", errors.New("550 relay not permitted"), "smtpErrorRelay"},
+		{"eof", errors.New("unexpected EOF"), "smtpErrorEof"},
+		{"unknown", errors.New("something nobody classified"), "smtpErrorUnknown"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := classifySMTPError(tc.err)
+			if got != tc.want {
+				t.Fatalf("classifySMTPError = %q, want %q", got, tc.want)
+			}
+			if strings.HasPrefix(got, "pages.settings.") {
+				t.Fatalf("key %q carries the prefix the frontend adds, so it resolves to nothing", got)
+			}
+		})
+	}
+}

+ 10 - 8
internal/web/service/inbound.go

@@ -1044,7 +1044,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
 	if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
 		return inbound, false, err
 		return inbound, false, err
 	}
 	}
-	if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
+	if err := s.normalizeAmneziaWGSettings(inbound, ""); err != nil {
 		return inbound, false, err
 		return inbound, false, err
 	}
 	}
 	if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
 	if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
@@ -1565,7 +1565,12 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		return inbound, false, err
 		return inbound, false, err
 	}
 	}
 	s.normalizeMtprotoSecret(inbound)
 	s.normalizeMtprotoSecret(inbound)
-	if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
+
+	oldInbound, err := s.GetInbound(inbound.Id)
+	if err != nil {
+		return inbound, false, err
+	}
+	if err := s.normalizeAmneziaWGSettings(inbound, oldInbound.Settings); err != nil {
 		return inbound, false, err
 		return inbound, false, err
 	}
 	}
 	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
 	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
@@ -1581,11 +1586,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			}
 			}
 		}
 		}
 	}
 	}
-
-	oldInbound, err := s.GetInbound(inbound.Id)
-	if err != nil {
-		return inbound, false, err
-	}
 	// Grandfather a row that was already stored incomplete so it stays editable;
 	// Grandfather a row that was already stored incomplete so it stays editable;
 	// only a save that breaks a previously valid TLS block is refused.
 	// only a save that breaks a previously valid TLS block is refused.
 	if !s.FromNodeSync {
 	if !s.FromNodeSync {
@@ -1598,7 +1598,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	// Restore the stored NodeID before the port-conflict check so a node inbound
 	// Restore the stored NodeID before the port-conflict check so a node inbound
 	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
 	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
 	inbound.NodeID = oldInbound.NodeID
 	inbound.NodeID = oldInbound.NodeID
-	if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
+	// The node assignment is the stored one, so only a protocol change can
+	// introduce one; a row adopted from a node keeps the protocol it arrived with.
+	if inbound.NodeID != nil && inbound.Protocol != oldInbound.Protocol && !isNodeEligibleProtocol(inbound.Protocol) {
 		return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
 		return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
 	}
 	}
 
 

+ 42 - 5
internal/web/service/inbound_amneziawg.go

@@ -159,11 +159,45 @@ func fillAmneziaWGServerKeys(server *amneziawg.ServerSettings) error {
 	return nil
 	return nil
 }
 }
 
 
+// resolveAmneziaWGServerKeys settles the server keypair for a save. An omitted
+// key means "unchanged", never "mint a new one": rotating it silently
+// invalidates every client config already handed out.
+func resolveAmneziaWGServerKeys(server *amneziawg.ServerSettings, oldSettings string) error {
+	if server.PrivateKey == "" {
+		storedPriv, storedPub := storedAmneziaWGServerKeys(oldSettings)
+		if storedPriv == "" {
+			return fillAmneziaWGServerKeys(server)
+		}
+		server.PrivateKey, server.PublicKey = storedPriv, storedPub
+	}
+	if server.PublicKey == "" {
+		pub, err := wgutil.PublicKeyFromPrivate(server.PrivateKey)
+		if err != nil {
+			return fmt.Errorf("amneziawg: derive server public key: %w", err)
+		}
+		server.PublicKey = pub
+	}
+	return nil
+}
+
+// storedAmneziaWGServerKeys returns the keypair already saved for this inbound.
+// oldSettings is empty on a first save, and need not be valid AmneziaWG JSON.
+func storedAmneziaWGServerKeys(oldSettings string) (priv, pub string) {
+	if strings.TrimSpace(oldSettings) == "" {
+		return "", ""
+	}
+	var prev amneziawg.InboundSettings
+	if err := json.Unmarshal([]byte(oldSettings), &prev); err != nil || prev.Server == nil {
+		return "", ""
+	}
+	return prev.Server.PrivateKey, prev.Server.PublicKey
+}
+
 // normalizeAmneziaWGSettings ensures an AmneziaWG inbound's settings have a
 // normalizeAmneziaWGSettings ensures an AmneziaWG inbound's settings have a
 // valid server block, generating one (fresh obfuscation params + keypair) on
 // valid server block, generating one (fresh obfuscation params + keypair) on
 // first save and validating a manually-edited one so a bad entry can't bring
 // first save and validating a manually-edited one so a bad entry can't bring
 // the interface down on the next apply. A no-op for every other protocol.
 // the interface down on the next apply. A no-op for every other protocol.
-func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) error {
+func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldSettings string) error {
 	if inbound.Protocol != model.AmneziaWG {
 	if inbound.Protocol != model.AmneziaWG {
 		return nil
 		return nil
 	}
 	}
@@ -193,10 +227,8 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro
 			return err
 			return err
 		}
 		}
 		parsed.Server = server
 		parsed.Server = server
-	} else if parsed.Server.PrivateKey == "" {
-		if err := fillAmneziaWGServerKeys(parsed.Server); err != nil {
-			return err
-		}
+	} else if err := resolveAmneziaWGServerKeys(parsed.Server, oldSettings); err != nil {
+		return err
 	}
 	}
 	parsed.Server.HeaderProtectionKey = strings.TrimSpace(parsed.Server.HeaderProtectionKey)
 	parsed.Server.HeaderProtectionKey = strings.TrimSpace(parsed.Server.HeaderProtectionKey)
 	for _, f := range []*string{
 	for _, f := range []*string{
@@ -265,6 +297,11 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro
 		if err != nil {
 		if err != nil {
 			return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
 			return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
 		}
 		}
+		// An enabled peer with no address is skipped by InstanceFromInbound, and
+		// if it was the only one the whole inbound never starts, silently.
+		if c.Enable && len(normalized) == 0 {
+			return fmt.Errorf("amneziawg: client %q: allowedIPs is required", c.Email)
+		}
 		c.AllowedIPs = normalized
 		c.AllowedIPs = normalized
 	}
 	}
 
 

+ 76 - 6
internal/web/service/inbound_amneziawg_test.go

@@ -14,8 +14,18 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 )
 )
 
 
+// A real X25519 pair, so PublicKeyFromPrivate agrees with the stored value.
+var awgTestPrivateKey, awgTestPublicKey = func() (string, string) {
+	priv, pub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		panic(err)
+	}
+	return priv, pub
+}()
+
 func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
 func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
 	setupConflictDB(t)
 	setupConflictDB(t)
 	svc := &InboundService{}
 	svc := &InboundService{}
@@ -134,7 +144,7 @@ func TestNormalizeAmneziaWGSettings_GeneratesFull31Set(t *testing.T) {
 	setupConflictDB(t)
 	setupConflictDB(t)
 	svc := &InboundService{}
 	svc := &InboundService{}
 	inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Settings: ""}
 	inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Settings: ""}
-	if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
+	if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
 		t.Fatalf("normalize empty settings: %v", err)
 		t.Fatalf("normalize empty settings: %v", err)
 	}
 	}
 
 
@@ -188,7 +198,7 @@ func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) {
 			Port:     51820,
 			Port:     51820,
 			Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` + c.snippet + `},"clients":[]}`,
 			Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` + c.snippet + `},"clients":[]}`,
 		}
 		}
-		if err := svc.normalizeAmneziaWGSettings(inbound); err == nil {
+		if err := svc.normalizeAmneziaWGSettings(inbound, ""); err == nil {
 			t.Errorf("%s must be rejected", c.name)
 			t.Errorf("%s must be rejected", c.name)
 		}
 		}
 	}
 	}
@@ -203,7 +213,7 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesRangeValues(t *testing.T) {
 		Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` +
 		Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` +
 			`"rekeyAfterTime":"110 - 140","rejectAfterTime":"190-250","keepaliveTimeout":"   "},"clients":[]}`,
 			`"rekeyAfterTime":"110 - 140","rejectAfterTime":"190-250","keepaliveTimeout":"   "},"clients":[]}`,
 	}
 	}
-	if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
+	if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
 		t.Fatalf("normalize: %v", err)
 		t.Fatalf("normalize: %v", err)
 	}
 	}
 	var parsed amneziawg.InboundSettings
 	var parsed amneziawg.InboundSettings
@@ -245,7 +255,7 @@ func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T
 			`"clients":[{"email":"a@x","enable":true,"publicKey":"pk",` +
 			`"clients":[{"email":"a@x","enable":true,"publicKey":"pk",` +
 			`"allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`,
 			`"allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`,
 	}
 	}
-	err := svc.normalizeAmneziaWGSettings(inbound)
+	err := svc.normalizeAmneziaWGSettings(inbound, "")
 	if err == nil {
 	if err == nil {
 		t.Fatalf("an allowedIPs entry carrying a config-injection payload must be rejected; settings became:\n%s", inbound.Settings)
 		t.Fatalf("an allowedIPs entry carrying a config-injection payload must be rejected; settings became:\n%s", inbound.Settings)
 	}
 	}
@@ -263,7 +273,7 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T)
 		Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
 		Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
 			`"clients":[{"email":"a@x","enable":true,"publicKey":"pk","allowedIPs":[" 10.8.1.2 "]}]}`,
 			`"clients":[{"email":"a@x","enable":true,"publicKey":"pk","allowedIPs":[" 10.8.1.2 "]}]}`,
 	}
 	}
-	if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
+	if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
 		t.Fatalf("normalize: %v", err)
 		t.Fatalf("normalize: %v", err)
 	}
 	}
 	var parsed amneziawg.InboundSettings
 	var parsed amneziawg.InboundSettings
@@ -375,7 +385,7 @@ func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) {
 		t.Fatalf("marshal settings: %v", err)
 		t.Fatalf("marshal settings: %v", err)
 	}
 	}
 	inbound := &model.Inbound{Protocol: model.AmneziaWG, Settings: string(bs)}
 	inbound := &model.Inbound{Protocol: model.AmneziaWG, Settings: string(bs)}
-	if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound); err != nil {
+	if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, ""); err != nil {
 		t.Fatalf("normalizeAmneziaWGSettings: %v", err)
 		t.Fatalf("normalizeAmneziaWGSettings: %v", err)
 	}
 	}
 	for _, key := range []string{`"primaryDns"`, `"secondaryDns"`} {
 	for _, key := range []string{`"primaryDns"`, `"secondaryDns"`} {
@@ -384,3 +394,63 @@ func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) {
 		}
 		}
 	}
 	}
 }
 }
+
+// An enabled peer with no address is skipped by InstanceFromInbound, and when it
+// is the only one the entire inbound never starts, with nothing logged anywhere.
+func TestNormalizeAmneziaWGSettings_RejectsEmptyClientAllowedIPs(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+	inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51823, Settings: `{
+		"server": {"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey + `","subnetIp":"10.8.1.0","subnetCidr":24},
+		"clients": [{"email":"ghost","enable":true,"publicKey":"` + awgTestPublicKey + `","allowedIPs":[]}]
+	}`}
+	err := svc.normalizeAmneziaWGSettings(inbound, "")
+	if err == nil || !strings.Contains(err.Error(), "allowedIPs is required") {
+		t.Fatalf("err = %v, want an allowedIPs refusal naming the client", err)
+	}
+	if !strings.Contains(fmt.Sprint(err), "ghost") {
+		t.Fatalf("error must name the offending client, got %v", err)
+	}
+}
+
+// Omitting the server keys on update means "unchanged": minting a fresh pair
+// invalidates every client config already distributed, with no warning.
+func TestNormalizeAmneziaWGSettings_KeepsStoredServerKeysWhenOmitted(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+	stored := `{"server":{"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey + `","subnetIp":"10.8.1.0","subnetCidr":24}}`
+
+	inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51824, Settings: `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24,"randomTrailers":true}}`}
+	if err := svc.normalizeAmneziaWGSettings(inbound, stored); err != nil {
+		t.Fatalf("normalize: %v", err)
+	}
+	var parsed amneziawg.InboundSettings
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
+		t.Fatalf("normalized settings must carry a server block (err=%v): %s", err, inbound.Settings)
+	}
+	if parsed.Server.PrivateKey != awgTestPrivateKey || parsed.Server.PublicKey != awgTestPublicKey {
+		t.Fatalf("server keypair was rotated by an unrelated edit: private=%q public=%q", parsed.Server.PrivateKey, parsed.Server.PublicKey)
+	}
+}
+
+// A payload carrying only the private half used to pass straight through, so
+// every rendered client config got "PublicKey = " with nothing after it.
+func TestNormalizeAmneziaWGSettings_DerivesServerPublicKeyFromPrivate(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+	inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51825, Settings: `{"server":{"privateKey":"` + awgTestPrivateKey + `","subnetIp":"10.8.1.0","subnetCidr":24}}`}
+	if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
+		t.Fatalf("normalize: %v", err)
+	}
+	var parsed amneziawg.InboundSettings
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
+		t.Fatalf("normalized settings must carry a server block (err=%v): %s", err, inbound.Settings)
+	}
+	want, err := wgutil.PublicKeyFromPrivate(awgTestPrivateKey)
+	if err != nil {
+		t.Fatalf("derive expected key: %v", err)
+	}
+	if parsed.Server.PublicKey != want {
+		t.Fatalf("server publicKey = %q, want %q derived from the supplied private key", parsed.Server.PublicKey, want)
+	}
+}

+ 67 - 0
internal/web/service/inbound_node_protocol_update_test.go

@@ -0,0 +1,67 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// A node-managed inbound arrives by adoption, so its protocol can be one the
+// master never assigns itself. Editing its share metadata must still work.
+func TestUpdateInbound_NodeMtprotoShareAddrIsEditable(t *testing.T) {
+	setupConflictDB(t)
+	nodeID := 5
+	seedNodeRow(t, database.GetDB(), &model.Node{Id: nodeID, Name: "n5", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
+	seedInboundConflictNode(t, "mt-node", "127.0.0.1", 4063, model.MTProto, `{}`,
+		`{"clients":[{"email":"mt-node-c","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`, &nodeID)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "mt-node").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	update := existing
+	update.ShareAddrStrategy = "custom"
+	update.ShareAddr = "new-share.example.com"
+	updated, _, err := (&InboundService{}).UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound on a node-managed mtproto inbound: %v", err)
+	}
+	if updated.NodeID == nil || *updated.NodeID != nodeID {
+		t.Fatalf("nodeID = %v, want %d preserved", updated.NodeID, nodeID)
+	}
+
+	var hosts []model.Host
+	if err := database.GetDB().Where("inbound_id = ?", existing.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Address != "new-share.example.com" {
+		t.Fatalf("hosts = %+v, want one new-share.example.com host", hosts)
+	}
+}
+
+// Converting a node inbound to a protocol the master's sidecars only reconcile
+// for local rows is still refused: those loops query node_id IS NULL.
+func TestUpdateInbound_RejectsProtocolChangeToNodeIneligible(t *testing.T) {
+	setupConflictDB(t)
+	nodeID := 6
+	seedNodeRow(t, database.GetDB(), &model.Node{Id: nodeID, Name: "n6", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
+	seedInboundConflictNode(t, "vless-node", "127.0.0.1", 4064, model.VLESS,
+		`{"network":"tcp","security":"none"}`,
+		`{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"vn-c","enable":true}],"decryption":"none"}`, &nodeID)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "vless-node").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	update := existing
+	update.Protocol = model.MTProto
+	update.Settings = `{"clients":[{"email":"vn-c","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`
+	if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil ||
+		!strings.Contains(err.Error(), "cannot be assigned to a node") {
+		t.Fatalf("err = %v, want a node-eligibility refusal", err)
+	}
+}

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "انتهت مهلة الاتصال — تعذّر الوصول إلى الخادم",
       "smtpErrorTimeout": "انتهت مهلة الاتصال — تعذّر الوصول إلى الخادم",
       "smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان",
       "smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان",
       "smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم",
       "smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم",
-      "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "خطأ SMTP — راجع سجل اللوحة للتفاصيل",
       "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
       "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
       "remarkTemplate": "قالب الملاحظة",
       "remarkTemplate": "قالب الملاحظة",
       "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
       "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",

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

@@ -1516,7 +1516,7 @@
       "smtpErrorTimeout": "Connection timeout — host unreachable",
       "smtpErrorTimeout": "Connection timeout — host unreachable",
       "smtpErrorRelay": "Server rejects sending from this address",
       "smtpErrorRelay": "Server rejects sending from this address",
       "smtpErrorEof": "Connection closed by server",
       "smtpErrorEof": "Connection closed by server",
-      "smtpErrorUnknown": "SMTP error: {{ .Error }}",
+      "smtpErrorUnknown": "SMTP error — see the panel log for details",
       "eventMemoryHigh": "Memory high (%)",
       "eventMemoryHigh": "Memory high (%)",
       "validation": {
       "validation": {
         "pathLeadingSlash": "Path must start with /"
         "pathLeadingSlash": "Path must start with /"

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Tiempo de conexión agotado: servidor inaccesible",
       "smtpErrorTimeout": "Tiempo de conexión agotado: servidor inaccesible",
       "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
       "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
       "smtpErrorEof": "Conexión cerrada por el servidor",
       "smtpErrorEof": "Conexión cerrada por el servidor",
-      "smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Error de SMTP — consulta el registro del panel",
       "eventMemoryHigh": "Uso de memoria alto (%)",
       "eventMemoryHigh": "Uso de memoria alto (%)",
       "remarkTemplate": "Plantilla de notas",
       "remarkTemplate": "Plantilla de notas",
       "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
       "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",

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

@@ -1398,7 +1398,7 @@
       "smtpErrorTimeout": "مهلت اتصال به پایان رسید — میزبان در دسترس نیست",
       "smtpErrorTimeout": "مهلت اتصال به پایان رسید — میزبان در دسترس نیست",
       "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
       "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
       "smtpErrorEof": "اتصال توسط سرور بسته شد",
       "smtpErrorEof": "اتصال توسط سرور بسته شد",
-      "smtpErrorUnknown": "خطای SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "خطای SMTP — جزئیات در لاگ پنل",
       "eventMemoryHigh": "مصرف حافظه بالا (%)",
       "eventMemoryHigh": "مصرف حافظه بالا (%)",
       "validation": {
       "validation": {
         "pathLeadingSlash": "مسیر باید با / شروع شود"
         "pathLeadingSlash": "مسیر باید با / شروع شود"

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Koneksi waktu habis — host tidak dapat dijangkau",
       "smtpErrorTimeout": "Koneksi waktu habis — host tidak dapat dijangkau",
       "smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
       "smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
       "smtpErrorEof": "Koneksi ditutup oleh server",
       "smtpErrorEof": "Koneksi ditutup oleh server",
-      "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Kesalahan SMTP — lihat log panel",
       "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "remarkTemplate": "Templat Catatan",
       "remarkTemplate": "Templat Catatan",
       "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
       "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "接続がタイムアウトしました — ホストに到達できません",
       "smtpErrorTimeout": "接続がタイムアウトしました — ホストに到達できません",
       "smtpErrorRelay": "サーバーはこのアドレスからの送信を拒否しています",
       "smtpErrorRelay": "サーバーはこのアドレスからの送信を拒否しています",
       "smtpErrorEof": "サーバーによって接続が閉じられました",
       "smtpErrorEof": "サーバーによって接続が閉じられました",
-      "smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
+      "smtpErrorUnknown": "SMTPエラー — 詳細はパネルのログを参照",
       "eventMemoryHigh": "メモリ使用率が高い (%)",
       "eventMemoryHigh": "メモリ使用率が高い (%)",
       "remarkTemplate": "備考テンプレート",
       "remarkTemplate": "備考テンプレート",
       "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
       "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Tempo de conexão esgotado — host inacessível",
       "smtpErrorTimeout": "Tempo de conexão esgotado — host inacessível",
       "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
       "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
       "smtpErrorEof": "Conexão encerrada pelo servidor",
       "smtpErrorEof": "Conexão encerrada pelo servidor",
-      "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Erro de SMTP — consulte o log do painel",
       "eventMemoryHigh": "Uso de memória alto (%)",
       "eventMemoryHigh": "Uso de memória alto (%)",
       "remarkTemplate": "Modelo de Observação",
       "remarkTemplate": "Modelo de Observação",
       "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
       "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Таймаут соединения — хост недоступен",
       "smtpErrorTimeout": "Таймаут соединения — хост недоступен",
       "smtpErrorRelay": "Сервер отклоняет отправку с этого адреса",
       "smtpErrorRelay": "Сервер отклоняет отправку с этого адреса",
       "smtpErrorEof": "Соединение закрыто сервером",
       "smtpErrorEof": "Соединение закрыто сервером",
-      "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Ошибка SMTP — подробности в журнале панели",
       "eventMemoryHigh": "Превышение порога памяти (%)",
       "eventMemoryHigh": "Превышение порога памяти (%)",
       "remarkTemplate": "Шаблон примечания",
       "remarkTemplate": "Шаблон примечания",
       "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
       "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Bağlantı zaman aşımına uğradı — sunucuya ulaşılamıyor",
       "smtpErrorTimeout": "Bağlantı zaman aşımına uğradı — sunucuya ulaşılamıyor",
       "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
       "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
       "smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı",
       "smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı",
-      "smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
+      "smtpErrorUnknown": "SMTP hatası — ayrıntılar için panel günlüğüne bakın",
       "eventMemoryHigh": "Bellek kullanımı yüksek (%)",
       "eventMemoryHigh": "Bellek kullanımı yüksek (%)",
       "remarkTemplate": "Açıklama Şablonu",
       "remarkTemplate": "Açıklama Şablonu",
       "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
       "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Час очікування з'єднання вичерпано — хост недоступний",
       "smtpErrorTimeout": "Час очікування з'єднання вичерпано — хост недоступний",
       "smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси",
       "smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси",
       "smtpErrorEof": "З'єднання закрито сервером",
       "smtpErrorEof": "З'єднання закрито сервером",
-      "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Помилка SMTP — подробиці в журналі панелі",
       "eventMemoryHigh": "Високе використання пам'яті (%)",
       "eventMemoryHigh": "Високе використання пам'яті (%)",
       "remarkTemplate": "Шаблон примітки",
       "remarkTemplate": "Шаблон примітки",
       "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
       "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Hết thời gian kết nối — không thể truy cập máy chủ",
       "smtpErrorTimeout": "Hết thời gian kết nối — không thể truy cập máy chủ",
       "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
       "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
       "smtpErrorEof": "Kết nối đã bị máy chủ đóng",
       "smtpErrorEof": "Kết nối đã bị máy chủ đóng",
-      "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Lỗi SMTP — xem nhật ký bảng điều khiển",
       "eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
       "eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
       "remarkTemplate": "Mẫu ghi chú",
       "remarkTemplate": "Mẫu ghi chú",
       "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
       "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "连接超时——主机无法访问",
       "smtpErrorTimeout": "连接超时——主机无法访问",
       "smtpErrorRelay": "服务器拒绝从此地址发送",
       "smtpErrorRelay": "服务器拒绝从此地址发送",
       "smtpErrorEof": "连接被服务器关闭",
       "smtpErrorEof": "连接被服务器关闭",
-      "smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
+      "smtpErrorUnknown": "SMTP 错误 — 详情见面板日志",
       "eventMemoryHigh": "内存使用率高 (%)",
       "eventMemoryHigh": "内存使用率高 (%)",
       "remarkTemplate": "备注模板",
       "remarkTemplate": "备注模板",
       "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
       "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "連線逾時 — 無法連線至主機",
       "smtpErrorTimeout": "連線逾時 — 無法連線至主機",
       "smtpErrorRelay": "伺服器拒絕從此地址傳送",
       "smtpErrorRelay": "伺服器拒絕從此地址傳送",
       "smtpErrorEof": "連線已被伺服器關閉",
       "smtpErrorEof": "連線已被伺服器關閉",
-      "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
+      "smtpErrorUnknown": "SMTP 錯誤 — 詳情見面板日誌",
       "eventMemoryHigh": "記憶體使用率高 (%)",
       "eventMemoryHigh": "記憶體使用率高 (%)",
       "remarkTemplate": "備註範本",
       "remarkTemplate": "備註範本",
       "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
       "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",