chore(updater): move updater packages to pkg/updaters/<name>

This commit is contained in:
Quentin McGaw
2026-04-23 03:47:57 +00:00
parent 628b0a22e2
commit d96752c734
164 changed files with 732 additions and 343 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/qdm12/gluetun/internal/provider/common"
"github.com/qdm12/gluetun/internal/provider/ivpn/updater"
"github.com/qdm12/gluetun/pkg/updaters/ivpn"
)
type Provider struct {
@@ -22,7 +22,7 @@ func New(storage common.Storage, randSource rand.Source,
return &Provider{
storage: storage,
randSource: randSource,
Fetcher: updater.New(client, updaterWarner, parallelResolver),
Fetcher: ivpn.New(client, updaterWarner, parallelResolver),
}
}
-63
View File
@@ -1,63 +0,0 @@
package updater
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
)
var errHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
type apiData struct {
Servers []apiServer `json:"servers"`
}
type apiServer struct {
Hostnames apiHostnames `json:"hostnames"`
IsActive bool `json:"is_active"`
Country string `json:"country"`
City string `json:"city"`
ISP string `json:"isp"`
WgPubKey string `json:"wg_public_key"`
}
type apiHostnames struct {
OpenVPN string `json:"openvpn"`
Wireguard string `json:"wireguard"`
}
func fetchAPI(ctx context.Context, client *http.Client) (
data apiData, err error,
) {
const url = "https://api.ivpn.net/v4/servers/stats"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return data, err
}
response, err := client.Do(request)
if err != nil {
return data, err
}
if response.StatusCode != http.StatusOK {
_ = response.Body.Close()
return data, fmt.Errorf("%w: %d %s",
errHTTPStatusCodeNotOK, response.StatusCode, response.Status)
}
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&data); err != nil {
_ = response.Body.Close()
return data, fmt.Errorf("decoding response body: %w", err)
}
if err := response.Body.Close(); err != nil {
return data, fmt.Errorf("closing response body: %w", err)
}
return data, nil
}
@@ -1,94 +0,0 @@
package updater
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_fetchAPI(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
responseStatus int
responseBody io.ReadCloser
data apiData
err error
}{
"http response status not ok": {
responseStatus: http.StatusNoContent,
err: errors.New("HTTP status code not OK: 204 No Content"),
},
"nil body": {
responseStatus: http.StatusOK,
err: errors.New("decoding response body: EOF"),
},
"no server": {
responseStatus: http.StatusOK,
responseBody: io.NopCloser(strings.NewReader(`{}`)),
},
"success": {
responseStatus: http.StatusOK,
responseBody: io.NopCloser(strings.NewReader(`{"servers":[
{"country":"Country1","city":"City A","isp":"xyz","is_active":true,"hostnames":{"openvpn":"hosta"}},
{"country":"Country2","city":"City B","isp":"abc","is_active":false,"hostnames":{"openvpn":"hostb"}}
]}`)),
data: apiData{
Servers: []apiServer{
{
Country: "Country1",
City: "City A",
IsActive: true,
ISP: "xyz",
Hostnames: apiHostnames{
OpenVPN: "hosta",
},
},
{
Country: "Country2",
City: "City B",
ISP: "abc",
Hostnames: apiHostnames{
OpenVPN: "hostb",
},
},
},
},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client := &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, r.URL.String(), "https://api.ivpn.net/v4/servers/stats")
return &http.Response{
StatusCode: testCase.responseStatus,
Status: http.StatusText(testCase.responseStatus),
Body: testCase.responseBody,
}, nil
}),
}
data, err := fetchAPI(ctx, client)
assert.Equal(t, testCase.data, data)
if testCase.err != nil {
require.Error(t, err)
assert.Equal(t, testCase.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
-28
View File
@@ -1,28 +0,0 @@
package updater
import (
"time"
"github.com/qdm12/gluetun/internal/updater/resolver"
)
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
const (
maxFailRatio = 0.1
maxDuration = 20 * time.Second
betweenDuration = time.Second
maxNoNew = 2
maxFails = 2
)
return resolver.ParallelSettings{
Hosts: hosts,
MaxFailRatio: maxFailRatio,
Repeat: resolver.RepeatSettings{
MaxDuration: maxDuration,
BetweenDuration: betweenDuration,
MaxNoNew: maxNoNew,
MaxFails: maxFails,
SortIPs: true,
},
}
}
@@ -1,9 +0,0 @@
package updater
import "net/http"
type roundTripFunc func(r *http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
-110
View File
@@ -1,110 +0,0 @@
package updater
import (
"context"
"fmt"
"sort"
"strings"
"github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/provider/common"
)
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
servers []models.Server, err error,
) {
data, err := fetchAPI(ctx, u.client)
if err != nil {
return nil, fmt.Errorf("fetching API: %w", err)
}
hosts := make(map[string]struct{}, len(data.Servers))
for _, serverData := range data.Servers {
openVPNHost := serverData.Hostnames.OpenVPN
if openVPNHost != "" {
hosts[openVPNHost] = struct{}{}
}
wireguardHost := serverData.Hostnames.Wireguard
if wireguardHost != "" {
hosts[wireguardHost] = struct{}{}
}
}
if len(hosts) < minServers {
return nil, fmt.Errorf("%w: %d and expected at least %d",
common.ErrNotEnoughServers, len(hosts), minServers)
}
hostsSlice := make(sort.StringSlice, 0, len(hosts))
for host := range hosts {
hostsSlice = append(hostsSlice, host)
}
hostsSlice.Sort() // for predictable unit tests
resolveSettings := parallelResolverSettings(hostsSlice)
hostToIPs, warnings, err := u.parallelResolver.Resolve(ctx, resolveSettings)
for _, warning := range warnings {
u.warner.Warn(warning)
}
if err != nil {
return nil, err
}
if len(hostToIPs) < minServers {
return nil, fmt.Errorf("%w: %d and expected at least %d",
common.ErrNotEnoughServers, len(servers), minServers)
}
servers = make([]models.Server, 0, len(hostToIPs))
for _, serverData := range data.Servers {
city, region := parseCity(serverData.City)
server := models.Server{
Country: serverData.Country,
City: city,
Region: region,
ISP: serverData.ISP,
}
openVPNHostname := serverData.Hostnames.OpenVPN
wireguardHostname := serverData.Hostnames.Wireguard
if openVPNHostname == "" && wireguardHostname == "" {
warning := fmt.Sprintf("server data %v has no OpenVPN nor Wireguard hostname", serverData)
warnings = append(warnings, warning)
continue
}
if openVPNHostname != "" {
openVPNServer := server
openVPNServer.Hostname = openVPNHostname
openVPNServer.VPN = vpn.OpenVPN
openVPNServer.UDP = true
openVPNServer.TCP = true
openVPNServer.IPs = hostToIPs[openVPNHostname]
servers = append(servers, openVPNServer)
}
if wireguardHostname != "" {
wireguardServer := server
wireguardServer.Hostname = wireguardHostname
wireguardServer.VPN = vpn.Wireguard
wireguardServer.IPs = hostToIPs[wireguardHostname]
wireguardServer.WgPubKey = serverData.WgPubKey
servers = append(servers, wireguardServer)
}
}
sort.Sort(models.SortableServers(servers))
return servers, nil
}
func parseCity(city string) (parsedCity, region string) {
commaIndex := strings.Index(city, ", ")
if commaIndex == -1 {
return city, ""
}
return city[:commaIndex], city[commaIndex+2:]
}
@@ -1,183 +0,0 @@
package updater
import (
"context"
"errors"
"io"
"net/http"
"net/netip"
"strings"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/provider/common"
"github.com/qdm12/gluetun/internal/updater/resolver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_Updater_GetServers(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
// Inputs
minServers int
// Mocks
warnerBuilder func(ctrl *gomock.Controller) common.Warner
// From API
responseBody string
responseStatus int
// Resolution
expectResolve bool
resolveSettings resolver.ParallelSettings
hostToIPs map[string][]netip.Addr
resolveWarnings []string
resolveErr error
// Output
servers []models.Server
err error
}{
"http response error": {
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
responseStatus: http.StatusNoContent,
err: errors.New("fetching API: HTTP status code not OK: 204 No Content"),
},
"resolve error": {
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
warner := common.NewMockWarner(ctrl)
warner.EXPECT().Warn("resolve warning")
return warner
},
responseBody: `{"servers":[
{"hostnames":{"openvpn":"hosta"}}
]}`,
responseStatus: http.StatusOK,
expectResolve: true,
resolveSettings: resolver.ParallelSettings{
Hosts: []string{"hosta"},
MaxFailRatio: 0.1,
Repeat: resolver.RepeatSettings{
MaxDuration: 20 * time.Second,
BetweenDuration: time.Second,
MaxNoNew: 2,
MaxFails: 2,
SortIPs: true,
},
},
resolveWarnings: []string{"resolve warning"},
resolveErr: errors.New("dummy"),
err: errors.New("dummy"),
},
"not enough servers": {
minServers: 2,
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
responseBody: `{"servers":[
{"hostnames":{"openvpn":"hosta"}}
]}`,
responseStatus: http.StatusOK,
err: errors.New("not enough servers found: 1 and expected at least 2"),
},
"success": {
minServers: 1,
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
warner := common.NewMockWarner(ctrl)
warner.EXPECT().Warn("resolve warning")
return warner
},
responseBody: `{"servers":[
{"country":"Country1","city":"City A","hostnames":{"openvpn":"hosta"}},
{"country":"Country2","city":"City B","hostnames":{"openvpn":"hostb"},"wg_public_key":"xyz"},
{"country":"Country3","city":"City C","hostnames":{"wireguard":"hostc"},"wg_public_key":"xyz"}
]}`,
responseStatus: http.StatusOK,
expectResolve: true,
resolveSettings: resolver.ParallelSettings{
Hosts: []string{"hosta", "hostb", "hostc"},
MaxFailRatio: 0.1,
Repeat: resolver.RepeatSettings{
MaxDuration: 20 * time.Second,
BetweenDuration: time.Second,
MaxNoNew: 2,
MaxFails: 2,
SortIPs: true,
},
},
hostToIPs: map[string][]netip.Addr{
"hosta": {netip.AddrFrom4([4]byte{1, 1, 1, 1}), netip.AddrFrom4([4]byte{2, 2, 2, 2})},
"hostb": {netip.AddrFrom4([4]byte{3, 3, 3, 3}), netip.AddrFrom4([4]byte{4, 4, 4, 4})},
"hostc": {netip.AddrFrom4([4]byte{5, 5, 5, 5}), netip.AddrFrom4([4]byte{6, 6, 6, 6})},
},
resolveWarnings: []string{"resolve warning"},
servers: []models.Server{
{
VPN: vpn.OpenVPN, Country: "Country1",
City: "City A", Hostname: "hosta", TCP: true, UDP: true,
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1}), netip.AddrFrom4([4]byte{2, 2, 2, 2})},
},
{
VPN: vpn.OpenVPN, Country: "Country2",
City: "City B", Hostname: "hostb", TCP: true, UDP: true,
IPs: []netip.Addr{netip.AddrFrom4([4]byte{3, 3, 3, 3}), netip.AddrFrom4([4]byte{4, 4, 4, 4})},
},
{
VPN: vpn.Wireguard,
Country: "Country3", City: "City C",
Hostname: "hostc",
WgPubKey: "xyz",
IPs: []netip.Addr{netip.AddrFrom4([4]byte{5, 5, 5, 5}), netip.AddrFrom4([4]byte{6, 6, 6, 6})},
},
},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
ctx := context.Background()
client := &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, r.URL.String(), "https://api.ivpn.net/v4/servers/stats")
return &http.Response{
StatusCode: testCase.responseStatus,
Status: http.StatusText(testCase.responseStatus),
Body: io.NopCloser(strings.NewReader(testCase.responseBody)),
}, nil
}),
}
parallelResolver := common.NewMockParallelResolver(ctrl)
if testCase.expectResolve {
parallelResolver.EXPECT().Resolve(ctx, testCase.resolveSettings).
Return(testCase.hostToIPs, testCase.resolveWarnings, testCase.resolveErr)
}
warner := testCase.warnerBuilder(ctrl)
updater := &Updater{
client: client,
parallelResolver: parallelResolver,
warner: warner,
}
servers, err := updater.FetchServers(ctx, testCase.minServers)
assert.Equal(t, testCase.servers, servers)
if testCase.err != nil {
require.Error(t, err)
assert.Equal(t, testCase.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
-23
View File
@@ -1,23 +0,0 @@
package updater
import (
"net/http"
"github.com/qdm12/gluetun/internal/provider/common"
)
type Updater struct {
client *http.Client
parallelResolver common.ParallelResolver
warner common.Warner
}
func New(client *http.Client, warner common.Warner,
parallelResolver common.ParallelResolver,
) *Updater {
return &Updater{
client: client,
parallelResolver: parallelResolver,
warner: warner,
}
}