mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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
This commit is contained in:
parent
fcc6787a64
commit
6ee462ac8e
8 changed files with 66 additions and 8 deletions
|
|
@ -752,6 +752,23 @@ export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHost
|
||||||
return fallbackHostname;
|
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
|
// Returns the client array for protocols that have one. SS returns its
|
||||||
// clients only in 2022-blake3 multi-user mode (matches the legacy
|
// clients only in 2022-blake3 multi-user mode (matches the legacy
|
||||||
// `this.clients` getter, which used isSSMultiUser to gate). Returns null
|
// `this.clients` getter, which used isSSMultiUser to gate). Returns null
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
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 { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
|
@ -260,11 +260,11 @@ export default function InboundsPage() {
|
||||||
remark: projected.remark,
|
remark: projected.remark,
|
||||||
remarkModel,
|
remarkModel,
|
||||||
hostOverride: hostOverrideFor(dbInbound),
|
hostOverride: hostOverrideFor(dbInbound),
|
||||||
fallbackHostname: window.location.hostname,
|
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||||
}),
|
}),
|
||||||
fileName: projected.remark || 'inbound',
|
fileName: projected.remark || 'inbound',
|
||||||
});
|
});
|
||||||
}, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
|
}, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||||
|
|
||||||
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
||||||
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
|
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
|
||||||
|
|
@ -298,11 +298,11 @@ export default function InboundsPage() {
|
||||||
remark: projected.remark,
|
remark: projected.remark,
|
||||||
remarkModel,
|
remarkModel,
|
||||||
hostOverride: hostOverrideFor(ib),
|
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') });
|
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 exportAllSubs = useCallback(async () => {
|
||||||
const hydrated = await Promise.all(
|
const hydrated = await Promise.all(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
genAllLinks,
|
genAllLinks,
|
||||||
genWireguardConfigs,
|
genWireguardConfigs,
|
||||||
genWireguardLinks,
|
genWireguardLinks,
|
||||||
|
preferPublicHost,
|
||||||
} from '@/lib/xray/inbound-link';
|
} from '@/lib/xray/inbound-link';
|
||||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||||
|
|
||||||
|
|
@ -113,7 +114,7 @@ export default function InboundInfoModal({
|
||||||
setClientStats(stats);
|
setClientStats(stats);
|
||||||
|
|
||||||
const inboundForLinks = inboundFromDb(dbInbound);
|
const inboundForLinks = inboundFromDb(dbInbound);
|
||||||
const fallbackHostname = window.location.hostname;
|
const fallbackHostname = preferPublicHost(window.location.hostname, subSettings?.publicHost ?? '');
|
||||||
if (info.protocol === Protocols.WIREGUARD) {
|
if (info.protocol === Protocols.WIREGUARD) {
|
||||||
setWireguardConfigs(
|
setWireguardConfigs(
|
||||||
genWireguardConfigs({
|
genWireguardConfigs({
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
genWireguardConfigs,
|
genWireguardConfigs,
|
||||||
genWireguardLinks,
|
genWireguardLinks,
|
||||||
isPostQuantumLink,
|
isPostQuantumLink,
|
||||||
|
preferPublicHost,
|
||||||
} from '@/lib/xray/inbound-link';
|
} from '@/lib/xray/inbound-link';
|
||||||
import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
|
import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
|
||||||
import QrPanel from './QrPanel';
|
import QrPanel from './QrPanel';
|
||||||
|
|
@ -57,7 +58,7 @@ export default function QrCodeModal({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !dbInbound) return;
|
if (!open || !dbInbound) return;
|
||||||
const inbound = inboundFromDb(dbInbound);
|
const inbound = inboundFromDb(dbInbound);
|
||||||
const fallbackHostname = window.location.hostname;
|
const fallbackHostname = preferPublicHost(window.location.hostname, subSettings?.publicHost ?? '');
|
||||||
if (inbound.protocol === Protocols.WIREGUARD) {
|
if (inbound.protocol === Protocols.WIREGUARD) {
|
||||||
const peerRemark = client?.email
|
const peerRemark = client?.email
|
||||||
? `${dbInbound.remark}-${client.email}`
|
? `${dbInbound.remark}-${client.email}`
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ export interface SubSettings {
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
subJsonEnable: boolean;
|
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>;
|
type DBInboundInstance = InstanceType<typeof DBInbound>;
|
||||||
|
|
@ -135,7 +139,8 @@ export function useInbounds() {
|
||||||
subURI: defaults.subURI || '',
|
subURI: defaults.subURI || '',
|
||||||
subJsonURI: defaults.subJsonURI || '',
|
subJsonURI: defaults.subJsonURI || '',
|
||||||
subJsonEnable: !!defaults.subJsonEnable,
|
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(() => {
|
useEffect(() => {
|
||||||
if (defaults.datepicker) setDatepicker(datepicker);
|
if (defaults.datepicker) setDatepicker(datepicker);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ export const DefaultsPayloadSchema = z.object({
|
||||||
remarkModel: z.string().optional(),
|
remarkModel: z.string().optional(),
|
||||||
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||||
ipLimitEnable: z.boolean().optional(),
|
ipLimitEnable: z.boolean().optional(),
|
||||||
|
webDomain: z.string().optional(),
|
||||||
|
subDomain: z.string().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export type DefaultsPayload = z.infer<typeof DefaultsPayloadSchema>;
|
export type DefaultsPayload = z.infer<typeof DefaultsPayloadSchema>;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
genVmessLink,
|
genVmessLink,
|
||||||
genWireguardConfig,
|
genWireguardConfig,
|
||||||
genWireguardLink,
|
genWireguardLink,
|
||||||
|
preferPublicHost,
|
||||||
resolveAddr,
|
resolveAddr,
|
||||||
} from '@/lib/xray/inbound-link';
|
} from '@/lib/xray/inbound-link';
|
||||||
import { InboundSchema } from '@/schemas/api/inbound';
|
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', () => {
|
describe('genInboundLinks orchestrator', () => {
|
||||||
// Every full-inbound fixture should produce the same \r\n-joined link
|
// Every full-inbound fixture should produce the same \r\n-joined link
|
||||||
// block at this baseline.
|
// block at this baseline.
|
||||||
|
|
|
||||||
|
|
@ -958,6 +958,8 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||||
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
||||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
"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)
|
result := make(map[string]any)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue