4 Commits 0a30a03cb7 ... d291e1c5ee

Autore SHA1 Messaggio Data
  Sanaei d291e1c5ee Bump Go toolchain and x dependencies 16 ore fa
  Mr. Nickson ecadfd0e60 fix(clients): stop recomputing the summary badges from the client_stats snapshot (#6169) 17 ore fa
  MMX d05e44e401 fix(outbound): import Hysteria2 salamander properly from standard obfs params (#6166) 17 ore fa
  Kuzz007 9165ab67eb fix(install): preserve custom bin/ files (e.g. hand-added geoip) across updates (#6152) 17 ore fa

+ 18 - 0
deploy/test/smoke-noninteractive.sh

@@ -87,6 +87,24 @@ docker run --rm \
             *) echo "FAIL: panel did not serve (status ${code:-none})"; tail -n 30 /tmp/xui.log; exit 1 ;;
         esac
 
+        echo "--- verifying a second install preserves custom bin/ files ---"
+        echo "custom-sentinel" > /usr/local/x-ui/bin/geoip_custom.dat
+        geoip_sum_before=$(sha256sum /usr/local/x-ui/bin/geoip.dat | cut -d" " -f1)
+
+        if [ -n "${XUI_SMOKE_VERSION:-}" ]; then
+            cat /root/install.sh | bash -s -- "$XUI_SMOKE_VERSION"
+        else
+            cat /root/install.sh | bash
+        fi
+
+        test -f /usr/local/x-ui/bin/geoip_custom.dat \
+            || { echo "FAIL: custom bin/ file did not survive a second install"; exit 1; }
+        [ "$(cat /usr/local/x-ui/bin/geoip_custom.dat)" = "custom-sentinel" ] \
+            || { echo "FAIL: custom bin/ file content changed across a second install"; exit 1; }
+        geoip_sum_after=$(sha256sum /usr/local/x-ui/bin/geoip.dat | cut -d" " -f1)
+        [ "$geoip_sum_after" = "$geoip_sum_before" ] \
+            || { echo "FAIL: bundled geoip.dat changed across a same-version reinstall"; exit 1; }
+
         echo "SMOKE_PASS: user=$XUI_USERNAME port=$XUI_PANEL_PORT path=$XUI_WEB_BASE_PATH"
     '
 

+ 3 - 87
frontend/src/hooks/useClients.ts

@@ -85,51 +85,6 @@ export interface ClientSpeedEntry {
 
 type ClientStatRow = ClientTraffic & { email?: string };
 
-// Mirror of the server's buildClientsSummary (web/service/client.go). The
-// client_stats WS event already carries every client's traffic, so the
-// summary card can be recomputed live from it instead of waiting for a list
-// refetch — keep the two in lockstep.
-export function computeClientsSummary(
-  stats: ClientStatRow[],
-  onlineSet: Set<string>,
-  expireDiffMs: number,
-  trafficDiffBytes: number,
-): ClientsSummary {
-  const now = Date.now();
-  const online: string[] = [];
-  const depleted: string[] = [];
-  const expiring: string[] = [];
-  const deactive: string[] = [];
-  let active = 0;
-  for (const c of stats) {
-    const email = c.email;
-    if (!email) continue;
-    const used = (c.up || 0) + (c.down || 0);
-    const total = c.total || 0;
-    const exhausted = total > 0 && used >= total;
-    const expired = (c.expiryTime || 0) > 0 && (c.expiryTime || 0) <= now;
-    if (c.enable && onlineSet.has(email)) online.push(email);
-    if (exhausted || expired) { depleted.push(email); continue; }
-    if (!c.enable) { deactive.push(email); continue; }
-    const nearExpiry = (c.expiryTime || 0) > 0 && (c.expiryTime || 0) - now < expireDiffMs;
-    const nearLimit = total > 0 && total - used < trafficDiffBytes;
-    if (nearExpiry || nearLimit) expiring.push(email);
-    else active += 1;
-  }
-  return {
-    total: stats.length,
-    active,
-    onlineCount: online.length,
-    depletedCount: depleted.length,
-    expiringCount: expiring.length,
-    deactiveCount: deactive.length,
-    online,
-    depleted,
-    expiring,
-    deactive,
-  };
-}
-
 export function sameSpeedMap(
   a: Record<string, ClientSpeedEntry>,
   b: Record<string, ClientSpeedEntry>,
@@ -144,37 +99,6 @@ export function sameSpeedMap(
   return true;
 }
 
-// The field list computeClientsSummary reads, and deliberately nothing else.
-// lastOnline in particular churns for every online client on every push and no
-// counter depends on it, so including it here would defeat the comparison.
-export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean {
-  if (a.length !== b.length) return false;
-  for (let i = 0; i < a.length; i++) {
-    const left = a[i];
-    const right = b[i];
-    if (left.email !== right.email
-      || left.up !== right.up
-      || left.down !== right.down
-      || left.total !== right.total
-      || left.enable !== right.enable
-      || left.expiryTime !== right.expiryTime) return false;
-  }
-  return true;
-}
-
-export function pickClientsSummary(
-  serverSummary: ClientsSummary,
-  allClientStats: ClientStatRow[],
-  onlineSet: Set<string>,
-  expireDiffMs: number,
-  trafficDiffBytes: number,
-): ClientsSummary {
-  if (allClientStats.length === 0) return serverSummary;
-  if (serverSummary.total > allClientStats.length) return serverSummary;
-  const live = computeClientsSummary(allClientStats, onlineSet, expireDiffMs, trafficDiffBytes);
-  return { ...live, total: serverSummary.total || live.total };
-}
-
 function buildQS(p: ClientQueryParams): string {
   const sp = new URLSearchParams();
   sp.set('page', String(p.page || 1));
@@ -272,6 +196,7 @@ export function useClients(options: UseClientsOptions = {}) {
     // List is sorted/paged server-side, so the WS patch can't add new or
     // re-sort rows; poll the current page to keep it live (pauses when hidden).
     refetchInterval: 5000,
+    refetchOnWindowFocus: 'always',
     placeholderData: keepPreviousData,
   });
 
@@ -349,17 +274,12 @@ export function useClients(options: UseClientsOptions = {}) {
   // settings request still lets the page fall back and render.
   const settingsReady = defaultsQuery.isFetched;
 
-  const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
   const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
-  const summary = useMemo<ClientsSummary>(
-    () => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
-    [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
-  );
+  const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
 
   const invalidateAll = useCallback(
     () => {
       markLocalInvalidate();
-      setAllClientStats([]);
       return Promise.all([
         queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
         queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
@@ -658,12 +578,8 @@ export function useClients(options: UseClientsOptions = {}) {
 
   const applyClientStatsEvent = useCallback((payload: unknown) => {
     if (!payload || typeof payload !== 'object') return;
-    const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
+    const p = payload as { clients?: ClientStatRow[] };
     if (!Array.isArray(p.clients) || p.clients.length === 0) return;
-    if (p.snapshot !== false) {
-      const rows = p.clients;
-      setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows));
-    }
     const active = queryRef.current;
     if (!active) return;
     const byEmail = new Map<string, ClientTraffic>();

+ 43 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -226,6 +226,47 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
   }
 }
 
+function ensureFinalMask(stream: Raw): Raw {
+  if (!stream.finalmask || typeof stream.finalmask !== 'object') stream.finalmask = {};
+  return stream.finalmask as Raw;
+}
+
+// Rebuild the salamander mask from the standard Hysteria2 obfs pair (every
+// non-3x-ui client, and this panel's own generator, speak it instead of the
+// private fm=<json> dump). A salamander mask already carrying a password via fm=
+// wins; a password-less one is completed rather than left empty.
+function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
+  if ((params.get('obfs') ?? '').toLowerCase() !== 'salamander') return;
+  const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
+  if (!password) return;
+  const finalmask = ensureFinalMask(stream);
+  const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
+  const existing = udp.find((m) => m && typeof m === 'object' && (m as Raw).type === 'salamander') as Raw | undefined;
+  if (existing) {
+    const settings = (existing.settings && typeof existing.settings === 'object'
+      ? existing.settings
+      : (existing.settings = {})) as Raw;
+    if (typeof settings.password !== 'string' || settings.password.length === 0) settings.password = password;
+    return;
+  }
+  finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
+}
+
+// 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.
+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 QUIC_PARAMS_NUMERIC_KEYS = [
   'initStreamReceiveWindow',
   'maxStreamReceiveWindow',
@@ -525,6 +566,8 @@ export function parseHysteria2Link(link: string): Raw | null {
     },
   };
   applyFinalMaskParam(stream, params);
+  applyHysteria2Obfs(stream, params);
+  applyHysteria2Hop(stream, params);
   return {
     protocol: 'hysteria',
     tag: decodeRemark(url),

+ 0 - 4
frontend/src/pages/inbounds/useInbounds.ts

@@ -261,10 +261,6 @@ export function useInbounds() {
           const stats = statsByEmail.get(client.email.toLowerCase());
           const exhausted = stats != null && stats.total > 0 && stats.up + stats.down >= stats.total;
           const expired = stats != null && stats.expiryTime > 0 && stats.expiryTime <= now;
-          // Depleted wins over disabled (same priority as computeClientsSummary):
-          // the auto-disable job also flips client.enable off in settings when a
-          // client ends, so checking enable first would file every ended client
-          // under "Disabled".
           if (expired || exhausted) {
             depleted.push(client.email);
             continue;

+ 0 - 142
frontend/src/test/clients-summary.test.ts

@@ -1,142 +0,0 @@
-import { describe, it, expect } from 'vitest';
-
-import { computeClientsSummary, pickClientsSummary, sameSpeedMap, sameSummaryInputs } from '@/hooks/useClients';
-import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
-
-// Parity with web/service/client.go buildClientsSummary: the same client must
-// land in the same bucket whether the count comes from the server (list fetch)
-// or is recomputed live from the client_stats WS event. A mismatch would make
-// the summary card "jump" on refresh.
-type Row = ClientTraffic & { email?: string };
-
-const GB = 1024 * 1024 * 1024;
-const DAY = 86_400_000;
-
-function row(over: Partial<Row>): Row {
-  return { email: 'x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0, ...over } as Row;
-}
-
-describe('computeClientsSummary', () => {
-  it('buckets each client the way the Go service does', () => {
-    const now = Date.now();
-    const stats: Row[] = [
-      row({ email: 'online@x', enable: true }),
-      row({ email: 'offline@x', enable: true }),
-      row({ email: 'disabled@x', enable: false }),
-      row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
-      row({ email: 'expired@x', enable: true, expiryTime: now - DAY }),
-      row({ email: 'nearexpiry@x', enable: true, expiryTime: now + DAY }),
-      row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
-    ];
-    const online = new Set(['online@x', 'disabled@x']); // disabled-but-online must NOT count as online
-    const expireDiffMs = 3 * DAY;
-    const trafficDiffBytes = 1 * GB;
-
-    const s = computeClientsSummary(stats, online, expireDiffMs, trafficDiffBytes);
-
-    expect(s.total).toBe(7);
-    expect(s.online).toEqual(['online@x']);
-    expect(s.depleted.sort()).toEqual(['exhausted@x', 'expired@x']);
-    expect(s.deactive).toEqual(['disabled@x']);
-    expect(s.expiring.sort()).toEqual(['nearexpiry@x', 'nearlimit@x']);
-    expect(s.active).toBe(2); // online@x + offline@x
-  });
-
-  it('reports a counter alongside every bucket list', () => {
-    const stats: Row[] = [
-      row({ email: 'online@x', enable: true }),
-      row({ email: 'disabled@x', enable: false }),
-      row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
-      row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
-    ];
-    const s = computeClientsSummary(stats, new Set(['online@x']), 3 * DAY, 1 * GB);
-
-    // The server caps its lists but never its counters; the live recompute has
-    // both, so the summary card reads the same either way.
-    expect(s.onlineCount).toBe(s.online.length);
-    expect(s.depletedCount).toBe(s.depleted.length);
-    expect(s.expiringCount).toBe(s.expiring.length);
-    expect(s.deactiveCount).toBe(s.deactive.length);
-    expect(s.active + s.depletedCount + s.expiringCount + s.deactiveCount).toBe(s.total);
-  });
-
-  it('depleted wins over disabled and over online', () => {
-    const stats: Row[] = [
-      row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
-    ];
-    const s = computeClientsSummary(stats, new Set(['a@x']), 0, 0);
-    expect(s.depleted).toEqual(['a@x']);
-    expect(s.deactive).toEqual([]);
-    expect(s.online).toEqual([]); // disabled is never online
-  });
-
-  it('unlimited + no expiry is active', () => {
-    const stats: Row[] = [row({ email: 'a@x', enable: true, total: 0, expiryTime: 0 })];
-    const s = computeClientsSummary(stats, new Set(), 3 * DAY, 1 * GB);
-    expect(s.active).toBe(1);
-    expect(s.expiring).toEqual([]);
-    expect(s.depleted).toEqual([]);
-  });
-});
-
-describe('pickClientsSummary', () => {
-  const serverSummary: ClientsSummary = {
-    total: 67, active: 58,
-    onlineCount: 0, depletedCount: 4, expiringCount: 3, deactiveCount: 2,
-    online: [], depleted: [], expiring: [], deactive: [],
-  };
-
-  it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
-    const shortSnapshot: Row[] = Array.from({ length: 58 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
-    const s = pickClientsSummary(serverSummary, shortSnapshot, new Set(), 3 * DAY, 1 * GB);
-    expect(s).toEqual(serverSummary);
-  });
-
-  it('uses the live recompute when the snapshot covers every client', () => {
-    const fullSnapshot: Row[] = Array.from({ length: 67 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
-    const s = pickClientsSummary(serverSummary, fullSnapshot, new Set(), 3 * DAY, 1 * GB);
-    expect(s.total).toBe(67);
-    expect(s.active).toBe(67);
-  });
-
-  it('falls back to the server summary before the first WS snapshot arrives', () => {
-    const s = pickClientsSummary(serverSummary, [], new Set(), 3 * DAY, 1 * GB);
-    expect(s).toEqual(serverSummary);
-  });
-});
-
-describe('websocket payload identity preservation', () => {
-  const speed = (up: number, down: number) => ({ up, down });
-
-  it('treats an unchanged speed map as unchanged', () => {
-    const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
-    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
-    expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
-    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
-    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
-    expect(sameSpeedMap({}, {})).toBe(true);
-  });
-
-  it('compares exactly the fields the summary reads, and ignores lastOnline', () => {
-    const base: Row[] = [row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99 })];
-
-    // lastOnline moves for every online client on every push and no counter
-    // depends on it, so it must not force a new snapshot.
-    const onlyLastOnlineMoved: Row[] = [
-      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, lastOnline: 12345 }),
-    ];
-    expect(sameSummaryInputs(base, onlyLastOnlineMoved)).toBe(true);
-
-    for (const changed of [
-      row({ email: 'b@x', up: 1, down: 2, total: 10, expiryTime: 99 }),
-      row({ email: 'a@x', up: 2, down: 2, total: 10, expiryTime: 99 }),
-      row({ email: 'a@x', up: 1, down: 3, total: 10, expiryTime: 99 }),
-      row({ email: 'a@x', up: 1, down: 2, total: 11, expiryTime: 99 }),
-      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 100 }),
-      row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, enable: false }),
-    ]) {
-      expect(sameSummaryInputs(base, [changed])).toBe(false);
-    }
-    expect(sameSummaryInputs(base, [])).toBe(false);
-  });
-});

+ 111 - 0
frontend/src/test/clients-summary.test.tsx

@@ -0,0 +1,111 @@
+import type { ReactNode } from 'react';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { sameSpeedMap, useClients } from '@/hooks/useClients';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+import type { ClientsSummary } from '@/schemas/client';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe('websocket payload identity preservation', () => {
+  const speed = (up: number, down: number) => ({ up, down });
+
+  it('treats an unchanged speed map as unchanged', () => {
+    const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
+    expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
+    expect(sameSpeedMap({}, {})).toBe(true);
+  });
+});
+
+describe('client summary always reflects the server, never a client_stats recompute (#6116)', () => {
+  const serverSummary: ClientsSummary = {
+    total: 3, active: 3,
+    onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
+    online: [], depleted: [], expiring: [], deactive: [],
+  };
+
+  const pagedResponse = {
+    items: [],
+    total: 3,
+    filtered: 3,
+    page: 1,
+    pageSize: 25,
+    groups: [],
+    summary: serverSummary,
+  };
+
+  function mockPanel() {
+    vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
+      if (url.includes('/clients/list/paged')) return new Msg(true, '', pagedResponse);
+      if (url.includes('/inbounds/options')) return new Msg(true, '', []);
+      return new Msg(true, '', null);
+    });
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
+      if (url.includes('/setting/defaultSettings')) return new Msg(true, '', { pageSize: 25 });
+      if (url.includes('/clients/onlines')) return new Msg(true, '', []);
+      return new Msg(true, '', null);
+    });
+  }
+
+  function wrapperFor() {
+    const queryClient = makeTestQueryClient();
+    return ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+  }
+
+  async function loadedHook() {
+    mockPanel();
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    act(() => {
+      result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
+    });
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    expect(result.current.summary).toEqual(serverSummary);
+    return result;
+  }
+
+  it('stays pinned to the server summary across a client_stats push carrying an orphan row with no matching gap', async () => {
+    const result = await loadedHook();
+
+    act(() => {
+      result.current.applyClientStatsEvent({
+        snapshot: true,
+        clients: [
+          { email: 'a@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
+          { email: 'b@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
+          { email: 'c@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
+          { email: 'ghost@x', enable: false, up: 0, down: 0, total: 1, expiryTime: 1 },
+        ],
+      });
+    });
+
+    expect(result.current.summary).toEqual(serverSummary);
+  });
+
+  it('stays pinned to the server summary across a client_stats push where an orphan and a gap net out to the server total', async () => {
+    const result = await loadedHook();
+
+    act(() => {
+      result.current.applyClientStatsEvent({
+        snapshot: true,
+        clients: [
+          { email: 'a@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
+          { email: 'b@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
+          { email: 'ghost@x', enable: false, up: 0, down: 0, total: 1, expiryTime: 1 },
+        ],
+      });
+    });
+
+    expect(result.current.summary).toEqual(serverSummary);
+  });
+});

+ 87 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -300,10 +300,97 @@ describe('parseHysteria2Link', () => {
     const finalmask = stream.finalmask as Record<string, unknown>;
     expect(finalmask).toBeDefined();
     const udp = finalmask.udp as Array<Record<string, unknown>>;
+    expect(udp).toHaveLength(1);
     expect(udp[0].type).toBe('salamander');
     expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
   });
 
+  it('reconstructs the salamander mask from standard obfs= without fm=', () => {
+    const link = 'hysteria2://[email protected]:8443?security=tls&sni=news.domain.org'
+      + '&obfs=salamander&obfs-password=ftwfgb9655hh2mgo#hy2-std-obfs';
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    expect(finalmask).toBeDefined();
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    expect(udp).toHaveLength(1);
+    expect(udp[0].type).toBe('salamander');
+    expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
+  });
+
+  it('adds no salamander mask when the link carries neither obfs nor fm', () => {
+    const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&sni=srv#hy2-plain');
+    expect(out).not.toBeNull();
+    expect((out!.streamSettings as Record<string, unknown>).finalmask).toBeUndefined();
+  });
+
+  it('ignores obfs=salamander when no obfs-password is present', () => {
+    const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&obfs=salamander#hy2-nopw');
+    expect(out).not.toBeNull();
+    expect((out!.streamSettings as Record<string, unknown>).finalmask).toBeUndefined();
+  });
+
+  it.each([
+    ['obfs_password', 'obfs_password=aliaspw', 'aliaspw'],
+    ['obfsPassword', 'obfsPassword=camelpw', 'camelpw'],
+    ['case-insensitive type', 'obfs=Salamander&obfs-password=mixed', 'mixed'],
+  ])('accepts the %s form of the obfs pair', (_name, query, want) => {
+    const base = query.includes('obfs=') ? query : `obfs=salamander&${query}`;
+    const out = parseHysteria2Link(`hysteria2://auth@srv:443?security=tls&${base}#hy2-alias`);
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    expect(udp).toHaveLength(1);
+    expect(udp[0].type).toBe('salamander');
+    expect((udp[0].settings as Record<string, unknown>).password).toBe(want);
+  });
+
+  it('appends the obfs salamander mask alongside a non-salamander fm mask', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      udp: [{ type: 'mkcp-legacy', settings: { header: 'srtp' } }],
+    }));
+    const link = `hysteria2://auth@srv:443?security=tls&fm=${fm}&obfs=salamander&obfs-password=added#hy2-append`;
+    const out = parseHysteria2Link(link);
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    expect(udp).toHaveLength(2);
+    expect(udp[0].type).toBe('mkcp-legacy');
+    expect(udp[1].type).toBe('salamander');
+    expect((udp[1].settings as Record<string, unknown>).password).toBe('added');
+  });
+
+  it('fills the password of a password-less fm salamander mask from obfs', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      udp: [{ type: 'salamander', settings: {} }],
+    }));
+    const link = `hysteria2://auth@srv:443?security=tls&fm=${fm}&obfs=salamander&obfs-password=fromobfs#hy2-fill`;
+    const out = parseHysteria2Link(link);
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    expect(udp).toHaveLength(1);
+    expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
+  });
+
+  it('reconstructs udpHop from the standard mport param', () => {
+    const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport');
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<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');
+  });
+
+  it('lets an fm= udpHop win over mport', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      quicParams: { udpHop: { ports: '30000-40000', interval: '7-9' } },
+    }));
+    const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
+    const out = parseHysteria2Link(link);
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<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');
+  });
+
   it('round-trips the salamander packetSize (Gecko) under fm', () => {
     const fm = encodeURIComponent(JSON.stringify({
       udp: [{ type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } }],

+ 13 - 13
go.mod

@@ -1,6 +1,6 @@
 module github.com/mhsanaei/3x-ui/v3
 
-go 1.26.5
+go 1.26.6
 
 require (
 	github.com/gin-contrib/gzip v1.2.6
@@ -24,9 +24,9 @@ require (
 	github.com/xlzd/gotp v0.1.0
 	github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
 	go.uber.org/atomic v1.11.0
-	golang.org/x/crypto v0.54.0
+	golang.org/x/crypto v0.55.0
 	golang.org/x/sys v0.47.0
-	golang.org/x/text v0.40.0
+	golang.org/x/text v0.41.0
 	google.golang.org/grpc v1.83.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gorm.io/driver/postgres v1.6.2
@@ -42,7 +42,7 @@ require (
 	github.com/bytedance/gopkg v0.1.4 // indirect
 	github.com/bytedance/sonic v1.15.2 // indirect
 	github.com/bytedance/sonic/loader v0.5.2 // indirect
-	github.com/cloudflare/circl v1.6.4 // indirect
+	github.com/cloudflare/circl v1.6.5 // indirect
 	github.com/cloudwego/base64x v0.1.7 // indirect
 	github.com/ebitengine/purego v0.10.2 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.15 // indirect
@@ -78,12 +78,12 @@ require (
 	github.com/pion/stun/v3 v3.1.6 // indirect
 	github.com/pion/transport/v4 v4.1.0 // indirect
 	github.com/pires/go-proxyproto v0.15.0 // indirect
-	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+	github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.61.0 // indirect
 	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
-	github.com/sagernet/sing v0.8.11 // indirect
+	github.com/sagernet/sing v0.8.13 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/tklauser/go-sysconf v0.4.0 // indirect
 	github.com/tklauser/numcpus v0.12.0 // indirect
@@ -98,18 +98,18 @@ require (
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 	go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
-	golang.org/x/arch v0.29.0 // indirect
-	golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
-	golang.org/x/mod v0.38.0 // indirect
-	golang.org/x/net v0.57.0
+	golang.org/x/arch v0.30.0 // indirect
+	golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect
+	golang.org/x/mod v0.40.0 // indirect
+	golang.org/x/net v0.58.0
 	golang.org/x/sync v0.22.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
-	golang.org/x/tools v0.48.0 // indirect
+	golang.org/x/tools v0.49.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
-	google.golang.org/protobuf v1.36.11
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
+	google.golang.org/protobuf v1.36.12
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 )

+ 24 - 24
go.sum

@@ -16,8 +16,8 @@ github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj
 github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
-github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
+github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
+github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
 github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
 github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -160,8 +160,8 @@ github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBk
 github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
+github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
 github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
 github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
 github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -174,8 +174,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
 github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
-github.com/sagernet/sing v0.8.11 h1:AKZRvjFPHtAXwGCjOJrzAQPiZxr8mobhuSUqkHf+VQw=
-github.com/sagernet/sing v0.8.11/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
+github.com/sagernet/sing v0.8.13 h1:yVoXnx9nPxfjlwD4Tp+Wd9zuW2tfiSVrcRDBZNbKRCw=
+github.com/sagernet/sing v0.8.13/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
 github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
 github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
 github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
@@ -246,16 +246,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
-golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
-golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
-golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
-golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
-golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
-golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
-golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
-golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
-golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
-golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
+golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
+golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
+golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
+golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
 golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
 golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -265,12 +265,12 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
-golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
 golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
-golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
-golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
 golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
@@ -279,12 +279,12 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
 golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
 google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
-google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
-google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

+ 59 - 0
install.sh

@@ -1478,6 +1478,7 @@ install_x-ui() {
     fi
 
     # Stop x-ui service and remove old resources
+    local custom_bin_backup=""
     if [[ -e ${xui_folder}/ ]]; then
         if [[ $release == "alpine" ]]; then
             rc-service x-ui stop
@@ -1489,6 +1490,31 @@ install_x-ui() {
         # an inbound port with an outdated secret, silently breaking new clients.
         # The freshly installed panel respawns a clean mtg per inbound on start.
         pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
+
+        # bin/ is about to be wiped wholesale by the tar extraction below. The
+        # release only ships known assets (xray/mtg binaries, the bundled
+        # geoip*/geosite*.dat sets) -- anything else in bin/ was placed there
+        # by the admin (e.g. a hand-added custom geoip/geosite file referenced
+        # from a routing rule via "ext:<file>:<code>") and would otherwise be
+        # silently deleted on every update, breaking Xray at next start with
+        # "failed to open <file>: no such file or directory" for any routing
+        # rule that references it. Moved aside rather than copied: a rename
+        # on the same filesystem is atomic (no truncated file if disk space
+        # runs out mid-copy, unlike `cp`) and keeps the snapshot under
+        # /usr/local rather than a separate, possibly small/tmpfs $TMPDIR.
+        if [[ -d "${xui_folder}/bin" ]]; then
+            custom_bin_backup="${xui_folder%/x-ui}/x-ui-bin-backup.$$"
+            rm -rf "${custom_bin_backup}"
+            if ! mv "${xui_folder}/bin" "${custom_bin_backup}"; then
+                custom_bin_backup=""
+                echo -e "${yellow}Could not back up bin/ -- custom files there will not be preserved across this update${plain}"
+            fi
+        fi
+        # Sole cleanup path for the backup from here on -- covers both the
+        # two `exit 1`s below (extraction/binary-missing failures) and an
+        # interrupted update (Ctrl-C, signal) before the restore runs.
+        # Cleared once the restore below finishes normally.
+        trap '[[ -n "${custom_bin_backup}" ]] && rm -rf "${custom_bin_backup}"' EXIT INT TERM
         rm ${xui_folder}/ -rf
     fi
 
@@ -1529,6 +1555,39 @@ install_x-ui() {
         chmod +x bin/mtg-linux-$(arch)
     fi
 
+    # Restore anything from the old bin/ that the fresh release doesn't ship
+    # (custom geoip/geosite files, or anything else an admin hand-placed
+    # there) -- never overwrites a same-named file the new release provides,
+    # so bundled assets (geoip.dat, geoip_RU.dat, ...) still get the fresh
+    # per-release copy. Runs after the arch-rename above so xray-linux-arm32/
+    # mtg-linux-arm already exist under their final names there and aren't
+    # mistaken for custom files needing a restore. Skips paths the panel
+    # itself regenerates at runtime (config.json, mtproto/*.toml -- see
+    # internal/xray/process.go, internal/mtproto/manager.go): those aren't
+    # admin-placed, and restoring a stale one only resurrects dead state (an
+    # orphaned mtg config for a since-deleted inbound) or the wrong
+    # directory permissions.
+    if [[ -n "${custom_bin_backup}" ]]; then
+        local restored_custom_bin=()
+        while IFS= read -r -d '' f; do
+            local rel="${f#"${custom_bin_backup}"/}"
+            case "${rel}" in
+                config.json | mtproto | mtproto/*) continue ;;
+            esac
+            if [[ ! -e "bin/${rel}" ]]; then
+                mkdir -p "bin/$(dirname "${rel}")"
+                cp -a "${f}" "bin/${rel}"
+                restored_custom_bin+=("${rel}")
+            fi
+        done < <(find "${custom_bin_backup}" \( -type f -o -type l \) -print0)
+        rm -rf "${custom_bin_backup}"
+        custom_bin_backup=""
+        if [[ ${#restored_custom_bin[@]} -gt 0 ]]; then
+            echo -e "${green}Restored custom file(s) in bin/ not shipped by this release: ${restored_custom_bin[*]}${plain}"
+        fi
+    fi
+    trap - EXIT INT TERM
+
     # Update x-ui cli and se set permission
     mv -f "${xui_script_temp}" /usr/bin/x-ui
     if [[ $? -ne 0 ]]; then

+ 65 - 0
internal/util/link/outbound.go

@@ -457,6 +457,8 @@ func parseHysteria2(link string) (*ParseResult, error) {
 		},
 	}
 	applyFinalMask(stream, params)
+	applyHysteria2Obfs(stream, params)
+	applyHysteria2Hop(stream, params)
 
 	identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
 
@@ -686,6 +688,69 @@ func applyFinalMask(stream map[string]any, p url.Values) {
 	}
 }
 
+// applyHysteria2Obfs rebuilds the salamander mask from the standard Hysteria2
+// obfs=salamander & obfs-password=<pw> pair (every non-3x-ui client, and this
+// panel's own generator, speak it instead of the private fm=<json> dump). A
+// salamander mask already carrying a password via fm= wins; a password-less one
+// is completed rather than left empty.
+func applyHysteria2Obfs(stream map[string]any, p url.Values) {
+	if !strings.EqualFold(p.Get("obfs"), "salamander") {
+		return
+	}
+	password := firstParam(p, "obfs-password", "obfs_password", "obfsPassword")
+	if password == "" {
+		return
+	}
+	finalmask := ensureChildMap(stream, "finalmask")
+	udp, _ := finalmask["udp"].([]any)
+	for _, m := range udp {
+		mask, ok := m.(map[string]any)
+		if !ok || mask["type"] != "salamander" {
+			continue
+		}
+		settings, ok := mask["settings"].(map[string]any)
+		if !ok {
+			settings = map[string]any{}
+			mask["settings"] = settings
+		}
+		if pw, _ := settings["password"].(string); pw == "" {
+			settings["password"] = password
+		}
+		return
+	}
+	finalmask["udp"] = append(udp, map[string]any{
+		"type":     "salamander",
+		"settings": map[string]any{"password": password},
+	})
+}
+
+// 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.
+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 != "" {
+			return
+		}
+	}
+	quicParams["udpHop"] = map[string]any{"ports": ports, "interval": "5-10"}
+}
+
+func ensureChildMap(parent map[string]any, key string) map[string]any {
+	m, ok := parent[key].(map[string]any)
+	if !ok {
+		m = map[string]any{}
+		parent[key] = m
+	}
+	return m
+}
+
 // sanitizeFinalMaskQuicParams coerces the strictly numeric quicParams fields
 // of a finalmask blob taken verbatim from a share link's fm= parameter.
 // Xray-core rejects the whole config at startup when e.g. keepAlivePeriod

+ 167 - 0
internal/util/link/outbound_test.go

@@ -114,6 +114,173 @@ func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) {
 	}
 }
 
+func salamanderPassword(t *testing.T, res *ParseResult) (string, bool) {
+	t.Helper()
+	stream, ok := res.Outbound["streamSettings"].(map[string]any)
+	if !ok {
+		t.Fatalf("missing streamSettings: %v", res.Outbound)
+	}
+	finalmask, ok := stream["finalmask"].(map[string]any)
+	if !ok {
+		return "", false
+	}
+	udp, ok := finalmask["udp"].([]any)
+	if !ok {
+		return "", false
+	}
+	for _, m := range udp {
+		mask, _ := m.(map[string]any)
+		if mask == nil || mask["type"] != "salamander" {
+			continue
+		}
+		settings, _ := mask["settings"].(map[string]any)
+		pw, _ := settings["password"].(string)
+		return pw, true
+	}
+	return "", false
+}
+
+func finalmaskUDP(t *testing.T, res *ParseResult) []any {
+	t.Helper()
+	stream, _ := res.Outbound["streamSettings"].(map[string]any)
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	udp, _ := finalmask["udp"].([]any)
+	return udp
+}
+
+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)
+	if !ok {
+		return "", false
+	}
+	ports, _ := udpHop["ports"].(string)
+	return ports, true
+}
+
+func TestParseHysteria2_Obfs(t *testing.T) {
+	cases := []struct {
+		name    string
+		query   string
+		wantPw  string
+		wantSet bool
+	}{
+		{"standard", "obfs=salamander&obfs-password=s3cr3t", "s3cr3t", true},
+		{"snake-case alias", "obfs=salamander&obfs_password=aliaspw", "aliaspw", true},
+		{"camel-case alias", "obfs=salamander&obfsPassword=camelpw", "camelpw", true},
+		{"case-insensitive type", "obfs=Salamander&obfs-password=mixed", "mixed", true},
+		{"no obfs", "sni=ex.com", "", false},
+		{"obfs without password", "obfs=salamander", "", false},
+		{"unknown obfs type", "obfs=random&obfs-password=x", "", false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			res, err := ParseLink("hysteria2://[email protected]:443?security=tls&" + c.query + "#node")
+			if err != nil {
+				t.Fatalf("parse hysteria2: %v", err)
+			}
+			if res.Outbound["protocol"] != "hysteria" {
+				t.Fatalf("bad protocol: %v", res.Outbound["protocol"])
+			}
+			pw, ok := salamanderPassword(t, res)
+			if ok != c.wantSet {
+				t.Fatalf("salamander mask present = %v, want %v (stream: %v)", ok, c.wantSet, res.Outbound["streamSettings"])
+			}
+			if pw != c.wantPw {
+				t.Errorf("salamander password: got %q, want %q", pw, c.wantPw)
+			}
+		})
+	}
+}
+
+func TestParseHysteria2_ObfsFinalMaskPrecedence(t *testing.T) {
+	cases := []struct {
+		name       string
+		fm         string
+		obfsPw     string
+		wantPw     string
+		wantUDPLen int
+	}{
+		{
+			name:       "fm password wins over obfs",
+			fm:         `{"udp":[{"type":"salamander","settings":{"password":"fromfm"}}]}`,
+			obfsPw:     "fromobfs",
+			wantPw:     "fromfm",
+			wantUDPLen: 1,
+		},
+		{
+			name:       "obfs fills password-less fm mask",
+			fm:         `{"udp":[{"type":"salamander","settings":{}}]}`,
+			obfsPw:     "fromobfs",
+			wantPw:     "fromobfs",
+			wantUDPLen: 1,
+		},
+		{
+			name:       "obfs appends alongside a non-salamander mask",
+			fm:         `{"udp":[{"type":"mkcp-legacy","settings":{"header":"srtp"}}]}`,
+			obfsPw:     "fromobfs",
+			wantPw:     "fromobfs",
+			wantUDPLen: 2,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			link := "hysteria2://[email protected]:443?security=tls&fm=" + url.QueryEscape(c.fm) +
+				"&obfs=salamander&obfs-password=" + c.obfsPw + "#node"
+			res, err := ParseLink(link)
+			if err != nil {
+				t.Fatalf("parse hysteria2: %v", err)
+			}
+			pw, ok := salamanderPassword(t, res)
+			if !ok {
+				t.Fatalf("salamander mask missing: %v", res.Outbound["streamSettings"])
+			}
+			if pw != c.wantPw {
+				t.Errorf("salamander password: got %q, want %q", pw, c.wantPw)
+			}
+			if udp := finalmaskUDP(t, res); len(udp) != c.wantUDPLen {
+				t.Errorf("udp mask count: got %d, want %d (%v)", len(udp), c.wantUDPLen, udp)
+			}
+		})
+	}
+}
+
+func TestParseHysteria2_Mport(t *testing.T) {
+	cases := []struct {
+		name      string
+		query     string
+		wantPorts string
+		wantHop   bool
+	}{
+		{"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"}}}`),
+			wantPorts: "30000-40000",
+			wantHop:   true,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			res, err := ParseLink("hysteria2://[email protected]:443?security=tls&" + c.query + "#node")
+			if err != nil {
+				t.Fatalf("parse hysteria2: %v", err)
+			}
+			ports, ok := hopPorts(t, res)
+			if ok != c.wantHop {
+				t.Fatalf("udpHop present = %v, want %v (stream: %v)", ok, c.wantHop, res.Outbound["streamSettings"])
+			}
+			if ports != c.wantPorts {
+				t.Errorf("hop ports: got %q, want %q", ports, c.wantPorts)
+			}
+		})
+	}
+}
+
 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"))