diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index c7486ca4..eed2ff8d 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/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 diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 1ab35a31..e78ef93d 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/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( diff --git a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx b/frontend/src/pages/inbounds/info/InboundInfoModal.tsx index 31b2239d..c66f44a1 100644 --- a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx +++ b/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({ diff --git a/frontend/src/pages/inbounds/qr/QrCodeModal.tsx b/frontend/src/pages/inbounds/qr/QrCodeModal.tsx index 11a1711e..df5d461f 100644 --- a/frontend/src/pages/inbounds/qr/QrCodeModal.tsx +++ b/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}` diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index 5d13e854..db34de0b 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/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; @@ -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); diff --git a/frontend/src/schemas/defaults.ts b/frontend/src/schemas/defaults.ts index 61d79a24..4f3f3576 100644 --- a/frontend/src/schemas/defaults.ts +++ b/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; diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 9e893544..a87323fd 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/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. diff --git a/web/service/setting.go b/web/service/setting.go index 3662d206..f7f0651e 100644 --- a/web/service/setting.go +++ b/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)