mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 01:17:15 +00:00
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:
@@ -84,7 +84,14 @@ with a routing rule.
|
|||||||
|
|
||||||
3x-ui can fetch NordVPN (NordLynx/WireGuard) credentials from an access token (or
|
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
|
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
|
## PIA WireGuard
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,14 @@ WARP به سرور شما امکان میدهد ترافیک خود را از
|
|||||||
|
|
||||||
3x-ui میتواند اعتبارنامههای NordVPN (NordLynx/WireGuard) را از یک توکن دسترسی دریافت کند (یا
|
3x-ui میتواند اعتبارنامههای NordVPN (NordLynx/WireGuard) را از یک توکن دسترسی دریافت کند (یا
|
||||||
یک کلید خصوصی را مستقیماً بپذیرد) و کشورها/سرورها را فهرست کند تا بتوانید یک خروجی NordVPN
|
یک کلید خصوصی را مستقیماً بپذیرد) و کشورها/سرورها را فهرست کند تا بتوانید یک خروجی NordVPN
|
||||||
بسازید.
|
بسازید. از **Xray → خروجیها → بیشتر → NordVPN** وارد شوید یا کلید خصوصی را ذخیره کنید،
|
||||||
|
سرور را انتخاب کنید و خروجی را بیفزایید. میتوان چند سرور افزود؛ هر hostname برچسب یکتای
|
||||||
|
`nord-<hostname>` دارد و نمیتوان آن را دو بار افزود.
|
||||||
|
|
||||||
|
**Reset** در هر ردیف، سرور، برچسب، peer و ارجاعهای مسیریابی را نگه میدارد و فقط کلید خصوصی
|
||||||
|
درون خروجی را از اعتبارنامهٔ ذخیرهشدهٔ فعلی تازه میکند. خروج فقط اعتبارنامهٔ ذخیرهشده را پاک
|
||||||
|
میکند و خروجیهای موجود همچنان از کلید درون خود استفاده میکنند. خروجیهای بلااستفادهٔ NordVPN
|
||||||
|
را از فهرست خروجیها حذف کنید.
|
||||||
|
|
||||||
## خروجی WireGuard PIA
|
## خروجی WireGuard PIA
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,15 @@ WARP. Также можно применить бесплатную лиценз
|
|||||||
|
|
||||||
3x-ui может получать учётные данные NordVPN (NordLynx/WireGuard) из токена доступа
|
3x-ui может получать учётные данные NordVPN (NordLynx/WireGuard) из токена доступа
|
||||||
(или принимать приватный ключ напрямую) и выводить список стран/серверов, чтобы вы
|
(или принимать приватный ключ напрямую) и выводить список стран/серверов, чтобы вы
|
||||||
могли построить outbound-соединение NordVPN.
|
могли построить outbound-соединение NordVPN. Откройте
|
||||||
|
**Xray → Исходящие → Ещё → NordVPN**, войдите или сохраните приватный ключ,
|
||||||
|
выберите сервер и добавьте исходящее. Можно добавить несколько серверов; каждый
|
||||||
|
hostname получает уникальный тег `nord-<hostname>` и не может быть добавлен дважды.
|
||||||
|
|
||||||
|
**Reset** в строке сохраняет сервер, тег, peer и ссылки маршрутизации, но обновляет
|
||||||
|
встроенный приватный ключ из текущих сохранённых учётных данных NordVPN. Выход
|
||||||
|
очищает только сохранённые учётные данные. Существующие исходящие продолжают
|
||||||
|
использовать встроенные ключи; удаляйте ненужные NordVPN-исходящие в общем списке.
|
||||||
|
|
||||||
## PIA WireGuard
|
## PIA WireGuard
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,13 @@ WARP 账户,并将其接入一个标签为 **`warp`** 的 WireGuard 出站:
|
|||||||
|
|
||||||
3x-ui 可以根据访问令牌获取 NordVPN(NordLynx/WireGuard)凭据(或
|
3x-ui 可以根据访问令牌获取 NordVPN(NordLynx/WireGuard)凭据(或
|
||||||
直接接受一个私钥),并列出国家/服务器,从而让你构建一个
|
直接接受一个私钥),并列出国家/服务器,从而让你构建一个
|
||||||
NordVPN 出站。
|
NordVPN 出站。打开 **Xray → 出站 → 更多 → NordVPN**,登录或保存私钥后选择服务器并
|
||||||
|
添加出站。可以连续添加多台服务器;每个 hostname 使用唯一的 `nord-<hostname>` 标签,
|
||||||
|
同一服务器不能重复添加。
|
||||||
|
|
||||||
|
对已添加行执行 **Reset** 时,会保留原服务器、标签、peer 和路由引用,只使用当前保存的
|
||||||
|
NordVPN 凭据刷新该出站内嵌的私钥。登出只清除保存的凭据,已有出站继续使用其内嵌密钥;
|
||||||
|
不再使用的 NordVPN 出站需要从出站列表中删除。
|
||||||
|
|
||||||
## PIA WireGuard
|
## PIA WireGuard
|
||||||
|
|
||||||
|
|||||||
@@ -144,19 +144,6 @@ export default function XrayPage() {
|
|||||||
if (idx >= 0) tt.outbounds.splice(idx, 1);
|
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(() => {
|
const advancedText = useMemo(() => {
|
||||||
if (advSettings === 'xraySetting') return xraySetting;
|
if (advSettings === 'xraySetting') return xraySetting;
|
||||||
const tpl = templateSettings;
|
const tpl = templateSettings;
|
||||||
@@ -393,8 +380,6 @@ export default function XrayPage() {
|
|||||||
onClose={() => setNordOpen(false)}
|
onClose={() => setNordOpen(false)}
|
||||||
onAddOutbound={onAddOutbound}
|
onAddOutbound={onAddOutbound}
|
||||||
onResetOutbound={onResetOutbound}
|
onResetOutbound={onResetOutbound}
|
||||||
onRemoveOutbound={onRemoveOutboundByIndex}
|
|
||||||
onRemoveRoutingRules={onRemoveRoutingRules}
|
|
||||||
/>
|
/>
|
||||||
<PiaModal
|
<PiaModal
|
||||||
open={piaOpen}
|
open={piaOpen}
|
||||||
|
|||||||
@@ -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 {
|
.nord-data-table {
|
||||||
margin: 5px 0;
|
flex: 1;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nord-data-table tr + tr td {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.nord-data-table td {
|
.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;
|
word-break: break-all;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nord-data-table td:first-child {
|
.nord-section-divider {
|
||||||
font-family: inherit;
|
margin: 18px 0 14px;
|
||||||
font-weight: 500;
|
color: var(--ant-color-text-secondary);
|
||||||
white-space: nowrap;
|
font-size: 12px;
|
||||||
width: 130px;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nord-data-table .row-odd {
|
.nord-location-form .ant-form-item {
|
||||||
background: var(--ant-color-fill-tertiary);
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-row {
|
.nord-location-grid {
|
||||||
display: inline-flex;
|
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;
|
align-items: center;
|
||||||
gap: 8px;
|
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;
|
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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-load-tag {
|
.nord-server-load {
|
||||||
margin-right: 0;
|
display: inline-flex;
|
||||||
flex-shrink: 0;
|
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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { LoginOutlined, SaveOutlined } from '@ant-design/icons';
|
||||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField } from '@/components/form/rhf';
|
||||||
|
import { countryFlag, countryName } from '../outbounds/outbounds-tab-helpers';
|
||||||
import './NordModal.css';
|
import './NordModal.css';
|
||||||
|
|
||||||
interface NordModalProps {
|
interface NordModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
templateSettings: { outbounds?: { tag?: string }[] } | null;
|
templateSettings: { outbounds?: NordOutboundRow[] } | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
||||||
onResetOutbound: (payload: {
|
onResetOutbound: (payload: {
|
||||||
@@ -19,8 +20,19 @@ interface NordModalProps {
|
|||||||
oldTag?: string;
|
oldTag?: string;
|
||||||
newTag: string;
|
newTag: string;
|
||||||
}) => void;
|
}) => 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 {
|
interface NordData {
|
||||||
@@ -45,12 +57,19 @@ interface NordServer {
|
|||||||
hostname: string;
|
hostname: string;
|
||||||
station: string;
|
station: string;
|
||||||
load: number;
|
load: number;
|
||||||
technologies?: { id: number; metadata?: { name: string; value: string }[] }[];
|
technologies?: { metadata?: { name: string; value: string }[] }[];
|
||||||
location_ids?: number[];
|
location_ids?: number[];
|
||||||
cityId?: number | null;
|
cityId?: number | null;
|
||||||
cityName?: string;
|
cityName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NordServerOption {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
searchText: string;
|
||||||
|
server: NordServer;
|
||||||
|
}
|
||||||
|
|
||||||
interface NordFormValues {
|
interface NordFormValues {
|
||||||
token: string;
|
token: string;
|
||||||
manualKey: string;
|
manualKey: string;
|
||||||
@@ -67,10 +86,30 @@ const EMPTY: NordFormValues = {
|
|||||||
serverId: null,
|
serverId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadColor(load: number): string {
|
function loadLevel(load: number): 'low' | 'medium' | 'high' {
|
||||||
if (load < 30) return 'green';
|
if (load < 30) return 'low';
|
||||||
if (load < 70) return 'orange';
|
if (load < 70) return 'medium';
|
||||||
return 'red';
|
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({
|
export default function NordModal({
|
||||||
@@ -79,10 +118,8 @@ export default function NordModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onAddOutbound,
|
onAddOutbound,
|
||||||
onResetOutbound,
|
onResetOutbound,
|
||||||
onRemoveOutbound,
|
|
||||||
onRemoveRoutingRules,
|
|
||||||
}: NordModalProps) {
|
}: NordModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nordData, setNordData] = useState<NordData | null>(null);
|
const [nordData, setNordData] = useState<NordData | null>(null);
|
||||||
@@ -92,18 +129,47 @@ export default function NordModal({
|
|||||||
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
|
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
|
||||||
const cityId = useWatch({ control: methods.control, name: 'cityId' });
|
const cityId = useWatch({ control: methods.control, name: 'cityId' });
|
||||||
const serverId = useWatch({ control: methods.control, name: 'serverId' });
|
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;
|
const list = templateSettings?.outbounds;
|
||||||
if (!list) return -1;
|
if (!list) return [];
|
||||||
return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
|
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]);
|
}, [templateSettings?.outbounds]);
|
||||||
|
|
||||||
|
const addedTags = useMemo(() => new Set(nordRows.map((row) => row.tag)), [nordRows]);
|
||||||
|
|
||||||
const filteredServers = useMemo(() => {
|
const filteredServers = useMemo(() => {
|
||||||
if (!cityId) return servers;
|
if (cityId == null) return servers;
|
||||||
return servers.filter((s) => s.cityId === cityId);
|
return servers.filter((s) => s.cityId === cityId);
|
||||||
}, [cityId, servers]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
|
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
|
||||||
}, [filteredServers, methods]);
|
}, [filteredServers, methods]);
|
||||||
@@ -174,8 +240,6 @@ export default function NordModal({
|
|||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
|
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
onRemoveOutbound(nordOutboundIndex);
|
|
||||||
onRemoveRoutingRules({ prefix: 'nord-' });
|
|
||||||
setNordData(null);
|
setNordData(null);
|
||||||
methods.reset(EMPTY);
|
methods.reset(EMPTY);
|
||||||
setCountries([]);
|
setCountries([]);
|
||||||
@@ -216,6 +280,7 @@ export default function NordModal({
|
|||||||
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
|
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
|
||||||
})
|
})
|
||||||
.sort((a: NordServer, b: NordServer) => a.load - b.load);
|
.sort((a: NordServer, b: NordServer) => a.load - b.load);
|
||||||
|
methods.setValue('cityId', null);
|
||||||
setServers(next);
|
setServers(next);
|
||||||
if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
|
if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -227,8 +292,9 @@ export default function NordModal({
|
|||||||
const selectedServerId = methods.getValues('serverId');
|
const selectedServerId = methods.getValues('serverId');
|
||||||
const server = servers.find((s) => s.id === selectedServerId);
|
const server = servers.find((s) => s.id === selectedServerId);
|
||||||
if (!server) return null;
|
if (!server) return null;
|
||||||
const tech = server.technologies?.find((tt) => tt.id === 35);
|
const publicKey = server.technologies
|
||||||
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
|
?.flatMap((technology) => technology.metadata ?? [])
|
||||||
|
.find((metadata) => metadata.name === 'public_key')?.value;
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
messageApi.error(t('pages.xray.nord.noPublicKey'));
|
messageApi.error(t('pages.xray.nord.noPublicKey'));
|
||||||
return null;
|
return null;
|
||||||
@@ -249,32 +315,49 @@ export default function NordModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addOutbound() {
|
function addOutbound() {
|
||||||
|
if (selectedAlreadyAdded) return;
|
||||||
const ob = buildNordOutbound();
|
const ob = buildNordOutbound();
|
||||||
if (!ob) return;
|
if (!ob) return;
|
||||||
|
const tag = typeof ob.tag === 'string' ? ob.tag : '';
|
||||||
|
if (tag && templateSettings?.outbounds?.some((outbound) => outbound?.tag === tag)) return;
|
||||||
onAddOutbound(ob);
|
onAddOutbound(ob);
|
||||||
messageApi.success(t('pages.xray.nord.outboundAdded'));
|
messageApi.success(t('pages.xray.nord.outboundAdded'));
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetOutbound() {
|
function resetOutbound(index: number) {
|
||||||
if (nordOutboundIndex === -1) return;
|
const existing = templateSettings?.outbounds?.[index];
|
||||||
const ob = buildNordOutbound();
|
if (
|
||||||
if (!ob) return;
|
!existing?.tag?.startsWith?.('nord-') ||
|
||||||
const oldTag = templateSettings?.outbounds?.[nordOutboundIndex]?.tag;
|
!isResettableNordOutbound(existing) ||
|
||||||
|
!isRecord(existing.settings) ||
|
||||||
|
!nordData?.private_key
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ob = {
|
||||||
|
...existing,
|
||||||
|
settings: { ...existing.settings, secretKey: nordData.private_key },
|
||||||
|
};
|
||||||
onResetOutbound({
|
onResetOutbound({
|
||||||
index: nordOutboundIndex,
|
index,
|
||||||
outbound: ob,
|
outbound: ob,
|
||||||
oldTag,
|
oldTag: existing.tag,
|
||||||
newTag: ob.tag as string,
|
newTag: existing.tag,
|
||||||
});
|
});
|
||||||
messageApi.success(t('pages.xray.nord.outboundUpdated'));
|
messageApi.success(t('pages.xray.nord.outboundUpdated'));
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messageContextHolder}
|
{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}>
|
<FormProvider {...methods}>
|
||||||
{nordData == null ? (
|
{nordData == null ? (
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -284,18 +367,13 @@ export default function NordModal({
|
|||||||
key: 'token',
|
key: 'token',
|
||||||
label: t('pages.xray.nord.accessToken'),
|
label: t('pages.xray.nord.accessToken'),
|
||||||
children: (
|
children: (
|
||||||
<Form
|
<Form colon={false} layout="vertical" className="nord-login-form">
|
||||||
colon={false}
|
|
||||||
labelCol={{ md: { span: 6 } }}
|
|
||||||
wrapperCol={{ md: { span: 18 } }}
|
|
||||||
className="mt-20"
|
|
||||||
>
|
|
||||||
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
|
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
|
||||||
<Input placeholder={t('pages.xray.nord.accessToken')} />
|
<Input placeholder={t('pages.xray.nord.accessToken')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
className="mt-10"
|
className="nord-login-action"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
icon={<LoginOutlined />}
|
icon={<LoginOutlined />}
|
||||||
onClick={login}
|
onClick={login}
|
||||||
@@ -309,18 +387,13 @@ export default function NordModal({
|
|||||||
key: 'key',
|
key: 'key',
|
||||||
label: t('pages.xray.nord.privateKey'),
|
label: t('pages.xray.nord.privateKey'),
|
||||||
children: (
|
children: (
|
||||||
<Form
|
<Form colon={false} layout="vertical" className="nord-login-form">
|
||||||
colon={false}
|
|
||||||
labelCol={{ md: { span: 6 } }}
|
|
||||||
wrapperCol={{ md: { span: 18 } }}
|
|
||||||
className="mt-20"
|
|
||||||
>
|
|
||||||
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
|
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
|
||||||
<Input placeholder={t('pages.xray.nord.privateKey')} />
|
<Input placeholder={t('pages.xray.nord.privateKey')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
className="mt-10"
|
className="nord-login-action"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined />}
|
||||||
onClick={saveKey}
|
onClick={saveKey}
|
||||||
@@ -334,10 +407,11 @@ export default function NordModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="nord-account-card">
|
||||||
<table className="nord-data-table">
|
<table className="nord-data-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
{nordData.token && (
|
{nordData.token && (
|
||||||
<tr className="row-odd">
|
<tr>
|
||||||
<td>{t('pages.xray.nord.accessToken')}</td>
|
<td>{t('pages.xray.nord.accessToken')}</td>
|
||||||
<td>{nordData.token}</td>
|
<td>{nordData.token}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -348,19 +422,15 @@ export default function NordModal({
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<Button loading={loading} danger onClick={logout}>
|
||||||
<Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
|
|
||||||
{t('logout')}
|
{t('logout')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
<Divider className="nord-section-divider">{t('pages.xray.warp.settings')}</Divider>
|
||||||
|
|
||||||
<Form
|
<Form colon={false} layout="vertical" className="nord-location-form">
|
||||||
colon={false}
|
<div className="nord-location-grid">
|
||||||
labelCol={{ md: { span: 6 } }}
|
|
||||||
wrapperCol={{ md: { span: 18 } }}
|
|
||||||
className="mt-10"
|
|
||||||
>
|
|
||||||
<FormField
|
<FormField
|
||||||
name="countryId"
|
name="countryId"
|
||||||
label={t('pages.xray.outbound.country')}
|
label={t('pages.xray.outbound.country')}
|
||||||
@@ -368,17 +438,23 @@ export default function NordModal({
|
|||||||
onAfterChange={(v) => fetchServers(v as number)}
|
onAfterChange={(v) => fetchServers(v as number)}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
data-testid="nord-country-select"
|
||||||
showSearch={{ optionFilterProp: 'label' }}
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
options={countries.map((c) => ({
|
options={countries.map((c) => {
|
||||||
|
const name = countryName(c.code, locale) || c.name || c.code;
|
||||||
|
const flag = countryFlag(c.code);
|
||||||
|
return {
|
||||||
value: c.id,
|
value: c.id,
|
||||||
label: `${c.name} (${c.code})`,
|
label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
|
||||||
}))}
|
};
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{cities.length > 0 && (
|
{cities.length > 0 && (
|
||||||
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
||||||
<Select
|
<Select
|
||||||
|
data-testid="nord-city-select"
|
||||||
showSearch={{ optionFilterProp: 'label' }}
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
options={[
|
options={[
|
||||||
{ value: null, label: t('pages.xray.outbound.allCities') },
|
{ value: null, label: t('pages.xray.outbound.allCities') },
|
||||||
@@ -389,54 +465,128 @@ export default function NordModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredServers.length > 0 && (
|
{filteredServers.length > 0 && (
|
||||||
|
<div className="nord-server-field">
|
||||||
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
|
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
|
||||||
<Select
|
<Select<number, NordServerOption>
|
||||||
showSearch={{ optionFilterProp: 'label' }}
|
data-testid="nord-server-select"
|
||||||
options={filteredServers.map((s) => ({
|
classNames={{ popup: { root: 'nord-server-popup' } }}
|
||||||
value: s.id,
|
listHeight={320}
|
||||||
label: `${s.cityName} ${s.name} ${s.hostname}`,
|
listItemHeight={58}
|
||||||
children: (
|
options={serverOptions}
|
||||||
<span className="server-row">
|
showSearch={{
|
||||||
<span className="server-name">
|
filterOption: (input, option) =>
|
||||||
{s.cityName} - {s.name}
|
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>
|
||||||
<Tag color={loadColor(s.load)} className="server-load-tag">
|
<span aria-hidden="true">·</span>
|
||||||
{s.load}%
|
<span className="nord-server-option-address">
|
||||||
</Tag>
|
{server.station}:51820
|
||||||
</span>
|
</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>
|
</FormField>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
|
<div className="nord-add-actions">
|
||||||
{nordOutboundIndex >= 0 ? (
|
<div className="nord-already-added" aria-live="polite">
|
||||||
<>
|
{selectedAlreadyAdded
|
||||||
<Tag color="green">{t('enabled')}</Tag>
|
? t('pages.xray.nord.alreadyAdded', { reset: t('reset') })
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
danger
|
disabled={!serverId || selectedAlreadyAdded}
|
||||||
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}
|
loading={loading}
|
||||||
onClick={addOutbound}
|
onClick={addOutbound}
|
||||||
>
|
>
|
||||||
{t('pages.xray.warp.addOutbound')}
|
{t('pages.xray.warp.addOutbound')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nordRows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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' }] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,11 +18,14 @@ type NordService struct {
|
|||||||
|
|
||||||
var nordHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
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).
|
// maxResponseSize limits the maximum size of NordVPN API responses (10 MB).
|
||||||
const maxResponseSize = 10 << 20
|
const maxResponseSize = 10 << 20
|
||||||
|
|
||||||
func (s *NordService) GetCountries() (string, error) {
|
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 {
|
if reqErr != nil {
|
||||||
return "", reqErr
|
return "", reqErr
|
||||||
}
|
}
|
||||||
@@ -48,7 +51,7 @@ func (s *NordService) GetServers(countryId string) (string, error) {
|
|||||||
return "", common.NewError("invalid country ID")
|
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)
|
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
if reqErr != nil {
|
if reqErr != nil {
|
||||||
return "", reqErr
|
return "", reqErr
|
||||||
@@ -65,30 +68,9 @@ func (s *NordService) GetServers(countryId string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var data map[string]any
|
|
||||||
if err := json.Unmarshal(body, &data); err != nil {
|
|
||||||
return string(body), 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *NordService) SetKey(privateKey string) (string, error) {
|
func (s *NordService) SetKey(privateKey string) (string, error) {
|
||||||
if privateKey == "" {
|
if privateKey == "" {
|
||||||
return "", common.NewError("private key cannot be empty")
|
return "", common.NewError("private key cannot be empty")
|
||||||
@@ -106,7 +88,7 @@ func (s *NordService) SetKey(privateKey string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *NordService) GetCredentials(token 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)
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "لم يتم العثور على خوادم للدولة المحددة",
|
"noServers": "لم يتم العثور على خوادم للدولة المحددة",
|
||||||
"noPublicKey": "الخادم المحدد لا يُعلن عن مفتاح NordLynx العام.",
|
"noPublicKey": "الخادم المحدد لا يُعلن عن مفتاح NordLynx العام.",
|
||||||
"outboundAdded": "تمت إضافة صادر NordVPN",
|
"outboundAdded": "تمت إضافة صادر NordVPN",
|
||||||
"outboundUpdated": "تم تحديث صادر NordVPN"
|
"outboundUpdated": "تم تحديث صادر NordVPN",
|
||||||
|
"serverLoad": "حمل الخادم",
|
||||||
|
"addedServers": "الخوادم المضافة",
|
||||||
|
"alreadyAdded": "هذا الخادم موجود بالفعل في قائمة الصادرات. استخدم {reset} لتحديث مفتاحه."
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
"changeIp": "تغيير الـ IP",
|
"changeIp": "تغيير الـ IP",
|
||||||
|
|||||||
@@ -1970,7 +1970,10 @@
|
|||||||
"noServers": "No servers found for the selected country",
|
"noServers": "No servers found for the selected country",
|
||||||
"noPublicKey": "Selected server does not advertise a NordLynx public key.",
|
"noPublicKey": "Selected server does not advertise a NordLynx public key.",
|
||||||
"outboundAdded": "NordVPN outbound added",
|
"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": {
|
"warp": {
|
||||||
"changeIp": "Change IP",
|
"changeIp": "Change IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "No se encontraron servidores para el país seleccionado",
|
"noServers": "No se encontraron servidores para el país seleccionado",
|
||||||
"noPublicKey": "El servidor seleccionado no anuncia una clave pública NordLynx.",
|
"noPublicKey": "El servidor seleccionado no anuncia una clave pública NordLynx.",
|
||||||
"outboundAdded": "Salida NordVPN añadida",
|
"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": {
|
"warp": {
|
||||||
"changeIp": "Cambiar IP",
|
"changeIp": "Cambiar IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "سروری برای کشور انتخابی پیدا نشد",
|
"noServers": "سروری برای کشور انتخابی پیدا نشد",
|
||||||
"noPublicKey": "سرور انتخابی کلید عمومی NordLynx اعلام نمیکند.",
|
"noPublicKey": "سرور انتخابی کلید عمومی NordLynx اعلام نمیکند.",
|
||||||
"outboundAdded": "خروجی NordVPN اضافه شد",
|
"outboundAdded": "خروجی NordVPN اضافه شد",
|
||||||
"outboundUpdated": "خروجی NordVPN بهروزرسانی شد"
|
"outboundUpdated": "خروجی NordVPN بهروزرسانی شد",
|
||||||
|
"serverLoad": "بار سرور",
|
||||||
|
"addedServers": "سرورهای اضافهشده",
|
||||||
|
"alreadyAdded": "این سرور از قبل در فهرست خروجیها وجود دارد. برای تازهسازی کلید آن از {reset} استفاده کنید."
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
"changeIp": "تغییر IP",
|
"changeIp": "تغییر IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "Tidak ada server ditemukan untuk negara yang dipilih",
|
"noServers": "Tidak ada server ditemukan untuk negara yang dipilih",
|
||||||
"noPublicKey": "Server yang dipilih tidak mengumumkan kunci publik NordLynx.",
|
"noPublicKey": "Server yang dipilih tidak mengumumkan kunci publik NordLynx.",
|
||||||
"outboundAdded": "Outbound NordVPN ditambahkan",
|
"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": {
|
"warp": {
|
||||||
"changeIp": "Ganti IP",
|
"changeIp": "Ganti IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "選択した国のサーバーが見つかりません",
|
"noServers": "選択した国のサーバーが見つかりません",
|
||||||
"noPublicKey": "選択したサーバーは NordLynx 公開鍵を公開していません。",
|
"noPublicKey": "選択したサーバーは NordLynx 公開鍵を公開していません。",
|
||||||
"outboundAdded": "NordVPN アウトバウンドを追加しました",
|
"outboundAdded": "NordVPN アウトバウンドを追加しました",
|
||||||
"outboundUpdated": "NordVPN アウトバウンドを更新しました"
|
"outboundUpdated": "NordVPN アウトバウンドを更新しました",
|
||||||
|
"serverLoad": "サーバー負荷",
|
||||||
|
"addedServers": "追加済みサーバー",
|
||||||
|
"alreadyAdded": "このサーバーはすでにアウトバウンド一覧にあります。{reset} で鍵を更新してください。"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
"changeIp": "IP を変更",
|
"changeIp": "IP を変更",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "Nenhum servidor encontrado para o país selecionado",
|
"noServers": "Nenhum servidor encontrado para o país selecionado",
|
||||||
"noPublicKey": "O servidor selecionado não anuncia uma chave pública NordLynx.",
|
"noPublicKey": "O servidor selecionado não anuncia uma chave pública NordLynx.",
|
||||||
"outboundAdded": "Saída NordVPN adicionada",
|
"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": {
|
"warp": {
|
||||||
"changeIp": "Alterar IP",
|
"changeIp": "Alterar IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "Серверов для выбранной страны не найдено",
|
"noServers": "Серверов для выбранной страны не найдено",
|
||||||
"noPublicKey": "Выбранный сервер не сообщает публичный ключ NordLynx.",
|
"noPublicKey": "Выбранный сервер не сообщает публичный ключ NordLynx.",
|
||||||
"outboundAdded": "Исходящий NordVPN добавлен",
|
"outboundAdded": "Исходящий NordVPN добавлен",
|
||||||
"outboundUpdated": "Исходящий NordVPN обновлён"
|
"outboundUpdated": "Исходящий NordVPN обновлён",
|
||||||
|
"serverLoad": "Нагрузка сервера",
|
||||||
|
"addedServers": "Добавленные серверы",
|
||||||
|
"alreadyAdded": "Этот сервер уже есть в списке исходящих подключений. Используйте {reset}, чтобы обновить его ключ."
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
"changeIp": "Сменить IP",
|
"changeIp": "Сменить IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "Seçilen ülke için sunucu bulunamadı.",
|
"noServers": "Seçilen ülke için sunucu bulunamadı.",
|
||||||
"noPublicKey": "Seçilen sunucu NordLynx genel anahtarı yayınlamıyor.",
|
"noPublicKey": "Seçilen sunucu NordLynx genel anahtarı yayınlamıyor.",
|
||||||
"outboundAdded": "NordVPN giden bağlantı eklendi.",
|
"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": {
|
"warp": {
|
||||||
"changeIp": "IP Değiştir",
|
"changeIp": "IP Değiştir",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "Серверів для обраної країни не знайдено",
|
"noServers": "Серверів для обраної країни не знайдено",
|
||||||
"noPublicKey": "Обраний сервер не повідомляє публічного ключа NordLynx.",
|
"noPublicKey": "Обраний сервер не повідомляє публічного ключа NordLynx.",
|
||||||
"outboundAdded": "Вихідний NordVPN додано",
|
"outboundAdded": "Вихідний NordVPN додано",
|
||||||
"outboundUpdated": "Вихідний NordVPN оновлено"
|
"outboundUpdated": "Вихідний NordVPN оновлено",
|
||||||
|
"serverLoad": "Навантаження сервера",
|
||||||
|
"addedServers": "Додані сервери",
|
||||||
|
"alreadyAdded": "Цей сервер уже є у списку вихідних підключень. Використайте {reset}, щоб оновити його ключ."
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
"changeIp": "Змінити IP",
|
"changeIp": "Змінити IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "Không tìm thấy máy chủ cho quốc gia đã chọn",
|
"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.",
|
"noPublicKey": "Máy chủ đã chọn không công bố khóa công khai NordLynx.",
|
||||||
"outboundAdded": "Đã thêm outbound NordVPN",
|
"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": {
|
"warp": {
|
||||||
"changeIp": "Đổi IP",
|
"changeIp": "Đổi IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "未找到选定国家/地区的服务器",
|
"noServers": "未找到选定国家/地区的服务器",
|
||||||
"noPublicKey": "选定的服务器未公布 NordLynx 公钥。",
|
"noPublicKey": "选定的服务器未公布 NordLynx 公钥。",
|
||||||
"outboundAdded": "NordVPN 出站已添加",
|
"outboundAdded": "NordVPN 出站已添加",
|
||||||
"outboundUpdated": "NordVPN 出站已更新"
|
"outboundUpdated": "NordVPN 出站已更新",
|
||||||
|
"serverLoad": "服务器负载",
|
||||||
|
"addedServers": "已添加的服务器",
|
||||||
|
"alreadyAdded": "此服务器已在出站列表中。请使用{reset}刷新其密钥。"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
"changeIp": "更换 IP",
|
"changeIp": "更换 IP",
|
||||||
|
|||||||
@@ -1852,7 +1852,10 @@
|
|||||||
"noServers": "未找到選定國家/地區的伺服器",
|
"noServers": "未找到選定國家/地區的伺服器",
|
||||||
"noPublicKey": "選定的伺服器未公布 NordLynx 公鑰。",
|
"noPublicKey": "選定的伺服器未公布 NordLynx 公鑰。",
|
||||||
"outboundAdded": "NordVPN 出站已新增",
|
"outboundAdded": "NordVPN 出站已新增",
|
||||||
"outboundUpdated": "NordVPN 出站已更新"
|
"outboundUpdated": "NordVPN 出站已更新",
|
||||||
|
"serverLoad": "伺服器負載",
|
||||||
|
"addedServers": "已新增的伺服器",
|
||||||
|
"alreadyAdded": "此伺服器已在出站清單中。請使用{reset}重新整理其金鑰。"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
"changeIp": "更換 IP",
|
"changeIp": "更換 IP",
|
||||||
|
|||||||
Reference in New Issue
Block a user