diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 78106b8b7..06d7c7efe 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -56,6 +56,7 @@ body: - PureVPN - Surfshark - TorGuard + - VPNSecure.me - VPNUnlimited - VyprVPN - WeVPN diff --git a/.github/labels.yml b/.github/labels.yml index 5b2cddc6c..f5f92d167 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -70,6 +70,8 @@ - name: ":cloud: Torguard" color: "cfe8d4" description: "" +- name: ":cloud: VPNSecure.me" + color: "cfe8d4" - name: ":cloud: VPNUnlimited" color: "cfe8d4" description: "" diff --git a/Dockerfile b/Dockerfile index c43f4938e..40b18adac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -105,6 +105,8 @@ ENV VPN_SERVICE_PROVIDER=pia \ FREE_ONLY= \ # # Surfshark only: MULTIHOP_ONLY= \ + # # VPN Secure only: + PREMIUM_ONLY= \ # Firewall FIREWALL=on \ FIREWALL_VPN_INPUT_PORTS= \ diff --git a/README.md b/README.md index 5b6ca7d00..67b7d8860 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,9 @@ *Lightweight swiss-knife-like VPN client to tunnel to Cyberghost, ExpressVPN, FastestVPN, HideMyAss, IPVanish, IVPN, Mullvad, NordVPN, Perfect Privacy, Privado, Private Internet Access, PrivateVPN, -ProtonVPN, PureVPN, Surfshark, TorGuard, VPNUnlimited, VyprVPN, WeVPN and Windscribe VPN servers +ProtonVPN, PureVPN, Surfshark, TorGuard, VPNSecure.me, VPNUnlimited, VyprVPN, WeVPN and Windscribe VPN servers using Go, OpenVPN or Wireguard, iptables, DNS over TLS, ShadowSocks and an HTTP proxy* - ![Title image](https://raw.githubusercontent.com/qdm12/gluetun/master/title.svg) [![Build status](https://github.com/qdm12/gluetun/actions/workflows/ci.yml/badge.svg)](https://github.com/qdm12/gluetun/actions/workflows/ci.yml) @@ -61,7 +60,7 @@ using Go, OpenVPN or Wireguard, iptables, DNS over TLS, ShadowSocks and an HTTP ## Features - Based on Alpine 3.16 for a small Docker image of 29MB -- Supports: **Cyberghost**, **ExpressVPN**, **FastestVPN**, **HideMyAss**, **IPVanish**, **IVPN**, **Mullvad**, **NordVPN**, **Perfect Privacy**, **Privado**, **Private Internet Access**, **PrivateVPN**, **ProtonVPN**, **PureVPN**, **Surfshark**, **TorGuard**, **VPNUnlimited**, **Vyprvpn**, **WeVPN**, **Windscribe** servers +- Supports: **Cyberghost**, **ExpressVPN**, **FastestVPN**, **HideMyAss**, **IPVanish**, **IVPN**, **Mullvad**, **NordVPN**, **Perfect Privacy**, **Privado**, **Private Internet Access**, **PrivateVPN**, **ProtonVPN**, **PureVPN**, **Surfshark**, **TorGuard**, **VPNSecure.me**, **VPNUnlimited**, **Vyprvpn**, **WeVPN**, **Windscribe** servers - Supports OpenVPN for all providers listed - Supports Wireguard both kernelspace and userspace - For **Mullvad**, **Ivpn** and **Windscribe** diff --git a/internal/configuration/settings/errors.go b/internal/configuration/settings/errors.go index 0092c993b..d9f34460d 100644 --- a/internal/configuration/settings/errors.go +++ b/internal/configuration/settings/errors.go @@ -15,6 +15,7 @@ var ( ErrNameNotValid = errors.New("the server name specified is not valid") ErrOpenVPNClientKeyMissing = errors.New("client key is missing") ErrOpenVPNCustomPortNotAllowed = errors.New("custom endpoint port is not allowed") + ErrOpenVPNEncPrivateKeyMissing = errors.New("client encrypted private key is missing") ErrOpenVPNEncryptionPresetNotValid = errors.New("PIA encryption preset is not valid") ErrOpenVPNInterfaceNotValid = errors.New("interface name is not valid") ErrOpenVPNMSSFixIsTooHigh = errors.New("mssfix option value is too high") diff --git a/internal/configuration/settings/openvpn.go b/internal/configuration/settings/openvpn.go index 8f0340b21..8dab35204 100644 --- a/internal/configuration/settings/openvpn.go +++ b/internal/configuration/settings/openvpn.go @@ -50,6 +50,9 @@ type OpenVPN struct { // It can be set to the empty string to be ignored. // It cannot be nil in the internal state. ClientKey *string + // EncryptedPrivateKey is the content of an encrypted + // private key for OpenVPN. + EncryptedPrivateKey *string // PIAEncPreset is the encryption preset for // Private Internet Access. It can be set to an // empty string for other providers. @@ -115,6 +118,11 @@ func (o OpenVPN) validate(vpnProvider string) (err error) { return fmt.Errorf("client key: %w", err) } + err = validateOpenVPNEncryptedPrivateKey(vpnProvider, *o.EncryptedPrivateKey) + if err != nil { + return fmt.Errorf("encrypted private key: %w", err) + } + const maxMSSFix = 10000 if *o.MSSFix > maxMSSFix { return fmt.Errorf("%w: %d is over the maximum value of %d", @@ -174,10 +182,7 @@ func validateOpenVPNClientCertificate(vpnProvider, } _, err = parse.ExtractCert([]byte(clientCert)) - if err != nil { - return err - } - return nil + return err } func validateOpenVPNClientKey(vpnProvider, clientKey string) (err error) { @@ -202,23 +207,41 @@ func validateOpenVPNClientKey(vpnProvider, clientKey string) (err error) { return nil } +func validateOpenVPNEncryptedPrivateKey(vpnProvider, + encryptedPrivateKey string) (err error) { + if vpnProvider == providers.VPNSecure && encryptedPrivateKey == "" { + return ErrOpenVPNEncPrivateKeyMissing + } + + if encryptedPrivateKey == "" { + return nil + } + + _, err = parse.ExtractEncryptedPrivateKey([]byte(encryptedPrivateKey)) + if err != nil { + return fmt.Errorf("cannot extract encrypted private key: %w", err) + } + return nil +} + func (o *OpenVPN) copy() (copied OpenVPN) { return OpenVPN{ - Version: o.Version, - User: o.User, - Password: o.Password, - ConfFile: helpers.CopyStringPtr(o.ConfFile), - Ciphers: helpers.CopyStringSlice(o.Ciphers), - Auth: helpers.CopyStringPtr(o.Auth), - ClientCrt: helpers.CopyStringPtr(o.ClientCrt), - ClientKey: helpers.CopyStringPtr(o.ClientKey), - PIAEncPreset: helpers.CopyStringPtr(o.PIAEncPreset), - IPv6: helpers.CopyBoolPtr(o.IPv6), - MSSFix: helpers.CopyUint16Ptr(o.MSSFix), - Interface: o.Interface, - ProcessUser: o.ProcessUser, - Verbosity: helpers.CopyIntPtr(o.Verbosity), - Flags: helpers.CopyStringSlice(o.Flags), + Version: o.Version, + User: o.User, + Password: o.Password, + ConfFile: helpers.CopyStringPtr(o.ConfFile), + Ciphers: helpers.CopyStringSlice(o.Ciphers), + Auth: helpers.CopyStringPtr(o.Auth), + ClientCrt: helpers.CopyStringPtr(o.ClientCrt), + ClientKey: helpers.CopyStringPtr(o.ClientKey), + EncryptedPrivateKey: helpers.CopyStringPtr(o.EncryptedPrivateKey), + PIAEncPreset: helpers.CopyStringPtr(o.PIAEncPreset), + IPv6: helpers.CopyBoolPtr(o.IPv6), + MSSFix: helpers.CopyUint16Ptr(o.MSSFix), + Interface: o.Interface, + ProcessUser: o.ProcessUser, + Verbosity: helpers.CopyIntPtr(o.Verbosity), + Flags: helpers.CopyStringSlice(o.Flags), } } @@ -233,6 +256,7 @@ func (o *OpenVPN) mergeWith(other OpenVPN) { o.Auth = helpers.MergeWithStringPtr(o.Auth, other.Auth) o.ClientCrt = helpers.MergeWithStringPtr(o.ClientCrt, other.ClientCrt) o.ClientKey = helpers.MergeWithStringPtr(o.ClientKey, other.ClientKey) + o.EncryptedPrivateKey = helpers.MergeWithStringPtr(o.EncryptedPrivateKey, other.EncryptedPrivateKey) o.PIAEncPreset = helpers.MergeWithStringPtr(o.PIAEncPreset, other.PIAEncPreset) o.IPv6 = helpers.MergeWithBool(o.IPv6, other.IPv6) o.MSSFix = helpers.MergeWithUint16(o.MSSFix, other.MSSFix) @@ -254,6 +278,7 @@ func (o *OpenVPN) overrideWith(other OpenVPN) { o.Auth = helpers.OverrideWithStringPtr(o.Auth, other.Auth) o.ClientCrt = helpers.OverrideWithStringPtr(o.ClientCrt, other.ClientCrt) o.ClientKey = helpers.OverrideWithStringPtr(o.ClientKey, other.ClientKey) + o.EncryptedPrivateKey = helpers.OverrideWithStringPtr(o.EncryptedPrivateKey, other.EncryptedPrivateKey) o.PIAEncPreset = helpers.OverrideWithStringPtr(o.PIAEncPreset, other.PIAEncPreset) o.IPv6 = helpers.OverrideWithBool(o.IPv6, other.IPv6) o.MSSFix = helpers.OverrideWithUint16(o.MSSFix, other.MSSFix) @@ -273,6 +298,7 @@ func (o *OpenVPN) setDefaults(vpnProvider string) { o.Auth = helpers.DefaultStringPtr(o.Auth, "") o.ClientCrt = helpers.DefaultStringPtr(o.ClientCrt, "") o.ClientKey = helpers.DefaultStringPtr(o.ClientKey, "") + o.EncryptedPrivateKey = helpers.DefaultStringPtr(o.EncryptedPrivateKey, "") var defaultEncPreset string if vpnProvider == providers.PrivateInternetAccess { @@ -317,6 +343,10 @@ func (o OpenVPN) toLinesNode() (node *gotree.Node) { node.Appendf("Client key: %s", helpers.ObfuscateData(*o.ClientKey)) } + if *o.EncryptedPrivateKey != "" { + node.Appendf("Encrypted private key: %s", helpers.ObfuscateData(*o.ClientKey)) + } + if *o.PIAEncPreset != "" { node.Appendf("Private Internet Access encryption preset: %s", *o.PIAEncPreset) } diff --git a/internal/configuration/settings/openvpnselection.go b/internal/configuration/settings/openvpnselection.go index e62e5ebea..adb128cd1 100644 --- a/internal/configuration/settings/openvpnselection.go +++ b/internal/configuration/settings/openvpnselection.go @@ -60,8 +60,8 @@ func (o OpenVPNSelection) validate(vpnProvider string) (err error) { case providers.Expressvpn, providers.Fastestvpn, providers.Ipvanish, providers.Nordvpn, providers.Privado, providers.Purevpn, - providers.Surfshark, providers.VPNUnlimited, - providers.Vyprvpn: + providers.Surfshark, providers.VPNSecure, + providers.VPNUnlimited, providers.Vyprvpn: return fmt.Errorf("%w: for VPN service provider %s", ErrOpenVPNCustomPortNotAllowed, vpnProvider) default: diff --git a/internal/configuration/settings/serverselection.go b/internal/configuration/settings/serverselection.go index adc6f051d..0f83d412b 100644 --- a/internal/configuration/settings/serverselection.go +++ b/internal/configuration/settings/serverselection.go @@ -45,6 +45,10 @@ type ServerSelection struct { //nolint:maligned // FreeOnly is true if VPN servers that are not free should // be filtered. This is used with ProtonVPN and VPN Unlimited. FreeOnly *bool + // PremiumOnly is true if VPN servers that are not premium should + // be filtered. This is used with VPN Secure. + // TODO extend to providers using FreeOnly. + PremiumOnly *bool // StreamOnly is true if VPN servers not for streaming should // be filtered. This is used with VPNUnlimited. StreamOnly *bool @@ -63,8 +67,10 @@ type ServerSelection struct { //nolint:maligned var ( ErrOwnedOnlyNotSupported = errors.New("owned only filter is not supported") ErrFreeOnlyNotSupported = errors.New("free only filter is not supported") + ErrPremiumOnlyNotSupported = errors.New("premium only filter is not supported") ErrStreamOnlyNotSupported = errors.New("stream only filter is not supported") ErrMultiHopOnlyNotSupported = errors.New("multi hop only filter is not supported") + ErrFreePremiumBothSet = errors.New("free only and premium only filters are both set") ) func (ss *ServerSelection) validate(vpnServiceProvider string, @@ -103,6 +109,18 @@ func (ss *ServerSelection) validate(vpnServiceProvider string, ErrFreeOnlyNotSupported, vpnServiceProvider) } + if *ss.PremiumOnly && + !helpers.IsOneOf(vpnServiceProvider, + providers.VPNSecure, + ) { + return fmt.Errorf("%w: for VPN service provider %s", + ErrPremiumOnlyNotSupported, vpnServiceProvider) + } + + if *ss.FreeOnly && *ss.PremiumOnly { + return ErrFreePremiumBothSet + } + if *ss.StreamOnly && !helpers.IsOneOf(vpnServiceProvider, providers.Protonvpn, @@ -194,6 +212,7 @@ func (ss *ServerSelection) copy() (copied ServerSelection) { Numbers: helpers.CopyUint16Slice(ss.Numbers), OwnedOnly: helpers.CopyBoolPtr(ss.OwnedOnly), FreeOnly: helpers.CopyBoolPtr(ss.FreeOnly), + PremiumOnly: helpers.CopyBoolPtr(ss.PremiumOnly), StreamOnly: helpers.CopyBoolPtr(ss.StreamOnly), MultiHopOnly: helpers.CopyBoolPtr(ss.MultiHopOnly), OpenVPN: ss.OpenVPN.copy(), @@ -213,6 +232,7 @@ func (ss *ServerSelection) mergeWith(other ServerSelection) { ss.Numbers = helpers.MergeUint16Slices(ss.Numbers, other.Numbers) ss.OwnedOnly = helpers.MergeWithBool(ss.OwnedOnly, other.OwnedOnly) ss.FreeOnly = helpers.MergeWithBool(ss.FreeOnly, other.FreeOnly) + ss.PremiumOnly = helpers.MergeWithBool(ss.PremiumOnly, other.PremiumOnly) ss.StreamOnly = helpers.MergeWithBool(ss.StreamOnly, other.StreamOnly) ss.MultiHopOnly = helpers.MergeWithBool(ss.MultiHopOnly, other.MultiHopOnly) @@ -232,6 +252,7 @@ func (ss *ServerSelection) overrideWith(other ServerSelection) { ss.Numbers = helpers.OverrideWithUint16Slice(ss.Numbers, other.Numbers) ss.OwnedOnly = helpers.OverrideWithBool(ss.OwnedOnly, other.OwnedOnly) ss.FreeOnly = helpers.OverrideWithBool(ss.FreeOnly, other.FreeOnly) + ss.PremiumOnly = helpers.OverrideWithBool(ss.PremiumOnly, other.PremiumOnly) ss.StreamOnly = helpers.OverrideWithBool(ss.StreamOnly, other.StreamOnly) ss.MultiHopOnly = helpers.OverrideWithBool(ss.MultiHopOnly, other.MultiHopOnly) ss.OpenVPN.overrideWith(other.OpenVPN) @@ -243,6 +264,7 @@ func (ss *ServerSelection) setDefaults(vpnProvider string) { ss.TargetIP = helpers.DefaultIP(ss.TargetIP, net.IP{}) ss.OwnedOnly = helpers.DefaultBool(ss.OwnedOnly, false) ss.FreeOnly = helpers.DefaultBool(ss.FreeOnly, false) + ss.PremiumOnly = helpers.DefaultBool(ss.PremiumOnly, false) ss.StreamOnly = helpers.DefaultBool(ss.StreamOnly, false) ss.MultiHopOnly = helpers.DefaultBool(ss.MultiHopOnly, false) ss.OpenVPN.setDefaults(vpnProvider) @@ -299,6 +321,10 @@ func (ss ServerSelection) toLinesNode() (node *gotree.Node) { node.Appendf("Free only servers: yes") } + if *ss.PremiumOnly { + node.Appendf("Premium only servers: yes") + } + if *ss.StreamOnly { node.Appendf("Stream only servers: yes") } diff --git a/internal/configuration/sources/env/serverselection.go b/internal/configuration/sources/env/serverselection.go index 4c1cf2d16..5f47a3541 100644 --- a/internal/configuration/sources/env/serverselection.go +++ b/internal/configuration/sources/env/serverselection.go @@ -77,6 +77,12 @@ func (r *Reader) readServerSelection(vpnProvider, vpnType string) ( return ss, fmt.Errorf("environment variable FREE_ONLY: %w", err) } + // VPNSecure only + ss.PremiumOnly, err = envToBoolPtr("PREMIUM_ONLY") + if err != nil { + return ss, fmt.Errorf("environment variable PREMIUM_ONLY: %w", err) + } + // VPNUnlimited only ss.MultiHopOnly, err = envToBoolPtr("MULTIHOP_ONLY") if err != nil { diff --git a/internal/configuration/sources/files/openvpn.go b/internal/configuration/sources/files/openvpn.go index e3183a8bc..c632e481a 100644 --- a/internal/configuration/sources/files/openvpn.go +++ b/internal/configuration/sources/files/openvpn.go @@ -11,6 +11,7 @@ const ( OpenVPNClientKeyPath = "/gluetun/client.key" // OpenVPNClientCertificatePath is the OpenVPN client certificate filepath. OpenVPNClientCertificatePath = "/gluetun/client.crt" + openVPNEncryptedPrivateKey = "/gluetun/encrypted-private-key" ) func (r *Reader) readOpenVPN() (settings settings.OpenVPN, err error) { @@ -24,5 +25,10 @@ func (r *Reader) readOpenVPN() (settings settings.OpenVPN, err error) { return settings, fmt.Errorf("client certificate: %w", err) } + settings.EncryptedPrivateKey, err = ReadFromFile(openVPNEncryptedPrivateKey) + if err != nil { + return settings, fmt.Errorf("cannot read client encrypted private key: %w", err) + } + return settings, nil } diff --git a/internal/constants/providers/providers.go b/internal/constants/providers/providers.go index ca6830510..11f3cbecc 100644 --- a/internal/constants/providers/providers.go +++ b/internal/constants/providers/providers.go @@ -20,6 +20,7 @@ const ( Purevpn = "purevpn" Surfshark = "surfshark" Torguard = "torguard" + VPNSecure = "vpn secure" VPNUnlimited = "vpn unlimited" Vyprvpn = "vyprvpn" Wevpn = "wevpn" @@ -45,6 +46,7 @@ func All() []string { Purevpn, Surfshark, Torguard, + VPNSecure, VPNUnlimited, Vyprvpn, Wevpn, diff --git a/internal/models/markdown.go b/internal/models/markdown.go index 9fa1ed87d..ec8d4a8ac 100644 --- a/internal/models/markdown.go +++ b/internal/models/markdown.go @@ -32,6 +32,7 @@ const ( udpHeader = "UDP" multiHopHeader = "MultiHop" freeHeader = "Free" + premiumHeader = "Premium" streamHeader = "Stream" portForwardHeader = "Port forwarding" ) @@ -68,6 +69,8 @@ func (s *Server) ToMarkdown(headers ...string) (markdown string) { fields[i] = boolToMarkdown(s.MultiHop) case freeHeader: fields[i] = boolToMarkdown(s.Free) + case premiumHeader: + fields[i] = boolToMarkdown(s.Premium) case streamHeader: fields[i] = boolToMarkdown(s.Stream) case portForwardHeader: @@ -127,6 +130,8 @@ func getMarkdownHeaders(vpnProvider string) (headers []string) { return []string{regionHeader, countryHeader, cityHeader, hostnameHeader, multiHopHeader, tcpHeader, udpHeader} case providers.Torguard: return []string{countryHeader, cityHeader, hostnameHeader, tcpHeader, udpHeader} + case providers.VPNSecure: + return []string{regionHeader, cityHeader, hostnameHeader, premiumHeader} case providers.VPNUnlimited: return []string{countryHeader, cityHeader, hostnameHeader, freeHeader, streamHeader, tcpHeader, udpHeader} case providers.Vyprvpn: diff --git a/internal/models/server.go b/internal/models/server.go index 695d44e4d..1cc9cfc19 100644 --- a/internal/models/server.go +++ b/internal/models/server.go @@ -26,6 +26,7 @@ type Server struct { WgPubKey string `json:"wgpubkey,omitempty"` Free bool `json:"free,omitempty"` Stream bool `json:"stream,omitempty"` + Premium bool `json:"premium,omitempty"` PortForward bool `json:"port_forward,omitempty"` Keep bool `json:"keep,omitempty"` IPs []net.IP `json:"ips,omitempty"` diff --git a/internal/models/servers_test.go b/internal/models/servers_test.go index 73caeaf95..07ed146b8 100644 --- a/internal/models/servers_test.go +++ b/internal/models/servers_test.go @@ -40,6 +40,7 @@ func Test_AllServers_MarshalJSON(t *testing.T) { `"purevpn":{"version":0,"timestamp":0},` + `"surfshark":{"version":0,"timestamp":0},` + `"torguard":{"version":0,"timestamp":0},` + + `"vpn secure":{"version":0,"timestamp":0},` + `"vpn unlimited":{"version":0,"timestamp":0},` + `"vyprvpn":{"version":0,"timestamp":0},` + `"wevpn":{"version":0,"timestamp":0},` + @@ -84,6 +85,7 @@ func Test_AllServers_MarshalJSON(t *testing.T) { `"purevpn":{"version":0,"timestamp":0},` + `"surfshark":{"version":0,"timestamp":0},` + `"torguard":{"version":0,"timestamp":0},` + + `"vpn secure":{"version":0,"timestamp":0},` + `"vpn unlimited":{"version":0,"timestamp":0},` + `"vyprvpn":{"version":0,"timestamp":0},` + `"wevpn":{"version":0,"timestamp":0},` + diff --git a/internal/openvpn/parse/key.go b/internal/openvpn/parse/key.go index 7fef1a533..e3ddafcc7 100644 --- a/internal/openvpn/parse/key.go +++ b/internal/openvpn/parse/key.go @@ -12,3 +12,12 @@ func ExtractPrivateKey(b []byte) (keyData string, err error) { return keyData, nil } + +func ExtractEncryptedPrivateKey(b []byte) (keyData string, err error) { + keyData, err = extractPEM(b, "ENCRYPTED PRIVATE KEY") + if err != nil { + return "", fmt.Errorf("cannot extract PEM data: %w", err) + } + + return keyData, nil +} diff --git a/internal/provider/providers.go b/internal/provider/providers.go index bd0ef2861..b23c263a8 100644 --- a/internal/provider/providers.go +++ b/internal/provider/providers.go @@ -27,6 +27,7 @@ import ( "github.com/qdm12/gluetun/internal/provider/purevpn" "github.com/qdm12/gluetun/internal/provider/surfshark" "github.com/qdm12/gluetun/internal/provider/torguard" + "github.com/qdm12/gluetun/internal/provider/vpnsecure" "github.com/qdm12/gluetun/internal/provider/vpnunlimited" "github.com/qdm12/gluetun/internal/provider/vyprvpn" "github.com/qdm12/gluetun/internal/provider/wevpn" @@ -73,6 +74,7 @@ func NewProviders(storage Storage, timeNow func() time.Time, providers.Purevpn: purevpn.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver), providers.Surfshark: surfshark.New(storage, randSource, client, unzipper, updaterWarner, parallelResolver), providers.Torguard: torguard.New(storage, randSource, unzipper, updaterWarner, parallelResolver), + providers.VPNSecure: vpnsecure.New(storage, randSource, client, updaterWarner, parallelResolver), providers.VPNUnlimited: vpnunlimited.New(storage, randSource, unzipper, updaterWarner, parallelResolver), providers.Vyprvpn: vyprvpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver), providers.Wevpn: wevpn.New(storage, randSource, updaterWarner, parallelResolver), diff --git a/internal/provider/utils/filtering.go b/internal/provider/utils/filtering.go index 2124b7292..eb5ab7af5 100644 --- a/internal/provider/utils/filtering.go +++ b/internal/provider/utils/filtering.go @@ -40,6 +40,10 @@ func filterServer(server models.Server, return true } + if *selection.PremiumOnly && !server.Premium { + return true + } + if *selection.StreamOnly && !server.Stream { return true } diff --git a/internal/provider/utils/filtering_test.go b/internal/provider/utils/filtering_test.go index 63b272088..3d3ebef6e 100644 --- a/internal/provider/utils/filtering_test.go +++ b/internal/provider/utils/filtering_test.go @@ -88,6 +88,19 @@ func Test_FilterServers(t *testing.T) { {Free: true, VPN: vpn.OpenVPN, UDP: true}, }, }, + "filter by premium only": { + selection: settings.ServerSelection{ + PremiumOnly: boolPtr(true), + }.WithDefaults(providers.Surfshark), + servers: []models.Server{ + {Premium: false, VPN: vpn.OpenVPN, UDP: true}, + {Premium: true, VPN: vpn.OpenVPN, UDP: true}, + {Premium: false, VPN: vpn.OpenVPN, UDP: true}, + }, + filtered: []models.Server{ + {Premium: true, VPN: vpn.OpenVPN, UDP: true}, + }, + }, "filter by stream only": { selection: settings.ServerSelection{ StreamOnly: boolPtr(true), diff --git a/internal/provider/utils/openvpn.go b/internal/provider/utils/openvpn.go index ef5a936fd..7e5e98640 100644 --- a/internal/provider/utils/openvpn.go +++ b/internal/provider/utils/openvpn.go @@ -185,6 +185,12 @@ func OpenVPNConfig(provider OpenVPNProviderSettings, lines.addLines(WrapOpenvpnTLSCrypt(provider.TLSCrypt)) } + if *settings.EncryptedPrivateKey != "" { + keyData, err := parse.ExtractEncryptedPrivateKey([]byte(*settings.EncryptedPrivateKey)) + panicOnError(err, "cannot extract client encrypted private key") + lines.addLines(WrapOpenvpnEncryptedKey(keyData)) + } + if *settings.ClientCrt != "" { certData, err := parse.ExtractCert([]byte(*settings.ClientCrt)) panicOnError(err, "cannot extract client crt") @@ -291,6 +297,16 @@ func WrapOpenvpnKey(clientKey string) (lines []string) { } } +func WrapOpenvpnEncryptedKey(encryptedKey string) (lines []string) { + return []string{ + "", + "-----BEGIN ENCRYPTED PRIVATE KEY-----", + encryptedKey, + "-----END ENCRYPTED PRIVATE KEY-----", + "", + } +} + func WrapOpenvpnRSAKey(rsaPrivateKey string) (lines []string) { return []string{ "", diff --git a/internal/provider/vpnsecure/connection.go b/internal/provider/vpnsecure/connection.go new file mode 100644 index 000000000..d4ee645e5 --- /dev/null +++ b/internal/provider/vpnsecure/connection.go @@ -0,0 +1,14 @@ +package vpnsecure + +import ( + "github.com/qdm12/gluetun/internal/configuration/settings" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/utils" +) + +func (p *Provider) GetConnection(selection settings.ServerSelection) ( + connection models.Connection, err error) { + defaults := utils.NewConnectionDefaults(110, 1282, 0) //nolint:gomnd + return utils.GetConnection(p.Name(), + p.storage, selection, defaults, p.randSource) +} diff --git a/internal/provider/vpnsecure/openvpnconf.go b/internal/provider/vpnsecure/openvpnconf.go new file mode 100644 index 000000000..4e30c18c5 --- /dev/null +++ b/internal/provider/vpnsecure/openvpnconf.go @@ -0,0 +1,26 @@ +package vpnsecure + +import ( + "github.com/qdm12/gluetun/internal/configuration/settings" + "github.com/qdm12/gluetun/internal/constants/openvpn" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/utils" +) + +func (p *Provider) OpenVPNConfig(connection models.Connection, + settings settings.OpenVPN) (lines []string) { + //nolint:gomnd + providerSettings := utils.OpenVPNProviderSettings{ + RemoteCertTLS: true, + AuthUserPass: true, + Ping: 10, + Ciphers: []string{openvpn.AES256cbc}, + ExtraLines: []string{ + "comp-lzo", + "float", + }, + CA: "MIIEJjCCAw6gAwIBAgIJAMkzh6p4m6XfMA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJOWTERMA8GA1UEBxMITmV3IFlvcmsxFTATBgNVBAoTDHZwbnNlY3VyZS5tZTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEB2cG5zZWN1cmUubWUwIBcNMTcwNTA2MTMzMTQyWhgPMjkzODA4MjYxMzMxNDJaMGkxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJOWTERMA8GA1UEBxMITmV3IFlvcmsxFTATBgNVBAoTDHZwbnNlY3VyZS5tZTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEB2cG5zZWN1cmUubWUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDiClT1wcZ6oovYjSxUJIQplrBSQRKB44uymC8evohzK7q67x0NE2sLz5Zn9ZiC7RnXQCtEqJfHqjuqjaH5MghjhUDnRbZS/8ElxdGKn9FPvs9b+aTVGSfrQm5KKoVigwAye3ilNiWAyy6MDlBeoKluQ4xW7SGiVZRxLcJbLAmjmfCjBS7eUGbtA8riTkIegFo4WFiy9G76zQWw1V26kDhyzcJNT4xO7USMPUeZthy13g+zi9+rcILhEAnl776sIil6w8UVK8xevFKBlOPk+YyXlo4eZiuppq300ogaS+fX/0mfD7DDE+Gk5/nCeACDNiBlfQ3ol/De8Cm60HWEUtZVAgMBAAGjgc4wgcswHQYDVR0OBBYEFBJyf4mpGT3dIu65/1zAFqCgGxZoMIGbBgNVHSMEgZMwgZCAFBJyf4mpGT3dIu65/1zAFqCgGxZooW2kazBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCTlkxETAPBgNVBAcTCE5ldyBZb3JrMRUwEwYDVQQKEwx2cG5zZWN1cmUubWUxIzAhBgkqhkiG9w0BCQEWFHN1cHBvcnRAdnBuc2VjdXJlLm1lggkAyTOHqnibpd8wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEArbTAibGQilY4Lu2RAVPjNx14SfojueBroeN7NIpAFUfbifPQRWvLamzRfxFTO0PXRc2pw/It7oa8yM7BsZj0vOiZY2p1JBHZwKom6tiSUVENDGW6JaYtiaE8XPyjfA5Yhfx4FefmaJ1veDYid18S+VVpt+Y+UIUxNmg1JB3CCUwbjl+dWlcvDBy4+jI+sZ7A1LF3uX64ZucDQ/XrpuopHhvDjw7g1PpKXsRqBYL+cpxUI7GrINBa/rGvXqv/NvFH8bguggknWKxKhd+jyMqkW3Ws258e0OwHz7gQ+tTJ909tR0TxJhZGkHatNSbpwW1Y52A972+9gYJMadSfm4bUHA==", //nolint:lll + Cert: "MIIC9jCCAd6gAwIBAgICaUgwDQYJKoZIhvcNAQELBQAwaTELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAk5ZMREwDwYDVQQHEwhOZXcgWW9yazEVMBMGA1UEChMMdnBuc2VjdXJlLm1lMSMwIQYJKoZIhvcNAQkBFhRzdXBwb3J0QHZwbnNlY3VyZS5tZTAiGA8yMDIxMTAxNzEwMzEyNVoYDzIwMzEwMTAzMTAzMTI1WjBXMQswCQYDVQQGEwJVUzEPMA0GA1UECwwGT2ZmaWNlMRMwEQYDVQQDDApzdW5pbHdvb2RzMRUwEwYDVQQKDAx2cG5zZWN1cmUubWUxCzAJBgNVBAgMAk5ZMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkyNu1EDFWAoThW6hOPA7XNVAFhmeba+bzd1BFWvQgQo+c3U+tWxDorOv0CRM13hjDXK0DL0PIaEzXLp5B911AJoj1WAkBsc6KKYz0bBFc3waRAzXpn1zcSX0e3wh/A1KIJiXPFCzBRiaSHyFNjpE24ofyO1cTw3T5HnNNWExMoQIDAQABozowODAMBgNVHRMBAf8EAjAAMCgGCWCGSAGG+EIBDQQbFhlDcmVhdGVkIHdpdGggdnBuc2kgMC40LjE2MA0GCSqGSIb3DQEBCwUAA4IBAQDKJwjjYR9/l4ynxw98E9BC58Odj+383fMsoODxoJmADg4WtQ/GrteahlgYXTZK/YPBeO9WVHi1zSN3FZh55IRtatDHxHYI6PLxOrmulRCDxMrUoHY8Vyp6fP5sXhYt3iE9mEAVpSjdMCnR4w6lzhp7dBOoOXw5WvyWOUnoKffesW5/3UtCimTBhTQ/d63liaPND1qn4f/Q54oaSs7A7MTxYYWvw6K41QnDNmao+SsHbRTYntyBeF+L4WqmPVXbDIsDdBed2hVqBlLTvMmvsdxnKOIbX+oPdiV+Cb7CGWw/MS5rLpDm0Ncf2JoPzgb+ZiHdfcTJ41Lq8394ooL0Stbo", //nolint:lll + } + return utils.OpenVPNConfig(providerSettings, connection, settings) +} diff --git a/internal/provider/vpnsecure/provider.go b/internal/provider/vpnsecure/provider.go new file mode 100644 index 000000000..7f8d0a29e --- /dev/null +++ b/internal/provider/vpnsecure/provider.go @@ -0,0 +1,33 @@ +package vpnsecure + +import ( + "math/rand" + "net/http" + + "github.com/qdm12/gluetun/internal/constants/providers" + "github.com/qdm12/gluetun/internal/provider/common" + "github.com/qdm12/gluetun/internal/provider/utils" + "github.com/qdm12/gluetun/internal/provider/vpnsecure/updater" +) + +type Provider struct { + storage common.Storage + randSource rand.Source + utils.NoPortForwarder + common.Fetcher +} + +func New(storage common.Storage, randSource rand.Source, + client *http.Client, updaterWarner common.Warner, + parallelResolver common.ParallelResolver) *Provider { + return &Provider{ + storage: storage, + randSource: randSource, + NoPortForwarder: utils.NewNoPortForwarding(providers.VPNSecure), + Fetcher: updater.New(client, updaterWarner, parallelResolver), + } +} + +func (p *Provider) Name() string { + return providers.VPNSecure +} diff --git a/internal/provider/vpnsecure/updater/hosttoserver.go b/internal/provider/vpnsecure/updater/hosttoserver.go new file mode 100644 index 000000000..54a030723 --- /dev/null +++ b/internal/provider/vpnsecure/updater/hosttoserver.go @@ -0,0 +1,38 @@ +package updater + +import ( + "net" + + "github.com/qdm12/gluetun/internal/models" +) + +type hostToServer map[string]models.Server + +func (hts hostToServer) toHostsSlice() (hosts []string) { + hosts = make([]string, 0, len(hts)) + for host := range hts { + hosts = append(hosts, host) + } + 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.Server) { + servers = make([]models.Server, 0, len(hts)) + for _, server := range hts { + servers = append(servers, server) + } + return servers +} diff --git a/internal/provider/vpnsecure/updater/resolve.go b/internal/provider/vpnsecure/updater/resolve.go new file mode 100644 index 000000000..1758fab15 --- /dev/null +++ b/internal/provider/vpnsecure/updater/resolve.go @@ -0,0 +1,26 @@ +package updater + +import ( + "time" + + "github.com/qdm12/gluetun/internal/updater/resolver" +) + +func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) { + const ( + maxDuration = 5 * time.Second + maxFailRatio = 0.1 + maxNoNew = 2 + maxFails = 3 + ) + return resolver.ParallelSettings{ + Hosts: hosts, + MaxFailRatio: maxFailRatio, + Repeat: resolver.RepeatSettings{ + MaxDuration: maxDuration, + MaxNoNew: maxNoNew, + MaxFails: maxFails, + SortIPs: true, + }, + } +} diff --git a/internal/provider/vpnsecure/updater/servers.go b/internal/provider/vpnsecure/updater/servers.go new file mode 100644 index 000000000..60774d6b4 --- /dev/null +++ b/internal/provider/vpnsecure/updater/servers.go @@ -0,0 +1,57 @@ +package updater + +import ( + "context" + "fmt" + "sort" + + "github.com/qdm12/gluetun/internal/constants/vpn" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/common" +) + +func (u *Updater) FetchServers(ctx context.Context, minServers int) ( + servers []models.Server, err error) { + servers, err = fetchServers(ctx, u.client) + if err != nil { + return nil, fmt.Errorf("cannot fetch servers: %w", err) + } else if len(servers) < minServers { + return nil, fmt.Errorf("%w: %d and expected at least %d", + common.ErrNotEnoughServers, len(servers), minServers) + } + + hts := make(hostToServer, len(servers)) + for _, server := range servers { + hts[server.Hostname] = server + } + + hosts := hts.toHostsSlice() + + resolveSettings := parallelResolverSettings(hosts) + hostToIPs, warnings, err := u.parallelResolver.Resolve(ctx, resolveSettings) + for _, warning := range warnings { + u.warner.Warn(warning) + } + if err != nil { + return nil, err + } + + if len(hostToIPs) < minServers { + return nil, fmt.Errorf("%w: %d and expected at least %d", + common.ErrNotEnoughServers, len(servers), minServers) + } + + hts.adaptWithIPs(hostToIPs) + + servers = hts.toServersSlice() + + for i := range servers { + servers[i].VPN = vpn.OpenVPN + servers[i].UDP = true + servers[i].TCP = true + } + + sort.Sort(models.SortableServers(servers)) + + return servers, nil +} diff --git a/internal/provider/vpnsecure/updater/updater.go b/internal/provider/vpnsecure/updater/updater.go new file mode 100644 index 000000000..f15a2f3a1 --- /dev/null +++ b/internal/provider/vpnsecure/updater/updater.go @@ -0,0 +1,22 @@ +package updater + +import ( + "net/http" + + "github.com/qdm12/gluetun/internal/provider/common" +) + +type Updater struct { + client *http.Client + parallelResolver common.ParallelResolver + warner common.Warner +} + +func New(client *http.Client, warner common.Warner, + parallelResolver common.ParallelResolver) *Updater { + return &Updater{ + client: client, + parallelResolver: parallelResolver, + warner: warner, + } +} diff --git a/internal/provider/vpnsecure/updater/website.go b/internal/provider/vpnsecure/updater/website.go new file mode 100644 index 000000000..b9a604a53 --- /dev/null +++ b/internal/provider/vpnsecure/updater/website.go @@ -0,0 +1,130 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/qdm12/gluetun/internal/models" +) + +func fetchServers(ctx context.Context, client *http.Client) ( + servers []models.Server, err error) { + data, err := fetchHTML(ctx, client) + if err != nil { + return nil, fmt.Errorf("cannot fetch HTML code: %w", err) + } + + servers = parseHTML(string(data)) + return servers, nil +} + +var ErrHTTPStatusCode = errors.New("HTTP status code is not OK") + +func fetchHTML(ctx context.Context, client *http.Client) (data []byte, err error) { + const url = "https://www.vpnsecure.me/vpn-locations/" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %d %s", + ErrHTTPStatusCode, response.StatusCode, response.Status) + } + + data, err = io.ReadAll(response.Body) + if err != nil { + _ = response.Body.Close() + return nil, err + } + + err = response.Body.Close() + if err != nil { + return nil, err + } + + return data, nil +} + +func parseHTML(html string) (servers []models.Server) { + // Remove consecutive empty lines + for strings.Contains(html, "\n\n") { + html = strings.ReplaceAll(html, "\n\n", "\n") + } + + var block string + var readingBlock bool + for _, line := range strings.Split(html, "\n") { + line = strings.TrimSpace(line) + + const ( + blockStartString = `
` + blockEndString = `
` + ) + switch { + case strings.HasPrefix(line, blockStartString): + readingBlock = true + case strings.HasPrefix(line, blockEndString): + readingBlock = false + server, ok := extractFromHTMLBlock(block) + if ok { + servers = append(servers, server) + } + block = "" + case readingBlock && + !strings.Contains(line, ""): // ignore SVG element lines + block += line + "\n" + } + } + + return servers +} + +var ( + hostRegex = regexp.MustCompile(`
[a-z]+[0-9]*City: .+?`) + regionRegex = regexp.MustCompile(`
Region: .+?
`) + premiumRegex = regexp.MustCompile(`
Premium: [a-zA-Z]+?
`) +) + +func extractFromHTMLBlock(htmlBlock string) (server models.Server, ok bool) { + htmlBlock = strings.ReplaceAll(htmlBlock, "\n", "") + + host := regexTrimPrefixSuffix(htmlBlock, hostRegex, + "
", "City: ", "") + + server.Region = regexTrimPrefixSuffix(htmlBlock, regionRegex, + "
Region: ", "
") + + premiumString := regexTrimPrefixSuffix(htmlBlock, premiumRegex, + "
Premium: ", + "
") + server.Premium = strings.EqualFold(premiumString, "yes") + + return server, true +} + +func regexTrimPrefixSuffix(s string, regex *regexp.Regexp, + prefix, suffix string) (result string) { + result = regex.FindString(s) + result = strings.TrimPrefix(result, prefix) + result = strings.TrimSuffix(result, suffix) + return result +} diff --git a/internal/provider/vpnsecure/updater/website_test.go b/internal/provider/vpnsecure/updater/website_test.go new file mode 100644 index 000000000..dcece751e --- /dev/null +++ b/internal/provider/vpnsecure/updater/website_test.go @@ -0,0 +1,292 @@ +package updater + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/qdm12/gluetun/internal/models" + "github.com/stretchr/testify/assert" +) + +type roundTripFunc func(r *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func Test_fetchServers(t *testing.T) { + t.Parallel() + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + testCases := map[string]struct { + ctx context.Context + responseStatus int + responseBody io.ReadCloser + servers []models.Server + errWrapped error + errMessage string + }{ + "context canceled": { + ctx: canceledCtx, + errWrapped: context.Canceled, + errMessage: `cannot fetch HTML code: Get "https://www.vpnsecure.me/vpn-locations/": context canceled`, + }, + "success": { + ctx: context.Background(), + responseStatus: http.StatusOK, + responseBody: ioutil.NopCloser(strings.NewReader(` +
+
host +
+
City: City
+
Region: Region
+
Premium: YES
+
+ `)), + servers: []models.Server{ + { + Hostname: "host.isponeder.com", + City: "City", + Region: "Region", + Premium: true, + }, + }, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, r.URL.String(), "https://www.vpnsecure.me/vpn-locations/") + + ctxErr := r.Context().Err() + if ctxErr != nil { + return nil, ctxErr + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(testCase.responseStatus), + Body: testCase.responseBody, + }, nil + }), + } + + servers, err := fetchServers(testCase.ctx, client) + + assert.ErrorIs(t, err, testCase.errWrapped) + if testCase.errWrapped != nil { + assert.EqualError(t, err, testCase.errMessage) + } + assert.Equal(t, testCase.servers, servers) + }) + } +} + +func Test_fetchHTML(t *testing.T) { + t.Parallel() + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + testCases := map[string]struct { + ctx context.Context + responseStatus int + responseBody io.ReadCloser + data []byte + errWrapped error + errMessage string + }{ + "context canceled": { + ctx: canceledCtx, + errWrapped: context.Canceled, + errMessage: `Get "https://www.vpnsecure.me/vpn-locations/": context canceled`, + }, + "response status not ok": { + ctx: context.Background(), + responseStatus: http.StatusNotFound, + errWrapped: ErrHTTPStatusCode, + errMessage: `HTTP status code is not OK: 404 Not Found`, + }, + "success": { + ctx: context.Background(), + responseStatus: http.StatusOK, + responseBody: ioutil.NopCloser(strings.NewReader("some body")), + data: []byte("some body"), + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, r.URL.String(), "https://www.vpnsecure.me/vpn-locations/") + + ctxErr := r.Context().Err() + if ctxErr != nil { + return nil, ctxErr + } + + return &http.Response{ + StatusCode: testCase.responseStatus, + Status: http.StatusText(testCase.responseStatus), + Body: testCase.responseBody, + }, nil + }), + } + + data, err := fetchHTML(testCase.ctx, client) + + assert.ErrorIs(t, err, testCase.errWrapped) + if testCase.errWrapped != nil { + assert.EqualError(t, err, testCase.errMessage) + } + assert.Equal(t, testCase.data, data) + }) + } +} + +func Test_parseHTML(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + html string + servers []models.Server + }{ + "empty html": {}, + "html without blocks": { + html: "some html", + }, + "single block": { + html: ` +
+
host +
+
City: City
+
Region: Region
+
Premium: YES
+
+ `, + servers: []models.Server{ + { + Hostname: "host.isponeder.com", + City: "City", + Region: "Region", + Premium: true, + }, + }, + }, + "two block": { + html: ` + +
+
host +
+
City: City
+
Region: Region
+
Premium: YES
+
+ +
+
host2 +
+
City: City 2
+
Region: Region 2
+
Premium: No
+
+ `, + servers: []models.Server{ + { + Hostname: "host.isponeder.com", + City: "City", + Region: "Region", + Premium: true, + }, + { + Hostname: "host2.isponeder.com", + City: "City 2", + Region: "Region 2", + }, + }, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + servers := parseHTML(testCase.html) + + assert.Equal(t, testCase.servers, servers) + }) + } +} + +func Test_extractFromHTMLBlock(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + html string + server models.Server + ok bool + }{ + "empty html block": {}, + "host field absent": { + html: ` +
+
+
+
City: City
+
Region: Region
+
Premium: YES
+
+ `, + }, + "all fields present": { + html: ` +
+
host +
+
City: City
+
Region: Region
+
Premium: YES
+
+ `, + server: models.Server{ + Hostname: "host.isponeder.com", + City: "City", + Region: "Region", + Premium: true, + }, + ok: true, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + server, ok := extractFromHTMLBlock(testCase.html) + + assert.Equal(t, testCase.ok, ok) + assert.Equal(t, testCase.server, server) + }) + } +} diff --git a/internal/storage/formatting.go b/internal/storage/formatting.go index 1fa0e4611..ceeb29a6f 100644 --- a/internal/storage/formatting.go +++ b/internal/storage/formatting.go @@ -114,6 +114,10 @@ func noServerFoundError(selection settings.ServerSelection) (err error) { messageParts = append(messageParts, "free tier only") } + if *selection.PremiumOnly { + messageParts = append(messageParts, "premium tier only") + } + message := "for " + strings.Join(messageParts, "; ") return fmt.Errorf("%w: %s", ErrNoServerFound, message) diff --git a/internal/storage/read_test.go b/internal/storage/read_test.go index 89e46f42f..311a7d7b8 100644 --- a/internal/storage/read_test.go +++ b/internal/storage/read_test.go @@ -94,6 +94,7 @@ func Test_extractServersFromBytes(t *testing.T) { "purevpn": {"version": 1, "timestamp": 0}, "surfshark": {"version": 1, "timestamp": 0}, "torguard": {"version": 1, "timestamp": 0}, + "vpn secure": {"version": 1, "timestamp": 0}, "vpn unlimited": {"version": 1, "timestamp": 0}, "vyprvpn": {"version": 1, "timestamp": 0}, "wevpn": {"version": 1, "timestamp": 0}, @@ -120,6 +121,7 @@ func Test_extractServersFromBytes(t *testing.T) { "purevpn": {"version": 1, "timestamp": 1}, "surfshark": {"version": 1, "timestamp": 1}, "torguard": {"version": 1, "timestamp": 1}, + "vpn secure": {"version": 1, "timestamp": 1}, "vpn unlimited": {"version": 1, "timestamp": 1}, "vyprvpn": {"version": 1, "timestamp": 1}, "wevpn": {"version": 1, "timestamp": 1}, @@ -143,6 +145,7 @@ func Test_extractServersFromBytes(t *testing.T) { "Purevpn servers from file discarded because they have version 1 and hardcoded servers have version 2", "Surfshark servers from file discarded because they have version 1 and hardcoded servers have version 2", "Torguard servers from file discarded because they have version 1 and hardcoded servers have version 2", + "Vpn Secure servers from file discarded because they have version 1 and hardcoded servers have version 2", "Vpn Unlimited servers from file discarded because they have version 1 and hardcoded servers have version 2", "Vyprvpn servers from file discarded because they have version 1 and hardcoded servers have version 2", "Wevpn servers from file discarded because they have version 1 and hardcoded servers have version 2", diff --git a/internal/storage/servers.json b/internal/storage/servers.json index 8d4c55bbf..e3ccc9650 100644 --- a/internal/storage/servers.json +++ b/internal/storage/servers.json @@ -117649,6 +117649,840 @@ } ] }, + "vpn secure": { + "version": 1, + "timestamp": 1653686756, + "servers": [ + { + "vpn": "openvpn", + "region": "Auckland", + "city": "Auckland", + "hostname": "nz1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.121.168.31" + ] + }, + { + "vpn": "openvpn", + "region": "Brent", + "city": "Harlesden", + "hostname": "uk6.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "45.141.154.190" + ] + }, + { + "vpn": "openvpn", + "region": "Brussels Hoofdstedelijk Gewest", + "city": "Brussel", + "hostname": "be2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "37.120.236.166" + ] + }, + { + "vpn": "openvpn", + "region": "Bucure?ti", + "city": "Bucharest", + "hostname": "ro1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "5.252.178.107" + ] + }, + { + "vpn": "openvpn", + "region": "Budapest", + "city": "Budapest", + "hostname": "hu1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "194.71.130.93" + ] + }, + { + "vpn": "openvpn", + "region": "California", + "city": "Los Angeles", + "hostname": "us11.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.81.208.46" + ] + }, + { + "vpn": "openvpn", + "region": "California", + "city": "Los Angeles", + "hostname": "us13.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.13" + ] + }, + { + "vpn": "openvpn", + "region": "California", + "city": "Los Angeles", + "hostname": "us14.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.14" + ] + }, + { + "vpn": "openvpn", + "region": "California", + "city": "Los Angeles", + "hostname": "us15.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.10" + ] + }, + { + "vpn": "openvpn", + "region": "California", + "city": "Los Angeles", + "hostname": "us5.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.11" + ] + }, + { + "vpn": "openvpn", + "region": "California", + "city": "Los Angeles", + "hostname": "us6.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.12" + ] + }, + { + "vpn": "openvpn", + "region": "Capital Region", + "city": "Ballerup", + "hostname": "dk3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "37.120.145.132" + ] + }, + { + "vpn": "openvpn", + "region": "Capital Region", + "city": "Copenhagen", + "hostname": "dk1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "192.36.27.55" + ] + }, + { + "vpn": "openvpn", + "region": "Capital Region", + "city": "Copenhagen", + "hostname": "dk2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "192.36.27.61" + ] + }, + { + "vpn": "openvpn", + "region": "Central and Western", + "city": "Hong Kong", + "hostname": "hk1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "103.253.43.30" + ] + }, + { + "vpn": "openvpn", + "region": "Dublin City", + "city": "Dublin", + "hostname": "ie1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.224.197.60" + ] + }, + { + "vpn": "openvpn", + "region": "England", + "city": "Kent", + "hostname": "uk3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.62.86.143" + ] + }, + { + "vpn": "openvpn", + "region": "England", + "city": "London", + "hostname": "uk2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.221.43" + ] + }, + { + "vpn": "openvpn", + "region": "England", + "city": "London", + "hostname": "uk4.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.62.86.142" + ] + }, + { + "vpn": "openvpn", + "region": "England", + "city": "London", + "hostname": "uk5.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "45.141.154.190", + "46.17.63.208" + ] + }, + { + "vpn": "openvpn", + "region": "England", + "city": "Manchester", + "hostname": "uk7.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "77.243.187.81" + ] + }, + { + "vpn": "openvpn", + "region": "Flanders", + "city": "Zaventem", + "hostname": "be1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "37.120.236.166" + ] + }, + { + "vpn": "openvpn", + "region": "Geneva", + "city": "Geneva", + "hostname": "ch1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "45.90.57.209" + ] + }, + { + "vpn": "openvpn", + "region": "Geneva", + "city": "Genève", + "hostname": "ch2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "45.90.58.5" + ] + }, + { + "vpn": "openvpn", + "region": "Georgia", + "city": "Atlanta", + "hostname": "us8.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "64.42.181.50" + ] + }, + { + "vpn": "openvpn", + "region": "Georgia", + "city": "Atlanta", + "hostname": "us9.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "region": "Grand Est", + "city": "Strasbourg", + "hostname": "fr3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "151.80.148.41" + ] + }, + { + "vpn": "openvpn", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.125.201.229" + ] + }, + { + "vpn": "openvpn", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.113.80" + ] + }, + { + "vpn": "openvpn", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.113.82" + ] + }, + { + "vpn": "openvpn", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de4.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.37.144" + ] + }, + { + "vpn": "openvpn", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de6.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.37.144" + ] + }, + { + "vpn": "openvpn", + "region": "Hesse", + "city": "Limburg an der Lahn", + "hostname": "de5.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.125.183.212" + ] + }, + { + "vpn": "openvpn", + "region": "Illinois", + "city": "Chicago", + "hostname": "us12.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "64.42.183.139" + ] + }, + { + "vpn": "openvpn", + "region": "Illinois", + "city": "Chicago", + "hostname": "us16.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "66.23.205.83" + ] + }, + { + "vpn": "openvpn", + "region": "Illinois", + "city": "Chicago", + "hostname": "us4.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "region": "Illinois", + "city": "Chicago", + "hostname": "us7.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "region": "Karnataka", + "city": "Doddaballapura", + "hostname": "in1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "142.93.222.157" + ] + }, + { + "vpn": "openvpn", + "region": "Lombardy", + "city": "Milan", + "hostname": "it1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "149.154.157.94" + ] + }, + { + "vpn": "openvpn", + "region": "Madrid", + "city": "Madrid", + "hostname": "es2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "95.85.89.55" + ] + }, + { + "vpn": "openvpn", + "region": "Maharashtra", + "city": "Mumbai", + "hostname": "ae1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "103.57.251.73" + ] + }, + { + "vpn": "openvpn", + "region": "Mazovia", + "city": "Warsaw", + "hostname": "pl1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "89.207.169.53" + ] + }, + { + "vpn": "openvpn", + "region": "Moscow", + "city": "Moscow", + "hostname": "ru1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "213.183.56.97" + ] + }, + { + "vpn": "openvpn", + "region": "México", + "city": "Ampliación San Mateo (Colonia Solidaridad)", + "hostname": "mx1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "190.103.179.17" + ] + }, + { + "vpn": "openvpn", + "region": "New Jersey", + "city": "Secaucus", + "hostname": "us1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.100.25" + ] + }, + { + "vpn": "openvpn", + "region": "New South Wales", + "city": "Sydney", + "hostname": "au2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "139.99.131.191" + ] + }, + { + "vpn": "openvpn", + "region": "New South Wales", + "city": "Sydney", + "hostname": "au3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "37.120.234.22" + ] + }, + { + "vpn": "openvpn", + "region": "New South Wales", + "city": "Sydney", + "hostname": "au4.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "217.138.205.151" + ] + }, + { + "vpn": "openvpn", + "region": "New York", + "city": "New York City", + "hostname": "us10.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "region": "New York", + "city": "New York City", + "hostname": "us2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "region": "North Holland", + "city": "Haarlem", + "hostname": "nl1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.15.2.92" + ] + }, + { + "vpn": "openvpn", + "region": "Ontario", + "city": "Richmond Hill", + "hostname": "ca1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "149.56.46.132" + ] + }, + { + "vpn": "openvpn", + "region": "Ontario", + "city": "Richmond Hill", + "hostname": "ca2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.222.50.187" + ] + }, + { + "vpn": "openvpn", + "region": "Oregon", + "city": "Portland", + "hostname": "us3.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "167.160.91.10" + ] + }, + { + "vpn": "openvpn", + "region": "Oslo", + "city": "Oslo", + "hostname": "no1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "194.68.32.36" + ] + }, + { + "vpn": "openvpn", + "region": "Poltavs\u0026#039;ka Oblast\u0026#039;", + "city": "Kremenchuk", + "hostname": "ua1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "139.28.36.34" + ] + }, + { + "vpn": "openvpn", + "region": "Quebec", + "city": "Montréal", + "hostname": "ca3.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "67.43.234.50" + ] + }, + { + "vpn": "openvpn", + "region": "Queensland", + "city": "Brisbane", + "hostname": "au1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "51.161.157.216" + ] + }, + { + "vpn": "openvpn", + "region": "Sao Paulo", + "city": "Sao Paulo", + "hostname": "br1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "154.16.57.215" + ] + }, + { + "vpn": "openvpn", + "region": "Singapore", + "city": "Singapore", + "hostname": "sg1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "139.99.57.42" + ] + }, + { + "vpn": "openvpn", + "region": "South Holland", + "city": "Naaldwijk", + "hostname": "nl2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "212.83.133.203" + ] + }, + { + "vpn": "openvpn", + "region": "Special Capital Region of Jakarta", + "city": "Jakarta", + "hostname": "id1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "45.114.118.84" + ] + }, + { + "vpn": "openvpn", + "region": "Stockholm", + "city": "Stockholm", + "hostname": "no2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "194.68.32.36" + ] + }, + { + "vpn": "openvpn", + "region": "Stockholm", + "city": "Stockholm", + "hostname": "se2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "178.73.210.95" + ] + }, + { + "vpn": "openvpn", + "region": "Stockholm", + "city": "Stockholm", + "hostname": "se3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.117.89.229" + ] + }, + { + "vpn": "openvpn", + "region": "Tel Aviv", + "city": "Tel Aviv", + "hostname": "il1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "193.182.144.18" + ] + }, + { + "vpn": "openvpn", + "region": "Tokyo", + "city": "Tokyo", + "hostname": "jp2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "194.68.27.45" + ] + }, + { + "vpn": "openvpn", + "region": "Valencia", + "city": "Valencia", + "hostname": "se1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "79.141.174.55" + ] + }, + { + "vpn": "openvpn", + "region": "Vienna", + "city": "Vienna", + "hostname": "at1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "185.236.202.181" + ] + }, + { + "vpn": "openvpn", + "region": "Vienna", + "city": "Vienna", + "hostname": "at2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "91.151.16.17" + ] + }, + { + "vpn": "openvpn", + "region": "Western Cape", + "city": "Cape Town", + "hostname": "za1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "102.165.60.248" + ] + }, + { + "vpn": "openvpn", + "region": "Île-de-France", + "city": "Paris", + "hostname": "fr1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "87.98.158.99" + ] + }, + { + "vpn": "openvpn", + "region": "Île-de-France", + "city": "Paris", + "hostname": "fr2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "87.98.158.117" + ] + } + ] + }, "vpn unlimited": { "version": 1, "timestamp": 1629490858,