Feature: IVPN support

This commit is contained in:
Quentin McGaw (desktop)
2021-05-31 00:11:16 +00:00
parent 835fa6c41f
commit 8b8bab5c58
43 changed files with 1505 additions and 7 deletions

3
.github/labels.yml vendored
View File

@@ -21,6 +21,9 @@
- name: ":cloud: HideMyAss"
color: "cfe8d4"
description: ""
- name: ":cloud: IVPN"
color: "cfe8d4"
description: ""
- name: ":cloud: FastestVPN"
color: "cfe8d4"
description: ""

View File

@@ -1,7 +1,7 @@
# Gluetun VPN client
*Lightweight swiss-knife-like VPN client to tunnel to Cyberghost, FastestVPN,
HideMyAss, Mullvad, NordVPN, Privado, Private Internet Access, PrivateVPN,
HideMyAss, IVPN, Mullvad, NordVPN, Privado, Private Internet Access, PrivateVPN,
ProtonVPN, PureVPN, Surfshark, TorGuard, VyprVPN and Windscribe VPN servers
using Go, OpenVPN, iptables, DNS over TLS, ShadowSocks and an HTTP proxy*
@@ -39,7 +39,7 @@ using Go, OpenVPN, iptables, DNS over TLS, ShadowSocks and an HTTP proxy*
## Features
- Based on Alpine 3.13 for a small Docker image of 52MB
- Supports: **Cyberghost**, **FastestVPN**, **HideMyAss**, **Mullvad**, **NordVPN**, **Privado**, **Private Internet Access**, **PrivateVPN**, **ProtonVPN**, **PureVPN**, **Surfshark**, **TorGuard**, **Vyprvpn**, **Windscribe** servers
- Supports: **Cyberghost**, **FastestVPN**, **HideMyAss**, **IVPN**, **Mullvad**, **NordVPN**, **Privado**, **Private Internet Access**, **PrivateVPN**, **ProtonVPN**, **PureVPN**, **Surfshark**, **TorGuard**, **Vyprvpn**, **Windscribe** servers
- Supports Openvpn only for now
- DNS over TLS baked in with service provider(s) of your choice
- DNS fine blocking of malicious/ads/surveillance hostnames and IP addresses, with live update every 24 hours

View File

@@ -33,6 +33,7 @@ func (c *cli) Update(ctx context.Context, args []string, os os.OS, logger loggin
flagSet.BoolVar(&options.Cyberghost, "cyberghost", false, "Update Cyberghost servers")
flagSet.BoolVar(&options.Fastestvpn, "fastestvpn", false, "Update FastestVPN servers")
flagSet.BoolVar(&options.HideMyAss, "hidemyass", false, "Update HideMyAss servers")
flagSet.BoolVar(&options.Ivpn, "ivpn", false, "Update IVPN servers")
flagSet.BoolVar(&options.Mullvad, "mullvad", false, "Update Mullvad servers")
flagSet.BoolVar(&options.Nordvpn, "nordvpn", false, "Update Nordvpn servers")
flagSet.BoolVar(&options.PIA, "pia", false, "Update Private Internet Access post-summer 2020 servers")

View File

@@ -0,0 +1,52 @@
package configuration
import (
"github.com/qdm12/gluetun/internal/constants"
)
func (settings *Provider) ivpnLines() (lines []string) {
if len(settings.ServerSelection.Countries) > 0 {
lines = append(lines, lastIndent+"Countries: "+commaJoin(settings.ServerSelection.Countries))
}
if len(settings.ServerSelection.Cities) > 0 {
lines = append(lines, lastIndent+"Cities: "+commaJoin(settings.ServerSelection.Cities))
}
if len(settings.ServerSelection.Hostnames) > 0 {
lines = append(lines, lastIndent+"Hostnames: "+commaJoin(settings.ServerSelection.Hostnames))
}
return lines
}
func (settings *Provider) readIvpn(r reader) (err error) {
settings.Name = constants.Ivpn
settings.ServerSelection.TCP, err = readProtocol(r.env)
if err != nil {
return err
}
settings.ServerSelection.TargetIP, err = readTargetIP(r.env)
if err != nil {
return err
}
settings.ServerSelection.Countries, err = r.env.CSVInside("COUNTRY", constants.IvpnCountryChoices())
if err != nil {
return err
}
settings.ServerSelection.Cities, err = r.env.CSVInside("CITY", constants.IvpnCityChoices())
if err != nil {
return err
}
settings.ServerSelection.Hostnames, err = r.env.CSVInside("SERVER_HOSTNAME", constants.IvpnHostnameChoices())
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,192 @@
package configuration
import (
"errors"
"net"
"testing"
"github.com/golang/mock/gomock"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/golibs/params/mock_params"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_Provider_ivpnLines(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
settings Provider
lines []string
}{
"empty settings": {},
"full settings": {
settings: Provider{
ServerSelection: ServerSelection{
Countries: []string{"A", "B"},
Cities: []string{"C", "D"},
Hostnames: []string{"E", "F"},
},
},
lines: []string{
"|--Countries: A, B",
"|--Cities: C, D",
"|--Hostnames: E, F",
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
lines := testCase.settings.ivpnLines()
assert.Equal(t, testCase.lines, lines)
})
}
}
func Test_Provider_readIvpn(t *testing.T) {
t.Parallel()
var errDummy = errors.New("dummy test error")
type singleStringCall struct {
call bool
value string
err error
}
type sliceStringCall struct {
call bool
values []string
err error
}
testCases := map[string]struct {
protocol singleStringCall
targetIP singleStringCall
countries sliceStringCall
cities sliceStringCall
hostnames sliceStringCall
settings Provider
err error
}{
"protocol error": {
protocol: singleStringCall{call: true, err: errDummy},
settings: Provider{
Name: constants.Ivpn,
},
err: errDummy,
},
"target IP error": {
protocol: singleStringCall{call: true},
targetIP: singleStringCall{call: true, value: "something", err: errDummy},
settings: Provider{
Name: constants.Ivpn,
},
err: errDummy,
},
"countries error": {
protocol: singleStringCall{call: true},
targetIP: singleStringCall{call: true},
countries: sliceStringCall{call: true, err: errDummy},
settings: Provider{
Name: constants.Ivpn,
},
err: errDummy,
},
"cities error": {
protocol: singleStringCall{call: true},
targetIP: singleStringCall{call: true},
countries: sliceStringCall{call: true},
cities: sliceStringCall{call: true, err: errDummy},
settings: Provider{
Name: constants.Ivpn,
},
err: errDummy,
},
"hostnames error": {
protocol: singleStringCall{call: true},
targetIP: singleStringCall{call: true},
countries: sliceStringCall{call: true},
cities: sliceStringCall{call: true},
hostnames: sliceStringCall{call: true, err: errDummy},
settings: Provider{
Name: constants.Ivpn,
},
err: errDummy,
},
"default settings": {
protocol: singleStringCall{call: true},
targetIP: singleStringCall{call: true},
countries: sliceStringCall{call: true},
cities: sliceStringCall{call: true},
hostnames: sliceStringCall{call: true},
settings: Provider{
Name: constants.Ivpn,
},
},
"set settings": {
protocol: singleStringCall{call: true, value: constants.TCP},
targetIP: singleStringCall{call: true, value: "1.2.3.4"},
countries: sliceStringCall{call: true, values: []string{"A", "B"}},
cities: sliceStringCall{call: true, values: []string{"C", "D"}},
hostnames: sliceStringCall{call: true, values: []string{"E", "F"}},
settings: Provider{
Name: constants.Ivpn,
ServerSelection: ServerSelection{
TCP: true,
TargetIP: net.IPv4(1, 2, 3, 4),
Countries: []string{"A", "B"},
Cities: []string{"C", "D"},
Hostnames: []string{"E", "F"},
},
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
env := mock_params.NewMockEnv(ctrl)
if testCase.protocol.call {
env.EXPECT().Inside("PROTOCOL", []string{constants.TCP, constants.UDP}, gomock.Any()).
Return(testCase.protocol.value, testCase.protocol.err)
}
if testCase.targetIP.call {
env.EXPECT().Get("OPENVPN_TARGET_IP").
Return(testCase.targetIP.value, testCase.targetIP.err)
}
if testCase.countries.call {
env.EXPECT().CSVInside("COUNTRY", constants.IvpnCountryChoices()).
Return(testCase.countries.values, testCase.countries.err)
}
if testCase.cities.call {
env.EXPECT().CSVInside("CITY", constants.IvpnCityChoices()).
Return(testCase.cities.values, testCase.cities.err)
}
if testCase.hostnames.call {
env.EXPECT().CSVInside("SERVER_HOSTNAME", constants.IvpnHostnameChoices()).
Return(testCase.hostnames.values, testCase.hostnames.err)
}
r := reader{env: env}
var settings Provider
err := settings.readIvpn(r)
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.settings, settings)
})
}
}

View File

@@ -62,7 +62,7 @@ var (
func (settings *OpenVPN) read(r reader) (err error) {
vpnsp, err := r.env.Inside("VPNSP", []string{
"cyberghost", "fastestvpn", "hidemyass", "mullvad", "nordvpn",
"cyberghost", "fastestvpn", "hidemyass", "ivpn", "mullvad", "nordvpn",
"privado", "pia", "private internet access", "privatevpn", "protonvpn",
"purevpn", "surfshark", "torguard", "vyprvpn", "windscribe"},
params.Default("private internet access"))
@@ -132,6 +132,8 @@ func (settings *OpenVPN) read(r reader) (err error) {
readProvider = settings.Provider.readFastestvpn
case constants.HideMyAss:
readProvider = settings.Provider.readHideMyAss
case constants.Ivpn:
readProvider = settings.Provider.readIvpn
case constants.Mullvad:
readProvider = settings.Provider.readMullvad
case constants.Nordvpn:

View File

@@ -36,6 +36,8 @@ func (settings *Provider) lines() (lines []string) {
providerLines = settings.fastestvpnLines()
case "hidemyass":
providerLines = settings.hideMyAssLines()
case "ivpn":
providerLines = settings.ivpnLines()
case "mullvad":
providerLines = settings.mullvadLines()
case "nordvpn":

View File

@@ -73,6 +73,23 @@ func Test_Provider_lines(t *testing.T) {
" |--Hostnames: e, f",
},
},
"ivpn": {
settings: Provider{
Name: constants.Ivpn,
ServerSelection: ServerSelection{
Countries: []string{"a", "b"},
Cities: []string{"c", "d"},
Hostnames: []string{"e", "f"},
},
},
lines: []string{
"|--Ivpn settings:",
" |--Network protocol: udp",
" |--Countries: a, b",
" |--Cities: c, d",
" |--Hostnames: e, f",
},
},
"mullvad": {
settings: Provider{
Name: constants.Mullvad,

View File

@@ -15,9 +15,9 @@ type ServerSelection struct { //nolint:maligned
// Cyberghost
Group string `json:"group"`
Countries []string `json:"countries"` // Fastestvpn, HideMyAss, Mullvad, PrivateVPN, Protonvpn, PureVPN
Cities []string `json:"cities"` // HideMyAss, Mullvad, PrivateVPN, Protonvpn, PureVPN, Windscribe
Hostnames []string `json:"hostnames"` // Fastestvpn, HideMyAss, PrivateVPN, Windscribe, Privado, Protonvpn
Countries []string `json:"countries"` // Fastestvpn, HideMyAss, IVPN, Mullvad, PrivateVPN, Protonvpn, PureVPN
Cities []string `json:"cities"` // HideMyAss, IVPN, Mullvad, PrivateVPN, Protonvpn, PureVPN, Windscribe
Hostnames []string `json:"hostnames"` // Fastestvpn, HideMyAss, IVPN, PrivateVPN, Windscribe, Privado, Protonvpn
Names []string `json:"names"` // Protonvpn
// Mullvad

View File

@@ -13,6 +13,7 @@ type Updater struct {
Cyberghost bool `json:"cyberghost"`
Fastestvpn bool `json:"fastestvpn"`
HideMyAss bool `json:"hidemyass"`
Ivpn bool `json:"ivpn"`
Mullvad bool `json:"mullvad"`
Nordvpn bool `json:"nordvpn"`
PIA bool `json:"pia"`
@@ -48,6 +49,7 @@ func (settings *Updater) lines() (lines []string) {
func (settings *Updater) read(r reader) (err error) {
settings.Cyberghost = true
settings.HideMyAss = true
settings.Ivpn = true
settings.Mullvad = true
settings.Nordvpn = true
settings.Privado = true

View File

@@ -0,0 +1,90 @@
package constants
import (
"net"
"github.com/qdm12/gluetun/internal/models"
)
//nolint:lll
const (
IvpnCA = "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"
IvpnOpenvpnStaticKeyV1 = "ac470c93ff9f5602a8aab37dee84a52814d10f20490ad23c47d5d82120c1bf859e93d0696b455d4a1b8d55d40c2685c41ca1d0aef29a3efd27274c4ef09020a3978fe45784b335da6df2d12db97bbb838416515f2a96f04715fd28949c6fe296a925cfada3f8b8928ed7fc963c1563272f5cf46e5e1d9c845d7703ca881497b7e6564a9d1dea9358adffd435295479f47d5298fabf5359613ff5992cb57ff081a04dfb81a26513a6b44a9b5490ad265f8a02384832a59cc3e075ad545461060b7bcab49bac815163cb80983dd51d5b1fd76170ffd904d8291071e96efc3fb777856c717b148d08a510f5687b8a8285dcffe737b98916dd15ef6235dee4266d3b"
)
func IvpnCountryChoices() (choices []string) {
servers := IvpnServers()
choices = make([]string, len(servers))
for i := range servers {
choices[i] = servers[i].Country
}
return makeUnique(choices)
}
func IvpnCityChoices() (choices []string) {
servers := IvpnServers()
choices = make([]string, len(servers))
for i := range servers {
choices[i] = servers[i].City
}
return makeUnique(choices)
}
func IvpnHostnameChoices() (choices []string) {
servers := IvpnServers()
choices = make([]string, len(servers))
for i := range servers {
choices[i] = servers[i].Hostname
}
return makeUnique(choices)
}
//nolint:lll
// IvpnServers returns a slice of all the server information for Ivpn.
func IvpnServers() []models.IvpnServer {
return []models.IvpnServer{
{Country: "Australia", City: "", Hostname: "au-nsw.gw.ivpn.net", IPs: []net.IP{{46, 102, 153, 242}}},
{Country: "Austria", City: "", Hostname: "at.gw.ivpn.net", IPs: []net.IP{{185, 244, 212, 66}}},
{Country: "Belgium", City: "", Hostname: "be.gw.ivpn.net", IPs: []net.IP{{194, 187, 251, 10}}},
{Country: "Brazil", City: "", Hostname: "br.gw.ivpn.net", IPs: []net.IP{{45, 162, 229, 130}}},
{Country: "Canada", City: "Montreal", Hostname: "ca-qc.gw.ivpn.net", IPs: []net.IP{{87, 101, 92, 26}}},
{Country: "Canada", City: "Toronto", Hostname: "ca.gw.ivpn.net", IPs: []net.IP{{104, 254, 90, 178}}},
{Country: "Czech Republic", City: "", Hostname: "cz.gw.ivpn.net", IPs: []net.IP{{195, 181, 160, 167}}},
{Country: "Denmark", City: "", Hostname: "dk.gw.ivpn.net", IPs: []net.IP{{185, 245, 84, 226}}},
{Country: "Finland", City: "", Hostname: "fi.gw.ivpn.net", IPs: []net.IP{{185, 112, 82, 12}}},
{Country: "France", City: "", Hostname: "fr.gw.ivpn.net", IPs: []net.IP{{185, 246, 211, 179}}},
{Country: "Germany", City: "", Hostname: "de.gw.ivpn.net", IPs: []net.IP{{178, 162, 211, 114}}},
{Country: "Hong Kong", City: "", Hostname: "hk.gw.ivpn.net", IPs: []net.IP{{209, 58, 188, 13}}},
{Country: "Hungary", City: "", Hostname: "hu.gw.ivpn.net", IPs: []net.IP{{185, 189, 114, 186}}},
{Country: "Iceland", City: "", Hostname: "is.gw.ivpn.net", IPs: []net.IP{{82, 221, 107, 178}}},
{Country: "Israel", City: "", Hostname: "il.gw.ivpn.net", IPs: []net.IP{{185, 191, 207, 194}}},
{Country: "Italy", City: "", Hostname: "it.gw.ivpn.net", IPs: []net.IP{{158, 58, 172, 73}}},
{Country: "Japan", City: "", Hostname: "jp.gw.ivpn.net", IPs: []net.IP{{91, 207, 174, 234}}},
{Country: "Luxembourg", City: "", Hostname: "lu.gw.ivpn.net", IPs: []net.IP{{92, 223, 89, 53}}},
{Country: "Netherlands", City: "", Hostname: "nl.gw.ivpn.net", IPs: []net.IP{{95, 211, 172, 95}}},
{Country: "Norway", City: "", Hostname: "no.gw.ivpn.net", IPs: []net.IP{{194, 242, 10, 150}}},
{Country: "Poland", City: "", Hostname: "pl.gw.ivpn.net", IPs: []net.IP{{185, 246, 208, 86}}},
{Country: "Portugal", City: "", Hostname: "pt.gw.ivpn.net", IPs: []net.IP{{94, 46, 175, 112}}},
{Country: "Romania", City: "", Hostname: "ro.gw.ivpn.net", IPs: []net.IP{{37, 120, 206, 50}}},
{Country: "Serbia", City: "", Hostname: "rs.gw.ivpn.net", IPs: []net.IP{{141, 98, 103, 250}}},
{Country: "Singapore", City: "", Hostname: "sg.gw.ivpn.net", IPs: []net.IP{{185, 128, 24, 186}}},
{Country: "Slovakia", City: "", Hostname: "sk.gw.ivpn.net", IPs: []net.IP{{185, 245, 85, 250}}},
{Country: "Sweden", City: "", Hostname: "se.gw.ivpn.net", IPs: []net.IP{{80, 67, 10, 138}}},
{Country: "Switzerland", City: "", Hostname: "ch.gw.ivpn.net", IPs: []net.IP{{185, 212, 170, 138}}},
{Country: "USA", City: "Atlanta", Hostname: "us-ga.gw.ivpn.net", IPs: []net.IP{{104, 129, 24, 146}}},
{Country: "USA", City: "Chicago", Hostname: "us-il.gw.ivpn.net", IPs: []net.IP{{72, 11, 137, 146}}},
{Country: "USA", City: "Dallas", Hostname: "us-tx.gw.ivpn.net", IPs: []net.IP{{96, 44, 189, 194}}},
{Country: "USA", City: "Las Vegas", Hostname: "us-nv.gw.ivpn.net", IPs: []net.IP{{185, 242, 5, 34}}},
{Country: "USA", City: "Los Angeles", Hostname: "us-ca.gw.ivpn.net", IPs: []net.IP{{69, 12, 80, 146}}},
{Country: "USA", City: "Miami", Hostname: "us-fl.gw.ivpn.net", IPs: []net.IP{{173, 44, 49, 90}}},
{Country: "USA", City: "New Jersey", Hostname: "us-nj.gw.ivpn.net", IPs: []net.IP{{23, 226, 128, 18}}},
{Country: "USA", City: "New York", Hostname: "us-ny.gw.ivpn.net", IPs: []net.IP{{64, 120, 44, 114}}},
{Country: "USA", City: "Phoenix", Hostname: "us-az.gw.ivpn.net", IPs: []net.IP{{193, 37, 254, 130}}},
{Country: "USA", City: "Salt Lake City", Hostname: "us-ut.gw.ivpn.net", IPs: []net.IP{{198, 105, 216, 28}}},
{Country: "USA", City: "Seattle", Hostname: "us-wa.gw.ivpn.net", IPs: []net.IP{{23, 19, 87, 209}}},
{Country: "USA", City: "Washington", Hostname: "us-dc.gw.ivpn.net", IPs: []net.IP{{207, 244, 108, 207}}},
{Country: "Ukraine", City: "", Hostname: "ua.gw.ivpn.net", IPs: []net.IP{{193, 203, 48, 54}}},
{Country: "United Kingdom", City: "London", Hostname: "gb.gw.ivpn.net", IPs: []net.IP{{185, 59, 221, 133}, {185, 59, 221, 88}}},
{Country: "United Kingdom", City: "Manchester", Hostname: "gb-man.gw.ivpn.net", IPs: []net.IP{{89, 238, 141, 228}}},
}
}

View File

@@ -21,6 +21,11 @@ func GetAllServers() (allServers models.AllServers) {
Timestamp: 1620435633,
Servers: HideMyAssServers(),
},
Ivpn: models.IvpnServers{
Version: 1,
Timestamp: 1622406883,
Servers: IvpnServers(),
},
Mullvad: models.MullvadServers{
Version: 2,
Timestamp: 1620500848,

View File

@@ -50,6 +50,11 @@ func Test_versions(t *testing.T) {
version: allServers.HideMyAss.Version,
digest: "a93b4057",
},
"Ivpn": {
model: models.IvpnServer{},
version: allServers.Ivpn.Version,
digest: "2eb80d28",
},
"Mullvad": {
model: models.MullvadServer{},
version: allServers.Mullvad.Version,
@@ -157,6 +162,11 @@ func Test_timestamps(t *testing.T) {
timestamp: allServers.HideMyAss.Timestamp,
digest: "8f872ac4",
},
"Ivpn": {
servers: allServers.Ivpn.Servers,
timestamp: allServers.Ivpn.Timestamp,
digest: "a648c5f1",
},
"Mullvad": {
servers: allServers.Mullvad.Servers,
timestamp: allServers.Mullvad.Timestamp,

View File

@@ -7,6 +7,8 @@ const (
Fastestvpn = "fastestvpn"
// HideMyAss is a VPN provider.
HideMyAss = "hidemyass"
// Ivpn is a VPN provider.
Ivpn = "ivpn"
// Mullvad is a VPN provider.
Mullvad = "mullvad"
// NordVPN is a VPN provider.

View File

@@ -47,6 +47,20 @@ func (s *HideMyAssServer) String() string {
s.Country, s.Region, s.City, s.Hostname, s.TCP, s.UDP, goStringifyIPs(s.IPs))
}
type IvpnServer struct {
Country string `json:"country"`
City string `json:"city"`
Hostname string `json:"hostname"`
TCP bool `json:"tcp"`
UDP bool `json:"udp"`
IPs []net.IP `json:"ips"`
}
func (s *IvpnServer) String() string {
return fmt.Sprintf("{Country: %q, City: %q, Hostname: %q, IPs: %s}",
s.Country, s.City, s.Hostname, goStringifyIPs(s.IPs))
}
type MullvadServer struct {
IPs []net.IP `json:"ips"`
IPsV6 []net.IP `json:"ipsv6"`

View File

@@ -5,6 +5,7 @@ type AllServers struct {
Cyberghost CyberghostServers `json:"cyberghost"`
Fastestvpn FastestvpnServers `json:"fastestvpn"`
HideMyAss HideMyAssServers `json:"hidemyass"`
Ivpn IvpnServers `json:"ivpn"`
Mullvad MullvadServers `json:"mullvad"`
Nordvpn NordvpnServers `json:"nordvpn"`
Privado PrivadoServers `json:"privado"`
@@ -22,6 +23,7 @@ func (a *AllServers) Count() int {
return len(a.Cyberghost.Servers) +
len(a.Fastestvpn.Servers) +
len(a.HideMyAss.Servers) +
len(a.Ivpn.Servers) +
len(a.Mullvad.Servers) +
len(a.Nordvpn.Servers) +
len(a.Privado.Servers) +
@@ -50,6 +52,11 @@ type HideMyAssServers struct {
Timestamp int64 `json:"timestamp"`
Servers []HideMyAssServer `json:"servers"`
}
type IvpnServers struct {
Version uint16 `json:"version"`
Timestamp int64 `json:"timestamp"`
Servers []IvpnServer `json:"servers"`
}
type MullvadServers struct {
Version uint16 `json:"version"`
Timestamp int64 `json:"timestamp"`

View File

@@ -196,7 +196,7 @@ func setConnectionToLines(lines []string, connection models.OpenVPNConnection) (
lines[i] = "proto " + connection.Protocol
case strings.HasPrefix(line, "remote "):
lines[i] = "remote " + connection.IP.String() + " " + strconv.Itoa(int(connection.Port))
lines[i] = "remote " + connection.RemoteLine()
}
}

View File

@@ -0,0 +1,45 @@
package ivpn
import (
"errors"
"github.com/qdm12/gluetun/internal/configuration"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/provider/utils"
)
var ErrProtocolUnsupported = errors.New("network protocol is not supported")
func (i *Ivpn) GetOpenVPNConnection(selection configuration.ServerSelection) (
connection models.OpenVPNConnection, err error) {
const port = 2049
const protocol = constants.UDP
if selection.TCP {
return connection, ErrProtocolUnsupported
}
servers, err := i.filterServers(selection)
if err != nil {
return connection, err
}
var connections []models.OpenVPNConnection
for _, server := range servers {
for _, IP := range server.IPs {
connection := models.OpenVPNConnection{
IP: IP,
Port: port,
Protocol: protocol,
Hostname: server.Hostname,
}
connections = append(connections, connection)
}
}
if selection.TargetIP != nil {
return utils.GetTargetIPConnection(connections, selection.TargetIP)
}
return utils.PickRandomConnection(connections, i.randSource), nil
}

View File

@@ -0,0 +1,29 @@
package ivpn
import (
"github.com/qdm12/gluetun/internal/configuration"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/provider/utils"
)
func (i *Ivpn) filterServers(selection configuration.ServerSelection) (
servers []models.IvpnServer, err error) {
for _, server := range i.servers {
switch {
case
utils.FilterByPossibilities(server.Country, selection.Countries),
utils.FilterByPossibilities(server.City, selection.Cities),
utils.FilterByPossibilities(server.Hostname, selection.Hostnames),
selection.TCP && !server.TCP,
!selection.TCP && !server.UDP:
default:
servers = append(servers, server)
}
}
if len(servers) == 0 {
return nil, utils.NoServerFoundError(selection)
}
return servers, nil
}

View File

@@ -0,0 +1,71 @@
package ivpn
import (
"strconv"
"github.com/qdm12/gluetun/internal/configuration"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/provider/utils"
)
func (i *Ivpn) BuildConf(connection models.OpenVPNConnection,
username string, settings configuration.OpenVPN) (lines []string) {
if settings.Cipher == "" {
settings.Cipher = constants.AES256cbc
}
lines = []string{
"client",
"dev tun",
"nobind",
"persist-key",
"ping 5",
"ping-exit 30",
"ping-timer-rem",
"tls-exit",
// IVPN specific
"remote-cert-tls server", // updated name of ns-cert-type
"comp-lzo no",
"key-direction 1",
"tls-cipher TLS-DHE-RSA-WITH-AES-256-CBC-SHA:TLS-DHE-DSS-WITH-AES-256-CBC-SHA:TLS-RSA-WITH-AES-256-CBC-SHA",
// Added constant values
"mute-replay-warnings",
"auth-nocache",
"pull-filter ignore \"auth-token\"", // prevent auth failed loops
"auth-retry nointeract",
"suppress-timestamps",
// Modified variables
"verb " + strconv.Itoa(settings.Verbosity),
"auth-user-pass " + constants.OpenVPNAuthConf,
"proto " + connection.Protocol,
"remote " + connection.RemoteLine(),
"verify-x509-name " + connection.Hostname, // + " name-prefix"
}
lines = append(lines, utils.CipherLines(settings.Cipher, settings.Version)...)
if settings.Auth != "" {
lines = append(lines, "auth "+settings.Auth)
}
if settings.MSSFix > 0 {
lines = append(lines, "mssfix "+strconv.Itoa(int(settings.MSSFix)))
}
if !settings.Root {
lines = append(lines, "user "+username)
}
lines = append(lines, utils.WrapOpenvpnCA(
constants.IvpnCA)...)
lines = append(lines, utils.WrapOpenvpnTLSAuth(
constants.IvpnOpenvpnStaticKeyV1)...)
lines = append(lines, "")
return lines
}

View File

@@ -0,0 +1,17 @@
package ivpn
import (
"context"
"net"
"net/http"
"github.com/qdm12/gluetun/internal/firewall"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/os"
)
func (i *Ivpn) PortForward(ctx context.Context, client *http.Client,
openFile os.OpenFileFunc, pfLogger logging.Logger, gateway net.IP,
fw firewall.Configurator, syncState func(port uint16) (pfFilepath string)) {
panic("port forwarding is not supported for Ivpn")
}

View File

@@ -0,0 +1,19 @@
package ivpn
import (
"math/rand"
"github.com/qdm12/gluetun/internal/models"
)
type Ivpn struct {
servers []models.IvpnServer
randSource rand.Source
}
func New(servers []models.IvpnServer, randSource rand.Source) *Ivpn {
return &Ivpn{
servers: servers,
randSource: randSource,
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/qdm12/gluetun/internal/provider/cyberghost"
"github.com/qdm12/gluetun/internal/provider/fastestvpn"
"github.com/qdm12/gluetun/internal/provider/hidemyass"
"github.com/qdm12/gluetun/internal/provider/ivpn"
"github.com/qdm12/gluetun/internal/provider/mullvad"
"github.com/qdm12/gluetun/internal/provider/nordvpn"
"github.com/qdm12/gluetun/internal/provider/privado"
@@ -48,6 +49,8 @@ func New(provider string, allServers models.AllServers, timeNow func() time.Time
return fastestvpn.New(allServers.Fastestvpn.Servers, randSource)
case constants.HideMyAss:
return hidemyass.New(allServers.HideMyAss.Servers, randSource)
case constants.Ivpn:
return ivpn.New(allServers.Ivpn.Servers, randSource)
case constants.Mullvad:
return mullvad.New(allServers.Mullvad.Servers, randSource)
case constants.Nordvpn:

View File

@@ -35,6 +35,7 @@ func (s *storage) mergeServers(hardcoded, persisted models.AllServers) models.Al
Cyberghost: s.mergeCyberghost(hardcoded.Cyberghost, persisted.Cyberghost),
Fastestvpn: s.mergeFastestvpn(hardcoded.Fastestvpn, persisted.Fastestvpn),
HideMyAss: s.mergeHideMyAss(hardcoded.HideMyAss, persisted.HideMyAss),
Ivpn: s.mergeIvpn(hardcoded.Ivpn, persisted.Ivpn),
Mullvad: s.mergeMullvad(hardcoded.Mullvad, persisted.Mullvad),
Nordvpn: s.mergeNordVPN(hardcoded.Nordvpn, persisted.Nordvpn),
Privado: s.mergePrivado(hardcoded.Privado, persisted.Privado),
@@ -90,6 +91,19 @@ func (s *storage) mergeHideMyAss(hardcoded, persisted models.HideMyAssServers) m
return persisted
}
func (s *storage) mergeIvpn(hardcoded, persisted models.IvpnServers) models.IvpnServers {
if persisted.Timestamp <= hardcoded.Timestamp {
return hardcoded
}
versionDiff := hardcoded.Version - persisted.Version
if versionDiff > 0 {
s.logVersionDiff("Ivpn", versionDiff)
return hardcoded
}
s.logTimeDiff("Ivpn", persisted.Timestamp, hardcoded.Timestamp)
return persisted
}
func (s *storage) mergeMullvad(hardcoded, persisted models.MullvadServers) models.MullvadServers {
if persisted.Timestamp <= hardcoded.Timestamp {
return hardcoded

View File

@@ -21,6 +21,7 @@ func countServers(allServers models.AllServers) int {
return len(allServers.Cyberghost.Servers) +
len(allServers.Fastestvpn.Servers) +
len(allServers.HideMyAss.Servers) +
len(allServers.Ivpn.Servers) +
len(allServers.Mullvad.Servers) +
len(allServers.Nordvpn.Servers) +
len(allServers.Privado.Servers) +

View File

@@ -7,6 +7,7 @@ import (
"github.com/qdm12/gluetun/internal/updater/providers/cyberghost"
"github.com/qdm12/gluetun/internal/updater/providers/fastestvpn"
"github.com/qdm12/gluetun/internal/updater/providers/hidemyass"
"github.com/qdm12/gluetun/internal/updater/providers/ivpn"
"github.com/qdm12/gluetun/internal/updater/providers/mullvad"
"github.com/qdm12/gluetun/internal/updater/providers/nordvpn"
"github.com/qdm12/gluetun/internal/updater/providers/pia"
@@ -74,6 +75,26 @@ func (u *updater) updateHideMyAss(ctx context.Context) (err error) {
return nil
}
func (u *updater) updateIvpn(ctx context.Context) (err error) {
minServers := getMinServers(len(u.servers.Ivpn.Servers))
servers, warnings, err := ivpn.GetServers(
ctx, u.unzipper, u.presolver, minServers)
if u.options.CLI {
for _, warning := range warnings {
u.logger.Warn("Ivpn: %s", warning)
}
}
if err != nil {
return err
}
if u.options.Stdout {
u.println(ivpn.Stringify(servers))
}
u.servers.Ivpn.Timestamp = u.timeNow().Unix()
u.servers.Ivpn.Servers = servers
return nil
}
func (u *updater) updateMullvad(ctx context.Context) (err error) {
minServers := getMinServers(len(u.servers.Mullvad.Servers))
servers, err := mullvad.GetServers(ctx, u.client, minServers)

View File

@@ -0,0 +1,16 @@
package ivpn
import (
"strings"
)
func parseFilename(fileName string) (country, city string) {
const suffix = ".ovpn"
fileName = strings.TrimSuffix(fileName, suffix)
parts := strings.Split(fileName, "-")
country = strings.ReplaceAll(parts[0], "_", " ")
if len(parts) > 1 {
city = strings.ReplaceAll(parts[1], "_", " ")
}
return country, city
}

View File

@@ -0,0 +1,41 @@
package ivpn
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_parseFilename(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
fileName string
country string
city string
}{
"empty filename": {},
"country only": {
fileName: "Country.ovpn",
country: "Country",
},
"country and city": {
fileName: "Country-City.ovpn",
country: "Country",
city: "City",
},
"composite country and city": {
fileName: "Coun_try-Ci_ty.ovpn",
country: "Coun try",
city: "Ci ty",
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
country, city := parseFilename(testCase.fileName)
assert.Equal(t, testCase.country, country)
assert.Equal(t, testCase.city, city)
})
}
}

View File

@@ -0,0 +1,58 @@
package ivpn
import (
"net"
"sort"
"github.com/qdm12/gluetun/internal/models"
)
type hostToServer map[string]models.IvpnServer
func (hts hostToServer) add(host, country, city string, tcp, udp bool) {
server, ok := hts[host]
if !ok {
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][]net.IP) {
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.IvpnServer) {
servers = make([]models.IvpnServer, 0, len(hts))
for _, server := range hts {
servers = append(servers, server)
}
return servers
}

View File

@@ -0,0 +1,211 @@
package ivpn
import (
"net"
"testing"
"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": {
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.IvpnServer{
Hostname: "host",
Country: "country",
City: "city",
TCP: true,
UDP: true,
},
},
},
"extend existing server": {
initialHTS: hostToServer{
"host": models.IvpnServer{
Hostname: "host",
Country: "country",
City: "city",
TCP: true,
},
},
host: "host",
country: "country",
city: "city",
tcp: false,
udp: true,
expectedHTS: hostToServer{
"host": models.IvpnServer{
Hostname: "host",
Country: "country",
City: "city",
TCP: true,
UDP: true,
},
},
},
}
for name, testCase := range testCases {
testCase := testCase
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 {
testCase := testCase
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][]net.IP
expectedHTS hostToServer
}{
"create server": {
initialHTS: hostToServer{},
hostToIPs: map[string][]net.IP{
"A": {{1, 2, 3, 4}},
},
expectedHTS: hostToServer{
"A": models.IvpnServer{
IPs: []net.IP{{1, 2, 3, 4}},
},
},
},
"add IPs to existing server": {
initialHTS: hostToServer{
"A": models.IvpnServer{
Country: "country",
},
},
hostToIPs: map[string][]net.IP{
"A": {{1, 2, 3, 4}},
},
expectedHTS: hostToServer{
"A": models.IvpnServer{
Country: "country",
IPs: []net.IP{{1, 2, 3, 4}},
},
},
},
"remove server without IP": {
initialHTS: hostToServer{
"A": models.IvpnServer{
Country: "country",
},
},
hostToIPs: map[string][]net.IP{},
expectedHTS: hostToServer{},
},
}
for name, testCase := range testCases {
testCase := testCase
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.IvpnServer
}{
"empty host to server": {
hts: hostToServer{},
servers: []models.IvpnServer{},
},
"multiple servers": {
hts: hostToServer{
"A": {Country: "A"},
"B": {Country: "B"},
},
servers: []models.IvpnServer{
{Country: "A"},
{Country: "B"},
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
servers := testCase.hts.toServersSlice()
assert.ElementsMatch(t, testCase.servers, servers)
})
}
}

View File

@@ -0,0 +1,36 @@
package ivpn
import (
"context"
"net"
"time"
"github.com/qdm12/gluetun/internal/updater/resolver"
)
func getResolveSettings(minServers int) (settings resolver.ParallelSettings) {
const (
maxFailRatio = 0.1
maxDuration = 20 * time.Second
betweenDuration = time.Second
maxNoNew = 2
maxFails = 2
)
return resolver.ParallelSettings{
MaxFailRatio: maxFailRatio,
MinFound: minServers,
Repeat: resolver.RepeatSettings{
MaxDuration: maxDuration,
BetweenDuration: betweenDuration,
MaxNoNew: maxNoNew,
MaxFails: maxFails,
},
}
}
func resolveHosts(ctx context.Context, presolver resolver.Parallel,
hosts []string, minServers int) (hostToIPs map[string][]net.IP,
warnings []string, err error) {
settings := getResolveSettings(minServers)
return presolver.Resolve(ctx, hosts, settings)
}

View File

@@ -0,0 +1,56 @@
package ivpn
import (
"context"
"errors"
"net"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/qdm12/gluetun/internal/updater/resolver"
"github.com/qdm12/gluetun/internal/updater/resolver/mock_resolver"
"github.com/stretchr/testify/assert"
)
func Test_resolveHosts(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
ctx := context.Background()
presolver := mock_resolver.NewMockParallel(ctrl)
hosts := []string{"host1", "host2"}
const minServers = 10
expectedHostToIPs := map[string][]net.IP{
"host1": {{1, 2, 3, 4}},
"host2": {{2, 3, 4, 5}},
}
expectedWarnings := []string{"warning1", "warning2"}
expectedErr := errors.New("dummy")
const (
maxFailRatio = 0.1
maxDuration = 20 * time.Second
betweenDuration = time.Second
maxNoNew = 2
maxFails = 2
)
expectedSettings := resolver.ParallelSettings{
MaxFailRatio: maxFailRatio,
MinFound: minServers,
Repeat: resolver.RepeatSettings{
MaxDuration: maxDuration,
BetweenDuration: betweenDuration,
MaxNoNew: maxNoNew,
MaxFails: maxFails,
},
}
presolver.EXPECT().Resolve(ctx, hosts, expectedSettings).
Return(expectedHostToIPs, expectedWarnings, expectedErr)
hostToIPs, warnings, err := resolveHosts(ctx, presolver, hosts, minServers)
assert.Equal(t, expectedHostToIPs, hostToIPs)
assert.Equal(t, expectedWarnings, warnings)
assert.Equal(t, expectedErr, err)
}

View File

@@ -0,0 +1,86 @@
// Package ivpn contains code to obtain the server information
// for the Surshark provider.
package ivpn
import (
"context"
"errors"
"fmt"
"strings"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/updater/openvpn"
"github.com/qdm12/gluetun/internal/updater/resolver"
"github.com/qdm12/gluetun/internal/updater/unzip"
)
var ErrNotEnoughServers = errors.New("not enough servers found")
func GetServers(ctx context.Context, unzipper unzip.Unzipper,
presolver resolver.Parallel, minServers int) (
servers []models.IvpnServer, warnings []string, err error) {
const url = "https://www.ivpn.net/releases/config/ivpn-openvpn-config.zip"
contents, err := unzipper.FetchAndExtract(ctx, url)
if err != nil {
return nil, nil, err
} else if len(contents) < minServers {
return nil, nil, fmt.Errorf("%w: %d and expected at least %d",
ErrNotEnoughServers, len(contents), minServers)
}
hts := make(hostToServer)
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
warning := err.Error() + ": in " + fileName
warnings = append(warnings, warning)
continue
}
host, warning, err := openvpn.ExtractHost(content)
if warning != "" {
warnings = append(warnings, warning)
}
if err != nil {
// treat error as warning and go to next file
warning := err.Error() + " in " + fileName
warnings = append(warnings, warning)
continue
}
country, city := parseFilename(fileName)
hts.add(host, country, city, tcp, udp)
}
if len(hts) < minServers {
return nil, warnings, fmt.Errorf("%w: %d and expected at least %d",
ErrNotEnoughServers, len(hts), minServers)
}
hosts := hts.toHostsSlice()
hostToIPs, newWarnings, err := resolveHosts(ctx, presolver, hosts, minServers)
warnings = append(warnings, newWarnings...)
if err != nil {
return nil, warnings, err
}
hts.adaptWithIPs(hostToIPs)
servers = hts.toServersSlice()
if len(servers) < minServers {
return nil, warnings, fmt.Errorf("%w: %d and expected at least %d",
ErrNotEnoughServers, len(servers), minServers)
}
sortServers(servers)
return servers, warnings, nil
}

View File

@@ -0,0 +1,142 @@
package ivpn
import (
"context"
"errors"
"net"
"testing"
"github.com/golang/mock/gomock"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/updater/resolver"
"github.com/qdm12/gluetun/internal/updater/resolver/mock_resolver"
"github.com/qdm12/gluetun/internal/updater/unzip/mock_unzip"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_GetServers(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
// Inputs
minServers int
// Unzip
unzipContents map[string][]byte
unzipErr error
// Resolution
expectResolve bool
hostsToResolve []string
resolveSettings resolver.ParallelSettings
hostToIPs map[string][]net.IP
resolveWarnings []string
resolveErr error
// Output
servers []models.IvpnServer
warnings []string
err error
}{
"unzipper error": {
unzipErr: errors.New("dummy"),
err: errors.New("dummy"),
},
"not enough unzip contents": {
minServers: 1,
unzipContents: map[string][]byte{},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"no openvpn file": {
minServers: 1,
unzipContents: map[string][]byte{"somefile.txt": {}},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"invalid proto": {
minServers: 1,
unzipContents: map[string][]byte{"badproto.ovpn": []byte(`proto invalid`)},
warnings: []string{"unknown protocol: invalid: in badproto.ovpn"},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"no host": {
minServers: 1,
unzipContents: map[string][]byte{"nohost.ovpn": []byte(``)},
warnings: []string{"remote host not found in nohost.ovpn"},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"multiple hosts": {
minServers: 1,
unzipContents: map[string][]byte{
"MultiHosts.ovpn": []byte("remote hosta\nremote hostb"),
},
expectResolve: true,
hostsToResolve: []string{"hosta"},
resolveSettings: getResolveSettings(1),
warnings: []string{"only using the first host \"hosta\" and discarding 1 other hosts"},
err: errors.New("not enough servers found: 0 and expected at least 1"),
},
"resolve error": {
unzipContents: map[string][]byte{
"config.ovpn": []byte("remote hosta"),
},
expectResolve: true,
hostsToResolve: []string{"hosta"},
resolveSettings: getResolveSettings(0),
resolveWarnings: []string{"resolve warning"},
resolveErr: errors.New("dummy"),
warnings: []string{"resolve warning"},
err: errors.New("dummy"),
},
"success": {
minServers: 1,
unzipContents: map[string][]byte{
"Country1-City_A.ovpn": []byte("remote hosta"),
"Country2-City_B.ovpn": []byte("remote hostb"),
},
expectResolve: true,
hostsToResolve: []string{"hosta", "hostb"},
resolveSettings: getResolveSettings(1),
hostToIPs: map[string][]net.IP{
"hosta": {{1, 1, 1, 1}, {2, 2, 2, 2}},
"hostb": {{3, 3, 3, 3}, {4, 4, 4, 4}},
},
resolveWarnings: []string{"resolve warning"},
servers: []models.IvpnServer{
{Country: "Country1", City: "City A", Hostname: "hosta", UDP: true, IPs: []net.IP{{1, 1, 1, 1}, {2, 2, 2, 2}}},
{Country: "Country2", City: "City B", Hostname: "hostb", UDP: true, IPs: []net.IP{{3, 3, 3, 3}, {4, 4, 4, 4}}},
},
warnings: []string{"resolve warning"},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
ctx := context.Background()
unzipper := mock_unzip.NewMockUnzipper(ctrl)
const zipURL = "https://www.ivpn.net/releases/config/ivpn-openvpn-config.zip"
unzipper.EXPECT().FetchAndExtract(ctx, zipURL).
Return(testCase.unzipContents, testCase.unzipErr)
presolver := mock_resolver.NewMockParallel(ctrl)
if testCase.expectResolve {
presolver.EXPECT().Resolve(ctx, testCase.hostsToResolve, testCase.resolveSettings).
Return(testCase.hostToIPs, testCase.resolveWarnings, testCase.resolveErr)
}
servers, warnings, err := GetServers(ctx, unzipper, presolver, testCase.minServers)
assert.Equal(t, testCase.servers, servers)
assert.Equal(t, testCase.warnings, warnings)
if testCase.err != nil {
require.Error(t, err)
assert.Equal(t, testCase.err.Error(), err.Error())
} else {
assert.NoError(t, err)
}
})
}
}

View File

@@ -0,0 +1,19 @@
package ivpn
import (
"sort"
"github.com/qdm12/gluetun/internal/models"
)
func sortServers(servers []models.IvpnServer) {
sort.Slice(servers, func(i, j int) bool {
if servers[i].Country == servers[j].Country {
if servers[i].City == servers[j].City {
return servers[i].Hostname < servers[j].Hostname
}
return servers[i].City < servers[j].City
}
return servers[i].Country < servers[j].Country
})
}

View File

@@ -0,0 +1,40 @@
package ivpn
import (
"testing"
"github.com/qdm12/gluetun/internal/models"
"github.com/stretchr/testify/assert"
)
func Test_sortServers(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
initialServers []models.IvpnServer
sortedServers []models.IvpnServer
}{
"no server": {},
"sorted servers": {
initialServers: []models.IvpnServer{
{Country: "B", City: "A", Hostname: "A"},
{Country: "A", City: "A", Hostname: "B"},
{Country: "A", City: "A", Hostname: "A"},
{Country: "A", City: "B", Hostname: "A"},
},
sortedServers: []models.IvpnServer{
{Country: "A", City: "A", Hostname: "A"},
{Country: "A", City: "A", Hostname: "B"},
{Country: "A", City: "B", Hostname: "A"},
{Country: "B", City: "A", Hostname: "A"},
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
sortServers(testCase.initialServers)
assert.Equal(t, testCase.sortedServers, testCase.initialServers)
})
}
}

View File

@@ -0,0 +1,14 @@
package ivpn
import "github.com/qdm12/gluetun/internal/models"
func Stringify(servers []models.IvpnServer) (s string) {
s = "func IvpnServers() []models.IvpnServer {\n"
s += " return []models.IvpnServer{\n"
for _, server := range servers {
s += " " + server.String() + ",\n"
}
s += " }\n"
s += "}"
return s
}

View File

@@ -0,0 +1,43 @@
package ivpn
import (
"testing"
"github.com/qdm12/gluetun/internal/models"
"github.com/stretchr/testify/assert"
)
func Test_Stringify(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
servers []models.IvpnServer
s string
}{
"no server": {
s: `func IvpnServers() []models.IvpnServer {
return []models.IvpnServer{
}
}`,
},
"multiple servers": {
servers: []models.IvpnServer{
{Country: "A"},
{Country: "B"},
},
s: `func IvpnServers() []models.IvpnServer {
return []models.IvpnServer{
{Country: "A", City: "", Hostname: "", IPs: []net.IP{}},
{Country: "B", City: "", Hostname: "", IPs: []net.IP{}},
}
}`,
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
s := Stringify(testCase.servers)
assert.Equal(t, testCase.s, s)
})
}
}

View File

@@ -0,0 +1,53 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/qdm12/gluetun/internal/updater/resolver (interfaces: Parallel)
// Package mock_resolver is a generated GoMock package.
package mock_resolver
import (
context "context"
net "net"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
resolver "github.com/qdm12/gluetun/internal/updater/resolver"
)
// MockParallel is a mock of Parallel interface.
type MockParallel struct {
ctrl *gomock.Controller
recorder *MockParallelMockRecorder
}
// MockParallelMockRecorder is the mock recorder for MockParallel.
type MockParallelMockRecorder struct {
mock *MockParallel
}
// NewMockParallel creates a new mock instance.
func NewMockParallel(ctrl *gomock.Controller) *MockParallel {
mock := &MockParallel{ctrl: ctrl}
mock.recorder = &MockParallelMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockParallel) EXPECT() *MockParallelMockRecorder {
return m.recorder
}
// Resolve mocks base method.
func (m *MockParallel) Resolve(arg0 context.Context, arg1 []string, arg2 resolver.ParallelSettings) (map[string][]net.IP, []string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Resolve", arg0, arg1, arg2)
ret0, _ := ret[0].(map[string][]net.IP)
ret1, _ := ret[1].([]string)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// Resolve indicates an expected call of Resolve.
func (mr *MockParallelMockRecorder) Resolve(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockParallel)(nil).Resolve), arg0, arg1, arg2)
}

View File

@@ -7,6 +7,8 @@ import (
"net"
)
//go:generate mockgen -destination=mock_$GOPACKAGE/$GOFILE . Parallel
type Parallel interface {
Resolve(ctx context.Context, hosts []string, settings ParallelSettings) (
hostToIPs map[string][]net.IP, warnings []string, err error)

View File

@@ -0,0 +1,50 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/qdm12/gluetun/internal/updater/unzip (interfaces: Unzipper)
// Package mock_unzip is a generated GoMock package.
package mock_unzip
import (
context "context"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockUnzipper is a mock of Unzipper interface.
type MockUnzipper struct {
ctrl *gomock.Controller
recorder *MockUnzipperMockRecorder
}
// MockUnzipperMockRecorder is the mock recorder for MockUnzipper.
type MockUnzipperMockRecorder struct {
mock *MockUnzipper
}
// NewMockUnzipper creates a new mock instance.
func NewMockUnzipper(ctrl *gomock.Controller) *MockUnzipper {
mock := &MockUnzipper{ctrl: ctrl}
mock.recorder = &MockUnzipperMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockUnzipper) EXPECT() *MockUnzipperMockRecorder {
return m.recorder
}
// FetchAndExtract mocks base method.
func (m *MockUnzipper) FetchAndExtract(arg0 context.Context, arg1 string) (map[string][]byte, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FetchAndExtract", arg0, arg1)
ret0, _ := ret[0].(map[string][]byte)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FetchAndExtract indicates an expected call of FetchAndExtract.
func (mr *MockUnzipperMockRecorder) FetchAndExtract(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchAndExtract", reflect.TypeOf((*MockUnzipper)(nil).FetchAndExtract), arg0, arg1)
}

View File

@@ -7,6 +7,8 @@ import (
"net/http"
)
//go:generate mockgen -destination=mock_$GOPACKAGE/$GOFILE . Unzipper
type Unzipper interface {
FetchAndExtract(ctx context.Context, url string) (contents map[string][]byte, err error)
}

View File

@@ -84,6 +84,16 @@ func (u *updater) UpdateServers(ctx context.Context) (allServers models.AllServe
}
}
if u.options.Ivpn {
u.logger.Info("updating Ivpn servers...")
if err := u.updateIvpn(ctx); err != nil {
u.logger.Error(err)
}
if err := ctx.Err(); err != nil {
return allServers, err
}
}
if u.options.Mullvad {
u.logger.Info("updating Mullvad servers...")
if err := u.updateMullvad(ctx); err != nil {