11 Commits acf3603dc8 ... d0edbcec81

Autor SHA1 Mensagem Data
  Sanaei d0edbcec81 feat(xray): update xray-core to v26.9.9 and follow the udpHop move 7 horas atrás
  Sanaei cfd596a489 fix(amneziawg): let a cleared header protection key reach a running device 8 horas atrás
  Sanaei efc603f59c fix(settings): show the SMTP failure reason instead of a raw i18n key 8 horas atrás
  Sanaei c392f367e1 fix(dns): stop offering a port field that DoH entries discard 8 horas atrás
  Sanaei 705b291d34 fix(amneziawg): stop losing an inbound and its server keys on the API path 8 horas atrás
  Sanaei 4e423fa452 fix(sub): drop Reality parameters when a host forces plain TLS 8 horas atrás
  Sanaei 65c5580e7d fix(clients): keep per-peer keys when a client spans several tunnel inbounds 8 horas atrás
  Sanaei 2004340d1d fix(inbounds): let a node-adopted inbound keep its own protocol on edit 9 horas atrás
  Sanaei bc57548a35 fix(clients): withdraw the delete tombstone when the email is re-created 9 horas atrás
  Sanaei 246d9207a5 fix(sub): emit a bare host in Clash proxies 9 horas atrás
  Sanaei 20d7f91c65 refactor(ci): add an adversarial pass and name the analyst briefing 11 horas atrás
50 arquivos alterados com 926 adições e 131 exclusões
  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`.
 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.
 
             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
             runs, and the support facts reporters most often get wrong - the random
             generated credentials, the distro-dependent service environment file, the

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

@@ -124,7 +124,7 @@ jobs:
           cd x-ui/bin
 
           # 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
             fetch ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -287,7 +287,7 @@ jobs:
           cd x-ui\bin
 
           # 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"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
 fi
 mkdir -p 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"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 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
   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
 
 - Anything CI already enforces: golangci-lint and gofumpt, oxlint, format

+ 24 - 24
bot_context_test.go

@@ -1,7 +1,7 @@
 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 (
 	"os"
@@ -12,9 +12,9 @@ import (
 )
 
 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 {
@@ -32,7 +32,7 @@ func section(t *testing.T, doc, from, to string) string {
 	t.Helper()
 	i := strings.Index(doc, from)
 	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):]
 	if before, _, ok := strings.Cut(rest, to); ok {
@@ -41,18 +41,18 @@ func section(t *testing.T, doc, from, to string) string {
 	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)
 	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")
 	if err != nil {
 		t.Fatalf("glob locales: %v", err)
 	}
 	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)
 }
 
-func TestBotContextNamesRealCIJobs(t *testing.T) {
-	doc := readRepoFile(t, botContextPath)
+func TestAnalystContextNamesRealCIJobs(t *testing.T) {
+	doc := readRepoFile(t, analystContextPath)
 	ci := readRepoFile(t, ciWorkflowPath)
 	table := section(t, doc, "## What CI runs", "**What CI does NOT prove.**")
 	rows := regexp.MustCompile("(?m)^\\| `([a-z0-9-]+)` \\|").FindAllStringSubmatch(table, -1)
 	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 {
 		t.Run(r[1], func(t *testing.T) {
 			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
 	// fresh clone, created by `make dist-stub` and `npm ci`.
 	generated := map[string]bool{
@@ -97,7 +97,7 @@ func TestBotContextNamesRealPaths(t *testing.T) {
 	}
 	seen := map[string]bool{}
 	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) {
 			p := m[1]
 			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")
 	// [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.
 	gates := regexp.MustCompile("`((?:XUI|XRAY)_[A-Z0-9_]+)`").FindAllStringSubmatch(table, -1)
 	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
 	err := filepath.WalkDir("internal", func(path string, d os.DirEntry, err error) error {
@@ -147,7 +147,7 @@ func TestBotContextSkipGatesExist(t *testing.T) {
 					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>.
 // 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
@@ -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) {
     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 }];
 }
 
-// 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 {
   const ports = firstParam(params, 'mport');
   if (!ports) return;
   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 = [

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

@@ -137,7 +137,13 @@ export default function DnsServerModal({
   onConfirm,
 }: DnsServerModalProps) {
   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 expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
   const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
@@ -168,13 +174,15 @@ export default function DnsServerModal({
           >
             <Input />
           </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">
             <Input />
           </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>;
 
+// '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([
   'salamander',
   'mkcp-legacy',
@@ -28,6 +30,7 @@ export const UdpMaskTypeSchema = z.enum([
   'noise',
   'sudoku',
   'realm',
+  'udphop',
 ]);
 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 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).
 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`] = `
 {
   "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);
   });
 
+  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)', () => {
     const [, raw] = fixtures[0];
     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');
   });
 
-  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(
       'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
     );
@@ -451,16 +453,25 @@ describe('parseHysteria2Link', () => {
       string,
       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(
       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`;
@@ -469,12 +480,11 @@ describe('parseHysteria2Link', () => {
       string,
       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', () => {

+ 1 - 1
go.mod

@@ -25,7 +25,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.74.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
 	golang.org/x/crypto v0.56.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/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/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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 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, "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 != "" {
-		hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey)
+		var err error
+		hpHex, err = wireguard.KeyToHex(opts.HeaderProtectionKey)
 		if err != nil {
 			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 != "" {
 		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 {
 		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)
 	}
 
@@ -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.
 		subReq.renderHostRemark(inbound, client, extPrxy, network)
 		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 {
 			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)
 	}
 }
+
+// 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).
 // securityToApply mirrors the legacy externalProxy loop: "same" keeps the base
 // security, otherwise the endpoint's forceTls wins; "none" strips TLS hint
@@ -87,6 +100,7 @@ func (s *SubService) buildEndpointLinks(
 			securityToApply = e.ForceTls
 		}
 		nextParams := cloneStringMap(params)
+		dropBaseRealityParams(nextParams, baseSecurity, securityToApply)
 		applyEndpointTLSParams(e, nextParams, securityToApply)
 		applyEndpointRealityParams(e, nextParams, securityToApply)
 		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)
 	}
 }
+
+// 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"))
 }
 
-// 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 {
 	finalmask, _ := stream["finalmask"].(map[string]any)
+	if ports := udpHopMaskPorts(finalmask); ports != "" {
+		return ports
+	}
 	quicParams, _ := finalmask["quicParams"].(map[string]any)
 	udpHop, _ := quicParams["udpHop"].(map[string]any)
 	ports, _ := udpHop["ports"].(string)
 	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
 // frontend editor, so both link generators emit identical URIs.
 const (
@@ -2469,6 +2490,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{
 	"noise":         {},
 	"header-custom": {},
 	"realm":         {},
+	"udphop":        {},
 }
 
 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) {
 	if hasFinalMaskContent(nil) {
 		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) {
 	withHop := func(ports any) 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 {
 		name   string
@@ -1144,6 +1169,14 @@ func TestHysteriaHopPorts(t *testing.T) {
 		want   string
 	}{
 		{"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"},
 		{"empty string", withHop(""), ""},
 		{"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
-// 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) {
 	ports := firstParam(p, "mport")
 	if ports == "" {
 		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
 		}
 	}
-	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 {

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

@@ -148,16 +148,25 @@ func finalmaskUDP(t *testing.T, res *ParseResult) []any {
 	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) {
 	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 {
 		return "", false
 	}
-	ports, _ := udpHop["ports"].(string)
+	ports, _ := settings["remotePorts"].(string)
 	return ports, true
 }
 
@@ -258,11 +267,17 @@ func TestParseHysteria2_Mport(t *testing.T) {
 		{"standard mport", "mport=20000-50000", "20000-50000", true},
 		{"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",
 			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 {
 		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) {
 	modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
 	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 {
 		if failed[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())
 			continue
 		}
+		createdEmails = append(createdEmails, prep[idx].client.Email)
 		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
 }
 

+ 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.
 		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)
 }
 
@@ -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
 	// mints the shared credentials on the first inbound, later ones reuse them.
 	applies := make([]inboundApply, 0, len(inboundIds))
@@ -670,6 +678,13 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		clientForInbound := updated
 		if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
 			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) {
 			// A single shared AllowedIPs field (the common case for a caller
 			// that never sends AllowedIPsByInbound) must never overwrite an
@@ -903,6 +918,19 @@ func (s *ClientService) hasTunnelAttachment(inboundSvc *InboundService, inboundI
 	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
 // inside ib's own configured subnet(s). AmneziaWG only: its kernel interface
 // 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
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"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
@@ -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")
 	}
 }
+
+// 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 {
 	msg := err.Error()
 	msgLower := strings.ToLower(msg)
 
 	switch {
 	case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
-		return "pages.settings.smtpErrorAuth"
+		return "smtpErrorAuth"
 	case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
-		return "pages.settings.smtpErrorStarttls"
+		return "smtpErrorStarttls"
 	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"):
-		return "pages.settings.smtpErrorRefused"
+		return "smtpErrorRefused"
 	case strings.Contains(msgLower, "timeout"):
-		return "pages.settings.smtpErrorTimeout"
+		return "smtpErrorTimeout"
 	case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
-		return "pages.settings.smtpErrorRelay"
+		return "smtpErrorRelay"
 	case strings.Contains(msgLower, "eof"):
-		return "pages.settings.smtpErrorEof"
+		return "smtpErrorEof"
 	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 (
 	"bufio"
+	"errors"
 	"fmt"
 	"io"
 	"mime"
@@ -222,3 +223,33 @@ func TestBuildMessageStripsHeaderInjection(t *testing.T) {
 		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 {
 		return inbound, false, err
 	}
-	if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
+	if err := s.normalizeAmneziaWGSettings(inbound, ""); err != nil {
 		return inbound, false, err
 	}
 	if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
@@ -1565,7 +1565,12 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		return inbound, false, err
 	}
 	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
 	}
 	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;
 	// only a save that breaks a previously valid TLS block is refused.
 	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
 	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
 	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)
 	}
 

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

@@ -159,11 +159,45 @@ func fillAmneziaWGServerKeys(server *amneziawg.ServerSettings) error {
 	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
 // 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
 // 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 {
 		return nil
 	}
@@ -193,10 +227,8 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro
 			return err
 		}
 		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)
 	for _, f := range []*string{
@@ -265,6 +297,11 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) erro
 		if err != nil {
 			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
 	}
 

+ 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/model"
 	"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) {
 	setupConflictDB(t)
 	svc := &InboundService{}
@@ -134,7 +144,7 @@ func TestNormalizeAmneziaWGSettings_GeneratesFull31Set(t *testing.T) {
 	setupConflictDB(t)
 	svc := &InboundService{}
 	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)
 	}
 
@@ -188,7 +198,7 @@ func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) {
 			Port:     51820,
 			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)
 		}
 	}
@@ -203,7 +213,7 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesRangeValues(t *testing.T) {
 		Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` +
 			`"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)
 	}
 	var parsed amneziawg.InboundSettings
@@ -245,7 +255,7 @@ func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T
 			`"clients":[{"email":"a@x","enable":true,"publicKey":"pk",` +
 			`"allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`,
 	}
-	err := svc.normalizeAmneziaWGSettings(inbound)
+	err := svc.normalizeAmneziaWGSettings(inbound, "")
 	if err == nil {
 		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},` +
 			`"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)
 	}
 	var parsed amneziawg.InboundSettings
@@ -375,7 +385,7 @@ func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) {
 		t.Fatalf("marshal settings: %v", err)
 	}
 	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)
 	}
 	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": "انتهت مهلة الاتصال — تعذّر الوصول إلى الخادم",
       "smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان",
       "smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم",
-      "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "خطأ SMTP — راجع سجل اللوحة للتفاصيل",
       "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
       "remarkTemplate": "قالب الملاحظة",
       "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",

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

@@ -1516,7 +1516,7 @@
       "smtpErrorTimeout": "Connection timeout — host unreachable",
       "smtpErrorRelay": "Server rejects sending from this address",
       "smtpErrorEof": "Connection closed by server",
-      "smtpErrorUnknown": "SMTP error: {{ .Error }}",
+      "smtpErrorUnknown": "SMTP error — see the panel log for details",
       "eventMemoryHigh": "Memory high (%)",
       "validation": {
         "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",
       "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
       "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 (%)",
       "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.",

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

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

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Koneksi waktu habis — host tidak dapat dijangkau",
       "smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
       "smtpErrorEof": "Koneksi ditutup oleh server",
-      "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Kesalahan SMTP — lihat log panel",
       "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "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.",

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

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

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Tempo de conexão esgotado — host inacessível",
       "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
       "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 (%)",
       "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.",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Таймаут соединения — хост недоступен",
       "smtpErrorRelay": "Сервер отклоняет отправку с этого адреса",
       "smtpErrorEof": "Соединение закрыто сервером",
-      "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Ошибка SMTP — подробности в журнале панели",
       "eventMemoryHigh": "Превышение порога памяти (%)",
       "remarkTemplate": "Шаблон примечания",
       "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",
       "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
       "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 (%)",
       "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.",

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

@@ -1394,7 +1394,7 @@
       "smtpErrorTimeout": "Час очікування з'єднання вичерпано — хост недоступний",
       "smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси",
       "smtpErrorEof": "З'єднання закрито сервером",
-      "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
+      "smtpErrorUnknown": "Помилка SMTP — подробиці в журналі панелі",
       "eventMemoryHigh": "Високе використання пам'яті (%)",
       "remarkTemplate": "Шаблон примітки",
       "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ủ",
       "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
       "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 (%)",
       "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.",

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

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

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

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