mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 09:27:15 +00:00
feat(nord): support multi-server NordLynx outbounds (#6311)
* feat(nord): support multi-server NordLynx outbounds * fix(nord): address verified PR review findings Tighten the NordVPN multi-outbound implementation and its regression coverage based on the verified review feedback. - remove the redundant Xray validation test that duplicated the base branch and did not exercise multiple outbounds - make NordModal tests wait for server loading and assert the modal close callback, duplicate-server state, and endpoint behavior - add coverage for resolving the NordLynx public key from technology metadata instead of a numeric technology ID - use a real httptest server for Nord integration tests through an injectable API base URL - represent the All Cities sentinel consistently as null and reset it when a country changes The existing NordVPN API contracts and persisted outbound schema remain unchanged.
This commit is contained in:
@@ -18,11 +18,14 @@ type NordService struct {
|
||||
|
||||
var nordHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// nordAPIBase is a var so integration tests can use a local HTTP server.
|
||||
var nordAPIBase = "https://api.nordvpn.com"
|
||||
|
||||
// maxResponseSize limits the maximum size of NordVPN API responses (10 MB).
|
||||
const maxResponseSize = 10 << 20
|
||||
|
||||
func (s *NordService) GetCountries() (string, error) {
|
||||
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.nordvpn.com/v1/countries", nil)
|
||||
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, nordAPIBase+"/v1/servers/countries?filters[servers_technologies][identifier]=wireguard_udp", nil)
|
||||
if reqErr != nil {
|
||||
return "", reqErr
|
||||
}
|
||||
@@ -48,7 +51,7 @@ func (s *NordService) GetServers(countryId string) (string, error) {
|
||||
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)
|
||||
url := fmt.Sprintf("%s/v2/servers?limit=0&filters[servers_technologies][identifier]=wireguard_udp&filters[country_id]=%s", nordAPIBase, countryId)
|
||||
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if reqErr != nil {
|
||||
return "", reqErr
|
||||
@@ -65,28 +68,7 @@ func (s *NordService) GetServers(countryId string) (string, error) {
|
||||
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
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func (s *NordService) SetKey(privateKey string) (string, error) {
|
||||
@@ -106,7 +88,7 @@ func (s *NordService) SetKey(privateKey string) (string, error) {
|
||||
}
|
||||
|
||||
func (s *NordService) GetCredentials(token string) (string, error) {
|
||||
url := "https://api.nordvpn.com/v1/users/services/credentials"
|
||||
url := nordAPIBase + "/v1/users/services/credentials"
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stubNordAPI(t *testing.T, handler http.HandlerFunc) {
|
||||
t.Helper()
|
||||
previous := nordAPIBase
|
||||
server := httptest.NewServer(handler)
|
||||
nordAPIBase = server.URL
|
||||
t.Cleanup(func() {
|
||||
nordAPIBase = previous
|
||||
server.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestNordCountriesOnlyRequestsNordLynxServerCountries(t *testing.T) {
|
||||
stubNordAPI(t, func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path != "/v1/servers/countries" {
|
||||
t.Errorf("country path = %q", req.URL.Path)
|
||||
}
|
||||
if got := req.URL.Query().Get("filters[servers_technologies][identifier]"); got != "wireguard_udp" {
|
||||
t.Errorf("NordLynx technology filter = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `[{"id":228,"name":"United States","code":"US"}]`)
|
||||
})
|
||||
|
||||
got, err := (&NordService{}).GetCountries()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, `"code":"US"`) {
|
||||
t.Fatalf("countries = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNordServersPreserveLowLoadServers(t *testing.T) {
|
||||
stubNordAPI(t, func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path != "/v2/servers" {
|
||||
t.Errorf("server path = %q", req.URL.Path)
|
||||
}
|
||||
if got := req.URL.Query().Get("filters[country_id]"); got != "225" {
|
||||
t.Errorf("country filter = %q", got)
|
||||
}
|
||||
if got := req.URL.Query().Get("filters[servers_technologies][identifier]"); got != "wireguard_udp" {
|
||||
t.Errorf("NordLynx technology filter = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"servers":[{"id":1,"load":0},{"id":2,"load":4}]}`)
|
||||
})
|
||||
|
||||
got, err := (&NordService{}).GetServers("225")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
Servers []struct {
|
||||
Load int `json:"load"`
|
||||
} `json:"servers"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Servers) != 2 || payload.Servers[0].Load != 0 || payload.Servers[1].Load != 4 {
|
||||
t.Fatalf("servers = %+v", payload.Servers)
|
||||
}
|
||||
}
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "لم يتم العثور على خوادم للدولة المحددة",
|
||||
"noPublicKey": "الخادم المحدد لا يُعلن عن مفتاح NordLynx العام.",
|
||||
"outboundAdded": "تمت إضافة صادر NordVPN",
|
||||
"outboundUpdated": "تم تحديث صادر NordVPN"
|
||||
"outboundUpdated": "تم تحديث صادر NordVPN",
|
||||
"serverLoad": "حمل الخادم",
|
||||
"addedServers": "الخوادم المضافة",
|
||||
"alreadyAdded": "هذا الخادم موجود بالفعل في قائمة الصادرات. استخدم {reset} لتحديث مفتاحه."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "تغيير الـ IP",
|
||||
|
||||
@@ -1970,7 +1970,10 @@
|
||||
"noServers": "No servers found for the selected country",
|
||||
"noPublicKey": "Selected server does not advertise a NordLynx public key.",
|
||||
"outboundAdded": "NordVPN outbound added",
|
||||
"outboundUpdated": "NordVPN outbound updated"
|
||||
"outboundUpdated": "NordVPN outbound updated",
|
||||
"serverLoad": "Server load",
|
||||
"addedServers": "Added servers",
|
||||
"alreadyAdded": "This server is already in the outbound list. Use {reset} to refresh its key."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Change IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "No se encontraron servidores para el país seleccionado",
|
||||
"noPublicKey": "El servidor seleccionado no anuncia una clave pública NordLynx.",
|
||||
"outboundAdded": "Salida NordVPN añadida",
|
||||
"outboundUpdated": "Salida NordVPN actualizada"
|
||||
"outboundUpdated": "Salida NordVPN actualizada",
|
||||
"serverLoad": "Carga del servidor",
|
||||
"addedServers": "Servidores añadidos",
|
||||
"alreadyAdded": "Este servidor ya está en la lista de salidas. Usa {reset} para actualizar su clave."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Cambiar IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "سروری برای کشور انتخابی پیدا نشد",
|
||||
"noPublicKey": "سرور انتخابی کلید عمومی NordLynx اعلام نمیکند.",
|
||||
"outboundAdded": "خروجی NordVPN اضافه شد",
|
||||
"outboundUpdated": "خروجی NordVPN بهروزرسانی شد"
|
||||
"outboundUpdated": "خروجی NordVPN بهروزرسانی شد",
|
||||
"serverLoad": "بار سرور",
|
||||
"addedServers": "سرورهای اضافهشده",
|
||||
"alreadyAdded": "این سرور از قبل در فهرست خروجیها وجود دارد. برای تازهسازی کلید آن از {reset} استفاده کنید."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "تغییر IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "Tidak ada server ditemukan untuk negara yang dipilih",
|
||||
"noPublicKey": "Server yang dipilih tidak mengumumkan kunci publik NordLynx.",
|
||||
"outboundAdded": "Outbound NordVPN ditambahkan",
|
||||
"outboundUpdated": "Outbound NordVPN diperbarui"
|
||||
"outboundUpdated": "Outbound NordVPN diperbarui",
|
||||
"serverLoad": "Beban server",
|
||||
"addedServers": "Server yang ditambahkan",
|
||||
"alreadyAdded": "Server ini sudah ada dalam daftar outbound. Gunakan {reset} untuk memperbarui kuncinya."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Ganti IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "選択した国のサーバーが見つかりません",
|
||||
"noPublicKey": "選択したサーバーは NordLynx 公開鍵を公開していません。",
|
||||
"outboundAdded": "NordVPN アウトバウンドを追加しました",
|
||||
"outboundUpdated": "NordVPN アウトバウンドを更新しました"
|
||||
"outboundUpdated": "NordVPN アウトバウンドを更新しました",
|
||||
"serverLoad": "サーバー負荷",
|
||||
"addedServers": "追加済みサーバー",
|
||||
"alreadyAdded": "このサーバーはすでにアウトバウンド一覧にあります。{reset} で鍵を更新してください。"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "IP を変更",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "Nenhum servidor encontrado para o país selecionado",
|
||||
"noPublicKey": "O servidor selecionado não anuncia uma chave pública NordLynx.",
|
||||
"outboundAdded": "Saída NordVPN adicionada",
|
||||
"outboundUpdated": "Saída NordVPN atualizada"
|
||||
"outboundUpdated": "Saída NordVPN atualizada",
|
||||
"serverLoad": "Carga do servidor",
|
||||
"addedServers": "Servidores adicionados",
|
||||
"alreadyAdded": "Este servidor já está na lista de saídas. Use {reset} para atualizar sua chave."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Alterar IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "Серверов для выбранной страны не найдено",
|
||||
"noPublicKey": "Выбранный сервер не сообщает публичный ключ NordLynx.",
|
||||
"outboundAdded": "Исходящий NordVPN добавлен",
|
||||
"outboundUpdated": "Исходящий NordVPN обновлён"
|
||||
"outboundUpdated": "Исходящий NordVPN обновлён",
|
||||
"serverLoad": "Нагрузка сервера",
|
||||
"addedServers": "Добавленные серверы",
|
||||
"alreadyAdded": "Этот сервер уже есть в списке исходящих подключений. Используйте {reset}, чтобы обновить его ключ."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Сменить IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "Seçilen ülke için sunucu bulunamadı.",
|
||||
"noPublicKey": "Seçilen sunucu NordLynx genel anahtarı yayınlamıyor.",
|
||||
"outboundAdded": "NordVPN giden bağlantı eklendi.",
|
||||
"outboundUpdated": "NordVPN giden bağlantı güncellendi."
|
||||
"outboundUpdated": "NordVPN giden bağlantı güncellendi.",
|
||||
"serverLoad": "Sunucu yükü",
|
||||
"addedServers": "Eklenen sunucular",
|
||||
"alreadyAdded": "Bu sunucu zaten giden bağlantı listesinde. Anahtarını yenilemek için {reset} kullanın."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "IP Değiştir",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "Серверів для обраної країни не знайдено",
|
||||
"noPublicKey": "Обраний сервер не повідомляє публічного ключа NordLynx.",
|
||||
"outboundAdded": "Вихідний NordVPN додано",
|
||||
"outboundUpdated": "Вихідний NordVPN оновлено"
|
||||
"outboundUpdated": "Вихідний NordVPN оновлено",
|
||||
"serverLoad": "Навантаження сервера",
|
||||
"addedServers": "Додані сервери",
|
||||
"alreadyAdded": "Цей сервер уже є у списку вихідних підключень. Використайте {reset}, щоб оновити його ключ."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Змінити IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "Không tìm thấy máy chủ cho quốc gia đã chọn",
|
||||
"noPublicKey": "Máy chủ đã chọn không công bố khóa công khai NordLynx.",
|
||||
"outboundAdded": "Đã thêm outbound NordVPN",
|
||||
"outboundUpdated": "Đã cập nhật outbound NordVPN"
|
||||
"outboundUpdated": "Đã cập nhật outbound NordVPN",
|
||||
"serverLoad": "Tải máy chủ",
|
||||
"addedServers": "Máy chủ đã thêm",
|
||||
"alreadyAdded": "Máy chủ này đã có trong danh sách outbound. Dùng {reset} để làm mới khóa của máy chủ."
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "Đổi IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "未找到选定国家/地区的服务器",
|
||||
"noPublicKey": "选定的服务器未公布 NordLynx 公钥。",
|
||||
"outboundAdded": "NordVPN 出站已添加",
|
||||
"outboundUpdated": "NordVPN 出站已更新"
|
||||
"outboundUpdated": "NordVPN 出站已更新",
|
||||
"serverLoad": "服务器负载",
|
||||
"addedServers": "已添加的服务器",
|
||||
"alreadyAdded": "此服务器已在出站列表中。请使用{reset}刷新其密钥。"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "更换 IP",
|
||||
|
||||
@@ -1852,7 +1852,10 @@
|
||||
"noServers": "未找到選定國家/地區的伺服器",
|
||||
"noPublicKey": "選定的伺服器未公布 NordLynx 公鑰。",
|
||||
"outboundAdded": "NordVPN 出站已新增",
|
||||
"outboundUpdated": "NordVPN 出站已更新"
|
||||
"outboundUpdated": "NordVPN 出站已更新",
|
||||
"serverLoad": "伺服器負載",
|
||||
"addedServers": "已新增的伺服器",
|
||||
"alreadyAdded": "此伺服器已在出站清單中。請使用{reset}重新整理其金鑰。"
|
||||
},
|
||||
"warp": {
|
||||
"changeIp": "更換 IP",
|
||||
|
||||
Reference in New Issue
Block a user