diff options
| author | Peter Liu <30622363+PedroLiu1999@users.noreply.github.com> | 2026-04-20 01:41:50 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-20 01:41:50 +0300 |
| commit | 36b2a586756fc279304170976c9d486498d55919 (patch) | |
| tree | 71a1dd665fa14a0fc9dad28c3ef421e3e3d7fe7c /web/service | |
| parent | 59e98592251cac1dedb93905cb368a3ba93ad19c (diff) | |
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 <ho3ein.sanaei@gmail.com>
Diffstat (limited to 'web/service')
| -rw-r--r-- | web/service/nord.go | 145 | ||||
| -rw-r--r-- | web/service/setting.go | 9 |
2 files changed, 154 insertions, 0 deletions
diff --git a/web/service/nord.go b/web/service/nord.go new file mode 100644 index 00000000..db0a48f9 --- /dev/null +++ b/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("") +} diff --git a/web/service/setting.go b/web/service/setting.go index 468a7960..04d8f6a8 100644 --- a/web/service/setting.go +++ b/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") } |
