mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 11:57:15 +00:00
feat(pia): add PIA login-and-add WireGuard outbounds (#6272)
* feat(pia): add login-and-add WireGuard outbounds (#2) * fix(pia): keep PIA outbounds identifiable after the editor strips hostname The outbound editor drops piaHostname, so last-segment matching failed for hyphenated servers. Identify rows by the computed tag, re-encrypt stored tokens onto the active key, skip unusable catalog rows, and always release the catalog refresh latch.
This commit is contained in:
@@ -3160,7 +3160,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Xray Settings",
|
||||
"description": "Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray."
|
||||
"description": "Xray configuration template, outbound management, Warp/Nord/PIA integration, and config testing. All endpoints under /panel/api/xray."
|
||||
},
|
||||
{
|
||||
"name": "Subscription Server",
|
||||
@@ -11143,6 +11143,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/xray/pia/{action}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Xray Settings"
|
||||
],
|
||||
"summary": "Manage PIA WireGuard integration. The action parameter selects the operation.",
|
||||
"operationId": "post_panel_api_xray_pia_action",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "action",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "countries — list available countries from the signed PIA server list. servers — list regions and WireGuard servers in a country (sends countryCode). reg — sign in with a PIA username and password (sends username, password). data — return the signed-in account hint. del — delete stored PIA credentials. addKey — register a WireGuard key with the selected server (sends hostname) and return fields to build the outbound.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/xray/resetOutboundsTraffic": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -1699,7 +1699,7 @@ export const sections: readonly Section[] = [
|
||||
id: 'xray-settings',
|
||||
title: 'Xray Settings',
|
||||
description:
|
||||
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray.',
|
||||
'Xray configuration template, outbound management, Warp/Nord/PIA integration, and config testing. All endpoints under /panel/api/xray.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1799,6 +1799,43 @@ export const sections: readonly Section[] = [
|
||||
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/pia/:action',
|
||||
summary: 'Manage PIA WireGuard integration. The action parameter selects the operation.',
|
||||
params: [
|
||||
{
|
||||
name: 'action',
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
desc: 'countries — list available countries from the signed PIA server list. servers — list regions and WireGuard servers in a country (sends countryCode). reg — sign in with a PIA username and password (sends username, password). data — return the signed-in account hint. del — delete stored PIA credentials. addKey — register a WireGuard key with the selected server (sends hostname) and return fields to build the outbound.',
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
},
|
||||
{
|
||||
name: 'countryCode',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=servers.',
|
||||
},
|
||||
{
|
||||
name: 'hostname',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=addKey.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/resetOutboundsTraffic',
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
detectBalancerCycles,
|
||||
} from './balancers/balancer-loopback';
|
||||
import { DnsTab } from './dns';
|
||||
import { WarpModal, NordModal } from './overrides';
|
||||
import { WarpModal, NordModal, PiaModal } from './overrides';
|
||||
import './XrayPage.css';
|
||||
|
||||
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
|
||||
@@ -82,6 +82,7 @@ export default function XrayPage() {
|
||||
|
||||
const [warpOpen, setWarpOpen] = useState(false);
|
||||
const [nordOpen, setNordOpen] = useState(false);
|
||||
const [piaOpen, setPiaOpen] = useState(false);
|
||||
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -264,6 +265,7 @@ export default function XrayPage() {
|
||||
onTestAll={testAllOutbounds}
|
||||
onShowWarp={() => setWarpOpen(true)}
|
||||
onShowNord={() => setNordOpen(true)}
|
||||
onShowPia={() => setPiaOpen(true)}
|
||||
onRefreshXrayData={fetchAll}
|
||||
/>
|
||||
);
|
||||
@@ -394,6 +396,13 @@ export default function XrayPage() {
|
||||
onRemoveOutbound={onRemoveOutboundByIndex}
|
||||
onRemoveRoutingRules={onRemoveRoutingRules}
|
||||
/>
|
||||
<PiaModal
|
||||
open={piaOpen}
|
||||
templateSettings={templateSettings}
|
||||
onClose={() => setPiaOpen(false)}
|
||||
onAddOutbound={onAddOutbound}
|
||||
onResetOutbound={onResetOutbound}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -95,6 +95,7 @@ interface OutboundsTabProps {
|
||||
onTestAll: (mode: string) => void;
|
||||
onShowWarp: () => void;
|
||||
onShowNord: () => void;
|
||||
onShowPia: () => void;
|
||||
onRefreshXrayData?: () => void;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,7 @@ export default function OutboundsTab({
|
||||
onTestAll,
|
||||
onShowWarp,
|
||||
onShowNord,
|
||||
onShowPia,
|
||||
onRefreshXrayData,
|
||||
}: OutboundsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -550,6 +552,12 @@ export default function OutboundsTab({
|
||||
items: [
|
||||
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
||||
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
||||
{
|
||||
key: 'pia',
|
||||
icon: <ApiOutlined />,
|
||||
label: t('pages.xray.pia.menu'),
|
||||
onClick: onShowPia,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'import',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
.pia-data-table {
|
||||
margin: 5px 0;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.pia-data-table td {
|
||||
padding: 4px 8px;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pia-data-table td:first-child {
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.pia-data-table .row-odd {
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.pia-already-added {
|
||||
margin-top: 8px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pia-added-table {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.pia-added-table td {
|
||||
padding: 6px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.pia-added-table td:first-child {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.pia-added-table td:last-child {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Form, Input, message, Modal, Select } from 'antd';
|
||||
import { LoginOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { countryFlag, countryName } from '../outbounds/outbounds-tab-helpers';
|
||||
import './PiaModal.css';
|
||||
|
||||
interface PiaOutboundRow {
|
||||
tag?: string;
|
||||
piaHostname?: string;
|
||||
}
|
||||
|
||||
interface PiaModalProps {
|
||||
open: boolean;
|
||||
templateSettings: { outbounds?: PiaOutboundRow[] } | null;
|
||||
onClose: () => void;
|
||||
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
||||
onResetOutbound: (payload: {
|
||||
index: number;
|
||||
outbound: Record<string, unknown>;
|
||||
oldTag?: string;
|
||||
newTag: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface PiaAccount {
|
||||
username?: string;
|
||||
accountHint?: string;
|
||||
}
|
||||
|
||||
interface PiaCountry {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface PiaRegion {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PiaServer {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
regionId: string;
|
||||
regionName: string;
|
||||
}
|
||||
|
||||
interface PiaKey {
|
||||
tag: string;
|
||||
hostname: string;
|
||||
secretKey: string;
|
||||
address: string;
|
||||
publicKey: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
interface PiaFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
countryCode: string | null;
|
||||
regionId: string | null;
|
||||
hostname: string | null;
|
||||
}
|
||||
|
||||
const EMPTY: PiaFormValues = {
|
||||
username: '',
|
||||
password: '',
|
||||
countryCode: null,
|
||||
regionId: null,
|
||||
hostname: null,
|
||||
};
|
||||
|
||||
function piaHostnameOf(outbound: PiaOutboundRow): string {
|
||||
if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
|
||||
return outbound.piaHostname.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function piaTagPart(s: string, stripDomain: boolean): string {
|
||||
s = s.trim().toLowerCase();
|
||||
if (stripDomain) {
|
||||
const i = s.indexOf('.');
|
||||
if (i > 0) s = s.slice(0, i);
|
||||
}
|
||||
return s.replaceAll('_', '-');
|
||||
}
|
||||
|
||||
function piaOutboundTag(regionId: string, hostname: string): string {
|
||||
const region = piaTagPart(regionId, false);
|
||||
const server = piaTagPart(hostname, true);
|
||||
if (!region) return `pia-${server}`;
|
||||
return `pia-${region}-${server}`;
|
||||
}
|
||||
|
||||
function buildPiaOutbound(key: PiaKey): Record<string, unknown> {
|
||||
return {
|
||||
tag: key.tag || `pia-${key.hostname}`,
|
||||
piaHostname: key.hostname,
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: key.secretKey,
|
||||
address: [key.address],
|
||||
mtu: 1420,
|
||||
noKernelTun: true,
|
||||
peers: [
|
||||
{
|
||||
publicKey: key.publicKey,
|
||||
endpoint: key.endpoint,
|
||||
allowedIPs: ['0.0.0.0/0'],
|
||||
keepAlive: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function PiaModal({
|
||||
open,
|
||||
templateSettings,
|
||||
onClose,
|
||||
onAddOutbound,
|
||||
onResetOutbound,
|
||||
}: PiaModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [piaData, setPiaData] = useState<PiaAccount | null>(null);
|
||||
const [countries, setCountries] = useState<PiaCountry[]>([]);
|
||||
const [regions, setRegions] = useState<PiaRegion[]>([]);
|
||||
const [servers, setServers] = useState<PiaServer[]>([]);
|
||||
const methods = useForm<PiaFormValues>({ defaultValues: EMPTY });
|
||||
const regionId = useWatch({ control: methods.control, name: 'regionId' });
|
||||
const hostname = useWatch({ control: methods.control, name: 'hostname' });
|
||||
const locale = i18n.resolvedLanguage || i18n.language;
|
||||
|
||||
const piaRows = useMemo(() => {
|
||||
const list = templateSettings?.outbounds;
|
||||
if (!list) return [];
|
||||
return list.flatMap((outbound, index) => {
|
||||
if (!outbound?.tag?.startsWith?.('pia-')) return [];
|
||||
return [{ index, tag: outbound.tag, hostname: piaHostnameOf(outbound) }];
|
||||
});
|
||||
}, [templateSettings?.outbounds]);
|
||||
|
||||
const addedHostnames = useMemo(
|
||||
() => new Set(piaRows.map((row) => row.hostname).filter(Boolean)),
|
||||
[piaRows],
|
||||
);
|
||||
const addedTags = useMemo(
|
||||
() => new Set(piaRows.map((row) => row.tag).filter(Boolean)),
|
||||
[piaRows],
|
||||
);
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
if (!regionId) return servers;
|
||||
return servers.filter((s) => s.regionId === regionId);
|
||||
}, [regionId, servers]);
|
||||
|
||||
const selectedServer = filteredServers.find((s) => s.hostname === hostname);
|
||||
const selectedTag = selectedServer
|
||||
? piaOutboundTag(selectedServer.regionId, selectedServer.hostname)
|
||||
: '';
|
||||
const selectedAlreadyAdded = Boolean(
|
||||
(hostname && addedHostnames.has(hostname)) || (selectedTag && addedTags.has(selectedTag)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
methods.setValue('hostname', filteredServers.length > 0 ? filteredServers[0].hostname : null);
|
||||
}, [filteredServers, methods]);
|
||||
|
||||
const fetchCountries = useCallback(async () => {
|
||||
const msg = await HttpUtil.post<PiaCountry[]>('/panel/api/xray/pia/countries');
|
||||
if (msg?.success && Array.isArray(msg.obj)) setCountries(msg.obj);
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<PiaAccount | null>('/panel/api/xray/pia/data');
|
||||
if (msg?.success) {
|
||||
const next = msg.obj ?? null;
|
||||
setPiaData(next);
|
||||
if (next) await fetchCountries();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchCountries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await fetchData();
|
||||
if (cancelled) return;
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, fetchData]);
|
||||
|
||||
async function login() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<PiaAccount>('/panel/api/xray/pia/reg', {
|
||||
username: methods.getValues('username'),
|
||||
password: methods.getValues('password'),
|
||||
});
|
||||
if (msg?.success && msg.obj) {
|
||||
setPiaData(msg.obj);
|
||||
methods.setValue('password', '');
|
||||
await fetchCountries();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/pia/del');
|
||||
if (msg?.success) {
|
||||
setPiaData(null);
|
||||
methods.reset(EMPTY);
|
||||
setCountries([]);
|
||||
setRegions([]);
|
||||
setServers([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchServers(newCountryCode: string) {
|
||||
setLoading(true);
|
||||
setServers([]);
|
||||
setRegions([]);
|
||||
methods.setValue('hostname', null);
|
||||
methods.setValue('regionId', null);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ regions?: PiaRegion[]; servers?: PiaServer[] }>(
|
||||
'/panel/api/xray/pia/servers',
|
||||
{ countryCode: newCountryCode },
|
||||
);
|
||||
if (!msg?.success || !msg.obj) return;
|
||||
const nextRegions = msg.obj.regions || [];
|
||||
const nextServers = msg.obj.servers || [];
|
||||
setRegions(nextRegions);
|
||||
setServers(nextServers);
|
||||
if (nextServers.length === 0) messageApi.warning(t('pages.xray.pia.noServers'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionOutbound(
|
||||
selectedHostname: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (!selectedHostname) return null;
|
||||
const msg = await HttpUtil.post<PiaKey>('/panel/api/xray/pia/addKey', {
|
||||
hostname: selectedHostname,
|
||||
});
|
||||
if (!msg?.success) return null;
|
||||
if (!msg.obj?.secretKey || !msg.obj.publicKey || !msg.obj.endpoint || !msg.obj.address) {
|
||||
messageApi.error(t('pages.xray.pia.provisionFailed'));
|
||||
return null;
|
||||
}
|
||||
return buildPiaOutbound(msg.obj);
|
||||
}
|
||||
|
||||
async function addOutbound() {
|
||||
const selected = methods.getValues('hostname');
|
||||
if (!selected || selectedAlreadyAdded) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const ob = await provisionOutbound(selected);
|
||||
if (!ob) return;
|
||||
const tag = typeof ob.tag === 'string' ? ob.tag : '';
|
||||
if (tag && templateSettings?.outbounds?.some((outbound) => outbound?.tag === tag)) return;
|
||||
onAddOutbound(ob);
|
||||
messageApi.success(t('pages.xray.pia.outboundAdded'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetOutbound(index: number, selectedHostname: string) {
|
||||
if (!selectedHostname) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const ob = await provisionOutbound(selectedHostname);
|
||||
if (!ob) return;
|
||||
const oldTag = templateSettings?.outbounds?.[index]?.tag;
|
||||
onResetOutbound({
|
||||
index,
|
||||
outbound: ob,
|
||||
oldTag,
|
||||
newTag: ob.tag as string,
|
||||
});
|
||||
messageApi.success(t('pages.xray.pia.outboundUpdated'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal open={open} title="Private Internet Access WireGuard" footer={null} onCancel={onClose}>
|
||||
<FormProvider {...methods}>
|
||||
{piaData == null ? (
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-20"
|
||||
>
|
||||
<FormField name="username" label={t('pages.xray.pia.username')}>
|
||||
<Input placeholder={t('pages.xray.pia.username')} autoComplete="username" />
|
||||
</FormField>
|
||||
<FormField name="password" label={t('pages.xray.pia.password')}>
|
||||
<Input.Password
|
||||
placeholder={t('pages.xray.pia.password')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-10"
|
||||
loading={loading}
|
||||
icon={<LoginOutlined />}
|
||||
onClick={() => void login()}
|
||||
>
|
||||
{t('login')}
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<>
|
||||
<table className="pia-data-table">
|
||||
<tbody>
|
||||
<tr className="row-odd">
|
||||
<td>{t('pages.xray.pia.account')}</td>
|
||||
<td>{piaData.accountHint || piaData.username}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button
|
||||
loading={loading}
|
||||
type="primary"
|
||||
danger
|
||||
className="mt-8"
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
{t('logout')}
|
||||
</Button>
|
||||
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
||||
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 6 } }}
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-10"
|
||||
>
|
||||
<FormField
|
||||
name="countryCode"
|
||||
label={t('pages.xray.outbound.country')}
|
||||
transform={{ input: (v) => v ?? undefined }}
|
||||
onAfterChange={(v) => void fetchServers(v as string)}
|
||||
>
|
||||
<Select
|
||||
data-testid="pia-country-select"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={countries.map((c) => {
|
||||
const name = countryName(c.code, locale) || c.code;
|
||||
const flag = countryFlag(c.code);
|
||||
return {
|
||||
value: c.code,
|
||||
label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{regions.length > 0 && (
|
||||
<FormField name="regionId" label={t('pages.xray.pia.region')}>
|
||||
<Select
|
||||
data-testid="pia-region-select"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={[
|
||||
{ value: null, label: t('pages.xray.pia.allRegions') },
|
||||
...regions.map((r) => ({ value: r.id, label: r.name })),
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{filteredServers.length > 0 && (
|
||||
<FormField name="hostname" label={t('pages.xray.outbound.server')}>
|
||||
<Select
|
||||
data-testid="pia-server-select"
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
options={filteredServers.map((s) => ({
|
||||
value: s.hostname,
|
||||
label: `${s.regionName} ${s.hostname} ${s.ip}`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-10"
|
||||
disabled={!hostname || selectedAlreadyAdded}
|
||||
loading={loading}
|
||||
onClick={() => void addOutbound()}
|
||||
>
|
||||
{t('pages.xray.warp.addOutbound')}
|
||||
</Button>
|
||||
{selectedAlreadyAdded && (
|
||||
<div className="pia-already-added">
|
||||
{t('pages.xray.pia.alreadyAdded', { reset: t('reset') })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{piaRows.length > 0 && (
|
||||
<>
|
||||
<Divider className="my-10">{t('pages.xray.pia.addedServers')}</Divider>
|
||||
<table className="pia-added-table" data-testid="pia-added-table">
|
||||
<tbody>
|
||||
{piaRows.map((row) => (
|
||||
<tr key={`${row.index}-${row.tag}`}>
|
||||
<td>{row.tag}</td>
|
||||
<td>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={!row.tag}
|
||||
data-testid={`pia-reset-${row.index}`}
|
||||
onClick={() =>
|
||||
void resetOutbound(row.index, row.hostname || row.tag || '')
|
||||
}
|
||||
>
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as WarpModal } from './WarpModal';
|
||||
export { default as NordModal } from './NordModal';
|
||||
export { default as PiaModal } from './PiaModal';
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('OutboundsTab hidden-loopback index mapping', () => {
|
||||
onTestAll={vi.fn()}
|
||||
onShowWarp={vi.fn()}
|
||||
onShowNord={vi.fn()}
|
||||
onShowPia={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -77,6 +78,7 @@ describe('OutboundsTab hidden-loopback index mapping', () => {
|
||||
onTestAll={vi.fn()}
|
||||
onShowWarp={vi.fn()}
|
||||
onShowNord={vi.fn()}
|
||||
onShowPia={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import PiaModal from '@/pages/xray/overrides/PiaModal';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const ACCOUNT = { username: 'p1234567', accountHint: 'p*****67' };
|
||||
const COUNTRIES = [{ code: 'US' }, { code: 'DE' }, { code: 'AL' }];
|
||||
const SERVERS = {
|
||||
regions: [
|
||||
{ id: 'us-east', name: 'US East' },
|
||||
{ id: 'us-west', name: 'US West' },
|
||||
{ id: 'al', name: 'Albania' },
|
||||
],
|
||||
servers: [
|
||||
{ hostname: 'useast1', ip: '198.51.100.10', regionId: 'us-east', regionName: 'US East' },
|
||||
{ hostname: 'uswest1', ip: '198.51.100.30', regionId: 'us-west', regionName: 'US West' },
|
||||
{
|
||||
hostname: 'Server-12406-1a',
|
||||
ip: '198.51.100.40',
|
||||
regionId: 'al',
|
||||
regionName: 'Albania',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function piaApiPost(url: string, data?: unknown) {
|
||||
if (url === '/panel/api/xray/pia/data') return new Msg(true, '', ACCOUNT);
|
||||
if (url === '/panel/api/xray/pia/countries') return new Msg(true, '', COUNTRIES);
|
||||
if (url === '/panel/api/xray/pia/servers') {
|
||||
const code = (data as { countryCode?: string } | undefined)?.countryCode?.toUpperCase();
|
||||
if (code === 'AL') {
|
||||
return new Msg(true, '', {
|
||||
regions: [SERVERS.regions[2]],
|
||||
servers: [SERVERS.servers[2]],
|
||||
});
|
||||
}
|
||||
if (code === 'US') {
|
||||
return new Msg(true, '', {
|
||||
regions: SERVERS.regions.slice(0, 2),
|
||||
servers: SERVERS.servers.slice(0, 2),
|
||||
});
|
||||
}
|
||||
return new Msg(true, '', { regions: [], servers: [] });
|
||||
}
|
||||
if (url === '/panel/api/xray/pia/addKey') {
|
||||
const hostname = (data as { hostname?: string } | undefined)?.hostname;
|
||||
if (hostname === 'uswest1') {
|
||||
return new Msg(true, '', {
|
||||
tag: 'pia-us-west-uswest1',
|
||||
hostname: 'uswest1',
|
||||
secretKey: 'secret-west',
|
||||
address: '10.8.0.2/32',
|
||||
publicKey: 'pubkey-west',
|
||||
endpoint: '198.51.100.30:1337',
|
||||
});
|
||||
}
|
||||
if (hostname === 'Server-12406-1a' || hostname === 'pia-al-server-12406-1a') {
|
||||
return new Msg(true, '', {
|
||||
tag: 'pia-al-server-12406-1a',
|
||||
hostname: 'Server-12406-1a',
|
||||
secretKey: 'secret-al',
|
||||
address: '10.8.0.3/32',
|
||||
publicKey: 'pubkey-al',
|
||||
endpoint: '198.51.100.40:1337',
|
||||
});
|
||||
}
|
||||
if (hostname === 'useast1' || hostname === 'pia-us-east-useast1') {
|
||||
return new Msg(true, '', {
|
||||
tag: 'pia-us-east-useast1',
|
||||
hostname: 'useast1',
|
||||
secretKey: 'secret',
|
||||
address: '10.8.0.1/32',
|
||||
publicKey: 'pubkey',
|
||||
endpoint: '198.51.100.10:1337',
|
||||
});
|
||||
}
|
||||
return new Msg(false, `Unexpected addKey hostname ${hostname}`, null);
|
||||
}
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
}
|
||||
|
||||
function mockPiaApi() {
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string, data?: unknown) =>
|
||||
piaApiPost(url, data),
|
||||
);
|
||||
}
|
||||
|
||||
function visibleOptions(): HTMLElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseOption(testId: string, labelPart: string) {
|
||||
const node = screen.getByTestId(testId);
|
||||
const select = node.closest('.ant-select') ?? node;
|
||||
const selector = select.querySelector('.ant-select-selector') ?? select;
|
||||
fireEvent.mouseDown(selector);
|
||||
await waitFor(() => expect(visibleOptions().length).toBeGreaterThan(0));
|
||||
const option = visibleOptions().find((item) =>
|
||||
(item.getAttribute('title') ?? item.textContent ?? '').includes(labelPart),
|
||||
);
|
||||
if (!option) throw new Error(`Missing option containing ${labelPart}`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
async function clickAddOutbound() {
|
||||
const addButton = await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /Add outbound/ });
|
||||
if ((btn as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
|
||||
return btn;
|
||||
});
|
||||
fireEvent.click(addButton);
|
||||
}
|
||||
|
||||
function expectPiaOutbound(
|
||||
outbound: Record<string, unknown>,
|
||||
want: {
|
||||
tag: string;
|
||||
hostname: string;
|
||||
secretKey: string;
|
||||
address: string;
|
||||
publicKey: string;
|
||||
endpoint: string;
|
||||
},
|
||||
) {
|
||||
expect(outbound).toMatchObject({
|
||||
tag: want.tag,
|
||||
piaHostname: want.hostname,
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: want.secretKey,
|
||||
address: [want.address],
|
||||
mtu: 1420,
|
||||
noKernelTun: true,
|
||||
peers: [
|
||||
{
|
||||
publicKey: want.publicKey,
|
||||
endpoint: want.endpoint,
|
||||
allowedIPs: ['0.0.0.0/0'],
|
||||
keepAlive: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function PiaHarness({ onAdded }: { onAdded?: (outbound: Record<string, unknown>) => void }) {
|
||||
const [outbounds, setOutbounds] = useState<Record<string, unknown>[]>([]);
|
||||
return (
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={(outbound) => {
|
||||
onAdded?.(outbound);
|
||||
setOutbounds((prev) => [...prev, outbound]);
|
||||
}}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('PIA modal', () => {
|
||||
it('shows username and password when not signed in', async () => {
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
|
||||
if (url === '/panel/api/xray/pia/data') return new Msg(true, '', null);
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('PIA username')).toBeTruthy());
|
||||
expect(screen.getByPlaceholderText('PIA password')).toBeTruthy();
|
||||
expect(screen.getByRole('dialog', { name: 'Private Internet Access WireGuard' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Log In/ })).toBeTruthy();
|
||||
expect(screen.queryByTestId('pia-country-select')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds two WireGuard outbounds for different servers', async () => {
|
||||
mockPiaApi();
|
||||
const added: Record<string, unknown>[] = [];
|
||||
renderWithProviders(<PiaHarness onAdded={(outbound) => added.push(outbound)} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await clickAddOutbound();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('pia-added-table')).toBeTruthy());
|
||||
expect(screen.getByText('pia-us-east-useast1')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Add outbound/ })).toBeTruthy();
|
||||
|
||||
await chooseOption('pia-server-select', 'uswest1');
|
||||
await clickAddOutbound();
|
||||
await waitFor(() => expect(screen.getByText('pia-us-west-uswest1')).toBeTruthy());
|
||||
expect(added).toHaveLength(2);
|
||||
expectPiaOutbound(added[0], {
|
||||
tag: 'pia-us-east-useast1',
|
||||
hostname: 'useast1',
|
||||
secretKey: 'secret',
|
||||
address: '10.8.0.1/32',
|
||||
publicKey: 'pubkey',
|
||||
endpoint: '198.51.100.10:1337',
|
||||
});
|
||||
expectPiaOutbound(added[1], {
|
||||
tag: 'pia-us-west-uswest1',
|
||||
hostname: 'uswest1',
|
||||
secretKey: 'secret-west',
|
||||
address: '10.8.0.2/32',
|
||||
publicKey: 'pubkey-west',
|
||||
endpoint: '198.51.100.30:1337',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Add when the selected server is already in the list', async () => {
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1', piaHostname: 'useast1' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /Add outbound/ });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
expect(screen.getByText(/Use Reset to renew its key/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resets an existing PIA outbound in place', async () => {
|
||||
const onResetOutbound = vi.fn();
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1', piaHostname: 'useast1' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={onResetOutbound}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('pia-reset-0')).toBeTruthy());
|
||||
fireEvent.click(screen.getByTestId('pia-reset-0'));
|
||||
await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
|
||||
const payload = onResetOutbound.mock.calls[0][0] as {
|
||||
index: number;
|
||||
outbound: { tag: string; piaHostname: string; settings: { secretKey: string } };
|
||||
oldTag?: string;
|
||||
newTag: string;
|
||||
};
|
||||
expect(payload.index).toBe(0);
|
||||
expect(payload.oldTag).toBe('pia-us-east-useast1');
|
||||
expect(payload.newTag).toBe('pia-us-east-useast1');
|
||||
expectPiaOutbound(payload.outbound as Record<string, unknown>, {
|
||||
tag: 'pia-us-east-useast1',
|
||||
hostname: 'useast1',
|
||||
secretKey: 'secret',
|
||||
address: '10.8.0.1/32',
|
||||
publicKey: 'pubkey',
|
||||
endpoint: '198.51.100.10:1337',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Add for a hyphenated cn when only the tag remains', async () => {
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-al-server-12406-1a' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'AL');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /Add outbound/ });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Add when only the outbound tag remains', async () => {
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /Add outbound/ });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets from the outbound tag when piaHostname was stripped', async () => {
|
||||
const onResetOutbound = vi.fn();
|
||||
const posts: unknown[] = [];
|
||||
mockPiaApi();
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string, data?: unknown) => {
|
||||
if (url === '/panel/api/xray/pia/addKey') posts.push(data);
|
||||
return piaApiPost(url, data);
|
||||
});
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-al-server-12406-1a' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={onResetOutbound}
|
||||
/>,
|
||||
);
|
||||
|
||||
const reset = await waitFor(() => screen.getByTestId('pia-reset-0'));
|
||||
expect((reset as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(reset);
|
||||
await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
|
||||
expect(posts).toEqual([{ hostname: 'pia-al-server-12406-1a' }]);
|
||||
expectPiaOutbound(onResetOutbound.mock.calls[0][0].outbound as Record<string, unknown>, {
|
||||
tag: 'pia-al-server-12406-1a',
|
||||
hostname: 'Server-12406-1a',
|
||||
secretKey: 'secret-al',
|
||||
address: '10.8.0.3/32',
|
||||
publicKey: 'pubkey-al',
|
||||
endpoint: '198.51.100.40:1337',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add an outbound when addKey omits WireGuard fields', async () => {
|
||||
const onAddOutbound = vi.fn();
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
|
||||
if (url === '/panel/api/xray/pia/data') return new Msg(true, '', ACCOUNT);
|
||||
if (url === '/panel/api/xray/pia/countries') return new Msg(true, '', COUNTRIES);
|
||||
if (url === '/panel/api/xray/pia/servers') return new Msg(true, '', SERVERS);
|
||||
if (url === '/panel/api/xray/pia/addKey') {
|
||||
return new Msg(true, '', { tag: 'pia-us-east-useast1', hostname: 'useast1' });
|
||||
}
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
});
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={onAddOutbound}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await clickAddOutbound();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Could not build the PIA outbound. Try again.')).toBeTruthy(),
|
||||
);
|
||||
expect(onAddOutbound).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user