diff --git a/.github/labels.yml b/.github/labels.yml index d43afcfe..74512914 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -21,6 +21,9 @@ - name: ":cloud: HideMyAss" color: "cfe8d4" description: "" +- name: ":cloud: IVPN" + color: "cfe8d4" + description: "" - name: ":cloud: FastestVPN" color: "cfe8d4" description: "" diff --git a/README.md b/README.md index b21f8c11..d4c28dde 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/cli/update.go b/internal/cli/update.go index ef9538a0..037f61bb 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -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") diff --git a/internal/configuration/ivpn.go b/internal/configuration/ivpn.go new file mode 100644 index 00000000..fef2cb6b --- /dev/null +++ b/internal/configuration/ivpn.go @@ -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 +} diff --git a/internal/configuration/ivpn_test.go b/internal/configuration/ivpn_test.go new file mode 100644 index 00000000..db702ed2 --- /dev/null +++ b/internal/configuration/ivpn_test.go @@ -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) + }) + } +} diff --git a/internal/configuration/openvpn.go b/internal/configuration/openvpn.go index ef6c9a3a..023a5e29 100644 --- a/internal/configuration/openvpn.go +++ b/internal/configuration/openvpn.go @@ -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: diff --git a/internal/configuration/provider.go b/internal/configuration/provider.go index e3ffeb83..12a6fd8f 100644 --- a/internal/configuration/provider.go +++ b/internal/configuration/provider.go @@ -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": diff --git a/internal/configuration/provider_test.go b/internal/configuration/provider_test.go index aa4b2e25..d5be4363 100644 --- a/internal/configuration/provider_test.go +++ b/internal/configuration/provider_test.go @@ -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, diff --git a/internal/configuration/selection.go b/internal/configuration/selection.go index babf64f8..0374b523 100644 --- a/internal/configuration/selection.go +++ b/internal/configuration/selection.go @@ -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 diff --git a/internal/configuration/updater.go b/internal/configuration/updater.go index 958a4440..5b466706 100644 --- a/internal/configuration/updater.go +++ b/internal/configuration/updater.go @@ -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 diff --git a/internal/constants/ivpn.go b/internal/constants/ivpn.go new file mode 100644 index 00000000..365c8665 --- /dev/null +++ b/internal/constants/ivpn.go @@ -0,0 +1,90 @@ +package constants + +import ( + "net" + + "github.com/qdm12/gluetun/internal/models" +) + +//nolint:lll +const ( + IvpnCA = "MIIGoDCCBIigAwIBAgIJAJjvUclXmxtnMA0GCSqGSIb3DQEBCwUAMIGMMQswCQYDVQQGEwJDSDEPMA0GA1UECAwGWnVyaWNoMQ8wDQYDVQQHDAZadXJpY2gxETAPBgNVBAoMCElWUE4ubmV0MQ0wCwYDVQQLDARJVlBOMRgwFgYDVQQDDA9JVlBOIFJvb3QgQ0EgdjIxHzAdBgkqhkiG9w0BCQEWEHN1cHBvcnRAaXZwbi5uZXQwHhcNMjAwMjI2MTA1MjI5WhcNNDAwMjIxMTA1MjI5WjCBjDELMAkGA1UEBhMCQ0gxDzANBgNVBAgMBlp1cmljaDEPMA0GA1UEBwwGWnVyaWNoMREwDwYDVQQKDAhJVlBOLm5ldDENMAsGA1UECwwESVZQTjEYMBYGA1UEAwwPSVZQTiBSb290IENBIHYyMR8wHQYJKoZIhvcNAQkBFhBzdXBwb3J0QGl2cG4ubmV0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxHVeaQN3nYCLnGoEg6cY44AExbQ3W6XGKYwC9vI+HJbb1o0tAv56ryvc6eS6BdG5q9M8fHaHEE/jw9rtznioiXPwIEmqMqFPA9k1oRIQTGX73m+zHGtRpt9P4tGYhkvbqnN0OGI0H+j9R6cwKi7KpWIoTVibtyI7uuwgzC2nvDzVkLi63uvnCKRXcGy3VWC06uWFbqI9+QDrHHgdJA1F0wRfg0Iac7TE75yXItBMvNLbdZpge9SmplYWFQ2rVPG+n75KepJ+KW7PYfTP4Mh3R8A7h3/WRm03o3spf2aYw71t44voZ6agvslvwqGyczDytsLUny0U2zR7/mfEAyVbL8jqcWr2Df0m3TA0WxwdWvA51/RflVk9G96LncUkoxuBT56QSMtdjbMSqRgLfz1iPsglQEaCzUSqHfQExvONhXtNgy+Pr2+wGrEuSlLMee7aUEMTFEX/vHPZanCrUVYf5Vs8vDOirZjQSHJfgZfwj3nL5VLtIq6ekDhSAdrqCTILP3V2HbgdZGWPVQxl4YmQPKo0IJpse5Kb6TF2o0i90KhORcKg7qZA40sEbYLEwqTM7VBs1FahTXsOPAoMa7xZWV1TnigF5pdVS1l51dy5S8L4ErHFEnAp242BDuTClSLVnWDdofW0EZ0OkK7V9zKyVl75dlBgxMIS0y5MsK7IWicCAwEAAaOCAQEwgf4wHQYDVR0OBBYEFHUDcMOMo35yg2A/v0uYfkDE11CXMIHBBgNVHSMEgbkwgbaAFHUDcMOMo35yg2A/v0uYfkDE11CXoYGSpIGPMIGMMQswCQYDVQQGEwJDSDEPMA0GA1UECAwGWnVyaWNoMQ8wDQYDVQQHDAZadXJpY2gxETAPBgNVBAoMCElWUE4ubmV0MQ0wCwYDVQQLDARJVlBOMRgwFgYDVQQDDA9JVlBOIFJvb3QgQ0EgdjIxHzAdBgkqhkiG9w0BCQEWEHN1cHBvcnRAaXZwbi5uZXSCCQCY71HJV5sbZzAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAABAjRMJy+mXFLezAZ8iUgxOjNtSqkCv1aU78K1XkYUzbwNNrSIVGKfP9cqOEiComXY6nniws7QEV2IWilcdPKm0x57recrr9TExGGOTVGB/WdmsFfn0g/HgmxNvXypzG3qulBk4qQTymICdsl9vIPb1l9FSjKw1KgUVuCPaYq7xiXbZ/kZdZX49xeKtoDBrXKKhXVYoWus/S+k2IS8iCxvcp599y7LQJg5DOGlbaxFhsW4R+kfGOaegyhPvpaznguv02i7NLd99XqJhpv2jTUF5F3T23Z4KkL/wTo4zxz09DKOlELrE4ai++ilCt/mXWECXNOSNXzgszpe6WAs0h9R++sH+AzJyhBfIGgPUTxHHHvxBVLj3k6VCgF7mRP2Y+rTWa6d8AGI2+RaeyV9DVVH9UeSoU0Hv2JHiZL6dRERnyg8dyzKeTCke8poLIjXF+gyvI+22/xsL8jcNHi9Kji3Vpc3i0Mxzx3gu2N+PL71CwJilgqBgxj0firr3k8sFcWVSGos6RJ3IvFvThxYx0p255WrWM01fR9TktPYEfjDT9qpIJ8OrGlNOhWhYj+a45qibXDpaDdb/uBEmf2sSXNifjSeUyqu6cKfZvMqB7pS3l/AhuAOTT80E4sXLEoDxkFD4C78swZ8wyWRKwsWGIGABGAHwXEAoDiZ/jjFrEZT0=" + 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}}}, + } +} diff --git a/internal/constants/servers.go b/internal/constants/servers.go index 58097734..7a7d9616 100644 --- a/internal/constants/servers.go +++ b/internal/constants/servers.go @@ -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, diff --git a/internal/constants/servers_test.go b/internal/constants/servers_test.go index a3595eab..38167fea 100644 --- a/internal/constants/servers_test.go +++ b/internal/constants/servers_test.go @@ -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, diff --git a/internal/constants/vpn.go b/internal/constants/vpn.go index 9af13a99..d4e225df 100644 --- a/internal/constants/vpn.go +++ b/internal/constants/vpn.go @@ -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. diff --git a/internal/models/server.go b/internal/models/server.go index a5b354a9..6b33c3e1 100644 --- a/internal/models/server.go +++ b/internal/models/server.go @@ -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"` diff --git a/internal/models/servers.go b/internal/models/servers.go index a4fd7fd5..e604e71e 100644 --- a/internal/models/servers.go +++ b/internal/models/servers.go @@ -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"` diff --git a/internal/openvpn/custom.go b/internal/openvpn/custom.go index a252fbd0..298bf60b 100644 --- a/internal/openvpn/custom.go +++ b/internal/openvpn/custom.go @@ -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() } } diff --git a/internal/provider/ivpn/connection.go b/internal/provider/ivpn/connection.go new file mode 100644 index 00000000..810ffae7 --- /dev/null +++ b/internal/provider/ivpn/connection.go @@ -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 +} diff --git a/internal/provider/ivpn/filter.go b/internal/provider/ivpn/filter.go new file mode 100644 index 00000000..ae4d9da2 --- /dev/null +++ b/internal/provider/ivpn/filter.go @@ -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 +} diff --git a/internal/provider/ivpn/openvpnconf.go b/internal/provider/ivpn/openvpnconf.go new file mode 100644 index 00000000..1681df7c --- /dev/null +++ b/internal/provider/ivpn/openvpnconf.go @@ -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 +} diff --git a/internal/provider/ivpn/portforward.go b/internal/provider/ivpn/portforward.go new file mode 100644 index 00000000..4f8389d6 --- /dev/null +++ b/internal/provider/ivpn/portforward.go @@ -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") +} diff --git a/internal/provider/ivpn/provider.go b/internal/provider/ivpn/provider.go new file mode 100644 index 00000000..3e3cf8e9 --- /dev/null +++ b/internal/provider/ivpn/provider.go @@ -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, + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a3c79213..fe6bdc22 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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: diff --git a/internal/storage/merge.go b/internal/storage/merge.go index 02bd1120..34e84c16 100644 --- a/internal/storage/merge.go +++ b/internal/storage/merge.go @@ -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 diff --git a/internal/storage/sync.go b/internal/storage/sync.go index dd78d734..a3a7e4ae 100644 --- a/internal/storage/sync.go +++ b/internal/storage/sync.go @@ -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) + diff --git a/internal/updater/providers.go b/internal/updater/providers.go index ca05d192..4cbfbd99 100644 --- a/internal/updater/providers.go +++ b/internal/updater/providers.go @@ -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) diff --git a/internal/updater/providers/ivpn/filename.go b/internal/updater/providers/ivpn/filename.go new file mode 100644 index 00000000..f820e3c6 --- /dev/null +++ b/internal/updater/providers/ivpn/filename.go @@ -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 +} diff --git a/internal/updater/providers/ivpn/filename_test.go b/internal/updater/providers/ivpn/filename_test.go new file mode 100644 index 00000000..a9729c0e --- /dev/null +++ b/internal/updater/providers/ivpn/filename_test.go @@ -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) + }) + } +} diff --git a/internal/updater/providers/ivpn/hosttoserver.go b/internal/updater/providers/ivpn/hosttoserver.go new file mode 100644 index 00000000..cc7869cc --- /dev/null +++ b/internal/updater/providers/ivpn/hosttoserver.go @@ -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 +} diff --git a/internal/updater/providers/ivpn/hosttoserver_test.go b/internal/updater/providers/ivpn/hosttoserver_test.go new file mode 100644 index 00000000..7b3c0e9e --- /dev/null +++ b/internal/updater/providers/ivpn/hosttoserver_test.go @@ -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) + }) + } +} diff --git a/internal/updater/providers/ivpn/resolve.go b/internal/updater/providers/ivpn/resolve.go new file mode 100644 index 00000000..fedc27ea --- /dev/null +++ b/internal/updater/providers/ivpn/resolve.go @@ -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) +} diff --git a/internal/updater/providers/ivpn/resolve_test.go b/internal/updater/providers/ivpn/resolve_test.go new file mode 100644 index 00000000..af7d65e4 --- /dev/null +++ b/internal/updater/providers/ivpn/resolve_test.go @@ -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) +} diff --git a/internal/updater/providers/ivpn/servers.go b/internal/updater/providers/ivpn/servers.go new file mode 100644 index 00000000..41a34659 --- /dev/null +++ b/internal/updater/providers/ivpn/servers.go @@ -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 +} diff --git a/internal/updater/providers/ivpn/servers_test.go b/internal/updater/providers/ivpn/servers_test.go new file mode 100644 index 00000000..7cd54701 --- /dev/null +++ b/internal/updater/providers/ivpn/servers_test.go @@ -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) + } + }) + } +} diff --git a/internal/updater/providers/ivpn/sort.go b/internal/updater/providers/ivpn/sort.go new file mode 100644 index 00000000..520510ad --- /dev/null +++ b/internal/updater/providers/ivpn/sort.go @@ -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 + }) +} diff --git a/internal/updater/providers/ivpn/sort_test.go b/internal/updater/providers/ivpn/sort_test.go new file mode 100644 index 00000000..3a0ab6d3 --- /dev/null +++ b/internal/updater/providers/ivpn/sort_test.go @@ -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) + }) + } +} diff --git a/internal/updater/providers/ivpn/string.go b/internal/updater/providers/ivpn/string.go new file mode 100644 index 00000000..64955d7e --- /dev/null +++ b/internal/updater/providers/ivpn/string.go @@ -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 +} diff --git a/internal/updater/providers/ivpn/string_test.go b/internal/updater/providers/ivpn/string_test.go new file mode 100644 index 00000000..bf8a79d1 --- /dev/null +++ b/internal/updater/providers/ivpn/string_test.go @@ -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) + }) + } +} diff --git a/internal/updater/resolver/mock_resolver/parallel.go b/internal/updater/resolver/mock_resolver/parallel.go new file mode 100644 index 00000000..ad76dae2 --- /dev/null +++ b/internal/updater/resolver/mock_resolver/parallel.go @@ -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) +} diff --git a/internal/updater/resolver/parallel.go b/internal/updater/resolver/parallel.go index be33be46..66915c86 100644 --- a/internal/updater/resolver/parallel.go +++ b/internal/updater/resolver/parallel.go @@ -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) diff --git a/internal/updater/unzip/mock_unzip/unzip.go b/internal/updater/unzip/mock_unzip/unzip.go new file mode 100644 index 00000000..a866c705 --- /dev/null +++ b/internal/updater/unzip/mock_unzip/unzip.go @@ -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) +} diff --git a/internal/updater/unzip/unzip.go b/internal/updater/unzip/unzip.go index 2b638149..50f56844 100644 --- a/internal/updater/unzip/unzip.go +++ b/internal/updater/unzip/unzip.go @@ -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) } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index da40a536..38f7e437 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -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 {