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:
Masterain
2026-08-23 05:11:06 +08:00
committed by GitHub
parent a3e617215c
commit bd6a6aba43
73 changed files with 4095 additions and 31 deletions
+42 -1
View File
@@ -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": [
+38 -1
View File
@@ -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',
+10 -1
View File
@@ -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>,
);
+391
View File
@@ -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();
});
});