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
+41
View File
@@ -0,0 +1,41 @@
package ipvanish
import (
"errors"
"fmt"
"strings"
"github.com/qdm12/gluetun/internal/constants"
"golang.org/x/text/cases"
)
var errCountryCodeUnknown = errors.New("country code is unknown")
func parseFilename(fileName, hostname string, titleCaser cases.Caser) (
country, city string, err error,
) {
const prefix = "ipvanish-"
s := strings.TrimPrefix(fileName, prefix)
const ext = ".ovpn"
host := strings.Split(hostname, ".")[0]
suffix := "-" + host + ext
s = strings.TrimSuffix(s, suffix)
parts := strings.Split(s, "-")
countryCodes := constants.CountryCodes()
countryCode := strings.ToLower(parts[0])
country, ok := countryCodes[countryCode]
if !ok {
return "", "", fmt.Errorf("%w: %s", errCountryCodeUnknown, countryCode)
}
country = titleCaser.String(country)
if len(parts) > 1 {
city = strings.Join(parts[1:], " ")
city = titleCaser.String(city)
}
return country, city, nil
}
+56
View File
@@ -0,0 +1,56 @@
package ipvanish
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func Test_parseFilename(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
fileName string
hostname string
country string
city string
err error
}{
"unknown country code": {
fileName: "ipvanish-unknown-host.ovpn",
hostname: "host.ipvanish.com",
err: errors.New("country code is unknown: unknown"),
},
"country code only": {
fileName: "ipvanish-ca-host.ovpn",
hostname: "host.ipvanish.com",
country: "Canada",
},
"country code and city": {
fileName: "ipvanish-ca-sao-paulo-host.ovpn",
hostname: "host.ipvanish.com",
country: "Canada",
city: "Sao Paulo",
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
titleCaser := cases.Title(language.English)
country, city, err := parseFilename(testCase.fileName, testCase.hostname, titleCaser)
if testCase.err != nil {
require.Error(t, err)
assert.Equal(t, testCase.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
assert.Equal(t, testCase.country, country)
assert.Equal(t, testCase.city, city)
})
}
}
+60
View File
@@ -0,0 +1,60 @@
package ipvanish
import (
"net/netip"
"sort"
"github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gluetun/internal/models"
)
type hostToServer map[string]models.Server
func (hts hostToServer) add(host, country, city string, tcp, udp bool) {
server, ok := hts[host]
if !ok {
server.VPN = vpn.OpenVPN
server.Hostname = host
server.Country = country
server.City = city
}
if tcp {
server.TCP = tcp
}
if udp {
server.UDP = udp
}
hts[host] = server
}
func (hts hostToServer) toHostsSlice() (hosts []string) {
hosts = make([]string, 0, len(hts))
for host := range hts {
hosts = append(hosts, host)
}
sort.Slice(hosts, func(i, j int) bool {
return hosts[i] < hosts[j]
})
return hosts
}
func (hts hostToServer) adaptWithIPs(hostToIPs map[string][]netip.Addr) {
for host, IPs := range hostToIPs {
server := hts[host]
server.IPs = IPs
hts[host] = server
}
for host, server := range hts {
if len(server.IPs) == 0 {
delete(hts, host)
}
}
}
func (hts hostToServer) toServersSlice() (servers []models.Server) {
servers = make([]models.Server, 0, len(hts))
for _, server := range hts {
servers = append(servers, server)
}
return servers
}
+212
View File
@@ -0,0 +1,212 @@
package ipvanish
import (
"net/netip"
"testing"
"github.com/qdm12/gluetun/internal/constants/vpn"
"github.com/qdm12/gluetun/internal/models"
"github.com/stretchr/testify/assert"
)
func Test_hostToServer_add(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
initialHTS hostToServer
host string
country string
city string
tcp bool
udp bool
expectedHTS hostToServer
}{
"empty host to server": {
initialHTS: hostToServer{},
host: "host",
country: "country",
city: "city",
tcp: true,
udp: true,
expectedHTS: hostToServer{
"host": {
VPN: vpn.OpenVPN,
Hostname: "host",
Country: "country",
City: "city",
TCP: true,
UDP: true,
},
},
},
"add server": {
initialHTS: hostToServer{
"existing host": {},
},
host: "host",
country: "country",
city: "city",
tcp: true,
udp: true,
expectedHTS: hostToServer{
"existing host": {},
"host": models.Server{
VPN: vpn.OpenVPN,
Hostname: "host",
Country: "country",
City: "city",
TCP: true,
UDP: true,
},
},
},
"extend existing server": {
initialHTS: hostToServer{
"host": models.Server{
VPN: vpn.OpenVPN,
Hostname: "host",
Country: "country",
City: "city",
TCP: true,
},
},
host: "host",
country: "country",
city: "city",
tcp: false,
udp: true,
expectedHTS: hostToServer{
"host": models.Server{
VPN: vpn.OpenVPN,
Hostname: "host",
Country: "country",
City: "city",
TCP: true,
UDP: true,
},
},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
testCase.initialHTS.add(testCase.host, testCase.country, testCase.city, testCase.tcp, testCase.udp)
assert.Equal(t, testCase.expectedHTS, testCase.initialHTS)
})
}
}
func Test_hostToServer_toHostsSlice(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
hts hostToServer
hosts []string
}{
"empty host to server": {
hts: hostToServer{},
hosts: []string{},
},
"single host": {
hts: hostToServer{
"A": {},
},
hosts: []string{"A"},
},
"multiple hosts": {
hts: hostToServer{
"A": {},
"B": {},
},
hosts: []string{"A", "B"},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
hosts := testCase.hts.toHostsSlice()
assert.ElementsMatch(t, testCase.hosts, hosts)
})
}
}
func Test_hostToServer_adaptWithIPs(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
initialHTS hostToServer
hostToIPs map[string][]netip.Addr
expectedHTS hostToServer
}{
"create server": {
initialHTS: hostToServer{},
hostToIPs: map[string][]netip.Addr{
"A": {netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
expectedHTS: hostToServer{
"A": models.Server{
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
},
},
"add IPs to existing server": {
initialHTS: hostToServer{
"A": models.Server{
Country: "country",
},
},
hostToIPs: map[string][]netip.Addr{
"A": {netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
expectedHTS: hostToServer{
"A": models.Server{
Country: "country",
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
},
},
"remove server without IP": {
initialHTS: hostToServer{
"A": models.Server{
Country: "country",
},
},
hostToIPs: map[string][]netip.Addr{},
expectedHTS: hostToServer{},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
testCase.initialHTS.adaptWithIPs(testCase.hostToIPs)
assert.Equal(t, testCase.expectedHTS, testCase.initialHTS)
})
}
}
func Test_hostToServer_toServersSlice(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
hts hostToServer
servers []models.Server
}{
"empty host to server": {
hts: hostToServer{},
servers: []models.Server{},
},
"multiple servers": {
hts: hostToServer{
"A": {Country: "A"},
"B": {Country: "B"},
},
servers: []models.Server{
{Country: "A"},
{Country: "B"},
},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
servers := testCase.hts.toServersSlice()
assert.ElementsMatch(t, testCase.servers, servers)
})
}
}
+28
View File
@@ -0,0 +1,28 @@
package ipvanish
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,
},
}
}
+90
View File
@@ -0,0 +1,90 @@
package ipvanish
import (
"context"
"fmt"
"sort"
"strings"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/provider/common"
"github.com/qdm12/gluetun/internal/updater/openvpn"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
servers []models.Server, err error,
) {
const url = "https://configs.ipvanish.com/openvpn/v2.6.0-0/configs.zip"
contents, err := u.unzipper.FetchAndExtract(ctx, url)
if err != nil {
return nil, err
} else if len(contents) < minServers {
return nil, fmt.Errorf("%w: %d and expected at least %d",
common.ErrNotEnoughServers, len(contents), minServers)
}
hts := make(hostToServer)
titleCaser := cases.Title(language.English)
for fileName, content := range contents {
if !strings.HasSuffix(fileName, ".ovpn") {
continue // not an OpenVPN file
}
tcp, udp, err := openvpn.ExtractProto(content)
if err != nil {
// treat error as warning and go to next file
u.warner.Warn(err.Error() + " in " + fileName)
continue
}
hostname, warning, err := openvpn.ExtractHost(content)
if warning != "" {
u.warner.Warn(warning)
}
if err != nil {
// treat error as warning and go to next file
u.warner.Warn(err.Error() + " in " + fileName)
continue
}
country, city, err := parseFilename(fileName, hostname, titleCaser)
if err != nil {
// treat error as warning and go to next file
u.warner.Warn(err.Error() + " in " + fileName)
continue
}
hts.add(hostname, country, city, tcp, udp)
}
if len(hts) < minServers {
return nil, fmt.Errorf("%w: %d and expected at least %d",
common.ErrNotEnoughServers, len(hts), minServers)
}
hosts := hts.toHostsSlice()
resolveSettings := parallelResolverSettings(hosts)
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)
}
hts.adaptWithIPs(hostToIPs)
servers = hts.toServersSlice()
sort.Sort(models.SortableServers(servers))
return servers, nil
}
+224
View File
@@ -0,0 +1,224 @@
package ipvanish
import (
"context"
"errors"
"net/netip"
"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
// Unzip
unzipContents map[string][]byte
unzipErr error
// Resolution
expectResolve bool
resolverSettings resolver.ParallelSettings
hostToIPs map[string][]netip.Addr
resolveWarnings []string
resolveErr error
// Output
servers []models.Server
err error
}{
"unzipper error": {
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
unzipErr: errors.New("dummy"),
err: errors.New("dummy"),
},
"not enough unzip contents": {
minServers: 1,
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
unzipContents: map[string][]byte{},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"no openvpn file": {
minServers: 1,
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
unzipContents: map[string][]byte{"somefile.txt": {}},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"invalid proto": {
minServers: 1,
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
warner := common.NewMockWarner(ctrl)
warner.EXPECT().Warn("unknown protocol: invalid in badproto.ovpn")
return warner
},
unzipContents: map[string][]byte{"badproto.ovpn": []byte(`proto invalid`)},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"no host": {
minServers: 1,
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
warner := common.NewMockWarner(ctrl)
warner.EXPECT().Warn("remote host not found in nohost.ovpn")
return warner
},
unzipContents: map[string][]byte{"nohost.ovpn": []byte(``)},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"multiple hosts": {
minServers: 1,
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
warner := common.NewMockWarner(ctrl)
warner.EXPECT().Warn("only using the first host \"hosta\" and discarding 1 other hosts")
return warner
},
unzipContents: map[string][]byte{
"ipvanish-CA-City-A-hosta.ovpn": []byte("remote hosta\nremote hostb"),
},
expectResolve: true,
resolverSettings: resolver.ParallelSettings{
Hosts: []string{"hosta"},
MaxFailRatio: 0.1,
Repeat: resolver.RepeatSettings{
MaxDuration: 20 * time.Second,
BetweenDuration: time.Second,
MaxNoNew: 2,
MaxFails: 2,
SortIPs: true,
},
},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"resolve error": {
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
warner := common.NewMockWarner(ctrl)
warner.EXPECT().Warn("resolve warning")
return warner
},
unzipContents: map[string][]byte{
"ipvanish-CA-City-A-hosta.ovpn": []byte("remote hosta"),
},
expectResolve: true,
resolverSettings: 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"),
},
"filename parsing error": {
minServers: 1,
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
warner := common.NewMockWarner(ctrl)
warner.EXPECT().Warn("country code is unknown: unknown in ipvanish-unknown-City-A-hosta.ovpn")
return warner
},
unzipContents: map[string][]byte{
"ipvanish-unknown-City-A-hosta.ovpn": []byte("remote hosta"),
},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"success": {
minServers: 1,
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
warner := common.NewMockWarner(ctrl)
warner.EXPECT().Warn("resolve warning")
return warner
},
unzipContents: map[string][]byte{
"ipvanish-CA-City-A-hosta.ovpn": []byte("remote hosta"),
"ipvanish-LU-City-B-hostb.ovpn": []byte("remote hostb"),
},
expectResolve: true,
resolverSettings: resolver.ParallelSettings{
Hosts: []string{"hosta", "hostb"},
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})},
},
resolveWarnings: []string{"resolve warning"},
servers: []models.Server{
{
VPN: vpn.OpenVPN,
Country: "Canada",
City: "City A",
Hostname: "hosta",
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: "Luxembourg",
City: "City B",
Hostname: "hostb",
UDP: true,
IPs: []netip.Addr{netip.AddrFrom4([4]byte{3, 3, 3, 3}), netip.AddrFrom4([4]byte{4, 4, 4, 4})},
},
},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
ctx := context.Background()
unzipper := common.NewMockUnzipper(ctrl)
const zipURL = "https://configs.ipvanish.com/openvpn/v2.6.0-0/configs.zip"
unzipper.EXPECT().FetchAndExtract(ctx, zipURL).
Return(testCase.unzipContents, testCase.unzipErr)
parallelResolver := common.NewMockParallelResolver(ctrl)
if testCase.expectResolve {
parallelResolver.EXPECT().Resolve(ctx, testCase.resolverSettings).
Return(testCase.hostToIPs, testCase.resolveWarnings, testCase.resolveErr)
}
updater := &Updater{
unzipper: unzipper,
warner: testCase.warnerBuilder(ctrl),
parallelResolver: parallelResolver,
}
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)
}
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package ipvanish
import (
"github.com/qdm12/gluetun/internal/provider/common"
)
type Updater struct {
unzipper common.Unzipper
warner common.Warner
parallelResolver common.ParallelResolver
}
func New(unzipper common.Unzipper, warner common.Warner,
parallelResolver common.ParallelResolver,
) *Updater {
return &Updater{
unzipper: unzipper,
warner: warner,
parallelResolver: parallelResolver,
}
}
func (u *Updater) Version() uint16 {
return 2 //nolint:mnd
}