mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-07 06:32:07 +03:00
fix(hosts): advertise Hosts in every WireGuard, AmneziaWG and TUIC config
Invariant: an inbound's enabled Hosts are the endpoints every client config for it advertises, whichever surface renders that config. WireGuard and AmneziaWG broke it. Their raw generators ignored the externalProxy entries Hosts are injected as and always emitted resolveInboundAddress, so the raw subscription, the sub page .conf, the clients links API and "export all links" gave out the panel address while the JSON and Clash formats of the same inbound used the Host. advertisedEndpoints now states the fan-out once for mtproto, wireguard and amneziawg. The browser-built configs had the same gap. The Clients page WireGuard and AmneziaWG config blocks and QR panels, and its TUIC Clash config, used the panel hostname next to server links that already used Hosts; the Inbounds page peer configs, QR and export ignored them too. withMtprotoHostEndpoints becomes withHostEndpoints over a shared hostEndpointsFor mirror of the backend, the tunnel fan-outs render one config per Host, and the clients page waits for the hosts list the way the inbounds page does, so an empty list means "no hosts" rather than "not loaded yet".
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { hostToExternalProxyEntry, withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
|
||||
import { hostToExternalProxyEntry, withHostEndpoints } from '@/lib/hosts/host-link';
|
||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||
|
||||
describe('hostToExternalProxyEntry', () => {
|
||||
@@ -70,7 +70,7 @@ describe('hostToExternalProxyEntry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('withMtprotoHostEndpoints', () => {
|
||||
describe('withHostEndpoints', () => {
|
||||
const inbound = inboundFromDb({
|
||||
protocol: 'mtproto',
|
||||
port: 4060,
|
||||
@@ -81,7 +81,7 @@ describe('withMtprotoHostEndpoints', () => {
|
||||
});
|
||||
|
||||
it('projects enabled raw Hosts onto MTProto share endpoints', () => {
|
||||
const got = withMtprotoHostEndpoints(
|
||||
const got = withHostEndpoints(
|
||||
inbound,
|
||||
7,
|
||||
[
|
||||
@@ -103,7 +103,7 @@ describe('withMtprotoHostEndpoints', () => {
|
||||
});
|
||||
|
||||
it('inherits the inbound address for a port-only Host', () => {
|
||||
const got = withMtprotoHostEndpoints(
|
||||
const got = withHostEndpoints(
|
||||
inbound,
|
||||
7,
|
||||
[{ groupId: 'port-only', inboundIds: [7], hosts: [':8443'], port: 8443 }],
|
||||
@@ -116,7 +116,7 @@ describe('withMtprotoHostEndpoints', () => {
|
||||
});
|
||||
|
||||
it('ignores disabled, excluded and unrelated Hosts', () => {
|
||||
const got = withMtprotoHostEndpoints(
|
||||
const got = withHostEndpoints(
|
||||
inbound,
|
||||
7,
|
||||
[
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router';
|
||||
import ClientInfoModal from '@/pages/clients/ClientInfoModal';
|
||||
import ClientQrModal from '@/pages/clients/ClientQrModal';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import type { HostRecord } from '@/schemas/api/host';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const deAwgInbound: InboundOption = {
|
||||
@@ -182,4 +183,85 @@ describe('Multi-tunnel Client Modals', () => {
|
||||
expect(screen.getByText('DE · Kelsterbach')).toBeTruthy();
|
||||
expect(screen.getByText('FI · Helsinki')).toBeTruthy();
|
||||
});
|
||||
|
||||
// The subscription beside these configs advertises the inbound's Hosts, so
|
||||
// the panel-built configs must too — one per Host address.
|
||||
const edgeHosts: HostRecord[] = [
|
||||
{
|
||||
groupId: 'cdn',
|
||||
inboundIds: [201],
|
||||
hosts: ['edge.example.com:443', 'edge2.example.com'],
|
||||
remark: 'CDN',
|
||||
},
|
||||
];
|
||||
const edgeClient = { ...multiWgClient, inboundIds: [201] } as ClientRecord;
|
||||
const edgeLabels = [
|
||||
'US · New York - edge.example.com:443',
|
||||
'US · New York - edge2.example.com:51820',
|
||||
];
|
||||
|
||||
it('renders one ConfigBlock per Host in ClientInfoModal', () => {
|
||||
renderWithProviders(
|
||||
<ClientInfoModal
|
||||
open
|
||||
client={edgeClient}
|
||||
inboundsById={{ 201: usWgInbound }}
|
||||
isOnline={false}
|
||||
hosts={edgeHosts}
|
||||
onOpenChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const label of edgeLabels) expect(screen.getByText(label)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders one collapse panel per Host in ClientQrModal', () => {
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/clients']}>
|
||||
<ClientQrModal
|
||||
open
|
||||
client={edgeClient}
|
||||
inboundsById={{ 201: usWgInbound }}
|
||||
hosts={edgeHosts}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
for (const label of edgeLabels) expect(screen.getByText(label)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('builds the TUIC Clash config only from Hosts the Clash subscription serves', () => {
|
||||
const tuicInbound = { id: 301, remark: 'tuic', protocol: 'tuic', port: 8443 } as InboundOption;
|
||||
const tuicClient = {
|
||||
id: 'c4',
|
||||
email: 'TUIC-CLIENT',
|
||||
uuid: 'e79b9107-1607-4e6c-a496-d8f99e4f0dc5',
|
||||
password: 'secret',
|
||||
inboundIds: [301],
|
||||
} as unknown as ClientRecord;
|
||||
const hosts: HostRecord[] = [
|
||||
{ groupId: 'clash', inboundIds: [301], hosts: ['clash.example.com:443'] },
|
||||
{
|
||||
groupId: 'raw-only',
|
||||
inboundIds: [301],
|
||||
hosts: ['raw.example.com:443'],
|
||||
excludeFromSubTypes: ['clash'],
|
||||
},
|
||||
];
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/clients']}>
|
||||
<ClientQrModal
|
||||
open
|
||||
client={tuicClient}
|
||||
inboundsById={{ 301: tuicInbound }}
|
||||
hosts={hosts}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('TUIC config (Clash)')).toHaveLength(1);
|
||||
expect(screen.queryByText('raw.example.com:443')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import { withHostEndpoints } from '@/lib/hosts/host-link';
|
||||
import { formatTunnelConfigMeta } from '@/lib/inbounds/label';
|
||||
import { genAmneziaWGPeerConfigs, genWireguardPeerConfigs } from '@/lib/xray/inbound-link';
|
||||
import { buildAmneziaWGClientConfig } from '@/pages/clients/amneziawgConfig';
|
||||
import { buildTuicClientConfig } from '@/pages/clients/tuicConfig';
|
||||
import { tunnelConfigEndpoints } from '@/pages/clients/tunnelEndpoints';
|
||||
import { buildWireguardClientConfig } from '@/pages/clients/wireguardConfig';
|
||||
import { InboundSchema, type Inbound } from '@/schemas/api/inbound';
|
||||
import type { HostRecord } from '@/schemas/api/host';
|
||||
|
||||
const PANEL = 'panel.example.com';
|
||||
const HOSTS: HostRecord[] = [
|
||||
{
|
||||
groupId: 'cdn',
|
||||
inboundIds: [7],
|
||||
hosts: ['edge.example.com:443', 'edge2.example.com'],
|
||||
remark: 'CDN',
|
||||
},
|
||||
];
|
||||
|
||||
function endpointLines(configs: string[]): string[] {
|
||||
return configs.map((cfg) => cfg.match(/^Endpoint = (.*)$/m)?.[1] ?? '');
|
||||
}
|
||||
|
||||
// The panel's own tunnel previews must advertise the same Hosts the
|
||||
// subscription does, not the panel address (#6369 did this for MTProto).
|
||||
describe('inbounds page tunnel configs follow Hosts', () => {
|
||||
it('renders one WireGuard config per Host for each peer', () => {
|
||||
const inbound = InboundSchema.parse({
|
||||
port: 51820,
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: 'iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=',
|
||||
peers: [],
|
||||
clients: [
|
||||
{
|
||||
email: 'alice',
|
||||
privateKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
|
||||
allowedIPs: ['10.0.0.2/32'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const peers = genWireguardPeerConfigs({
|
||||
inbound: withHostEndpoints(inbound, 7, HOSTS, '', PANEL),
|
||||
remark: 'wg',
|
||||
fallbackHostname: PANEL,
|
||||
});
|
||||
expect(peers).toHaveLength(1);
|
||||
expect(endpointLines(peers[0])).toEqual(['edge.example.com:443', 'edge2.example.com:51820']);
|
||||
});
|
||||
|
||||
it('renders one AmneziaWG config per Host for each peer', () => {
|
||||
const inbound = {
|
||||
port: 51821,
|
||||
protocol: 'amneziawg',
|
||||
settings: {
|
||||
server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
|
||||
clients: [{ email: 'alice', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
|
||||
},
|
||||
streamSettings: {},
|
||||
} as unknown as Inbound;
|
||||
const peers = genAmneziaWGPeerConfigs({
|
||||
inbound: withHostEndpoints(inbound, 7, HOSTS, '', PANEL),
|
||||
remark: 'awg',
|
||||
fallbackHostname: PANEL,
|
||||
});
|
||||
expect(peers).toHaveLength(1);
|
||||
expect(endpointLines(peers[0])).toEqual(['edge.example.com:443', 'edge2.example.com:51821']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clients page tunnel configs follow Hosts', () => {
|
||||
const client = {
|
||||
email: 'alice',
|
||||
privateKey: 'clientPrivKey==',
|
||||
allowedIPs: '10.0.0.2/32',
|
||||
uuid: 'e79b9107-1607-4e6c-a496-d8f99e4f0dc5',
|
||||
password: 'secret',
|
||||
} as unknown as ClientRecord;
|
||||
|
||||
it('advertises each Host in the WireGuard config', () => {
|
||||
const inbound = { id: 7, remark: 'wg', protocol: 'wireguard', port: 51820 } as InboundOption;
|
||||
const configs = tunnelConfigEndpoints(inbound, HOSTS, PANEL, '').map((ep) =>
|
||||
buildWireguardClientConfig(client, inbound, PANEL, '', '', ep),
|
||||
);
|
||||
expect(endpointLines(configs)).toEqual(['edge.example.com:443', 'edge2.example.com:51820']);
|
||||
});
|
||||
|
||||
it('advertises each Host in the AmneziaWG config', () => {
|
||||
const inbound = {
|
||||
id: 7,
|
||||
remark: 'awg',
|
||||
protocol: 'amneziawg',
|
||||
port: 51821,
|
||||
awgServer: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
|
||||
} as unknown as InboundOption;
|
||||
const configs = tunnelConfigEndpoints(inbound, HOSTS, PANEL, '').map((ep) =>
|
||||
buildAmneziaWGClientConfig(client, inbound, PANEL, '', '', ep),
|
||||
);
|
||||
expect(endpointLines(configs)).toEqual(['edge.example.com:443', 'edge2.example.com:51821']);
|
||||
});
|
||||
|
||||
it('keeps the inbound address when no Host applies to it', () => {
|
||||
const inbound = { id: 8, remark: 'wg', protocol: 'wireguard', port: 51820 } as InboundOption;
|
||||
const configs = tunnelConfigEndpoints(inbound, HOSTS, PANEL, '').map((ep) =>
|
||||
buildWireguardClientConfig(client, inbound, PANEL, '', '', ep),
|
||||
);
|
||||
expect(endpointLines(configs)).toEqual([`${PANEL}:51820`]);
|
||||
});
|
||||
|
||||
it('applies a Host SNI, ALPN and insecure flag to the TUIC config', () => {
|
||||
const inbound = {
|
||||
id: 7,
|
||||
remark: 'tuic',
|
||||
protocol: 'tuic',
|
||||
port: 8443,
|
||||
tuicServer: { sni: 'inbound.sni', alpn: ['h3'] },
|
||||
} as unknown as InboundOption;
|
||||
const hosts: HostRecord[] = [
|
||||
{
|
||||
groupId: 'tuic',
|
||||
inboundIds: [7],
|
||||
hosts: ['tuic.example.com:9443'],
|
||||
sni: 'host.sni',
|
||||
alpn: ['h3', 'h2'],
|
||||
allowInsecure: true,
|
||||
},
|
||||
];
|
||||
const [ep] = tunnelConfigEndpoints(inbound, hosts, PANEL, '');
|
||||
const cfg = buildTuicClientConfig(client, inbound, PANEL, '', ep);
|
||||
expect(cfg).toContain('server: tuic.example.com');
|
||||
expect(cfg).toContain('port: 9443');
|
||||
expect(cfg).toContain('sni: host.sni');
|
||||
expect(cfg).toContain('alpn:\n - h3\n - h2\n');
|
||||
expect(cfg).toContain('skip-cert-verify: true');
|
||||
});
|
||||
|
||||
it('skips a Host excluded from Clash in the TUIC Clash config', () => {
|
||||
const inbound = { id: 7, remark: 'tuic', protocol: 'tuic', port: 8443 } as InboundOption;
|
||||
const hosts: HostRecord[] = [
|
||||
{
|
||||
groupId: 'raw-only',
|
||||
inboundIds: [7],
|
||||
hosts: ['raw.example.com:443'],
|
||||
excludeFromSubTypes: ['clash'],
|
||||
},
|
||||
];
|
||||
const configs = tunnelConfigEndpoints(inbound, hosts, PANEL, '', 'clash').map((ep) =>
|
||||
buildTuicClientConfig(client, inbound, PANEL, '', ep),
|
||||
);
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0]).toContain(`server: ${PANEL}`);
|
||||
});
|
||||
|
||||
it('names two Hosts of one inbound apart', () => {
|
||||
const inbound = { id: 7, remark: 'wg' };
|
||||
const a = formatTunnelConfigMeta(inbound, 'alice', 2, 'edge.example.com:443');
|
||||
const b = formatTunnelConfigMeta(inbound, 'alice', 2, 'edge2.example.com:51820');
|
||||
expect([a.fileName, b.fileName]).toEqual([
|
||||
'alice-wg-edge.example.com_443.conf',
|
||||
'alice-wg-edge2.example.com_51820.conf',
|
||||
]);
|
||||
expect([a.label, b.label]).toEqual([
|
||||
'wg - edge.example.com:443',
|
||||
'wg - edge2.example.com:51820',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user