diff --git a/docs/content/docs/en/operations/outbounds-routing.mdx b/docs/content/docs/en/operations/outbounds-routing.mdx index a8153e357..41961abae 100644 --- a/docs/content/docs/en/operations/outbounds-routing.mdx +++ b/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-` 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 diff --git a/docs/content/docs/fa/operations/outbounds-routing.mdx b/docs/content/docs/fa/operations/outbounds-routing.mdx index 91633a791..fb9d4ad11 100644 --- a/docs/content/docs/fa/operations/outbounds-routing.mdx +++ b/docs/content/docs/fa/operations/outbounds-routing.mdx @@ -85,7 +85,14 @@ WARP به سرور شما امکان می‌دهد ترافیک خود را از 3x-ui می‌تواند اعتبارنامه‌های NordVPN (NordLynx/WireGuard) را از یک توکن دسترسی دریافت کند (یا یک کلید خصوصی را مستقیماً بپذیرد) و کشورها/سرورها را فهرست کند تا بتوانید یک خروجی NordVPN -بسازید. +بسازید. از **Xray → خروجی‌ها → بیشتر → NordVPN** وارد شوید یا کلید خصوصی را ذخیره کنید، +سرور را انتخاب کنید و خروجی را بیفزایید. می‌توان چند سرور افزود؛ هر hostname برچسب یکتای +`nord-` دارد و نمی‌توان آن را دو بار افزود. + +**Reset** در هر ردیف، سرور، برچسب، peer و ارجاع‌های مسیریابی را نگه می‌دارد و فقط کلید خصوصی +درون خروجی را از اعتبارنامهٔ ذخیره‌شدهٔ فعلی تازه می‌کند. خروج فقط اعتبارنامهٔ ذخیره‌شده را پاک +می‌کند و خروجی‌های موجود همچنان از کلید درون خود استفاده می‌کنند. خروجی‌های بلااستفادهٔ NordVPN +را از فهرست خروجی‌ها حذف کنید. ## خروجی WireGuard PIA diff --git a/docs/content/docs/ru/operations/outbounds-routing.mdx b/docs/content/docs/ru/operations/outbounds-routing.mdx index 8a4c9eeb9..1a765e514 100644 --- a/docs/content/docs/ru/operations/outbounds-routing.mdx +++ b/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-` и не может быть добавлен дважды. + +**Reset** в строке сохраняет сервер, тег, peer и ссылки маршрутизации, но обновляет +встроенный приватный ключ из текущих сохранённых учётных данных NordVPN. Выход +очищает только сохранённые учётные данные. Существующие исходящие продолжают +использовать встроенные ключи; удаляйте ненужные NordVPN-исходящие в общем списке. ## PIA WireGuard diff --git a/docs/content/docs/zh/operations/outbounds-routing.mdx b/docs/content/docs/zh/operations/outbounds-routing.mdx index c8ab1617b..3d73a5341 100644 --- a/docs/content/docs/zh/operations/outbounds-routing.mdx +++ b/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-` 标签, +同一服务器不能重复添加。 + +对已添加行执行 **Reset** 时,会保留原服务器、标签、peer 和路由引用,只使用当前保存的 +NordVPN 凭据刷新该出站内嵌的私钥。登出只清除保存的凭据,已有出站继续使用其内嵌密钥; +不再使用的 NordVPN 出站需要从出站列表中删除。 ## PIA WireGuard diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index cdd3cd423..18a82d28e 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/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} /> .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%; + } } diff --git a/frontend/src/pages/xray/overrides/NordModal.tsx b/frontend/src/pages/xray/overrides/NordModal.tsx index 631f9c8ab..e03948d08 100644 --- a/frontend/src/pages/xray/overrides/NordModal.tsx +++ b/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) => 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 { + 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(null); @@ -92,18 +129,47 @@ export default function NordModal({ const methods = useForm({ 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(() => { 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( + () => + 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} - + {nordData == null ? ( +
+ + + {t('pages.xray.warp.settings')} + + +
+ v ?? undefined }} + onAfterChange={(v) => fetchServers(v as number)} + > + ({ value: c.id, label: c.name })), + ]} + /> + )} - - {t('pages.xray.nord.privateKey')} - {nordData.private_key} - - - - - - {t('pages.xray.warp.settings')} - - - v ?? undefined }} - onAfterChange={(v) => fetchServers(v as number)} - > - ({ value: c.id, label: c.name })), - ]} - /> - - )} - - {filteredServers.length > 0 && ( - -