NordModal.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. <script setup>
  2. import { computed, ref, watch } from 'vue';
  3. import { LoginOutlined, SaveOutlined } from '@ant-design/icons-vue';
  4. import { message } from 'ant-design-vue';
  5. import { HttpUtil } from '@/utils';
  6. // NordVPN provisioning modal — mirrors the legacy nord_modal.
  7. //
  8. // Login routes:
  9. // • access token (NordVPN account) → /panel/xray/nord/reg
  10. // • manual private key (existing wireguard key from NordLynx) →
  11. // /panel/xray/nord/setKey
  12. // Once authenticated, the country / city / server selectors fetch
  13. // from /panel/xray/nord/{countries,servers}, and the user can stage
  14. // a wireguard outbound (tag `nord-<hostname>`) for the parent's
  15. // outbound list.
  16. const props = defineProps({
  17. open: { type: Boolean, default: false },
  18. templateSettings: { type: Object, default: null },
  19. });
  20. const emit = defineEmits([
  21. 'update:open',
  22. 'add-outbound',
  23. 'reset-outbound',
  24. 'remove-outbound',
  25. // Routing rules referencing the deleted nord-* outbound need the
  26. // parent to clean them up — we emit, the parent purges.
  27. 'remove-routing-rules',
  28. ]);
  29. const loading = ref(false);
  30. const nordData = ref(null);
  31. const token = ref('');
  32. const manualKey = ref('');
  33. const countries = ref([]);
  34. const cities = ref([]);
  35. const servers = ref([]);
  36. const countryId = ref(null);
  37. const cityId = ref(null);
  38. const serverId = ref(null);
  39. const nordOutboundIndex = computed(() => {
  40. const list = props.templateSettings?.outbounds;
  41. if (!list) return -1;
  42. return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
  43. });
  44. const filteredServers = computed(() => {
  45. if (!cityId.value) return servers.value;
  46. return servers.value.filter((s) => s.cityId === cityId.value);
  47. });
  48. watch(() => props.open, (next) => {
  49. if (next) fetchData();
  50. });
  51. watch(() => filteredServers.value, (list) => {
  52. // Auto-select the first server in the visible list (lowest load
  53. // because servers were sorted ascending by load on fetch).
  54. serverId.value = list.length > 0 ? list[0].id : null;
  55. });
  56. // === API actions ====================================================
  57. async function fetchData() {
  58. loading.value = true;
  59. try {
  60. const msg = await HttpUtil.post('/panel/xray/nord/data');
  61. if (msg?.success) {
  62. nordData.value = msg.obj ? JSON.parse(msg.obj) : null;
  63. if (nordData.value) await fetchCountries();
  64. }
  65. } finally {
  66. loading.value = false;
  67. }
  68. }
  69. async function login() {
  70. loading.value = true;
  71. try {
  72. const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: token.value });
  73. if (msg?.success) {
  74. nordData.value = JSON.parse(msg.obj);
  75. await fetchCountries();
  76. }
  77. } finally {
  78. loading.value = false;
  79. }
  80. }
  81. async function saveKey() {
  82. loading.value = true;
  83. try {
  84. const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: manualKey.value });
  85. if (msg?.success) {
  86. nordData.value = JSON.parse(msg.obj);
  87. await fetchCountries();
  88. }
  89. } finally {
  90. loading.value = false;
  91. }
  92. }
  93. async function logout() {
  94. loading.value = true;
  95. try {
  96. const msg = await HttpUtil.post('/panel/xray/nord/del');
  97. if (msg?.success) {
  98. // Clean up the staged outbound + matching routing rules first
  99. // so a re-login doesn't carry stale references.
  100. emit('remove-outbound', nordOutboundIndex.value);
  101. emit('remove-routing-rules', { prefix: 'nord-' });
  102. nordData.value = null;
  103. token.value = '';
  104. manualKey.value = '';
  105. countries.value = [];
  106. cities.value = [];
  107. servers.value = [];
  108. countryId.value = null;
  109. cityId.value = null;
  110. serverId.value = null;
  111. }
  112. } finally {
  113. loading.value = false;
  114. }
  115. }
  116. async function fetchCountries() {
  117. const msg = await HttpUtil.post('/panel/xray/nord/countries');
  118. if (msg?.success) countries.value = JSON.parse(msg.obj);
  119. }
  120. async function fetchServers() {
  121. if (!countryId.value) return;
  122. loading.value = true;
  123. servers.value = [];
  124. cities.value = [];
  125. serverId.value = null;
  126. cityId.value = null;
  127. try {
  128. const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: countryId.value });
  129. if (!msg?.success) return;
  130. const data = JSON.parse(msg.obj);
  131. const locations = data.locations || [];
  132. const locToCity = {};
  133. const citiesMap = new Map();
  134. for (const loc of locations) {
  135. if (loc.country?.city) {
  136. citiesMap.set(loc.country.city.id, loc.country.city);
  137. locToCity[loc.id] = loc.country.city;
  138. }
  139. }
  140. cities.value = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
  141. servers.value = (data.servers || [])
  142. .map((s) => {
  143. const firstLocId = (s.location_ids || [])[0];
  144. const city = locToCity[firstLocId];
  145. return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
  146. })
  147. .sort((a, b) => a.load - b.load);
  148. if (servers.value.length === 0) {
  149. message.warning('No servers found for the selected country');
  150. }
  151. } finally {
  152. loading.value = false;
  153. }
  154. }
  155. // === Outbound staging ==============================================
  156. // NordVPN exposes its WireGuard public key via a "technologies"
  157. // array entry with id 35; the legacy modal pulls the key from the
  158. // metadata field of that entry. Same here.
  159. function buildNordOutbound() {
  160. const server = servers.value.find((s) => s.id === serverId.value);
  161. if (!server) return null;
  162. const tech = server.technologies?.find((t) => t.id === 35);
  163. const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
  164. if (!publicKey) {
  165. message.error('Selected server does not advertise a NordLynx public key.');
  166. return null;
  167. }
  168. return {
  169. tag: `nord-${server.hostname}`,
  170. protocol: 'wireguard',
  171. settings: {
  172. secretKey: nordData.value.private_key,
  173. address: ['10.5.0.2/32'],
  174. peers: [{ publicKey, endpoint: `${server.station}:51820` }],
  175. noKernelTun: false,
  176. },
  177. };
  178. }
  179. function addOutbound() {
  180. const ob = buildNordOutbound();
  181. if (!ob) return;
  182. emit('add-outbound', ob);
  183. message.success('NordVPN outbound added');
  184. close();
  185. }
  186. function resetOutbound() {
  187. if (nordOutboundIndex.value === -1) return;
  188. const ob = buildNordOutbound();
  189. if (!ob) return;
  190. // Tag rename across routing.rules is the parent's job — pass
  191. // both old and new tag in the payload.
  192. const oldTag = props.templateSettings.outbounds[nordOutboundIndex.value]?.tag;
  193. emit('reset-outbound', {
  194. index: nordOutboundIndex.value,
  195. outbound: ob,
  196. oldTag,
  197. newTag: ob.tag,
  198. });
  199. message.success('NordVPN outbound updated');
  200. close();
  201. }
  202. function close() { emit('update:open', false); }
  203. function loadColor(load) {
  204. if (load < 30) return 'green';
  205. if (load < 70) return 'orange';
  206. return 'red';
  207. }
  208. </script>
  209. <template>
  210. <a-modal :open="open" title="NordVPN NordLynx" :footer="null" :closable="true" :mask-closable="true" @cancel="close">
  211. <!-- WARP / NordVPN provisioning forms keep technical wire labels in
  212. English on purpose: they map directly to API field names users
  213. look up in vendor docs. Only the primary action buttons +
  214. dialog headers translate. -->
  215. <!-- Not authenticated → tabbed login (token or manual key) -->
  216. <template v-if="nordData == null">
  217. <a-tabs default-active-key="token">
  218. <a-tab-pane key="token" tab="Access token">
  219. <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
  220. <a-form-item label="Access token">
  221. <a-input v-model:value="token" placeholder="Access token" />
  222. <a-button type="primary" class="mt-10" :loading="loading" @click="login">
  223. <template #icon>
  224. <LoginOutlined />
  225. </template>
  226. Login
  227. </a-button>
  228. </a-form-item>
  229. </a-form>
  230. </a-tab-pane>
  231. <a-tab-pane key="key" tab="Private key">
  232. <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
  233. <a-form-item label="Private key">
  234. <a-input v-model:value="manualKey" placeholder="Private key" />
  235. <a-button type="primary" class="mt-10" :loading="loading" @click="saveKey">
  236. <template #icon>
  237. <SaveOutlined />
  238. </template>
  239. Save
  240. </a-button>
  241. </a-form-item>
  242. </a-form>
  243. </a-tab-pane>
  244. </a-tabs>
  245. </template>
  246. <!-- Authenticated → server picker + outbound controls -->
  247. <template v-else>
  248. <table class="nord-data-table">
  249. <tbody>
  250. <tr v-if="nordData.token" class="row-odd">
  251. <td>Access token</td>
  252. <td>{{ nordData.token }}</td>
  253. </tr>
  254. <tr>
  255. <td>Private key</td>
  256. <td>{{ nordData.private_key }}</td>
  257. </tr>
  258. </tbody>
  259. </table>
  260. <a-button :loading="loading" type="primary" danger class="mt-8" @click="logout">Logout</a-button>
  261. <a-divider class="zero-margin">Settings</a-divider>
  262. <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-10">
  263. <a-form-item label="Country">
  264. <a-select v-model:value="countryId" show-search option-filter-prop="label" @change="fetchServers">
  265. <a-select-option v-for="c in countries" :key="c.id" :value="c.id" :label="c.name">
  266. {{ c.name }} ({{ c.code }})
  267. </a-select-option>
  268. </a-select>
  269. </a-form-item>
  270. <a-form-item v-if="cities.length > 0" label="City">
  271. <a-select v-model:value="cityId" show-search option-filter-prop="label">
  272. <a-select-option :value="null" label="All cities">All cities</a-select-option>
  273. <a-select-option v-for="c in cities" :key="c.id" :value="c.id" :label="c.name">{{ c.name
  274. }}</a-select-option>
  275. </a-select>
  276. </a-form-item>
  277. <a-form-item v-if="filteredServers.length > 0" label="Server">
  278. <a-select v-model:value="serverId" show-search option-filter-prop="label">
  279. <a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id"
  280. :label="`${s.cityName} ${s.name} ${s.hostname}`">
  281. <span class="server-row">
  282. <span class="server-name">{{ s.cityName }} - {{ s.name }}</span>
  283. <a-tag :color="loadColor(s.load)" class="server-load-tag">{{ s.load }}%</a-tag>
  284. </span>
  285. </a-select-option>
  286. </a-select>
  287. </a-form-item>
  288. </a-form>
  289. <a-divider class="my-10">Outbound status</a-divider>
  290. <template v-if="nordOutboundIndex >= 0">
  291. <a-tag color="green">Enabled</a-tag>
  292. <a-button type="primary" danger :loading="loading" class="ml-8" @click="resetOutbound">
  293. Reset
  294. </a-button>
  295. </template>
  296. <template v-else>
  297. <a-tag color="orange">Disabled</a-tag>
  298. <a-button type="primary" class="ml-8" :disabled="!serverId" :loading="loading" @click="addOutbound">Add
  299. outbound</a-button>
  300. </template>
  301. </template>
  302. </a-modal>
  303. </template>
  304. <style scoped>
  305. .nord-data-table {
  306. margin: 5px 0;
  307. width: 100%;
  308. border-collapse: collapse;
  309. }
  310. .nord-data-table td {
  311. padding: 4px 8px;
  312. word-break: break-all;
  313. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  314. font-size: 12px;
  315. }
  316. .nord-data-table td:first-child {
  317. font-family: inherit;
  318. font-weight: 500;
  319. white-space: nowrap;
  320. width: 130px;
  321. }
  322. .row-odd {
  323. background: rgba(0, 0, 0, 0.03);
  324. }
  325. :global(body.dark) .row-odd {
  326. background: rgba(255, 255, 255, 0.04);
  327. }
  328. .zero-margin {
  329. margin: 0;
  330. }
  331. .mt-8 {
  332. margin-top: 8px;
  333. }
  334. .mt-10 {
  335. margin-top: 10px;
  336. }
  337. .mt-20 {
  338. margin-top: 20px;
  339. }
  340. .my-10 {
  341. margin: 10px 0;
  342. }
  343. .ml-8 {
  344. margin-left: 8px;
  345. }
  346. .server-row {
  347. display: inline-flex;
  348. align-items: center;
  349. gap: 8px;
  350. width: 100%;
  351. }
  352. .server-name {
  353. flex: 1;
  354. overflow: hidden;
  355. text-overflow: ellipsis;
  356. }
  357. .server-load-tag {
  358. margin-right: 0;
  359. flex-shrink: 0;
  360. }
  361. </style>