ソースを参照

feat: Add NordVPN NordLynx (WireGuard) integration (#3827)

* feat: Add NordVPN NordLynx (WireGuard) integration with dedicated UI and backend services.

* remove limit=10 to get all servers

* feat: add city selector to NordVPN modal

* feat: auto-select best server on country/city change

* feat: simplify filter logic and enforce > 7% load

* fix

---------

Co-authored-by: Sanaei <[email protected]>
Peter Liu 1 日 前
コミット
36b2a58675

+ 28 - 0
web/controller/xray_setting.go

@@ -17,6 +17,7 @@ type XraySettingController struct {
 	OutboundService    service.OutboundService
 	XrayService        service.XrayService
 	WarpService        service.WarpService
+	NordService        service.NordService
 }
 
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
@@ -35,6 +36,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 
 	g.POST("/", a.getXraySetting)
 	g.POST("/warp/:action", a.warp)
+	g.POST("/nord/:action", a.nord)
 	g.POST("/update", a.updateSetting)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/testOutbound", a.testOutbound)
@@ -123,6 +125,32 @@ func (a *XraySettingController) warp(c *gin.Context) {
 	jsonObj(c, resp, err)
 }
 
+// nord handles NordVPN-related operations based on the action parameter.
+func (a *XraySettingController) nord(c *gin.Context) {
+	action := c.Param("action")
+	var resp string
+	var err error
+	switch action {
+	case "countries":
+		resp, err = a.NordService.GetCountries()
+	case "servers":
+		countryId := c.PostForm("countryId")
+		resp, err = a.NordService.GetServers(countryId)
+	case "reg":
+		token := c.PostForm("token")
+		resp, err = a.NordService.GetCredentials(token)
+	case "setKey":
+		key := c.PostForm("key")
+		resp, err = a.NordService.SetKey(key)
+	case "data":
+		resp, err = a.NordService.GetNordData()
+	case "del":
+		err = a.NordService.DelNordData()
+	}
+
+	jsonObj(c, resp, err)
+}
+
 // getOutboundsTraffic retrieves the traffic statistics for outbounds.
 func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
 	outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()

+ 306 - 0
web/html/modals/nord_modal.html

@@ -0,0 +1,306 @@
+{{define "modals/nordModal"}}
+<a-modal id="nord-modal" v-model="nordModal.visible" title="NordVPN NordLynx"
+         :confirm-loading="nordModal.confirmLoading" :closable="true" :mask-closable="true"
+         :footer="null" :class="themeSwitcher.currentTheme">
+    <template v-if="nordModal.nordData == null">
+        <a-tabs default-active-key="token" :class="themeSwitcher.currentTheme">
+            <a-tab-pane key="token" tab='{{ i18n "pages.xray.outbound.accessToken" }}'>
+                <a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
+                    <a-form-item label='{{ i18n "pages.xray.outbound.accessToken" }}'>
+                        <a-input v-model="nordModal.token" placeholder='{{ i18n "pages.xray.outbound.accessToken" }}'></a-input>
+                        <div :style="{ marginTop: '10px' }">
+                            <a-button type="primary" icon="login" @click="login()" :loading="nordModal.confirmLoading">{{ i18n "login" }}</a-button>
+                        </div>
+                    </a-form-item>
+                </a-form>
+            </a-tab-pane>
+            <a-tab-pane key="key" tab='{{ i18n "pages.xray.outbound.privateKey" }}'>
+                <a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
+                    <a-form-item label='{{ i18n "pages.xray.outbound.privateKey" }}'>
+                        <a-input v-model="nordModal.manualKey" placeholder='{{ i18n "pages.xray.outbound.privateKey" }}'></a-input>
+                        <div :style="{ marginTop: '10px' }">
+                            <a-button type="primary" icon="save" @click="saveKey()" :loading="nordModal.confirmLoading">{{ i18n "save" }}</a-button>
+                        </div>
+                    </a-form-item>
+                </a-form>
+            </a-tab-pane>
+        </a-tabs>
+    </template>
+    <template v-else>
+        <table :style="{ margin: '5px 0', width: '100%' }">
+            <tr class="client-table-odd-row" v-if="nordModal.nordData.token">
+                <td>{{ i18n "pages.xray.outbound.accessToken" }}</td>
+                <td>[[ nordModal.nordData.token ]]</td>
+            </tr>
+            <tr>
+                <td>{{ i18n "pages.xray.outbound.privateKey" }}</td>
+                <td>[[ nordModal.nordData.private_key ]]</td>
+            </tr>
+        </table>
+        <a-button @click="logout" :loading="nordModal.confirmLoading" type="danger">{{ i18n "logout" }}</a-button>
+        <a-divider :style="{ margin: '0' }">{{ i18n "pages.xray.outbound.settings" }}</a-divider>
+        <a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '10px' }">
+            <a-form-item label='{{ i18n "pages.xray.outbound.country" }}'>
+                <a-select v-model="nordModal.countryId" @change="fetchServers" show-search option-filter-prop="label">
+                    <a-select-option v-for="c in nordModal.countries" :key="c.id" :value="c.id" :label="c.name">
+                        [[ c.name ]] ([[ c.code ]])
+                    </a-select-option>
+                </a-select>
+            </a-form-item>
+            <a-form-item label='{{ i18n "pages.xray.outbound.city" }}' v-if="nordModal.cities.length > 0">
+                <a-select v-model="nordModal.cityId" @change="onCityChange" show-search option-filter-prop="label">
+                    <a-select-option :key="0" :value="null" label='{{ i18n "pages.xray.outbound.allCities" }}'>
+                        {{ i18n "pages.xray.outbound.allCities" }}
+                    </a-select-option>
+                    <a-select-option v-for="c in nordModal.cities" :key="c.id" :value="c.id" :label="c.name">
+                        [[ c.name ]]
+                    </a-select-option>
+                </a-select>
+            </a-form-item>
+            <a-form-item label='{{ i18n "pages.xray.outbound.server" }}' v-if="filteredServers.length > 0">
+                <a-select v-model="nordModal.serverId">
+                    <a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
+                        [[ s.cityName ]] - [[ s.name ]] ({{ i18n "pages.xray.outbound.load" }}: [[ s.load ]]%)
+                    </a-select-option>
+                </a-select>
+            </a-form-item>
+        </a-form>
+        <a-divider :style="{ margin: '10px 0' }">{{ i18n "pages.xray.outbound.outboundStatus" }}</a-divider>
+        <a-form :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
+            <template v-if="nordOutboundIndex>=0">
+                <a-tag color="green" :style="{ lineHeight: '31px' }">{{ i18n "enabled" }}</a-tag>
+                <a-button @click="resetOutbound" :loading="nordModal.confirmLoading" type="danger">{{ i18n "reset" }}</a-button>
+            </template>
+            <template v-else>
+                <a-tag color="orange" :style="{ lineHeight: '31px' }">{{ i18n "disabled" }}</a-tag>
+                <a-button @click="addOutbound" :disabled="!nordModal.serverId" :loading="nordModal.confirmLoading" type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
+            </template>
+        </a-form>
+    </template>
+</a-modal>
+
+<script>
+    const nordModal = {
+        visible: false,
+        confirmLoading: false,
+        nordData: null,
+        token: '',
+        manualKey: '',
+        countries: [],
+        countryId: null,
+        cities: [],
+        cityId: null,
+        servers: [],
+        serverId: null,
+        show() {
+            this.visible = true;
+            this.getData();
+        },
+        close() {
+            this.visible = false;
+        },
+        loading(loading = true) {
+            this.confirmLoading = loading;
+        },
+        async getData() {
+            this.loading(true);
+            const msg = await HttpUtil.post('/panel/xray/nord/data');
+            if (msg.success) {
+                this.nordData = msg.obj ? JSON.parse(msg.obj) : null;
+                if (this.nordData) {
+                    await this.fetchCountries();
+                }
+            }
+            this.loading(false);
+        },
+        async login() {
+            this.loading(true);
+            const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: this.token });
+            if (msg.success) {
+                this.nordData = JSON.parse(msg.obj);
+                await this.fetchCountries();
+            }
+            this.loading(false);
+        },
+        async saveKey() {
+            this.loading(true);
+            const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: this.manualKey });
+            if (msg.success) {
+                this.nordData = JSON.parse(msg.obj);
+                await this.fetchCountries();
+            }
+            this.loading(false);
+        },
+        async logout(index) {
+            this.loading(true);
+            const msg = await HttpUtil.post('/panel/xray/nord/del');
+            if (msg.success) {
+                this.delOutbound(index);
+                this.delRouting();
+                this.nordData = null;
+                this.token = '';
+                this.manualKey = '';
+                this.countries = [];
+                this.cities = [];
+                this.servers = [];
+                this.countryId = null;
+                this.cityId = null;
+            }
+            this.loading(false);
+        },
+        async fetchCountries() {
+            const msg = await HttpUtil.post('/panel/xray/nord/countries');
+            if (msg.success) {
+                this.countries = JSON.parse(msg.obj);
+            }
+        },
+        async fetchServers() {
+            this.loading(true);
+            this.servers = [];
+            this.cities = [];
+            this.serverId = null;
+            this.cityId = null;
+            const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: this.countryId });
+            if (msg.success) {
+                const data = JSON.parse(msg.obj);
+                const locations = data.locations || [];
+                const locToCity = {};
+                const citiesMap = new Map();
+                locations.forEach(loc => {
+                    if (loc.country && loc.country.city) {
+                        citiesMap.set(loc.country.city.id, loc.country.city);
+                        locToCity[loc.id] = loc.country.city;
+                    }
+                });
+                this.cities = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
+                
+                this.servers = (data.servers || []).map(s => {
+                    const firstLocId = (s.location_ids || [])[0];
+                    const city = locToCity[firstLocId];
+                    s.cityId = city ? city.id : null;
+                    s.cityName = city ? city.name : 'Unknown';
+                    return s;
+                }).sort((a, b) => a.load - b.load);
+
+                if (this.servers.length > 0) {
+                    this.serverId = this.servers[0].id;
+                }
+
+                if (this.servers.length === 0) {
+                    app.$message.warning('No servers found for the selected country');
+                }
+            }
+            this.loading(false);
+        },
+        addOutbound() {
+            const server = this.servers.find(s => s.id === this.serverId);
+            if (!server) return;
+            
+            const tech = server.technologies.find(t => t.id === 35);
+            const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
+
+            const outbound = {
+                tag: `nord-${server.hostname}`,
+                protocol: 'wireguard',
+                settings: {
+                    secretKey: this.nordData.private_key,
+                    address: ['10.5.0.2/32'],
+                    peers: [{
+                        publicKey: publicKey,
+                        endpoint: server.station + ':51820'
+                    }],
+                    noKernelTun: false
+                }
+            };
+
+            app.templateSettings.outbounds.push(outbound);
+            app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
+            this.close();
+            app.$message.success('NordVPN outbound added');
+        },
+        resetOutbound(index) {
+            const server = this.servers.find(s => s.id === this.serverId);
+            if (!server || index === -1) return;
+            
+            const tech = server.technologies.find(t => t.id === 35);
+            const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
+
+            const oldTag = app.templateSettings.outbounds[index].tag;
+            const newTag = `nord-${server.hostname}`;
+
+            const outbound = {
+                tag: newTag,
+                protocol: 'wireguard',
+                settings: {
+                    secretKey: this.nordData.private_key,
+                    address: ['10.5.0.2/32'],
+                    peers: [{
+                        publicKey: publicKey,
+                        endpoint: server.station + ':51820'
+                    }],
+                    noKernelTun: false
+                }
+            };
+            app.templateSettings.outbounds[index] = outbound;
+            
+            // Sync routing rules
+            app.templateSettings.routing.rules.forEach(r => {
+                if (r.outboundTag === oldTag) {
+                    r.outboundTag = newTag;
+                }
+            });
+
+            app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
+            this.close();
+            app.$message.success('NordVPN outbound updated');
+        },
+        delOutbound(index) {
+            if (index !== -1) {
+                app.templateSettings.outbounds.splice(index, 1);
+                app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
+            }
+        },
+        delRouting() {
+            if (app.templateSettings && app.templateSettings.routing) {
+                app.templateSettings.routing.rules = app.templateSettings.routing.rules.filter(r => !r.outboundTag.startsWith("nord-"));
+            }
+        }
+    };
+
+    new Vue({
+        delimiters: ['[[', ']]'],
+        el: '#nord-modal',
+        data: {
+            nordModal: nordModal,
+        },
+        methods: {
+            login: () => nordModal.login(),
+            saveKey: () => nordModal.saveKey(),
+            logout() { nordModal.logout(this.nordOutboundIndex) },
+            fetchServers: () => nordModal.fetchServers(),
+            addOutbound: () => nordModal.addOutbound(),
+            resetOutbound() { nordModal.resetOutbound(this.nordOutboundIndex) },
+            onCityChange() {
+                if (this.filteredServers.length > 0) {
+                    this.nordModal.serverId = this.filteredServers[0].id;
+                } else {
+                    this.nordModal.serverId = null;
+                }
+            }
+        },
+        computed: {
+            nordOutboundIndex: {
+                get: function () {
+                    return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) : -1;
+                }
+            },
+            filteredServers: function() {
+                if (!this.nordModal.cityId) {
+                    return this.nordModal.servers;
+                }
+                return this.nordModal.servers.filter(s => s.cityId === this.nordModal.cityId);
+            }
+        }
+    });
+</script>
+{{end}}

+ 19 - 0
web/html/settings/xray/basics.html

@@ -313,6 +313,25 @@
                 </template>
             </template>
         </a-setting-list-item>
+        <a-setting-list-item paddings="small">
+            <template #title>{{ i18n "pages.xray.nordRouting" }}</template>
+            <template #control>
+                <template v-if="NordExist">
+                    <a-select mode="tags" :style="{ width: '100%' }"
+                        v-model="nordDomains"
+                        :dropdown-class-name="themeSwitcher.currentTheme">
+                        <a-select-option :value="p.value" :label="p.label"
+                            v-for="p in settingsData.ServicesOptions">
+                            <span>[[ p.label ]]</span>
+                        </a-select-option>
+                    </a-select>
+                </template>
+                <template v-else>
+                    <a-button type="primary" icon="api"
+                        @click="showNord()">{{ i18n "pages.xray.outbound.nordvpn" }}</a-button>
+                </template>
+            </template>
+        </a-setting-list-item>
     </a-collapse-panel>
     <a-collapse-panel key="6"
         header='{{ i18n "pages.settings.resetDefaultConfig"}}'>

+ 2 - 0
web/html/settings/xray/outbounds.html

@@ -9,6 +9,8 @@
                 </a-button>
                 <a-button type="primary" icon="cloud"
                     @click="showWarp()">WARP</a-button>
+                <a-button type="primary" icon="api"
+                    @click="showNord()">NordVPN</a-button>
             </a-space>
         </a-col>
         <a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">

+ 22 - 0
web/html/xray.html

@@ -163,6 +163,7 @@
 {{template "modals/dnsPresetsModal"}}
 {{template "modals/fakednsModal"}}
 {{template "modals/warpModal"}}
+{{template "modals/nordModal"}}
 <script>
   const rulesColumns = [
     { title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
@@ -1056,6 +1057,9 @@
       showWarp() {
         warpModal.show();
       },
+      showNord() {
+        nordModal.show();
+      },
       async loadCustomGeoAliases() {
         try {
           const msg = await HttpUtil.get('/panel/api/custom-geo/aliases');
@@ -1429,6 +1433,19 @@
           this.templateRuleSetter({ outboundTag: "warp", property: "domain", data: newValue });
         }
       },
+      nordTag: {
+        get: function () {
+          return this.templateSettings ? (this.templateSettings.outbounds.find((o) => o.tag.startsWith("nord-")) || { tag: "nord" }).tag : "nord";
+        }
+      },
+      nordDomains: {
+        get: function () {
+          return this.templateRuleGetter({ outboundTag: this.nordTag, property: "domain" });
+        },
+        set: function (newValue) {
+          this.templateRuleSetter({ outboundTag: this.nordTag, property: "domain", data: newValue });
+        }
+      },
       torrentSettings: {
         get: function () {
           return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
@@ -1446,6 +1463,11 @@
           return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp") >= 0 : false;
         },
       },
+      NordExist: {
+        get: function () {
+          return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) >= 0 : false;
+        },
+      },
       enableDNS: {
         get: function () {
           return this.templateSettings ? this.templateSettings.dns != null : false;

+ 145 - 0
web/service/nord.go

@@ -0,0 +1,145 @@
+package service
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v2/util/common"
+)
+
+type NordService struct {
+	SettingService
+}
+
+var nordHTTPClient = &http.Client{Timeout: 15 * time.Second}
+
+// maxResponseSize limits the maximum size of NordVPN API responses (10 MB).
+const maxResponseSize = 10 << 20
+
+func (s *NordService) GetCountries() (string, error) {
+	resp, err := nordHTTPClient.Get("https://api.nordvpn.com/v1/countries")
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
+	if err != nil {
+		return "", err
+	}
+	return string(body), nil
+}
+
+func (s *NordService) GetServers(countryId string) (string, error) {
+	// Validate countryId is numeric to prevent URL injection
+	for _, c := range countryId {
+		if c < '0' || c > '9' {
+			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)
+	resp, err := nordHTTPClient.Get(url)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
+	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
+}
+
+func (s *NordService) SetKey(privateKey string) (string, error) {
+	if privateKey == "" {
+		return "", common.NewError("private key cannot be empty")
+	}
+	nordData := map[string]string{
+		"private_key": privateKey,
+		"token":       "",
+	}
+	data, _ := json.Marshal(nordData)
+	err := s.SettingService.SetNord(string(data))
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+func (s *NordService) GetCredentials(token string) (string, error) {
+	url := "https://api.nordvpn.com/v1/users/services/credentials"
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return "", err
+	}
+	req.SetBasicAuth("token", token)
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
+	}
+
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
+	if err != nil {
+		return "", err
+	}
+
+	var creds map[string]any
+	if err := json.Unmarshal(body, &creds); err != nil {
+		return "", err
+	}
+
+	privateKey, ok := creds["nordlynx_private_key"].(string)
+	if !ok || privateKey == "" {
+		return "", common.NewError("failed to retrieve NordLynx private key")
+	}
+
+	nordData := map[string]string{
+		"private_key": privateKey,
+		"token":       token,
+	}
+	data, _ := json.Marshal(nordData)
+	err = s.SettingService.SetNord(string(data))
+	if err != nil {
+		return "", err
+	}
+
+	return string(data), nil
+}
+
+func (s *NordService) GetNordData() (string, error) {
+	return s.SettingService.GetNord()
+}
+
+func (s *NordService) DelNordData() error {
+	return s.SettingService.SetNord("")
+}

+ 9 - 0
web/service/setting.go

@@ -80,6 +80,7 @@ var defaultValueMap = map[string]string{
 	"subJsonRules":                "",
 	"datepicker":                  "gregorian",
 	"warp":                        "",
+	"nord":                        "",
 	"externalTrafficInformEnable": "false",
 	"externalTrafficInformURI":    "",
 	"xrayOutboundTestUrl":         "https://www.google.com/generate_204",
@@ -598,6 +599,14 @@ func (s *SettingService) SetWarp(data string) error {
 	return s.setString("warp", data)
 }
 
+func (s *SettingService) GetNord() (string, error) {
+	return s.getString("nord")
+}
+
+func (s *SettingService) SetNord(data string) error {
+	return s.setString("nord", data)
+}
+
 func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
 	return s.getBool("externalTrafficInformEnable")
 }

+ 12 - 0
web/translation/translate.ar_EG.toml

@@ -4,6 +4,8 @@
 "confirm" = "تأكيد"
 "cancel" = "إلغاء"
 "close" = "إغلاق"
+"save" = "حفظ"
+"logout" = "تسجيل خروج"
 "create" = "إنشاء"
 "update" = "تحديث"
 "copy" = "نسخ"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر IPv4."
 "warpRouting" = "توجيه WARP"
 "warpRoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر WARP."
+"nordRouting" = "توجيه NordVPN"
+"nordRoutingDesc" = "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر NordVPN."
 "Template" = "قالب إعدادات Xray المتقدم"
 "TemplateDesc" = "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده."
 "FreedomStrategy" = "استراتيجية بروتوكول الحرية"
@@ -573,6 +577,14 @@
 "testSuccess" = "الاختبار ناجح"
 "testFailed" = "فشل الاختبار"
 "testError" = "فشل اختبار المخرج"
+"nordvpn" = "NordVPN"
+"accessToken" = "رمز الوصول"
+"country" = "الدولة"
+"server" = "الخادم"
+"city" = "المدينة"
+"allCities" = "كل المدن"
+"privateKey" = "المفتاح الخاص"
+"load" = "الحمل"
 
 [pages.xray.balancer]
 "addBalancer" = "أضف موازن تحميل"

+ 12 - 0
web/translation/translate.en_US.toml

@@ -4,6 +4,8 @@
 "confirm" = "Confirm"
 "cancel" = "Cancel"
 "close" = "Close"
+"save" = "Save"
+"logout" = "Log Out"
 "create" = "Create"
 "update" = "Update"
 "copy" = "Copy"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "These options will route traffic based on a specific destination via IPv4."
 "warpRouting" = "WARP Routing"
 "warpRoutingDesc" = "These options will route traffic based on a specific destination via WARP."
+"nordRouting" = "NordVPN Routing"
+"nordRoutingDesc" = "These options will route traffic based on a specific destination via NordVPN."
 "Template" = "Advanced Xray Configuration Template"
 "TemplateDesc" = "The final Xray config file will be generated based on this template."
 "FreedomStrategy" = "Freedom Protocol Strategy"
@@ -573,6 +577,14 @@
 "testSuccess" = "Test successful"
 "testFailed" = "Test failed"
 "testError" = "Failed to test outbound"
+"nordvpn" = "NordVPN"
+"accessToken" = "Access Token"
+"country" = "Country"
+"server" = "Server"
+"city" = "City"
+"allCities" = "All Cities"
+"privateKey" = "Private Key"
+"load" = "Load"
 
 [pages.xray.balancer]
 "addBalancer" = "Add Balancer"

+ 12 - 0
web/translation/translate.es_ES.toml

@@ -4,6 +4,8 @@
 "confirm" = "Confirmar"
 "cancel" = "Cancelar"
 "close" = "Cerrar"
+"save" = "Guardar"
+"logout" = "Cerrar Sesión"
 "create" = "Crear"
 "update" = "Actualizar"
 "copy" = "Copiar"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "Estas opciones solo enrutarán a los dominios objetivo a través de IPv4."
 "warpRouting" = "Enrutamiento WARP"
 "warpRoutingDesc" = "Precaución: Antes de usar estas opciones, instale WARP en modo de proxy socks5 en su servidor siguiendo los pasos en el GitHub del panel. WARP enrutará el tráfico a los sitios web a través de los servidores de Cloudflare."
+"nordRouting" = "Enrutamiento NordVPN"
+"nordRoutingDesc" = "Estas opciones enrutarán el tráfico basado en un destino específico a través de NordVPN."
 "Template" = "Plantilla de Configuración de Xray"
 "TemplateDesc" = "Genera el archivo de configuración final de Xray basado en esta plantilla."
 "FreedomStrategy" = "Configurar Estrategia para el Protocolo Freedom"
@@ -573,6 +577,14 @@
 "testSuccess" = "Prueba exitosa"
 "testFailed" = "Prueba fallida"
 "testError" = "Error al probar la salida"
+"nordvpn" = "NordVPN"
+"accessToken" = "Token de acceso"
+"country" = "País"
+"server" = "Servidor"
+"city" = "Ciudad"
+"allCities" = "Todas las ciudades"
+"privateKey" = "Clave privada"
+"load" = "Carga"
 
 [pages.xray.balancer]
 "addBalancer" = "Agregar equilibrador"

+ 12 - 0
web/translation/translate.fa_IR.toml

@@ -4,6 +4,8 @@
 "confirm" = "تایید"
 "cancel" = "انصراف"
 "close" = "بستن"
+"save" = "ذخیره"
+"logout" = "خروج"
 "create" = "ایجاد"
 "update" = "به‌روزرسانی"
 "copy" = "کپی"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "این گزینه‌ها ترافیک را از طریق آی‌پی نسخه4 سرور، به مقصد هدایت می‌کند"
 "warpRouting" = "WARP مسیریابی"
 "warpRoutingDesc" = "این گزینه‌ها ترافیک‌ را از طریق وارپ کلادفلر به مقصد هدایت می‌کند"
+"nordRouting" = "مسیریابی NordVPN"
+"nordRoutingDesc" = "این گزینه‌ها ترافیک را بر اساس مقصد خاص از طریق NordVPN مسیریابی می‌کنند."
 "Template" = "‌پیکربندی پیشرفته الگو ایکس‌ری"
 "TemplateDesc" = "فایل پیکربندی نهایی ایکس‌ری بر اساس این الگو ایجاد می‌شود"
 "FreedomStrategy" = "Freedom استراتژی پروتکل"
@@ -573,6 +577,14 @@
 "testSuccess" = "تست موفقیت‌آمیز"
 "testFailed" = "تست ناموفق"
 "testError" = "خطا در تست خروجی"
+"nordvpn" = "NordVPN"
+"accessToken" = "توکن دسترسی"
+"country" = "کشور"
+"server" = "سرور"
+"privateKey" = "کلید خصوصی"
+"city" = "شهر"
+"allCities" = "همه شهرها"
+"load" = "فشار سرور"
 
 [pages.xray.balancer]
 "addBalancer" = "افزودن بالانسر"

+ 12 - 0
web/translation/translate.id_ID.toml

@@ -4,6 +4,8 @@
 "confirm" = "Konfirmasi"
 "cancel" = "Batal"
 "close" = "Tutup"
+"save" = "Simpan"
+"logout" = "Keluar"
 "create" = "Buat"
 "update" = "Perbarui"
 "copy" = "Salin"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui IPv4."
 "warpRouting" = "Perutean WARP"
 "warpRoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui WARP."
+"nordRouting" = "Routing NordVPN"
+"nordRoutingDesc" = "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui NordVPN."
 "Template" = "Template Konfigurasi Xray Lanjutan"
 "TemplateDesc" = "File konfigurasi Xray akhir akan dibuat berdasarkan template ini."
 "FreedomStrategy" = "Strategi Protokol Freedom"
@@ -573,6 +577,14 @@
 "testSuccess" = "Tes berhasil"
 "testFailed" = "Tes gagal"
 "testError" = "Gagal menguji outbound"
+"nordvpn" = "NordVPN"
+"accessToken" = "Token Akses"
+"country" = "Negara"
+"server" = "Server"
+"city" = "Kota"
+"allCities" = "Semua Kota"
+"privateKey" = "Kunci Privat"
+"load" = "Beban"
 
 [pages.xray.balancer]
 "addBalancer" = "Tambahkan Penyeimbang"

+ 12 - 0
web/translation/translate.ja_JP.toml

@@ -4,6 +4,8 @@
 "confirm" = "確認"
 "cancel" = "キャンセル"
 "close" = "閉じる"
+"save" = "保存"
+"logout" = "ログアウト"
 "create" = "作成"
 "update" = "更新"
 "copy" = "コピー"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "このオプションはIPv4のみを介してターゲットドメインへルーティングします"
 "warpRouting" = "WARP ルーティング"
 "warpRoutingDesc" = "注意:これらのオプションを使用する前に、パネルのGitHubの手順に従って、サーバーにsocks5プロキシモードでWARPをインストールしてください。WARPはCloudflareサーバー経由でトラフィックをウェブサイトにルーティングします。"
+"nordRouting" = "NordVPN ルーティング"
+"nordRoutingDesc" = "これらのオプションはNordVPN経由で特定の宛先にトラフィックをルーティングします。"
 "Template" = "高度なXray設定テンプレート"
 "TemplateDesc" = "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます"
 "FreedomStrategy" = "Freedom プロトコル戦略"
@@ -573,6 +577,14 @@
 "testSuccess" = "テスト成功"
 "testFailed" = "テスト失敗"
 "testError" = "アウトバウンドのテストに失敗しました"
+"nordvpn" = "NordVPN"
+"accessToken" = "アクセストークン"
+"country" = "国"
+"server" = "サーバー"
+"city" = "都市"
+"allCities" = "すべての都市"
+"privateKey" = "秘密鍵"
+"load" = "負荷"
 
 [pages.xray.balancer]
 "addBalancer" = "負荷分散追加"

+ 12 - 0
web/translation/translate.pt_BR.toml

@@ -4,6 +4,8 @@
 "confirm" = "Confirmar"
 "cancel" = "Cancelar"
 "close" = "Fechar"
+"save" = "Salvar"
+"logout" = "Sair"
 "create" = "Criar"
 "update" = "Atualizar"
 "copy" = "Copiar"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "Essas opções roteam o tráfego para um destino específico via IPv4."
 "warpRouting" = "Roteamento WARP"
 "warpRoutingDesc" = "Essas opções roteam o tráfego para um destino específico via WARP."
+"nordRouting" = "Roteamento NordVPN"
+"nordRoutingDesc" = "Essas opções roteiam o tráfego para um destino específico via NordVPN."
 "Template" = "Modelo de Configuração Avançada do Xray"
 "TemplateDesc" = "O arquivo final de configuração do Xray será gerado com base neste modelo."
 "FreedomStrategy" = "Estratégia do Protocolo Freedom"
@@ -573,6 +577,14 @@
 "testSuccess" = "Teste bem-sucedido"
 "testFailed" = "Teste falhou"
 "testError" = "Falha ao testar saída"
+"nordvpn" = "NordVPN"
+"accessToken" = "Token de Acesso"
+"country" = "País"
+"server" = "Servidor"
+"city" = "Cidade"
+"allCities" = "Todas as Cidades"
+"privateKey" = "Chave Privada"
+"load" = "Carga"
 
 [pages.xray.balancer]
 "addBalancer" = "Adicionar Balanceador"

+ 13 - 1
web/translation/translate.ru_RU.toml

@@ -4,6 +4,8 @@
 "confirm" = "Подтвердить"
 "cancel" = "Отмена"
 "close" = "Закрыть"
+"save" = "Сохранить"
+"logout" = "Выход"
 "create" = "Создать"
 "update" = "Обновить"
 "copy" = "Копировать"
@@ -495,7 +497,9 @@
 "ipv4Routing" = "Правила IPv4"
 "ipv4RoutingDesc" = "Эти параметры позволят клиентам маршрутизироваться к целевым доменам только через IPv4"
 "warpRouting" = "Правила WARP"
-"warpRoutingDesc" = " Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через WARP."
+"warpRoutingDesc" = " Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через WARP."
+"nordRouting" = "Маршрутизация NordVPN"
+"nordRoutingDesc" = "Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через NordVPN."
 "Template" = "Шаблон конфигурации Xray"
 "TemplateDesc" = "На основе шаблона создаётся конфигурационный файл Xray."
 "FreedomStrategy" = "Настройка стратегии протокола Freedom"
@@ -573,6 +577,14 @@
 "testSuccess" = "Тест успешен"
 "testFailed" = "Тест не пройден"
 "testError" = "Не удалось протестировать исходящее подключение"
+"nordvpn" = "NordVPN"
+"accessToken" = "Токен доступа"
+"country" = "Страна"
+"server" = "Сервер"
+"city" = "Город"
+"allCities" = "Все города"
+"privateKey" = "Приватный ключ"
+"load" = "Нагрузка"
 
 [pages.xray.balancer]
 "addBalancer" = "Создать балансировщик"

+ 12 - 0
web/translation/translate.tr_TR.toml

@@ -4,6 +4,8 @@
 "confirm" = "Onayla"
 "cancel" = "İptal"
 "close" = "Kapat"
+"save" = "Kaydet"
+"logout" = "Çıkış Yap"
 "create" = "Oluştur"
 "update" = "Güncelle"
 "copy" = "Kopyala"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "Bu seçenekler belirli bir varış yerine IPv4 üzerinden trafiği yönlendirir."
 "warpRouting" = "WARP Yönlendirme"
 "warpRoutingDesc" = "Bu seçenekler belirli bir varış yerine WARP üzerinden trafiği yönlendirir."
+"nordRouting" = "NordVPN Yönlendirme"
+"nordRoutingDesc" = "Bu seçenekler belirli bir varış yerine NordVPN üzerinden trafiği yönlendirir."
 "Template" = "Gelişmiş Xray Yapılandırma Şablonu"
 "TemplateDesc" = "Nihai Xray yapılandırma dosyası bu şablona göre oluşturulacaktır."
 "FreedomStrategy" = "Freedom Protokol Stratejisi"
@@ -573,6 +577,14 @@
 "testSuccess" = "Test başarılı"
 "testFailed" = "Test başarısız"
 "testError" = "Giden test edilemedi"
+"nordvpn" = "NordVPN"
+"accessToken" = "Erişim Jetonu"
+"country" = "Ülke"
+"server" = "Sunucu"
+"city" = "Şehir"
+"allCities" = "Tüm Şehirler"
+"privateKey" = "Özel Anahtar"
+"load" = "Yük"
 
 [pages.xray.balancer]
 "addBalancer" = "Dengeleyici Ekle"

+ 12 - 0
web/translation/translate.uk_UA.toml

@@ -4,6 +4,8 @@
 "confirm" = "Підтвердити"
 "cancel" = "Скасувати"
 "close" = "Закрити"
+"save" = "Зберегти"
+"logout" = "Вийти"
 "create" = "Створити"
 "update" = "Оновити"
 "copy" = "Копіювати"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "Ці параметри спрямовуватимуть трафік на основі певного призначення через IPv4."
 "warpRouting" = "WARP Маршрутизація"
 "warpRoutingDesc" = "Ці параметри маршрутизуватимуть трафік на основі певного пункту призначення через WARP."
+"nordRouting" = "Маршрутизація NordVPN"
+"nordRoutingDesc" = "Ці параметри маршрутизуватимуть трафік на основі певного пункту призначення через NordVPN."
 "Template" = "Шаблон розширеної конфігурації Xray"
 "TemplateDesc" = "Остаточний конфігураційний файл Xray буде створено на основі цього шаблону."
 "FreedomStrategy" = "Стратегія протоколу свободи"
@@ -573,6 +577,14 @@
 "testSuccess" = "Тест успішний"
 "testFailed" = "Тест не пройдено"
 "testError" = "Не вдалося протестувати вихідне з'єднання"
+"nordvpn" = "NordVPN"
+"accessToken" = "Токен доступу"
+"country" = "Країна"
+"server" = "Сервер"
+"city" = "Місто"
+"allCities" = "Усі міста"
+"privateKey" = "Приватний ключ"
+"load" = "Навантаження"
 
 [pages.xray.balancer]
 "addBalancer" = "Додати балансир"

+ 12 - 0
web/translation/translate.vi_VN.toml

@@ -4,6 +4,8 @@
 "confirm" = "Xác nhận"
 "cancel" = "Hủy bỏ"
 "close" = "Đóng"
+"save" = "Lưu"
+"logout" = "Đăng xuất"
 "create" = "Tạo"
 "update" = "Cập nhật"
 "copy" = "Sao chép"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "Những tùy chọn này sẽ chỉ định kết nối đến các tên miền mục tiêu qua IPv4."
 "warpRouting" = "Định tuyến WARP"
 "warpRoutingDesc" = "Cảnh báo: Trước khi sử dụng những tùy chọn này, hãy cài đặt WARP ở chế độ proxy socks5 trên máy chủ của bạn bằng cách làm theo các bước trên GitHub của bảng điều khiển. WARP sẽ định tuyến lưu lượng đến các trang web qua máy chủ Cloudflare."
+"nordRouting" = "Định tuyến NordVPN"
+"nordRoutingDesc" = "Các tùy chọn này sẽ định tuyến lưu lượng dựa trên đích cụ thể qua NordVPN."
 "Template" = "Mẫu Cấu hình Xray"
 "TemplateDesc" = "Tạo tệp cấu hình Xray cuối cùng dựa trên mẫu này."
 "FreedomStrategy" = "Cấu hình Chiến lược cho Giao thức Freedom"
@@ -573,6 +577,14 @@
 "testSuccess" = "Kiểm tra thành công"
 "testFailed" = "Kiểm tra thất bại"
 "testError" = "Không thể kiểm tra đầu ra"
+"nordvpn" = "NordVPN"
+"accessToken" = "Mã truy cập"
+"country" = "Quốc gia"
+"server" = "Máy chủ"
+"city" = "Thành phố"
+"allCities" = "Tất cả thành phố"
+"privateKey" = "Khóa riêng"
+"load" = "Tải"
 
 [pages.xray.balancer]
 "addBalancer" = "Thêm cân bằng"

+ 12 - 0
web/translation/translate.zh_CN.toml

@@ -4,6 +4,8 @@
 "confirm" = "确定"
 "cancel" = "取消"
 "close" = "关闭"
+"save" = "保存"
+"logout" = "登出"
 "create" = "创建"
 "update" = "更新"
 "copy" = "复制"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "此选项将仅通过 IPv4 路由到目标域"
 "warpRouting" = "WARP 路由"
 "warpRoutingDesc" = "注意:在使用这些选项之前,请按照面板 GitHub 上的步骤在你的服务器上以 socks5 代理模式安装 WARP。WARP 将通过 Cloudflare 服务器将流量路由到网站。"
+"nordRouting" = "NordVPN 路由"
+"nordRoutingDesc" = "这些选项将根据特定目的地通过 NordVPN 路由流量。"
 "Template" = "高级 Xray 配置模板"
 "TemplateDesc" = "最终的 Xray 配置文件将基于此模板生成"
 "FreedomStrategy" = "Freedom 协议策略"
@@ -573,6 +577,14 @@
 "testSuccess" = "测试成功"
 "testFailed" = "测试失败"
 "testError" = "测试出站失败"
+"nordvpn" = "NordVPN"
+"accessToken" = "访问令牌"
+"country" = "国家"
+"server" = "服务器"
+"city" = "城市"
+"allCities" = "所有城市"
+"privateKey" = "私钥"
+"load" = "负载"
 
 [pages.xray.balancer]
 "addBalancer" = "添加负载均衡"

+ 12 - 0
web/translation/translate.zh_TW.toml

@@ -4,6 +4,8 @@
 "confirm" = "確定"
 "cancel" = "取消"
 "close" = "關閉"
+"save" = "儲存"
+"logout" = "登出"
 "create" = "建立"
 "update" = "更新"
 "copy" = "複製"
@@ -496,6 +498,8 @@
 "ipv4RoutingDesc" = "此選項將僅通過 IPv4 路由到目標域"
 "warpRouting" = "WARP 路由"
 "warpRoutingDesc" = "注意:在使用這些選項之前,請按照面板 GitHub 上的步驟在你的伺服器上以 socks5 代理模式安裝 WARP。WARP 將通過 Cloudflare 伺服器將流量路由到網站。"
+"nordRouting" = "NordVPN 路由"
+"nordRoutingDesc" = "這些選項將根據特定目的地通過 NordVPN 路由流量。"
 "Template" = "高階 Xray 配置模板"
 "TemplateDesc" = "最終的 Xray 配置檔案將基於此模板生成"
 "FreedomStrategy" = "Freedom 協議策略"
@@ -573,6 +577,14 @@
 "testSuccess" = "測試成功"
 "testFailed" = "測試失敗"
 "testError" = "測試出站失敗"
+"nordvpn" = "NordVPN"
+"accessToken" = "訪問令牌"
+"country" = "國家"
+"server" = "伺服器"
+"city" = "城市"
+"allCities" = "所有城市"
+"privateKey" = "私密金鑰"
+"load" = "負載"
 
 [pages.xray.balancer]
 "addBalancer" = "新增負載均衡"