Browse Source

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.
Masterain 19 giờ trước cách đây
mục cha
commit
f9cfd87cb2

+ 8 - 1
docs/content/docs/en/operations/outbounds-routing.mdx

@@ -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
 

+ 8 - 1
docs/content/docs/fa/operations/outbounds-routing.mdx

@@ -85,7 +85,14 @@ WARP به سرور شما امکان می‌دهد ترافیک خود را از
 
 3x-ui می‌تواند اعتبارنامه‌های NordVPN (NordLynx/WireGuard) را از یک توکن دسترسی دریافت کند (یا
 یک کلید خصوصی را مستقیماً بپذیرد) و کشورها/سرورها را فهرست کند تا بتوانید یک خروجی NordVPN
-بسازید.
+بسازید. از **Xray → خروجی‌ها → بیشتر → NordVPN** وارد شوید یا کلید خصوصی را ذخیره کنید،
+سرور را انتخاب کنید و خروجی را بیفزایید. می‌توان چند سرور افزود؛ هر hostname برچسب یکتای
+`nord-<hostname>` دارد و نمی‌توان آن را دو بار افزود.
+
+**Reset** در هر ردیف، سرور، برچسب، peer و ارجاع‌های مسیریابی را نگه می‌دارد و فقط کلید خصوصی
+درون خروجی را از اعتبارنامهٔ ذخیره‌شدهٔ فعلی تازه می‌کند. خروج فقط اعتبارنامهٔ ذخیره‌شده را پاک
+می‌کند و خروجی‌های موجود همچنان از کلید درون خود استفاده می‌کنند. خروجی‌های بلااستفادهٔ NordVPN
+را از فهرست خروجی‌ها حذف کنید.
 
 ## خروجی WireGuard PIA
 

+ 9 - 1
docs/content/docs/ru/operations/outbounds-routing.mdx

@@ -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
 

+ 7 - 1
docs/content/docs/zh/operations/outbounds-routing.mdx

@@ -80,7 +80,13 @@ WARP 账户,并将其接入一个标签为 **`warp`** 的 WireGuard 出站:
 
 3x-ui 可以根据访问令牌获取 NordVPN(NordLynx/WireGuard)凭据(或
 直接接受一个私钥),并列出国家/服务器,从而让你构建一个
-NordVPN 出站。
+NordVPN 出站。打开 **Xray → 出站 → 更多 → NordVPN**,登录或保存私钥后选择服务器并
+添加出站。可以连续添加多台服务器;每个 hostname 使用唯一的 `nord-<hostname>` 标签,
+同一服务器不能重复添加。
+
+对已添加行执行 **Reset** 时,会保留原服务器、标签、peer 和路由引用,只使用当前保存的
+NordVPN 凭据刷新该出站内嵌的私钥。登出只清除保存的凭据,已有出站继续使用其内嵌密钥;
+不再使用的 NordVPN 出站需要从出站列表中删除。
 
 ## PIA WireGuard
 

+ 0 - 15
frontend/src/pages/xray/XrayPage.tsx

@@ -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}

+ 300 - 14
frontend/src/pages/xray/overrides/NordModal.css

@@ -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;
+.nord-section-divider {
+  margin: 18px 0 14px;
+  color: var(--ant-color-text-secondary);
+  font-size: 12px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+}
+
+.nord-location-form .ant-form-item {
+  margin-bottom: 0;
+}
+
+.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;
+  min-width: 0;
+}
+
+.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;
-  width: 130px;
 }
 
-.nord-data-table .row-odd {
-  background: var(--ant-color-fill-tertiary);
+.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-row {
+.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;
 }
 
-.server-name {
+.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;
 }
 
-.server-load-tag {
-  margin-right: 0;
-  flex-shrink: 0;
+.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%;
+  }
 }

+ 289 - 139
frontend/src/pages/xray/overrides/NordModal.tsx

@@ -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>
-                  )}
-                  <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>
-                )}
+                  </tbody>
+                </table>
+                <Button loading={loading} danger onClick={logout}>
+                  {t('logout')}
+                </Button>
+              </div>
+
+              <Divider className="nord-section-divider">{t('pages.xray.warp.settings')}</Divider>
 
-                {filteredServers.length > 0 && (
-                  <FormField name="serverId" label={t('pages.xray.outbound.server')}>
+              <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={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>
-                        ),
-                      }))}
+                      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>
+                  )}
+
+                  {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 ? (
-                <>
-                  <Tag color="green">{t('enabled')}</Tag>
-                  <Button
-                    type="primary"
-                    danger
-                    loading={loading}
-                    className="ml-8"
-                    onClick={resetOutbound}
-                  >
-                    {t('reset')}
-                  </Button>
-                </>
-              ) : (
+              <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="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 - 0
frontend/src/test/nord-modal.test.tsx

@@ -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
internal/web/service/integration/nord.go

@@ -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

+ 74 - 0
internal/web/service/integration/nord_test.go

@@ -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
internal/web/translation/ar-EG.json

@@ -1852,7 +1852,10 @@
         "noServers": "لم يتم العثور على خوادم للدولة المحددة",
         "noPublicKey": "الخادم المحدد لا يُعلن عن مفتاح NordLynx العام.",
         "outboundAdded": "تمت إضافة صادر NordVPN",
-        "outboundUpdated": "تم تحديث صادر NordVPN"
+        "outboundUpdated": "تم تحديث صادر NordVPN",
+        "serverLoad": "حمل الخادم",
+        "addedServers": "الخوادم المضافة",
+        "alreadyAdded": "هذا الخادم موجود بالفعل في قائمة الصادرات. استخدم {reset} لتحديث مفتاحه."
       },
       "warp": {
         "changeIp": "تغيير الـ IP",

+ 4 - 1
internal/web/translation/en-US.json

@@ -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
internal/web/translation/es-ES.json

@@ -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
internal/web/translation/fa-IR.json

@@ -1852,7 +1852,10 @@
         "noServers": "سروری برای کشور انتخابی پیدا نشد",
         "noPublicKey": "سرور انتخابی کلید عمومی NordLynx اعلام نمی‌کند.",
         "outboundAdded": "خروجی NordVPN اضافه شد",
-        "outboundUpdated": "خروجی NordVPN به‌روزرسانی شد"
+        "outboundUpdated": "خروجی NordVPN به‌روزرسانی شد",
+        "serverLoad": "بار سرور",
+        "addedServers": "سرورهای اضافه‌شده",
+        "alreadyAdded": "این سرور از قبل در فهرست خروجی‌ها وجود دارد. برای تازه‌سازی کلید آن از {reset} استفاده کنید."
       },
       "warp": {
         "changeIp": "تغییر IP",

+ 4 - 1
internal/web/translation/id-ID.json

@@ -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
internal/web/translation/ja-JP.json

@@ -1852,7 +1852,10 @@
         "noServers": "選択した国のサーバーが見つかりません",
         "noPublicKey": "選択したサーバーは NordLynx 公開鍵を公開していません。",
         "outboundAdded": "NordVPN アウトバウンドを追加しました",
-        "outboundUpdated": "NordVPN アウトバウンドを更新しました"
+        "outboundUpdated": "NordVPN アウトバウンドを更新しました",
+        "serverLoad": "サーバー負荷",
+        "addedServers": "追加済みサーバー",
+        "alreadyAdded": "このサーバーはすでにアウトバウンド一覧にあります。{reset} で鍵を更新してください。"
       },
       "warp": {
         "changeIp": "IP を変更",

+ 4 - 1
internal/web/translation/pt-BR.json

@@ -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
internal/web/translation/ru-RU.json

@@ -1852,7 +1852,10 @@
         "noServers": "Серверов для выбранной страны не найдено",
         "noPublicKey": "Выбранный сервер не сообщает публичный ключ NordLynx.",
         "outboundAdded": "Исходящий NordVPN добавлен",
-        "outboundUpdated": "Исходящий NordVPN обновлён"
+        "outboundUpdated": "Исходящий NordVPN обновлён",
+        "serverLoad": "Нагрузка сервера",
+        "addedServers": "Добавленные серверы",
+        "alreadyAdded": "Этот сервер уже есть в списке исходящих подключений. Используйте {reset}, чтобы обновить его ключ."
       },
       "warp": {
         "changeIp": "Сменить IP",

+ 4 - 1
internal/web/translation/tr-TR.json

@@ -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
internal/web/translation/uk-UA.json

@@ -1852,7 +1852,10 @@
         "noServers": "Серверів для обраної країни не знайдено",
         "noPublicKey": "Обраний сервер не повідомляє публічного ключа NordLynx.",
         "outboundAdded": "Вихідний NordVPN додано",
-        "outboundUpdated": "Вихідний NordVPN оновлено"
+        "outboundUpdated": "Вихідний NordVPN оновлено",
+        "serverLoad": "Навантаження сервера",
+        "addedServers": "Додані сервери",
+        "alreadyAdded": "Цей сервер уже є у списку вихідних підключень. Використайте {reset}, щоб оновити його ключ."
       },
       "warp": {
         "changeIp": "Змінити IP",

+ 4 - 1
internal/web/translation/vi-VN.json

@@ -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
internal/web/translation/zh-CN.json

@@ -1852,7 +1852,10 @@
         "noServers": "未找到选定国家/地区的服务器",
         "noPublicKey": "选定的服务器未公布 NordLynx 公钥。",
         "outboundAdded": "NordVPN 出站已添加",
-        "outboundUpdated": "NordVPN 出站已更新"
+        "outboundUpdated": "NordVPN 出站已更新",
+        "serverLoad": "服务器负载",
+        "addedServers": "已添加的服务器",
+        "alreadyAdded": "此服务器已在出站列表中。请使用{reset}刷新其密钥。"
       },
       "warp": {
         "changeIp": "更换 IP",

+ 4 - 1
internal/web/translation/zh-TW.json

@@ -1852,7 +1852,10 @@
         "noServers": "未找到選定國家/地區的伺服器",
         "noPublicKey": "選定的伺服器未公布 NordLynx 公鑰。",
         "outboundAdded": "NordVPN 出站已新增",
-        "outboundUpdated": "NordVPN 出站已更新"
+        "outboundUpdated": "NordVPN 出站已更新",
+        "serverLoad": "伺服器負載",
+        "addedServers": "已新增的伺服器",
+        "alreadyAdded": "此伺服器已在出站清單中。請使用{reset}重新整理其金鑰。"
       },
       "warp": {
         "changeIp": "更換 IP",