2026-05-21 20:56:36 +00:00
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
|
|
|
|
|
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
|
|
|
|
|
|
|
|
|
|
import { HttpUtil } from '@/utils';
|
|
|
|
|
import './NordModal.css';
|
|
|
|
|
|
|
|
|
|
interface NordModalProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
templateSettings: { outbounds?: { tag?: string }[] } | null;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onAddOutbound: (outbound: Record<string, unknown>) => void;
|
|
|
|
|
onResetOutbound: (payload: { index: number; outbound: Record<string, unknown>; oldTag?: string; newTag: string }) => void;
|
|
|
|
|
onRemoveOutbound: (index: number) => void;
|
|
|
|
|
onRemoveRoutingRules: (payload: { prefix: string }) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NordData {
|
|
|
|
|
token?: string;
|
|
|
|
|
private_key?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Country {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
code: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface City {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NordServer {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
hostname: string;
|
|
|
|
|
station: string;
|
|
|
|
|
load: number;
|
|
|
|
|
technologies?: { id: number; metadata?: { name: string; value: string }[] }[];
|
|
|
|
|
location_ids?: number[];
|
|
|
|
|
cityId?: number | null;
|
|
|
|
|
cityName?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadColor(load: number): string {
|
|
|
|
|
if (load < 30) return 'green';
|
|
|
|
|
if (load < 70) return 'orange';
|
|
|
|
|
return 'red';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function NordModal({
|
|
|
|
|
open,
|
|
|
|
|
templateSettings,
|
|
|
|
|
onClose,
|
|
|
|
|
onAddOutbound,
|
|
|
|
|
onResetOutbound,
|
|
|
|
|
onRemoveOutbound,
|
|
|
|
|
onRemoveRoutingRules,
|
|
|
|
|
}: NordModalProps) {
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [nordData, setNordData] = useState<NordData | null>(null);
|
|
|
|
|
const [token, setToken] = useState('');
|
|
|
|
|
const [manualKey, setManualKey] = useState('');
|
|
|
|
|
const [countries, setCountries] = useState<Country[]>([]);
|
|
|
|
|
const [cities, setCities] = useState<City[]>([]);
|
|
|
|
|
const [servers, setServers] = useState<NordServer[]>([]);
|
|
|
|
|
const [countryId, setCountryId] = useState<number | null>(null);
|
|
|
|
|
const [cityId, setCityId] = useState<number | null>(null);
|
|
|
|
|
const [serverId, setServerId] = useState<number | null>(null);
|
|
|
|
|
|
|
|
|
|
const nordOutboundIndex = useMemo(() => {
|
|
|
|
|
const list = templateSettings?.outbounds;
|
|
|
|
|
if (!list) return -1;
|
|
|
|
|
return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
|
|
|
|
|
}, [templateSettings?.outbounds]);
|
|
|
|
|
|
|
|
|
|
const filteredServers = useMemo(() => {
|
|
|
|
|
if (!cityId) return servers;
|
|
|
|
|
return servers.filter((s) => s.cityId === cityId);
|
|
|
|
|
}, [cityId, servers]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setServerId(filteredServers.length > 0 ? filteredServers[0].id : null);
|
|
|
|
|
}, [filteredServers]);
|
|
|
|
|
|
|
|
|
|
const fetchCountries = useCallback(async () => {
|
|
|
|
|
const msg = await HttpUtil.post('/panel/xray/nord/countries');
|
|
|
|
|
if (msg?.success) setCountries(JSON.parse(msg.obj));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const fetchData = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const msg = await HttpUtil.post('/panel/xray/nord/data');
|
|
|
|
|
if (msg?.success) {
|
|
|
|
|
const next = msg.obj ? JSON.parse(msg.obj) : null;
|
|
|
|
|
setNordData(next);
|
|
|
|
|
if (next) await fetchCountries();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [fetchCountries]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) fetchData();
|
|
|
|
|
}, [open, fetchData]);
|
|
|
|
|
|
|
|
|
|
async function login() {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const msg = await HttpUtil.post('/panel/xray/nord/reg', { token });
|
|
|
|
|
if (msg?.success) {
|
|
|
|
|
setNordData(JSON.parse(msg.obj));
|
|
|
|
|
await fetchCountries();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveKey() {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: manualKey });
|
|
|
|
|
if (msg?.success) {
|
|
|
|
|
setNordData(JSON.parse(msg.obj));
|
|
|
|
|
await fetchCountries();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function logout() {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const msg = await HttpUtil.post('/panel/xray/nord/del');
|
|
|
|
|
if (msg?.success) {
|
|
|
|
|
onRemoveOutbound(nordOutboundIndex);
|
|
|
|
|
onRemoveRoutingRules({ prefix: 'nord-' });
|
|
|
|
|
setNordData(null);
|
|
|
|
|
setToken('');
|
|
|
|
|
setManualKey('');
|
|
|
|
|
setCountries([]);
|
|
|
|
|
setCities([]);
|
|
|
|
|
setServers([]);
|
|
|
|
|
setCountryId(null);
|
|
|
|
|
setCityId(null);
|
|
|
|
|
setServerId(null);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchServers(newCountryId: number) {
|
|
|
|
|
setCountryId(newCountryId);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setServers([]);
|
|
|
|
|
setCities([]);
|
|
|
|
|
setServerId(null);
|
|
|
|
|
setCityId(null);
|
|
|
|
|
try {
|
|
|
|
|
const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: newCountryId });
|
|
|
|
|
if (!msg?.success) return;
|
|
|
|
|
const data = JSON.parse(msg.obj);
|
|
|
|
|
const locations = data.locations || [];
|
|
|
|
|
const locToCity: Record<number, City> = {};
|
|
|
|
|
const citiesMap = new Map<number, City>();
|
|
|
|
|
for (const loc of locations) {
|
|
|
|
|
if (loc.country?.city) {
|
|
|
|
|
citiesMap.set(loc.country.city.id, loc.country.city);
|
|
|
|
|
locToCity[loc.id] = loc.country.city;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setCities(Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name)));
|
|
|
|
|
const next: NordServer[] = (data.servers || [])
|
|
|
|
|
.map((s: NordServer) => {
|
|
|
|
|
const firstLocId = (s.location_ids || [])[0];
|
|
|
|
|
const city = firstLocId != null ? locToCity[firstLocId] : null;
|
|
|
|
|
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
|
|
|
|
|
})
|
|
|
|
|
.sort((a: NordServer, b: NordServer) => a.load - b.load);
|
|
|
|
|
setServers(next);
|
|
|
|
|
if (next.length === 0) message.warning('No servers found for the selected country');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildNordOutbound(): Record<string, unknown> | null {
|
|
|
|
|
const server = servers.find((s) => s.id === serverId);
|
|
|
|
|
if (!server) return null;
|
|
|
|
|
const tech = server.technologies?.find((tt) => tt.id === 35);
|
|
|
|
|
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
|
|
|
|
|
if (!publicKey) {
|
|
|
|
|
message.error('Selected server does not advertise a NordLynx public key.');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
tag: `nord-${server.hostname}`,
|
|
|
|
|
protocol: 'wireguard',
|
|
|
|
|
settings: {
|
|
|
|
|
secretKey: nordData?.private_key,
|
|
|
|
|
address: ['10.5.0.2/32'],
|
|
|
|
|
peers: [{ publicKey, endpoint: `${server.station}:51820` }],
|
|
|
|
|
noKernelTun: false,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addOutbound() {
|
|
|
|
|
const ob = buildNordOutbound();
|
|
|
|
|
if (!ob) return;
|
|
|
|
|
onAddOutbound(ob);
|
|
|
|
|
message.success('NordVPN outbound added');
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetOutbound() {
|
|
|
|
|
if (nordOutboundIndex === -1) return;
|
|
|
|
|
const ob = buildNordOutbound();
|
|
|
|
|
if (!ob) return;
|
|
|
|
|
const oldTag = templateSettings?.outbounds?.[nordOutboundIndex]?.tag;
|
|
|
|
|
onResetOutbound({
|
|
|
|
|
index: nordOutboundIndex,
|
|
|
|
|
outbound: ob,
|
|
|
|
|
oldTag,
|
|
|
|
|
newTag: ob.tag as string,
|
|
|
|
|
});
|
|
|
|
|
message.success('NordVPN outbound updated');
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-21 22:42:20 +00:00
|
|
|
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
|
2026-05-21 20:56:36 +00:00
|
|
|
{nordData == null ? (
|
|
|
|
|
<Tabs
|
|
|
|
|
defaultActiveKey="token"
|
|
|
|
|
items={[
|
|
|
|
|
{
|
|
|
|
|
key: 'token',
|
|
|
|
|
label: 'Access token',
|
|
|
|
|
children: (
|
|
|
|
|
<Form
|
|
|
|
|
colon={false}
|
|
|
|
|
labelCol={{ md: { span: 6 } }}
|
|
|
|
|
wrapperCol={{ md: { span: 18 } }}
|
|
|
|
|
className="mt-20"
|
|
|
|
|
>
|
|
|
|
|
<Form.Item label="Access token">
|
|
|
|
|
<Input
|
|
|
|
|
value={token}
|
|
|
|
|
placeholder="Access token"
|
|
|
|
|
onChange={(e) => setToken(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
|
|
|
|
|
Login
|
|
|
|
|
</Button>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Form>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'key',
|
|
|
|
|
label: 'Private key',
|
|
|
|
|
children: (
|
|
|
|
|
<Form
|
|
|
|
|
colon={false}
|
|
|
|
|
labelCol={{ md: { span: 6 } }}
|
|
|
|
|
wrapperCol={{ md: { span: 18 } }}
|
|
|
|
|
className="mt-20"
|
|
|
|
|
>
|
|
|
|
|
<Form.Item label="Private key">
|
|
|
|
|
<Input
|
|
|
|
|
value={manualKey}
|
|
|
|
|
placeholder="Private key"
|
|
|
|
|
onChange={(e) => setManualKey(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
|
|
|
|
|
Save
|
|
|
|
|
</Button>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Form>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<table className="nord-data-table">
|
|
|
|
|
<tbody>
|
|
|
|
|
{nordData.token && (
|
|
|
|
|
<tr className="row-odd">
|
|
|
|
|
<td>Access token</td>
|
|
|
|
|
<td>{nordData.token}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
<tr>
|
|
|
|
|
<td>Private key</td>
|
|
|
|
|
<td>{nordData.private_key}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
|
|
|
|
|
Logout
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Divider className="zero-margin">Settings</Divider>
|
|
|
|
|
|
|
|
|
|
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
|
|
|
|
|
<Form.Item label="Country">
|
|
|
|
|
<Select
|
|
|
|
|
value={countryId ?? undefined}
|
|
|
|
|
showSearch
|
|
|
|
|
optionFilterProp="label"
|
|
|
|
|
onChange={(v) => fetchServers(v)}
|
|
|
|
|
options={countries.map((c) => ({
|
|
|
|
|
value: c.id,
|
|
|
|
|
label: `${c.name} (${c.code})`,
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
{cities.length > 0 && (
|
|
|
|
|
<Form.Item label="City">
|
|
|
|
|
<Select
|
|
|
|
|
value={cityId}
|
|
|
|
|
showSearch
|
|
|
|
|
optionFilterProp="label"
|
|
|
|
|
onChange={setCityId}
|
|
|
|
|
options={[{ value: null, label: 'All cities' }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
|
|
|
|
|
/>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{filteredServers.length > 0 && (
|
|
|
|
|
<Form.Item label="Server">
|
|
|
|
|
<Select
|
|
|
|
|
value={serverId}
|
|
|
|
|
showSearch
|
|
|
|
|
optionFilterProp="label"
|
|
|
|
|
onChange={setServerId}
|
|
|
|
|
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>
|
|
|
|
|
),
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
)}
|
|
|
|
|
</Form>
|
|
|
|
|
|
|
|
|
|
<Divider className="my-10">Outbound status</Divider>
|
|
|
|
|
{nordOutboundIndex >= 0 ? (
|
|
|
|
|
<>
|
|
|
|
|
<Tag color="green">Enabled</Tag>
|
|
|
|
|
<Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
|
|
|
|
|
Reset
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Tag color="orange">Disabled</Tag>
|
|
|
|
|
<Button
|
|
|
|
|
type="primary"
|
|
|
|
|
className="ml-8"
|
|
|
|
|
disabled={!serverId}
|
|
|
|
|
loading={loading}
|
|
|
|
|
onClick={addOutbound}
|
|
|
|
|
>
|
|
|
|
|
Add outbound
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Modal>
|
|
|
|
|
);
|
|
|
|
|
}
|