소스 검색

fix(links): use configured domain for panel copy/QR links on loopback

The panel's copy/QR share links are built client-side and fell back to window.location.hostname, so reaching the panel over an SSH tunnel (127.0.0.1/localhost) leaked localhost into the links - unlike the backend subscription path, which falls back to the configured Sub/Web Domain (issue #4829).

Expose webDomain/subDomain via /defaultSettings and add preferPublicHost: when the browser host is loopback, prefer the configured Sub Domain (then Web Domain) for share/QR links. An explicit node override or per-inbound listen still wins; a routable browser host is kept as-is.

Closes #4829
MHSanaei 9 시간 전
부모
커밋
6ee462ac8e

+ 17 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -752,6 +752,23 @@ export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHost
   return fallbackHostname;
 }
 
+// A loopback browser host means the panel was reached through a tunnel (e.g.
+// SSH-forwarded 127.0.0.1/localhost), so it can never be a shareable link host.
+function isLoopbackHost(host: string): boolean {
+  const h = host.trim().replace(/^\[|\]$/g, '').toLowerCase();
+  return h === 'localhost' || h === '::1' || h.startsWith('127.');
+}
+
+// preferPublicHost is the browser-side analog of the backend's
+// configuredPublicHost: when the panel is reached on a loopback host, prefer a
+// configured public host (Sub/Web Domain) for share/QR links so they match the
+// subscription links instead of leaking localhost. An explicit per-inbound
+// listen or node override still wins, since resolveAddr only reaches the
+// fallbackHostname after those.
+export function preferPublicHost(browserHost: string, publicHost: string): string {
+  return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost;
+}
+
 // Returns the client array for protocols that have one. SS returns its
 // clients only in 2022-blake3 multi-user mode (matches the legacy
 // `this.clients` getter, which used isSSMultiUser to gate). Returns null

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

@@ -23,7 +23,7 @@ import {
 
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
 import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
-import { genInboundLinks } from '@/lib/xray/inbound-link';
+import { genInboundLinks, preferPublicHost } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 import { useTheme } from '@/hooks/useTheme';
@@ -260,11 +260,11 @@ export default function InboundsPage() {
         remark: projected.remark,
         remarkModel,
         hostOverride: hostOverrideFor(dbInbound),
-        fallbackHostname: window.location.hostname,
+        fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
       }),
       fileName: projected.remark || 'inbound',
     });
-  }, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
+  }, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
 
   const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
     openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
@@ -298,11 +298,11 @@ export default function InboundsPage() {
         remark: projected.remark,
         remarkModel,
         hostOverride: hostOverrideFor(ib),
-        fallbackHostname: window.location.hostname,
+        fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
       }));
     }
     openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
-  }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText, t]);
+  }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
 
   const exportAllSubs = useCallback(async () => {
     const hydrated = await Promise.all(

+ 2 - 1
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -11,6 +11,7 @@ import {
   genAllLinks,
   genWireguardConfigs,
   genWireguardLinks,
+  preferPublicHost,
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 
@@ -113,7 +114,7 @@ export default function InboundInfoModal({
     setClientStats(stats);
 
     const inboundForLinks = inboundFromDb(dbInbound);
-    const fallbackHostname = window.location.hostname;
+    const fallbackHostname = preferPublicHost(window.location.hostname, subSettings?.publicHost ?? '');
     if (info.protocol === Protocols.WIREGUARD) {
       setWireguardConfigs(
         genWireguardConfigs({

+ 2 - 1
frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -9,6 +9,7 @@ import {
   genWireguardConfigs,
   genWireguardLinks,
   isPostQuantumLink,
+  preferPublicHost,
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
 import QrPanel from './QrPanel';
@@ -57,7 +58,7 @@ export default function QrCodeModal({
   useEffect(() => {
     if (!open || !dbInbound) return;
     const inbound = inboundFromDb(dbInbound);
-    const fallbackHostname = window.location.hostname;
+    const fallbackHostname = preferPublicHost(window.location.hostname, subSettings?.publicHost ?? '');
     if (inbound.protocol === Protocols.WIREGUARD) {
       const peerRemark = client?.email
         ? `${dbInbound.remark}-${client.email}`

+ 6 - 1
frontend/src/pages/inbounds/useInbounds.ts

@@ -18,6 +18,10 @@ export interface SubSettings {
   subURI: string;
   subJsonURI: string;
   subJsonEnable: boolean;
+  // Configured public host (Sub Domain, else Web Domain) used as the share/QR
+  // link host when the panel is reached on a loopback address. Empty if neither
+  // is set.
+  publicHost: string;
 }
 
 type DBInboundInstance = InstanceType<typeof DBInbound>;
@@ -135,7 +139,8 @@ export function useInbounds() {
     subURI: defaults.subURI || '',
     subJsonURI: defaults.subJsonURI || '',
     subJsonEnable: !!defaults.subJsonEnable,
-  }), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
+    publicHost: defaults.subDomain || defaults.webDomain || '',
+  }), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable, defaults.subDomain, defaults.webDomain]);
 
   useEffect(() => {
     if (defaults.datepicker) setDatepicker(datepicker);

+ 2 - 0
frontend/src/schemas/defaults.ts

@@ -15,6 +15,8 @@ export const DefaultsPayloadSchema = z.object({
   remarkModel: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   ipLimitEnable: z.boolean().optional(),
+  webDomain: z.string().optional(),
+  subDomain: z.string().optional(),
 }).loose();
 
 export type DefaultsPayload = z.infer<typeof DefaultsPayloadSchema>;

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

@@ -10,6 +10,7 @@ import {
   genVmessLink,
   genWireguardConfig,
   genWireguardLink,
+  preferPublicHost,
   resolveAddr,
 } from '@/lib/xray/inbound-link';
 import { InboundSchema } from '@/schemas/api/inbound';
@@ -282,6 +283,35 @@ describe('resolveAddr precedence', () => {
   });
 });
 
+// #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
+// leak the loopback host into share/QR links; a configured public host wins.
+describe('preferPublicHost (loopback fallback)', () => {
+  it('keeps a routable browser host as-is even when a public host is configured', () => {
+    expect(preferPublicHost('panel.example.com', 'sub.example.com')).toBe('panel.example.com');
+    expect(preferPublicHost('203.0.113.7', 'sub.example.com')).toBe('203.0.113.7');
+  });
+
+  it('substitutes the public host for loopback browser hosts', () => {
+    for (const loop of ['127.0.0.1', 'localhost', '::1', '[::1]', '127.5.6.7']) {
+      expect(preferPublicHost(loop, 'sub.example.com')).toBe('sub.example.com');
+    }
+  });
+
+  it('leaves loopback untouched when no public host is configured', () => {
+    expect(preferPublicHost('127.0.0.1', '')).toBe('127.0.0.1');
+    expect(preferPublicHost('localhost', '')).toBe('localhost');
+  });
+
+  it('an explicit per-inbound listen still wins over the loopback fallback', () => {
+    const inbound = { listen: '203.0.113.9', port: 443, protocol: 'vless' as const };
+    expect(resolveAddr(
+      inbound as never,
+      '',
+      preferPublicHost('127.0.0.1', 'sub.example.com'),
+    )).toBe('203.0.113.9');
+  });
+});
+
 describe('genInboundLinks orchestrator', () => {
   // Every full-inbound fixture should produce the same \r\n-joined link
   // block at this baseline.

+ 2 - 0
web/service/setting.go

@@ -958,6 +958,8 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 		"remarkModel":    func() (any, error) { return s.GetRemarkModel() },
 		"datepicker":     func() (any, error) { return s.GetDatepicker() },
 		"ipLimitEnable":  func() (any, error) { return s.GetIpLimitEnable() },
+		"webDomain":      func() (any, error) { return s.GetWebDomain() },
+		"subDomain":      func() (any, error) { return s.GetSubDomain() },
 	}
 
 	result := make(map[string]any)