feat(nord): support multi-server NordLynx outbounds (#6311)

* feat(nord): support multi-server NordLynx outbounds

* fix(nord): address verified PR review findings

Tighten the NordVPN multi-outbound implementation and its regression
coverage based on the verified review feedback.

- remove the redundant Xray validation test that duplicated the base
  branch and did not exercise multiple outbounds
- make NordModal tests wait for server loading and assert the modal close
  callback, duplicate-server state, and endpoint behavior
- add coverage for resolving the NordLynx public key from technology
  metadata instead of a numeric technology ID
- use a real httptest server for Nord integration tests through an
  injectable API base URL
- represent the All Cities sentinel consistently as null and reset it
  when a country changes

The existing NordVPN API contracts and persisted outbound schema remain
unchanged.
This commit is contained in:
Masterain
2026-09-03 02:20:10 +08:00
committed by GitHub
parent f727d04f65
commit f9cfd87cb2
23 changed files with 1186 additions and 215 deletions
@@ -84,7 +84,14 @@ with a routing rule.
3x-ui can fetch NordVPN (NordLynx/WireGuard) credentials from an access token (or
accept a private key directly) and list countries/servers, so you can build a
NordVPN outbound.
NordVPN outbound. Open **Xray → Outbounds → More → NordVPN**, sign in or save a
private key, select a server, and add the outbound. You can add several servers;
each hostname has a unique `nord-<hostname>` tag and cannot be added twice.
**Reset** on an added row keeps its server, tag, peer, and routing references but
refreshes its embedded private key from the currently stored NordVPN credentials.
Logout clears only those stored credentials. Existing outbounds continue to use
their embedded keys; remove unused NordVPN outbounds from the Outbounds list.
## PIA WireGuard
@@ -85,7 +85,14 @@ WARP به سرور شما امکان می‌دهد ترافیک خود را از
3x-ui می‌تواند اعتبارنامه‌های NordVPN (NordLynx/WireGuard) را از یک توکن دسترسی دریافت کند (یا
یک کلید خصوصی را مستقیماً بپذیرد) و کشورها/سرورها را فهرست کند تا بتوانید یک خروجی NordVPN
بسازید.
بسازید. از **Xray → خروجی‌ها → بیشتر → NordVPN** وارد شوید یا کلید خصوصی را ذخیره کنید،
سرور را انتخاب کنید و خروجی را بیفزایید. می‌توان چند سرور افزود؛ هر hostname برچسب یکتای
`nord-<hostname>` دارد و نمی‌توان آن را دو بار افزود.
**Reset** در هر ردیف، سرور، برچسب، peer و ارجاع‌های مسیریابی را نگه می‌دارد و فقط کلید خصوصی
درون خروجی را از اعتبارنامهٔ ذخیره‌شدهٔ فعلی تازه می‌کند. خروج فقط اعتبارنامهٔ ذخیره‌شده را پاک
می‌کند و خروجی‌های موجود همچنان از کلید درون خود استفاده می‌کنند. خروجی‌های بلااستفادهٔ NordVPN
را از فهرست خروجی‌ها حذف کنید.
## خروجی WireGuard PIA
@@ -92,7 +92,15 @@ WARP. Также можно применить бесплатную лиценз
3x-ui может получать учётные данные NordVPN (NordLynx/WireGuard) из токена доступа
(или принимать приватный ключ напрямую) и выводить список стран/серверов, чтобы вы
могли построить outbound-соединение NordVPN.
могли построить outbound-соединение NordVPN. Откройте
**Xray → Исходящие → Ещё → NordVPN**, войдите или сохраните приватный ключ,
выберите сервер и добавьте исходящее. Можно добавить несколько серверов; каждый
hostname получает уникальный тег `nord-<hostname>` и не может быть добавлен дважды.
**Reset** в строке сохраняет сервер, тег, peer и ссылки маршрутизации, но обновляет
встроенный приватный ключ из текущих сохранённых учётных данных NordVPN. Выход
очищает только сохранённые учётные данные. Существующие исходящие продолжают
использовать встроенные ключи; удаляйте ненужные NordVPN-исходящие в общем списке.
## PIA WireGuard
@@ -80,7 +80,13 @@ WARP 账户,并将其接入一个标签为 **`warp`** 的 WireGuard 出站:
3x-ui 可以根据访问令牌获取 NordVPNNordLynx/WireGuard)凭据(或
直接接受一个私钥),并列出国家/服务器,从而让你构建一个
NordVPN 出站。
NordVPN 出站。打开 **Xray → 出站 → 更多 → NordVPN**,登录或保存私钥后选择服务器并
添加出站。可以连续添加多台服务器;每个 hostname 使用唯一的 `nord-<hostname>` 标签,
同一服务器不能重复添加。
对已添加行执行 **Reset** 时,会保留原服务器、标签、peer 和路由引用,只使用当前保存的
NordVPN 凭据刷新该出站内嵌的私钥。登出只清除保存的凭据,已有出站继续使用其内嵌密钥;
不再使用的 NordVPN 出站需要从出站列表中删除。
## PIA WireGuard
-15
View File
@@ -144,19 +144,6 @@ export default function XrayPage() {
if (idx >= 0) tt.outbounds.splice(idx, 1);
});
}
function onRemoveOutboundByIndex(index: number) {
mutate((tt) => {
if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
});
}
function onRemoveRoutingRules(payload: { prefix: string }) {
mutate((tt) => {
const rules = tt.routing?.rules;
if (!Array.isArray(rules)) return;
tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
});
}
const advancedText = useMemo(() => {
if (advSettings === 'xraySetting') return xraySetting;
const tpl = templateSettings;
@@ -393,8 +380,6 @@ export default function XrayPage() {
onClose={() => setNordOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={onRemoveOutboundByIndex}
onRemoveRoutingRules={onRemoveRoutingRules}
/>
<PiaModal
open={piaOpen}
+302 -16
View File
@@ -1,41 +1,327 @@
.nord-modal .ant-modal-container {
overflow: hidden;
}
.nord-modal .ant-modal-body {
max-height: min(720px, calc(100vh - 160px));
overflow-y: auto;
padding-right: 2px;
}
.nord-login-form {
margin-top: 20px;
}
.nord-login-action {
display: block;
margin-left: auto;
}
.nord-account-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-fill-quaternary);
}
.nord-data-table {
margin: 5px 0;
width: 100%;
flex: 1;
min-width: 0;
border-collapse: collapse;
}
.nord-data-table tr + tr td {
padding-top: 8px;
}
.nord-data-table td {
padding: 4px 8px;
padding: 0;
vertical-align: top;
}
.nord-data-table td:first-child {
width: 112px;
padding-right: 12px;
color: var(--ant-color-text-secondary);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.nord-data-table td:last-child {
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
.nord-section-divider {
margin: 18px 0 14px;
color: var(--ant-color-text-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.nord-data-table .row-odd {
background: var(--ant-color-fill-tertiary);
.nord-location-form .ant-form-item {
margin-bottom: 0;
}
.server-row {
display: inline-flex;
.nord-location-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 12px;
}
.nord-server-field {
grid-column: 1 / -1;
min-width: 0;
}
.nord-server-popup .ant-select-item-option {
min-height: 44px;
padding: 8px 10px;
}
.nord-server-popup .ant-select-item-option-content {
overflow: visible;
}
.nord-server-option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
}
.nord-server-option-copy {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.server-name {
.nord-server-option-name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
color: var(--ant-color-text);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.nord-server-option-meta {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
color: var(--ant-color-text-secondary);
font-size: 12px;
white-space: nowrap;
}
.nord-server-option-hostname {
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
text-overflow: ellipsis;
}
.nord-server-option-address,
.nord-selected-server-address,
.nord-added-server-endpoint {
color: var(--ant-color-text-tertiary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
white-space: nowrap;
}
.nord-server-option-address {
overflow: hidden;
text-overflow: ellipsis;
}
.server-load-tag {
margin-right: 0;
.nord-server-load {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 5px;
margin-left: auto;
padding: 2px 7px;
border: 1px solid currentcolor;
border-radius: 999px;
background: color-mix(in srgb, currentcolor 8%, transparent);
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.nord-server-load-low {
color: var(--ant-color-success);
}
.nord-server-load-medium {
color: var(--ant-color-warning);
}
.nord-server-load-high {
color: var(--ant-color-error);
}
.nord-server-load-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentcolor;
}
.nord-server-load-label {
color: var(--ant-color-text-secondary);
font-weight: 500;
}
.nord-server-load-value {
font-variant-numeric: tabular-nums;
}
.nord-selected-server {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.nord-selected-server-name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.nord-selected-server-hostname {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
color: var(--ant-color-text-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.nord-selected-server-address {
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
}
.nord-add-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 14px;
}
.nord-already-added {
flex: 1;
color: var(--ant-color-text-secondary);
font-size: 12px;
}
.nord-added-table {
width: 100%;
margin: 0;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
border-collapse: separate;
border-spacing: 0;
background: var(--ant-color-fill-quaternary);
overflow: hidden;
}
.nord-added-table tr + tr td {
border-top: 1px solid var(--ant-color-border-secondary);
}
.nord-added-table td {
padding: 9px 12px;
vertical-align: middle;
}
.nord-added-table td:first-child {
padding-right: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nord-added-server-tag {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-added-server-endpoint {
margin-left: 12px;
}
.nord-added-table td:last-child {
width: 1%;
white-space: nowrap;
text-align: right;
}
@media (max-width: 575px) {
.nord-modal {
max-width: calc(100vw - 24px);
margin: 12px auto;
}
.nord-modal .ant-modal-body {
max-height: calc(100vh - 124px);
}
.nord-account-card {
flex-direction: column;
}
.nord-account-card > .ant-btn {
align-self: flex-end;
}
.nord-location-grid {
grid-template-columns: minmax(0, 1fr);
}
.nord-server-field {
grid-column: auto;
}
.nord-selected-server-hostname,
.nord-selected-server-address,
.nord-server-load-label {
display: none;
}
.nord-server-option-meta > span:first-child,
.nord-server-option-meta > span[aria-hidden='true'],
.nord-server-option-address {
display: none;
}
.nord-add-actions {
align-items: stretch;
flex-direction: column;
}
.nord-add-actions .ant-btn {
width: 100%;
}
}
+292 -142
View File
@@ -1,16 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs } from 'antd';
import { LoginOutlined, SaveOutlined } 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 './NordModal.css';
interface NordModalProps {
open: boolean;
templateSettings: { outbounds?: { tag?: string }[] } | null;
templateSettings: { outbounds?: NordOutboundRow[] } | null;
onClose: () => void;
onAddOutbound: (outbound: Record<string, unknown>) => void;
onResetOutbound: (payload: {
@@ -19,8 +20,19 @@ interface NordModalProps {
oldTag?: string;
newTag: string;
}) => void;
onRemoveOutbound: (index: number) => void;
onRemoveRoutingRules: (payload: { prefix: string }) => void;
}
interface NordOutboundRow {
tag?: string;
protocol?: string;
settings?: unknown;
}
interface NordAddedRow {
index: number;
tag: string;
endpoint: string;
resettable: boolean;
}
interface NordData {
@@ -45,12 +57,19 @@ interface NordServer {
hostname: string;
station: string;
load: number;
technologies?: { id: number; metadata?: { name: string; value: string }[] }[];
technologies?: { metadata?: { name: string; value: string }[] }[];
location_ids?: number[];
cityId?: number | null;
cityName?: string;
}
interface NordServerOption {
value: number;
label: string;
searchText: string;
server: NordServer;
}
interface NordFormValues {
token: string;
manualKey: string;
@@ -67,10 +86,30 @@ const EMPTY: NordFormValues = {
serverId: null,
};
function loadColor(load: number): string {
if (load < 30) return 'green';
if (load < 70) return 'orange';
return 'red';
function loadLevel(load: number): 'low' | 'medium' | 'high' {
if (load < 30) return 'low';
if (load < 70) return 'medium';
return 'high';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isResettableNordOutbound(outbound: NordOutboundRow): boolean {
if (outbound.protocol !== 'wireguard' || !isRecord(outbound.settings)) return false;
return (
Array.isArray(outbound.settings.address) &&
outbound.settings.address.length > 0 &&
Array.isArray(outbound.settings.peers) &&
outbound.settings.peers.length > 0
);
}
function nordOutboundEndpoint(outbound: NordOutboundRow): string {
if (!isRecord(outbound.settings) || !Array.isArray(outbound.settings.peers)) return '';
const peer = outbound.settings.peers.find(isRecord);
return typeof peer?.endpoint === 'string' ? peer.endpoint : '';
}
export default function NordModal({
@@ -79,10 +118,8 @@ export default function NordModal({
onClose,
onAddOutbound,
onResetOutbound,
onRemoveOutbound,
onRemoveRoutingRules,
}: NordModalProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null);
@@ -92,18 +129,47 @@ export default function NordModal({
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
const cityId = useWatch({ control: methods.control, name: 'cityId' });
const serverId = useWatch({ control: methods.control, name: 'serverId' });
const locale = i18n.resolvedLanguage || i18n.language;
const nordOutboundIndex = useMemo(() => {
const nordRows = useMemo<NordAddedRow[]>(() => {
const list = templateSettings?.outbounds;
if (!list) return -1;
return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
if (!list) return [];
return list.flatMap((outbound, index) => {
const tag = outbound?.tag;
if (typeof tag !== 'string' || !tag.startsWith('nord-')) return [];
return [
{
index,
tag,
endpoint: nordOutboundEndpoint(outbound),
resettable: isResettableNordOutbound(outbound),
},
];
});
}, [templateSettings?.outbounds]);
const addedTags = useMemo(() => new Set(nordRows.map((row) => row.tag)), [nordRows]);
const filteredServers = useMemo(() => {
if (!cityId) return servers;
if (cityId == null) return servers;
return servers.filter((s) => s.cityId === cityId);
}, [cityId, servers]);
const selectedServer = filteredServers.find((server) => server.id === serverId);
const selectedTag = selectedServer ? `nord-${selectedServer.hostname}` : '';
const selectedAlreadyAdded = Boolean(selectedTag && addedTags.has(selectedTag));
const serverOptions = useMemo<NordServerOption[]>(
() =>
filteredServers.map((server) => ({
value: server.id,
label: server.hostname,
searchText:
`${server.cityName ?? ''} ${server.name} ${server.hostname} ${server.station}`.toLowerCase(),
server,
})),
[filteredServers],
);
useEffect(() => {
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
}, [filteredServers, methods]);
@@ -174,8 +240,6 @@ export default function NordModal({
try {
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
if (msg?.success) {
onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' });
setNordData(null);
methods.reset(EMPTY);
setCountries([]);
@@ -216,6 +280,7 @@ export default function NordModal({
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
})
.sort((a: NordServer, b: NordServer) => a.load - b.load);
methods.setValue('cityId', null);
setServers(next);
if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
} finally {
@@ -227,8 +292,9 @@ export default function NordModal({
const selectedServerId = methods.getValues('serverId');
const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
const publicKey = server.technologies
?.flatMap((technology) => technology.metadata ?? [])
.find((metadata) => metadata.name === 'public_key')?.value;
if (!publicKey) {
messageApi.error(t('pages.xray.nord.noPublicKey'));
return null;
@@ -249,32 +315,49 @@ export default function NordModal({
}
function addOutbound() {
if (selectedAlreadyAdded) return;
const ob = buildNordOutbound();
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.nord.outboundAdded'));
onClose();
}
function resetOutbound() {
if (nordOutboundIndex === -1) return;
const ob = buildNordOutbound();
if (!ob) return;
const oldTag = templateSettings?.outbounds?.[nordOutboundIndex]?.tag;
function resetOutbound(index: number) {
const existing = templateSettings?.outbounds?.[index];
if (
!existing?.tag?.startsWith?.('nord-') ||
!isResettableNordOutbound(existing) ||
!isRecord(existing.settings) ||
!nordData?.private_key
) {
return;
}
const ob = {
...existing,
settings: { ...existing.settings, secretKey: nordData.private_key },
};
onResetOutbound({
index: nordOutboundIndex,
index,
outbound: ob,
oldTag,
newTag: ob.tag as string,
oldTag: existing.tag,
newTag: existing.tag,
});
messageApi.success(t('pages.xray.nord.outboundUpdated'));
onClose();
}
return (
<>
{messageContextHolder}
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
<Modal
open={open}
title="NordVPN NordLynx"
footer={null}
width={680}
className="nord-modal"
onCancel={onClose}
>
<FormProvider {...methods}>
{nordData == null ? (
<Tabs
@@ -284,18 +367,13 @@ export default function NordModal({
key: 'token',
label: t('pages.xray.nord.accessToken'),
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form colon={false} layout="vertical" className="nord-login-form">
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
<Input placeholder={t('pages.xray.nord.accessToken')} />
</FormField>
<Button
type="primary"
className="mt-10"
className="nord-login-action"
loading={loading}
icon={<LoginOutlined />}
onClick={login}
@@ -309,18 +387,13 @@ export default function NordModal({
key: 'key',
label: t('pages.xray.nord.privateKey'),
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form colon={false} layout="vertical" className="nord-login-form">
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
<Input placeholder={t('pages.xray.nord.privateKey')} />
</FormField>
<Button
type="primary"
className="mt-10"
className="nord-login-action"
loading={loading}
icon={<SaveOutlined />}
onClick={saveKey}
@@ -334,109 +407,186 @@ export default function NordModal({
/>
) : (
<>
<table className="nord-data-table">
<tbody>
{nordData.token && (
<tr className="row-odd">
<td>{t('pages.xray.nord.accessToken')}</td>
<td>{nordData.token}</td>
<div className="nord-account-card">
<table className="nord-data-table">
<tbody>
{nordData.token && (
<tr>
<td>{t('pages.xray.nord.accessToken')}</td>
<td>{nordData.token}</td>
</tr>
)}
<tr>
<td>{t('pages.xray.nord.privateKey')}</td>
<td>{nordData.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} danger onClick={logout}>
{t('logout')}
</Button>
</div>
<Divider className="nord-section-divider">{t('pages.xray.warp.settings')}</Divider>
<Form colon={false} layout="vertical" className="nord-location-form">
<div className="nord-location-grid">
<FormField
name="countryId"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => fetchServers(v as number)}
>
<Select
data-testid="nord-country-select"
showSearch={{ optionFilterProp: 'label' }}
options={countries.map((c) => {
const name = countryName(c.code, locale) || c.name || c.code;
const flag = countryFlag(c.code);
return {
value: c.id,
label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
};
})}
/>
</FormField>
{cities.length > 0 && (
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
<Select
data-testid="nord-city-select"
showSearch={{ optionFilterProp: 'label' }}
options={[
{ value: null, label: t('pages.xray.outbound.allCities') },
...cities.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</FormField>
)}
<tr>
<td>{t('pages.xray.nord.privateKey')}</td>
<td>{nordData.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} type="primary" danger className="mt-8" onClick={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="countryId"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => fetchServers(v as number)}
>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={countries.map((c) => ({
value: c.id,
label: `${c.name} (${c.code})`,
}))}
/>
</FormField>
{cities.length > 0 && (
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={[
{ value: null, label: t('pages.xray.outbound.allCities') },
...cities.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</FormField>
)}
{filteredServers.length > 0 && (
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={filteredServers.map((s) => ({
value: s.id,
label: `${s.cityName} ${s.name} ${s.hostname}`,
children: (
<span className="server-row">
<span className="server-name">
{s.cityName} - {s.name}
</span>
<Tag color={loadColor(s.load)} className="server-load-tag">
{s.load}%
</Tag>
</span>
),
}))}
/>
</FormField>
)}
{filteredServers.length > 0 && (
<div className="nord-server-field">
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
<Select<number, NordServerOption>
data-testid="nord-server-select"
classNames={{ popup: { root: 'nord-server-popup' } }}
listHeight={320}
listItemHeight={58}
options={serverOptions}
showSearch={{
filterOption: (input, option) =>
option?.searchText.includes(input.trim().toLowerCase()) ?? false,
}}
optionRender={(option) => {
const server = option.data.server;
return (
<div className="nord-server-option">
<span className="nord-server-option-copy">
<span className="nord-server-option-name">{server.name}</span>
<span className="nord-server-option-meta">
<span>{server.cityName}</span>
<span aria-hidden="true">·</span>
<span className="nord-server-option-hostname">
{server.hostname}
</span>
<span aria-hidden="true">·</span>
<span className="nord-server-option-address">
{server.station}:51820
</span>
</span>
</span>
<span
className={`nord-server-load nord-server-load-${loadLevel(server.load)}`}
title={`${t('pages.xray.nord.serverLoad')}: ${server.load}%`}
>
<span className="nord-server-load-dot" aria-hidden="true" />
<span className="nord-server-load-label">
{t('pages.xray.nord.serverLoad')}
</span>
<span className="nord-server-load-value">{server.load}%</span>
</span>
</div>
);
}}
labelRender={() =>
selectedServer ? (
<span className="nord-selected-server">
<span className="nord-selected-server-name">
{selectedServer.name}
</span>
<span className="nord-selected-server-hostname">
{selectedServer.hostname}
</span>
<span className="nord-selected-server-address">
{selectedServer.station}:51820
</span>
<span
className={`nord-server-load nord-server-load-${loadLevel(selectedServer.load)}`}
title={`${t('pages.xray.nord.serverLoad')}: ${selectedServer.load}%`}
>
<span className="nord-server-load-dot" aria-hidden="true" />
<span className="nord-server-load-value">
{selectedServer.load}%
</span>
</span>
</span>
) : null
}
/>
</FormField>
</div>
)}
</div>
</Form>
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
{nordOutboundIndex >= 0 ? (
<div className="nord-add-actions">
<div className="nord-already-added" aria-live="polite">
{selectedAlreadyAdded
? t('pages.xray.nord.alreadyAdded', { reset: t('reset') })
: null}
</div>
<Button
type="primary"
disabled={!serverId || selectedAlreadyAdded}
loading={loading}
onClick={addOutbound}
>
{t('pages.xray.warp.addOutbound')}
</Button>
</div>
{nordRows.length > 0 && (
<>
<Tag color="green">{t('enabled')}</Tag>
<Button
type="primary"
danger
loading={loading}
className="ml-8"
onClick={resetOutbound}
>
{t('reset')}
</Button>
</>
) : (
<>
<Tag color="orange">{t('disabled')}</Tag>
<Button
type="primary"
className="ml-8"
disabled={!serverId}
loading={loading}
onClick={addOutbound}
>
{t('pages.xray.warp.addOutbound')}
</Button>
<Divider className="nord-section-divider">
{t('pages.xray.nord.addedServers')}
</Divider>
<table className="nord-added-table" data-testid="nord-added-table">
<tbody>
{nordRows.map((row) => (
<tr key={`${row.index}-${row.tag}`}>
<td>
<span className="nord-added-server-tag">{row.tag}</span>
{row.endpoint && (
<span className="nord-added-server-endpoint">{row.endpoint}</span>
)}
</td>
<td>
<Button
type="primary"
danger
size="small"
loading={loading}
disabled={!row.resettable}
data-testid={`nord-reset-${row.index}`}
onClick={() => resetOutbound(row.index)}
>
{t('reset')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
+427
View File
@@ -0,0 +1,427 @@
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import NordModal from '@/pages/xray/overrides/NordModal';
import { HttpUtil, Msg } from '@/utils';
import { renderWithProviders } from './test-utils';
const NORD_DATA = { token: 'nord-token', private_key: 'current-private-key' };
const COUNTRIES = [{ id: 228, name: 'United States', code: 'US' }];
const SERVER_DATA = {
locations: [
{ id: 10, country: { city: { id: 100, name: 'New York' } } },
{ id: 20, country: { city: { id: 200, name: 'Los Angeles' } } },
],
servers: [
{
id: 1,
name: 'United States #1',
hostname: 'us1.nordvpn.com',
station: '198.51.100.10',
load: 12,
location_ids: [10],
technologies: [{ id: 35, metadata: [{ name: 'public_key', value: 'public-one' }] }],
},
{
id: 2,
name: 'United States #2',
hostname: 'us2.nordvpn.com',
station: '198.51.100.20',
load: 24,
location_ids: [20],
technologies: [{ id: 35, metadata: [{ name: 'public_key', value: 'public-two' }] }],
},
],
};
function nordApiPost(url: string) {
if (url === '/panel/api/xray/nord/data') {
return new Msg(true, '', JSON.stringify(NORD_DATA));
}
if (url === '/panel/api/xray/nord/countries') {
return new Msg(true, '', JSON.stringify(COUNTRIES));
}
if (url === '/panel/api/xray/nord/servers') {
return new Msg(true, '', JSON.stringify(SERVER_DATA));
}
if (url === '/panel/api/xray/nord/del') return new Msg(true, '', '');
return new Msg(false, `Unexpected POST ${url}`, null);
}
function mockNordApi() {
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => nordApiPost(url));
}
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;
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
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 button = await waitFor(() => {
const candidate = screen.getByRole('button', { name: /Add outbound/ });
if ((candidate as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
return candidate;
});
fireEvent.click(button);
}
function NordHarness({
initial = [],
onAdded,
onClose = vi.fn(),
}: {
initial?: Record<string, unknown>[];
onAdded?: (outbound: Record<string, unknown>) => void;
onClose?: () => void;
}) {
const [outbounds, setOutbounds] = useState(initial);
return (
<>
<output data-testid="outbound-state">{JSON.stringify(outbounds)}</output>
<NordModal
open
templateSettings={{ outbounds }}
onClose={onClose}
onAddOutbound={(outbound) => {
onAdded?.(outbound);
setOutbounds((previous) => [...previous, outbound]);
}}
onResetOutbound={({ index, outbound }) => {
setOutbounds((previous) =>
previous.map((existing, current) => (current === index ? outbound : existing)),
);
}}
/>
</>
);
}
describe('NordVPN modal', () => {
it('shows access-token and private-key entry while signed out', async () => {
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
if (url === '/panel/api/xray/nord/data') return new Msg(true, '', '');
return new Msg(false, `Unexpected POST ${url}`, null);
});
renderWithProviders(
<NordModal
open
templateSettings={{ outbounds: [] }}
onClose={vi.fn()}
onAddOutbound={vi.fn()}
onResetOutbound={vi.fn()}
/>,
);
await waitFor(() => expect(screen.getByPlaceholderText('Access token')).toBeTruthy());
fireEvent.click(screen.getByRole('tab', { name: 'Private key' }));
expect(await screen.findByPlaceholderText('Private key')).toBeTruthy();
});
it('adds multiple different NordLynx outbounds without closing the modal', async () => {
mockNordApi();
const added: Record<string, unknown>[] = [];
const onClose = vi.fn();
renderWithProviders(
<NordHarness onAdded={(outbound) => added.push(outbound)} onClose={onClose} />,
);
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
await chooseOption('nord-country-select', 'United States');
await waitFor(() => expect(screen.getByTestId('nord-server-select')).toBeTruthy());
await clickAddOutbound();
await waitFor(() => expect(screen.getByTestId('nord-added-table')).toBeTruthy());
expect(screen.getByText('nord-us1.nordvpn.com')).toBeTruthy();
expect(
screen.getByTestId('nord-added-table').querySelector('.nord-added-server-endpoint')
?.textContent,
).toBe('198.51.100.10:51820');
expect(onClose).not.toHaveBeenCalled();
await chooseOption('nord-server-select', 'United States #2');
await clickAddOutbound();
await waitFor(() => expect(screen.getByText('nord-us2.nordvpn.com')).toBeTruthy());
expect(added).toHaveLength(2);
expect(added[0]).toMatchObject({
tag: 'nord-us1.nordvpn.com',
protocol: 'wireguard',
settings: {
secretKey: 'current-private-key',
address: ['10.5.0.2/32'],
peers: [{ publicKey: 'public-one', endpoint: '198.51.100.10:51820' }],
noKernelTun: true,
},
});
expect(added[1]).toMatchObject({
tag: 'nord-us2.nordvpn.com',
settings: {
peers: [{ publicKey: 'public-two', endpoint: '198.51.100.20:51820' }],
},
});
});
it('shows concise server details and load in the server picker', async () => {
mockNordApi();
renderWithProviders(<NordHarness />);
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
await chooseOption('nord-country-select', 'United States');
await waitFor(() => expect(screen.getByTestId('nord-server-select')).toBeTruthy());
const node = screen.getByTestId('nord-server-select');
const select = node.closest('.ant-select') ?? node;
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
await waitFor(() =>
expect(
document.querySelectorAll<HTMLElement>('.nord-server-popup .ant-select-item-option'),
).toHaveLength(2),
);
const options = Array.from(
document.querySelectorAll<HTMLElement>('.nord-server-popup .ant-select-item-option'),
);
expect(options[0].querySelector('.nord-server-option-name')?.textContent).toBe(
'United States #1',
);
expect(options[0].querySelector('.nord-server-option-hostname')?.textContent).toBe(
'us1.nordvpn.com',
);
expect(options[0].querySelector('.nord-server-option-address')?.textContent).toBe(
'198.51.100.10:51820',
);
expect(options[0].querySelector('.nord-server-load-value')?.textContent).toBe('12%');
expect(options[1].querySelector('.nord-server-load-value')?.textContent).toBe('24%');
});
it('shows the country flag and selects All Cities after loading servers', async () => {
mockNordApi();
renderWithProviders(<NordHarness />);
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
await chooseOption('nord-country-select', 'United States');
const countrySelect = screen.getByTestId('nord-country-select').closest('.ant-select');
expect(countrySelect?.textContent).toContain('🇺🇸 United States (US)');
await waitFor(() => {
const select = screen.getByTestId('nord-city-select').closest('.ant-select');
if (!select?.textContent?.includes('All Cities')) {
throw new Error('All Cities is not selected');
}
});
const serverNode = screen.getByTestId('nord-server-select');
const serverSelect = serverNode.closest('.ant-select') ?? serverNode;
fireEvent.mouseDown(serverSelect.querySelector('.ant-select-selector') ?? serverSelect);
await waitFor(() =>
expect(
document.querySelectorAll<HTMLElement>('.nord-server-popup .ant-select-item-option'),
).toHaveLength(2),
);
});
it('disables Add when the selected server is already present', async () => {
mockNordApi();
renderWithProviders(
<NordHarness initial={[{ tag: 'nord-us1.nordvpn.com', protocol: 'wireguard' }]} />,
);
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
await chooseOption('nord-country-select', 'United States');
await waitFor(() => expect(screen.getByTestId('nord-server-select')).toBeTruthy());
await waitFor(() => {
const button = screen.getByRole('button', { name: /Add outbound/ });
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText(/already in the outbound list/i)).toBeTruthy();
});
});
it('refreshes only the selected existing outbound private key', async () => {
mockNordApi();
const onResetOutbound = vi.fn();
const nordOutbound = {
tag: 'nord-us9.nordvpn.com',
protocol: 'wireguard',
sendThrough: '192.0.2.8',
settings: {
secretKey: 'old-private-key',
address: ['10.5.0.2/32'],
noKernelTun: true,
customOption: 'preserve-me',
peers: [{ publicKey: 'old-public', endpoint: '198.51.100.90:51820' }],
},
};
renderWithProviders(
<NordModal
open
templateSettings={{
outbounds: [{ tag: 'direct', protocol: 'freedom' }, nordOutbound],
}}
onClose={vi.fn()}
onAddOutbound={vi.fn()}
onResetOutbound={onResetOutbound}
/>,
);
const reset = await waitFor(() => screen.getByTestId('nord-reset-1'));
fireEvent.click(reset);
await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
expect(onResetOutbound.mock.calls[0][0]).toEqual({
index: 1,
outbound: {
...nordOutbound,
settings: { ...nordOutbound.settings, secretKey: 'current-private-key' },
},
oldTag: 'nord-us9.nordvpn.com',
newTag: 'nord-us9.nordvpn.com',
});
});
it('shows malformed Nord rows but disables their Reset action', async () => {
mockNordApi();
renderWithProviders(
<NordModal
open
templateSettings={{
outbounds: [{ tag: 'nord-broken', protocol: 'wireguard', settings: {} }],
}}
onClose={vi.fn()}
onAddOutbound={vi.fn()}
onResetOutbound={vi.fn()}
/>,
);
const reset = await waitFor(() => screen.getByTestId('nord-reset-0'));
expect((reset as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('nord-broken')).toBeTruthy();
});
it('clears credentials on logout without removing configured outbounds', async () => {
mockNordApi();
renderWithProviders(
<NordHarness
initial={[
{
tag: 'nord-us1.nordvpn.com',
protocol: 'wireguard',
settings: { secretKey: 'embedded-private-key' },
},
]}
/>,
);
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: 'Log Out' }));
await waitFor(() => expect(screen.getByPlaceholderText('Access token')).toBeTruthy());
expect(screen.getByTestId('outbound-state').textContent).toContain('nord-us1.nordvpn.com');
expect(vi.mocked(HttpUtil.post)).toHaveBeenCalledWith('/panel/api/xray/nord/del');
});
it('does not add a server that omits its NordLynx public key', async () => {
const onAddOutbound = vi.fn();
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
if (url === '/panel/api/xray/nord/data') {
return new Msg(true, '', JSON.stringify(NORD_DATA));
}
if (url === '/panel/api/xray/nord/countries') {
return new Msg(true, '', JSON.stringify(COUNTRIES));
}
if (url === '/panel/api/xray/nord/servers') {
return new Msg(
true,
'',
JSON.stringify({
...SERVER_DATA,
servers: [{ ...SERVER_DATA.servers[0], technologies: [{ id: 35, metadata: [] }] }],
}),
);
}
return new Msg(false, `Unexpected POST ${url}`, null);
});
renderWithProviders(
<NordModal
open
templateSettings={{ outbounds: [] }}
onClose={vi.fn()}
onAddOutbound={onAddOutbound}
onResetOutbound={vi.fn()}
/>,
);
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
await chooseOption('nord-country-select', 'United States');
await clickAddOutbound();
await waitFor(() =>
expect(
screen.getByText('Selected server does not advertise a NordLynx public key.'),
).toBeTruthy(),
);
expect(onAddOutbound).not.toHaveBeenCalled();
});
it('reads the NordLynx public key without coupling to a numeric technology ID', async () => {
const onAddOutbound = vi.fn();
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
if (url === '/panel/api/xray/nord/data') {
return new Msg(true, '', JSON.stringify(NORD_DATA));
}
if (url === '/panel/api/xray/nord/countries') {
return new Msg(true, '', JSON.stringify(COUNTRIES));
}
if (url === '/panel/api/xray/nord/servers') {
return new Msg(
true,
'',
JSON.stringify({
...SERVER_DATA,
servers: [
{
...SERVER_DATA.servers[0],
technologies: [
{ id: 999, metadata: [{ name: 'public_key', value: 'future-public-key' }] },
],
},
],
}),
);
}
return new Msg(false, `Unexpected POST ${url}`, null);
});
renderWithProviders(
<NordModal
open
templateSettings={{ outbounds: [] }}
onClose={vi.fn()}
onAddOutbound={onAddOutbound}
onResetOutbound={vi.fn()}
/>,
);
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
await chooseOption('nord-country-select', 'United States');
await clickAddOutbound();
await waitFor(() => expect(onAddOutbound).toHaveBeenCalledTimes(1));
expect(onAddOutbound.mock.calls[0][0]).toMatchObject({
settings: { peers: [{ publicKey: 'future-public-key' }] },
});
});
});
+7 -25
View File
@@ -18,11 +18,14 @@ type NordService struct {
var nordHTTPClient = &http.Client{Timeout: 15 * time.Second}
// nordAPIBase is a var so integration tests can use a local HTTP server.
var nordAPIBase = "https://api.nordvpn.com"
// maxResponseSize limits the maximum size of NordVPN API responses (10 MB).
const maxResponseSize = 10 << 20
func (s *NordService) GetCountries() (string, error) {
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.nordvpn.com/v1/countries", nil)
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, nordAPIBase+"/v1/servers/countries?filters[servers_technologies][identifier]=wireguard_udp", nil)
if reqErr != nil {
return "", reqErr
}
@@ -48,7 +51,7 @@ func (s *NordService) GetServers(countryId string) (string, error) {
return "", common.NewError("invalid country ID")
}
}
url := fmt.Sprintf("https://api.nordvpn.com/v2/servers?limit=0&filters[servers_technologies][id]=35&filters[country_id]=%s", countryId)
url := fmt.Sprintf("%s/v2/servers?limit=0&filters[servers_technologies][identifier]=wireguard_udp&filters[country_id]=%s", nordAPIBase, countryId)
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if reqErr != nil {
return "", reqErr
@@ -65,28 +68,7 @@ func (s *NordService) GetServers(countryId string) (string, error) {
if err != nil {
return "", err
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return string(body), nil
}
servers, ok := data["servers"].([]any)
if !ok {
return string(body), nil
}
var filtered []any
for _, s := range servers {
if server, ok := s.(map[string]any); ok {
if load, ok := server["load"].(float64); ok && load > 7 {
filtered = append(filtered, s)
}
}
}
data["servers"] = filtered
result, _ := json.Marshal(data)
return string(result), nil
return string(body), nil
}
func (s *NordService) SetKey(privateKey string) (string, error) {
@@ -106,7 +88,7 @@ func (s *NordService) SetKey(privateKey string) (string, error) {
}
func (s *NordService) GetCredentials(token string) (string, error) {
url := "https://api.nordvpn.com/v1/users/services/credentials"
url := nordAPIBase + "/v1/users/services/credentials"
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return "", err
@@ -0,0 +1,74 @@
package integration
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func stubNordAPI(t *testing.T, handler http.HandlerFunc) {
t.Helper()
previous := nordAPIBase
server := httptest.NewServer(handler)
nordAPIBase = server.URL
t.Cleanup(func() {
nordAPIBase = previous
server.Close()
})
}
func TestNordCountriesOnlyRequestsNordLynxServerCountries(t *testing.T) {
stubNordAPI(t, func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/v1/servers/countries" {
t.Errorf("country path = %q", req.URL.Path)
}
if got := req.URL.Query().Get("filters[servers_technologies][identifier]"); got != "wireguard_udp" {
t.Errorf("NordLynx technology filter = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `[{"id":228,"name":"United States","code":"US"}]`)
})
got, err := (&NordService{}).GetCountries()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, `"code":"US"`) {
t.Fatalf("countries = %s", got)
}
}
func TestNordServersPreserveLowLoadServers(t *testing.T) {
stubNordAPI(t, func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/v2/servers" {
t.Errorf("server path = %q", req.URL.Path)
}
if got := req.URL.Query().Get("filters[country_id]"); got != "225" {
t.Errorf("country filter = %q", got)
}
if got := req.URL.Query().Get("filters[servers_technologies][identifier]"); got != "wireguard_udp" {
t.Errorf("NordLynx technology filter = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"servers":[{"id":1,"load":0},{"id":2,"load":4}]}`)
})
got, err := (&NordService{}).GetServers("225")
if err != nil {
t.Fatal(err)
}
var payload struct {
Servers []struct {
Load int `json:"load"`
} `json:"servers"`
}
if err := json.Unmarshal([]byte(got), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Servers) != 2 || payload.Servers[0].Load != 0 || payload.Servers[1].Load != 4 {
t.Fatalf("servers = %+v", payload.Servers)
}
}
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "لم يتم العثور على خوادم للدولة المحددة",
"noPublicKey": "الخادم المحدد لا يُعلن عن مفتاح NordLynx العام.",
"outboundAdded": "تمت إضافة صادر NordVPN",
"outboundUpdated": "تم تحديث صادر NordVPN"
"outboundUpdated": "تم تحديث صادر NordVPN",
"serverLoad": "حمل الخادم",
"addedServers": "الخوادم المضافة",
"alreadyAdded": "هذا الخادم موجود بالفعل في قائمة الصادرات. استخدم {reset} لتحديث مفتاحه."
},
"warp": {
"changeIp": "تغيير الـ IP",
+4 -1
View File
@@ -1970,7 +1970,10 @@
"noServers": "No servers found for the selected country",
"noPublicKey": "Selected server does not advertise a NordLynx public key.",
"outboundAdded": "NordVPN outbound added",
"outboundUpdated": "NordVPN outbound updated"
"outboundUpdated": "NordVPN outbound updated",
"serverLoad": "Server load",
"addedServers": "Added servers",
"alreadyAdded": "This server is already in the outbound list. Use {reset} to refresh its key."
},
"warp": {
"changeIp": "Change IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "No se encontraron servidores para el país seleccionado",
"noPublicKey": "El servidor seleccionado no anuncia una clave pública NordLynx.",
"outboundAdded": "Salida NordVPN añadida",
"outboundUpdated": "Salida NordVPN actualizada"
"outboundUpdated": "Salida NordVPN actualizada",
"serverLoad": "Carga del servidor",
"addedServers": "Servidores añadidos",
"alreadyAdded": "Este servidor ya está en la lista de salidas. Usa {reset} para actualizar su clave."
},
"warp": {
"changeIp": "Cambiar IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "سروری برای کشور انتخابی پیدا نشد",
"noPublicKey": "سرور انتخابی کلید عمومی NordLynx اعلام نمی‌کند.",
"outboundAdded": "خروجی NordVPN اضافه شد",
"outboundUpdated": "خروجی NordVPN به‌روزرسانی شد"
"outboundUpdated": "خروجی NordVPN به‌روزرسانی شد",
"serverLoad": "بار سرور",
"addedServers": "سرورهای اضافه‌شده",
"alreadyAdded": "این سرور از قبل در فهرست خروجی‌ها وجود دارد. برای تازه‌سازی کلید آن از {reset} استفاده کنید."
},
"warp": {
"changeIp": "تغییر IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "Tidak ada server ditemukan untuk negara yang dipilih",
"noPublicKey": "Server yang dipilih tidak mengumumkan kunci publik NordLynx.",
"outboundAdded": "Outbound NordVPN ditambahkan",
"outboundUpdated": "Outbound NordVPN diperbarui"
"outboundUpdated": "Outbound NordVPN diperbarui",
"serverLoad": "Beban server",
"addedServers": "Server yang ditambahkan",
"alreadyAdded": "Server ini sudah ada dalam daftar outbound. Gunakan {reset} untuk memperbarui kuncinya."
},
"warp": {
"changeIp": "Ganti IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "選択した国のサーバーが見つかりません",
"noPublicKey": "選択したサーバーは NordLynx 公開鍵を公開していません。",
"outboundAdded": "NordVPN アウトバウンドを追加しました",
"outboundUpdated": "NordVPN アウトバウンドを更新しました"
"outboundUpdated": "NordVPN アウトバウンドを更新しました",
"serverLoad": "サーバー負荷",
"addedServers": "追加済みサーバー",
"alreadyAdded": "このサーバーはすでにアウトバウンド一覧にあります。{reset} で鍵を更新してください。"
},
"warp": {
"changeIp": "IP を変更",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "Nenhum servidor encontrado para o país selecionado",
"noPublicKey": "O servidor selecionado não anuncia uma chave pública NordLynx.",
"outboundAdded": "Saída NordVPN adicionada",
"outboundUpdated": "Saída NordVPN atualizada"
"outboundUpdated": "Saída NordVPN atualizada",
"serverLoad": "Carga do servidor",
"addedServers": "Servidores adicionados",
"alreadyAdded": "Este servidor já está na lista de saídas. Use {reset} para atualizar sua chave."
},
"warp": {
"changeIp": "Alterar IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "Серверов для выбранной страны не найдено",
"noPublicKey": "Выбранный сервер не сообщает публичный ключ NordLynx.",
"outboundAdded": "Исходящий NordVPN добавлен",
"outboundUpdated": "Исходящий NordVPN обновлён"
"outboundUpdated": "Исходящий NordVPN обновлён",
"serverLoad": "Нагрузка сервера",
"addedServers": "Добавленные серверы",
"alreadyAdded": "Этот сервер уже есть в списке исходящих подключений. Используйте {reset}, чтобы обновить его ключ."
},
"warp": {
"changeIp": "Сменить IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "Seçilen ülke için sunucu bulunamadı.",
"noPublicKey": "Seçilen sunucu NordLynx genel anahtarı yayınlamıyor.",
"outboundAdded": "NordVPN giden bağlantı eklendi.",
"outboundUpdated": "NordVPN giden bağlantı güncellendi."
"outboundUpdated": "NordVPN giden bağlantı güncellendi.",
"serverLoad": "Sunucu yükü",
"addedServers": "Eklenen sunucular",
"alreadyAdded": "Bu sunucu zaten giden bağlantı listesinde. Anahtarını yenilemek için {reset} kullanın."
},
"warp": {
"changeIp": "IP Değiştir",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "Серверів для обраної країни не знайдено",
"noPublicKey": "Обраний сервер не повідомляє публічного ключа NordLynx.",
"outboundAdded": "Вихідний NordVPN додано",
"outboundUpdated": "Вихідний NordVPN оновлено"
"outboundUpdated": "Вихідний NordVPN оновлено",
"serverLoad": "Навантаження сервера",
"addedServers": "Додані сервери",
"alreadyAdded": "Цей сервер уже є у списку вихідних підключень. Використайте {reset}, щоб оновити його ключ."
},
"warp": {
"changeIp": "Змінити IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "Không tìm thấy máy chủ cho quốc gia đã chọn",
"noPublicKey": "Máy chủ đã chọn không công bố khóa công khai NordLynx.",
"outboundAdded": "Đã thêm outbound NordVPN",
"outboundUpdated": "Đã cập nhật outbound NordVPN"
"outboundUpdated": "Đã cập nhật outbound NordVPN",
"serverLoad": "Tải máy chủ",
"addedServers": "Máy chủ đã thêm",
"alreadyAdded": "Máy chủ này đã có trong danh sách outbound. Dùng {reset} để làm mới khóa của máy chủ."
},
"warp": {
"changeIp": "Đổi IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "未找到选定国家/地区的服务器",
"noPublicKey": "选定的服务器未公布 NordLynx 公钥。",
"outboundAdded": "NordVPN 出站已添加",
"outboundUpdated": "NordVPN 出站已更新"
"outboundUpdated": "NordVPN 出站已更新",
"serverLoad": "服务器负载",
"addedServers": "已添加的服务器",
"alreadyAdded": "此服务器已在出站列表中。请使用{reset}刷新其密钥。"
},
"warp": {
"changeIp": "更换 IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "未找到選定國家/地區的伺服器",
"noPublicKey": "選定的伺服器未公布 NordLynx 公鑰。",
"outboundAdded": "NordVPN 出站已新增",
"outboundUpdated": "NordVPN 出站已更新"
"outboundUpdated": "NordVPN 出站已更新",
"serverLoad": "伺服器負載",
"addedServers": "已新增的伺服器",
"alreadyAdded": "此伺服器已在出站清單中。請使用{reset}重新整理其金鑰。"
},
"warp": {
"changeIp": "更換 IP",