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:
Masterain
2026-09-03 02:20:10 +08:00
committed by GitHub
parent f727d04f65
commit f9cfd87cb2
23 changed files with 1186 additions and 215 deletions
+7 -25
View File
@@ -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)
}
}
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "لم يتم العثور على خوادم للدولة المحددة",
"noPublicKey": "الخادم المحدد لا يُعلن عن مفتاح NordLynx العام.",
"outboundAdded": "تمت إضافة صادر NordVPN",
"outboundUpdated": "تم تحديث صادر NordVPN"
"outboundUpdated": "تم تحديث صادر NordVPN",
"serverLoad": "حمل الخادم",
"addedServers": "الخوادم المضافة",
"alreadyAdded": "هذا الخادم موجود بالفعل في قائمة الصادرات. استخدم {reset} لتحديث مفتاحه."
},
"warp": {
"changeIp": "تغيير الـ IP",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "سروری برای کشور انتخابی پیدا نشد",
"noPublicKey": "سرور انتخابی کلید عمومی NordLynx اعلام نمی‌کند.",
"outboundAdded": "خروجی NordVPN اضافه شد",
"outboundUpdated": "خروجی NordVPN به‌روزرسانی شد"
"outboundUpdated": "خروجی NordVPN به‌روزرسانی شد",
"serverLoad": "بار سرور",
"addedServers": "سرورهای اضافه‌شده",
"alreadyAdded": "این سرور از قبل در فهرست خروجی‌ها وجود دارد. برای تازه‌سازی کلید آن از {reset} استفاده کنید."
},
"warp": {
"changeIp": "تغییر IP",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "選択した国のサーバーが見つかりません",
"noPublicKey": "選択したサーバーは NordLynx 公開鍵を公開していません。",
"outboundAdded": "NordVPN アウトバウンドを追加しました",
"outboundUpdated": "NordVPN アウトバウンドを更新しました"
"outboundUpdated": "NordVPN アウトバウンドを更新しました",
"serverLoad": "サーバー負荷",
"addedServers": "追加済みサーバー",
"alreadyAdded": "このサーバーはすでにアウトバウンド一覧にあります。{reset} で鍵を更新してください。"
},
"warp": {
"changeIp": "IP を変更",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "Серверов для выбранной страны не найдено",
"noPublicKey": "Выбранный сервер не сообщает публичный ключ NordLynx.",
"outboundAdded": "Исходящий NordVPN добавлен",
"outboundUpdated": "Исходящий NordVPN обновлён"
"outboundUpdated": "Исходящий NordVPN обновлён",
"serverLoad": "Нагрузка сервера",
"addedServers": "Добавленные серверы",
"alreadyAdded": "Этот сервер уже есть в списке исходящих подключений. Используйте {reset}, чтобы обновить его ключ."
},
"warp": {
"changeIp": "Сменить IP",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "Серверів для обраної країни не знайдено",
"noPublicKey": "Обраний сервер не повідомляє публічного ключа NordLynx.",
"outboundAdded": "Вихідний NordVPN додано",
"outboundUpdated": "Вихідний NordVPN оновлено"
"outboundUpdated": "Вихідний NordVPN оновлено",
"serverLoad": "Навантаження сервера",
"addedServers": "Додані сервери",
"alreadyAdded": "Цей сервер уже є у списку вихідних підключень. Використайте {reset}, щоб оновити його ключ."
},
"warp": {
"changeIp": "Змінити IP",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "未找到选定国家/地区的服务器",
"noPublicKey": "选定的服务器未公布 NordLynx 公钥。",
"outboundAdded": "NordVPN 出站已添加",
"outboundUpdated": "NordVPN 出站已更新"
"outboundUpdated": "NordVPN 出站已更新",
"serverLoad": "服务器负载",
"addedServers": "已添加的服务器",
"alreadyAdded": "此服务器已在出站列表中。请使用{reset}刷新其密钥。"
},
"warp": {
"changeIp": "更换 IP",
+4 -1
View File
@@ -1852,7 +1852,10 @@
"noServers": "未找到選定國家/地區的伺服器",
"noPublicKey": "選定的伺服器未公布 NordLynx 公鑰。",
"outboundAdded": "NordVPN 出站已新增",
"outboundUpdated": "NordVPN 出站已更新"
"outboundUpdated": "NordVPN 出站已更新",
"serverLoad": "伺服器負載",
"addedServers": "已新增的伺服器",
"alreadyAdded": "此伺服器已在出站清單中。請使用{reset}重新整理其金鑰。"
},
"warp": {
"changeIp": "更換 IP",