diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 3d01c7d0..ff80e0de 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -57,6 +57,7 @@ body: - SlickVPN - Surfshark - TorGuard + - VPNSecure.me - VPNUnlimited - VyprVPN - WeVPN diff --git a/.github/labels.yml b/.github/labels.yml index a142c5e5..e01c13b8 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -73,6 +73,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 844f8e5b..cfb01dd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,6 +115,11 @@ ENV VPN_SERVICE_PROVIDER=pia \ OPENVPN_KEY= \ OPENVPN_CLIENTCRT_SECRETFILE=/run/secrets/openvpn_clientcrt \ OPENVPN_CLIENTKEY_SECRETFILE=/run/secrets/openvpn_clientkey \ + # # VPNSecure only: + OPENVPN_ENCRYPTED_KEY= \ + OPENVPN_ENCRYPTED_KEY_SECRETFILE=/run/secrets/openvpn_encrypted_key \ + OPENVPN_KEY_PASSPHRASE= \ + OPENVPN_KEY_PASSPHRASE_SECRETFILE=/run/secrets/openvpn_key_passphrase \ # # Nordvpn only: SERVER_NUMBER= \ # # PIA only: @@ -123,6 +128,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 5daf634b..93dfbb57 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Lightweight swiss-knife-like VPN client to multiple VPN service providers ## 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 0092c993..44c34834 100644 --- a/internal/configuration/settings/errors.go +++ b/internal/configuration/settings/errors.go @@ -17,6 +17,7 @@ var ( ErrOpenVPNCustomPortNotAllowed = errors.New("custom endpoint port is not allowed") ErrOpenVPNEncryptionPresetNotValid = errors.New("PIA encryption preset is not valid") ErrOpenVPNInterfaceNotValid = errors.New("interface name is not valid") + ErrOpenVPNKeyPassphraseIsEmpty = errors.New("key passphrase is empty") ErrOpenVPNMSSFixIsTooHigh = errors.New("mssfix option value is too high") ErrOpenVPNPasswordIsEmpty = errors.New("password is empty") ErrOpenVPNTCPNotSupported = errors.New("TCP protocol is not supported") diff --git a/internal/configuration/settings/openvpn.go b/internal/configuration/settings/openvpn.go index 393c6c22..71eb98d2 100644 --- a/internal/configuration/settings/openvpn.go +++ b/internal/configuration/settings/openvpn.go @@ -42,7 +42,7 @@ type OpenVPN struct { // It is ignored if it is set to the empty string. Auth *string // Cert is the OpenVPN certificate for the block. - // This is notably used by Cyberghost. + // This is notably used by Cyberghost and VPN secure. // It can be set to the empty string to be ignored. // It cannot be nil in the internal state. Cert *string @@ -51,6 +51,15 @@ type OpenVPN struct { // It can be set to the empty string to be ignored. // It cannot be nil in the internal state. Key *string + // EncryptedKey is the content of an encrypted + // key for OpenVPN. It is used by VPN secure. + // It defaults to the empty string meaning it is not + // to be used. KeyPassphrase must be set if this one is set. + EncryptedKey *string + // KeyPassphrase is the key passphrase to be used by OpenVPN + // to decrypt the EncryptedPrivateKey. It defaults to the + // empty string and must be set if EncryptedPrivateKey is set. + KeyPassphrase *string // PIAEncPreset is the encryption preset for // Private Internet Access. It can be set to an // empty string for other providers. @@ -116,6 +125,15 @@ func (o OpenVPN) validate(vpnProvider string) (err error) { return fmt.Errorf("client key: %w", err) } + err = validateOpenVPNEncryptedKey(vpnProvider, *o.EncryptedKey) + if err != nil { + return fmt.Errorf("encrypted key: %w", err) + } + + if *o.EncryptedKey != "" && *o.KeyPassphrase == "" { + return fmt.Errorf("%w", ErrOpenVPNKeyPassphraseIsEmpty) + } + const maxMSSFix = 10000 if *o.MSSFix > maxMSSFix { return fmt.Errorf("%w: %d is over the maximum value of %d", @@ -164,6 +182,7 @@ func validateOpenVPNClientCertificate(vpnProvider, switch vpnProvider { case providers.Cyberghost, + providers.VPNSecure, providers.VPNUnlimited: if clientCert == "" { return ErrMissingValue @@ -203,23 +222,42 @@ func validateOpenVPNClientKey(vpnProvider, clientKey string) (err error) { return nil } +func validateOpenVPNEncryptedKey(vpnProvider, + encryptedPrivateKey string) (err error) { + if vpnProvider == providers.VPNSecure && encryptedPrivateKey == "" { + return ErrMissingValue + } + + if encryptedPrivateKey == "" { + return nil + } + + _, err = extract.PEM([]byte(encryptedPrivateKey)) + if err != nil { + return fmt.Errorf("extracting encrypted key: %w", err) + } + return nil +} + func (o *OpenVPN) copy() (copied OpenVPN) { return OpenVPN{ - Version: o.Version, - User: helpers.CopyStringPtr(o.User), - Password: helpers.CopyStringPtr(o.Password), - ConfFile: helpers.CopyStringPtr(o.ConfFile), - Ciphers: helpers.CopyStringSlice(o.Ciphers), - Auth: helpers.CopyStringPtr(o.Auth), - Cert: helpers.CopyStringPtr(o.Cert), - Key: helpers.CopyStringPtr(o.Key), - 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: helpers.CopyStringPtr(o.User), + Password: helpers.CopyStringPtr(o.Password), + ConfFile: helpers.CopyStringPtr(o.ConfFile), + Ciphers: helpers.CopyStringSlice(o.Ciphers), + Auth: helpers.CopyStringPtr(o.Auth), + Cert: helpers.CopyStringPtr(o.Cert), + Key: helpers.CopyStringPtr(o.Key), + EncryptedKey: helpers.CopyStringPtr(o.EncryptedKey), + KeyPassphrase: helpers.CopyStringPtr(o.KeyPassphrase), + 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), } } @@ -234,6 +272,8 @@ func (o *OpenVPN) mergeWith(other OpenVPN) { o.Auth = helpers.MergeWithStringPtr(o.Auth, other.Auth) o.Cert = helpers.MergeWithStringPtr(o.Cert, other.Cert) o.Key = helpers.MergeWithStringPtr(o.Key, other.Key) + o.EncryptedKey = helpers.MergeWithStringPtr(o.EncryptedKey, other.EncryptedKey) + o.KeyPassphrase = helpers.MergeWithStringPtr(o.KeyPassphrase, other.KeyPassphrase) o.PIAEncPreset = helpers.MergeWithStringPtr(o.PIAEncPreset, other.PIAEncPreset) o.IPv6 = helpers.MergeWithBool(o.IPv6, other.IPv6) o.MSSFix = helpers.MergeWithUint16(o.MSSFix, other.MSSFix) @@ -255,6 +295,8 @@ func (o *OpenVPN) overrideWith(other OpenVPN) { o.Auth = helpers.OverrideWithStringPtr(o.Auth, other.Auth) o.Cert = helpers.OverrideWithStringPtr(o.Cert, other.Cert) o.Key = helpers.OverrideWithStringPtr(o.Key, other.Key) + o.EncryptedKey = helpers.OverrideWithStringPtr(o.EncryptedKey, other.EncryptedKey) + o.KeyPassphrase = helpers.OverrideWithStringPtr(o.KeyPassphrase, other.KeyPassphrase) o.PIAEncPreset = helpers.OverrideWithStringPtr(o.PIAEncPreset, other.PIAEncPreset) o.IPv6 = helpers.OverrideWithBool(o.IPv6, other.IPv6) o.MSSFix = helpers.OverrideWithUint16(o.MSSFix, other.MSSFix) @@ -277,6 +319,8 @@ func (o *OpenVPN) setDefaults(vpnProvider string) { o.Auth = helpers.DefaultStringPtr(o.Auth, "") o.Cert = helpers.DefaultStringPtr(o.Cert, "") o.Key = helpers.DefaultStringPtr(o.Key, "") + o.EncryptedKey = helpers.DefaultStringPtr(o.EncryptedKey, "") + o.KeyPassphrase = helpers.DefaultStringPtr(o.KeyPassphrase, "") var defaultEncPreset string if vpnProvider == providers.PrivateInternetAccess { @@ -321,6 +365,11 @@ func (o OpenVPN) toLinesNode() (node *gotree.Node) { node.Appendf("Client key: %s", helpers.ObfuscateData(*o.Key)) } + if *o.EncryptedKey != "" { + node.Appendf("Encrypted key: %s (key passhrapse %s)", + helpers.ObfuscateData(*o.EncryptedKey), helpers.ObfuscatePassword(*o.KeyPassphrase)) + } + 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 8e1f64be..0de020e8 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 adc6f051..0f83d412 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/openvpn.go b/internal/configuration/sources/env/openvpn.go index 727055ff..a5bd3ba5 100644 --- a/internal/configuration/sources/env/openvpn.go +++ b/internal/configuration/sources/env/openvpn.go @@ -11,7 +11,8 @@ import ( func (r *Reader) readOpenVPN() ( openVPN settings.OpenVPN, err error) { defer func() { - err = unsetEnvKeys([]string{"OPENVPN_KEY", "OPENVPN_CERT"}, err) + err = unsetEnvKeys([]string{"OPENVPN_KEY", "OPENVPN_CERT", + "OPENVPN_KEY_PASSPHRASE", "OPENVPN_ENCRYPTED_KEY"}, err) }() openVPN.Version = getCleanedEnv("OPENVPN_VERSION") @@ -40,6 +41,13 @@ func (r *Reader) readOpenVPN() ( return openVPN, fmt.Errorf("environment variable OPENVPN_KEY: %w", err) } + openVPN.EncryptedKey, err = readBase64OrNil("OPENVPN_ENCRYPTED_KEY") + if err != nil { + return openVPN, fmt.Errorf("environment variable OPENVPN_ENCRYPTED_KEY: %w", err) + } + + openVPN.KeyPassphrase = r.readOpenVPNKeyPassphrase() + openVPN.PIAEncPreset = r.readPIAEncryptionPreset() openVPN.IPv6, err = envToBoolPtr("OPENVPN_IPV6") @@ -94,6 +102,15 @@ func (r *Reader) readOpenVPNPassword() (password *string) { return password } +func (r *Reader) readOpenVPNKeyPassphrase() (passphrase *string) { + passphrase = new(string) + *passphrase = getCleanedEnv("OPENVPN_KEY_PASSPHRASE") + if *passphrase == "" { + return nil + } + return passphrase +} + func readBase64OrNil(envKey string) (valueOrNil *string, err error) { value := getCleanedEnv(envKey) if value == "" { diff --git a/internal/configuration/sources/env/serverselection.go b/internal/configuration/sources/env/serverselection.go index 4c1cf2d1..5f47a354 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 499c5c5f..896526b7 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" + openVPNEncryptedKey = "/gluetun/openvpn_encrypted_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.EncryptedKey, err = ReadFromFile(openVPNEncryptedKey) + if err != nil { + return settings, fmt.Errorf("reading encrypted key file: %w", err) + } + return settings, nil } diff --git a/internal/configuration/sources/secrets/openvpn.go b/internal/configuration/sources/secrets/openvpn.go index eec6df0a..303ad9e7 100644 --- a/internal/configuration/sources/secrets/openvpn.go +++ b/internal/configuration/sources/secrets/openvpn.go @@ -32,6 +32,22 @@ func readOpenVPN() ( return settings, fmt.Errorf("cannot read client key file: %w", err) } + settings.EncryptedKey, err = readSecretFileAsStringPtr( + "OPENVPN_ENCRYPTED_KEY_SECRETFILE", + "/run/secrets/openvpn_encrypted_key", + ) + if err != nil { + return settings, fmt.Errorf("reading encrypted key file: %w", err) + } + + settings.KeyPassphrase, err = readSecretFileAsStringPtr( + "OPENVPN_KEY_PASSPHRASE_SECRETFILE", + "/run/secrets/openvpn_key_passphrase", + ) + if err != nil { + return settings, fmt.Errorf("reading key passphrase file: %w", err) + } + settings.Cert, err = readSecretFileAsStringPtr( "OPENVPN_CLIENTCRT_SECRETFILE", "/run/secrets/openvpn_clientcrt", diff --git a/internal/constants/openvpn/paths.go b/internal/constants/openvpn/paths.go index 2c7a8265..6bf525a9 100644 --- a/internal/constants/openvpn/paths.go +++ b/internal/constants/openvpn/paths.go @@ -3,4 +3,7 @@ package openvpn const ( // AuthConf is the file path to the OpenVPN auth file. AuthConf = "/etc/openvpn/auth.conf" + // AskPassPath is the file path to the decryption passphrase for + // and encrypted private key, which is pointed by `askpass`. + AskPassPath = "/etc/openvpn/askpass" //nolint:gosec ) diff --git a/internal/constants/providers/providers.go b/internal/constants/providers/providers.go index 528dfe5b..01b089bc 100644 --- a/internal/constants/providers/providers.go +++ b/internal/constants/providers/providers.go @@ -22,6 +22,7 @@ const ( SlickVPN = "slickvpn" Surfshark = "surfshark" Torguard = "torguard" + VPNSecure = "vpnsecure" VPNUnlimited = "vpn unlimited" Vyprvpn = "vyprvpn" Wevpn = "wevpn" @@ -48,6 +49,7 @@ func All() []string { SlickVPN, Surfshark, Torguard, + VPNSecure, VPNUnlimited, Vyprvpn, Wevpn, diff --git a/internal/models/markdown.go b/internal/models/markdown.go index f0482559..e39acc18 100644 --- a/internal/models/markdown.go +++ b/internal/models/markdown.go @@ -29,6 +29,7 @@ const ( numberHeader = "Number" ownedHeader = "Owned" portForwardHeader = "Port forwarding" + premiumHeader = "Premium" regionHeader = "Region" streamHeader = "Stream" tcpHeader = "TCP" @@ -62,6 +63,8 @@ func (s *Server) ToMarkdown(headers ...string) (markdown string) { fields[i] = boolToMarkdown(s.Owned) case portForwardHeader: fields[i] = boolToMarkdown(s.PortForward) + case premiumHeader: + fields[i] = boolToMarkdown(s.Premium) case regionHeader: fields[i] = s.Region case streamHeader: @@ -129,6 +132,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 b6871a99..8749f01f 100644 --- a/internal/models/server.go +++ b/internal/models/server.go @@ -29,6 +29,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/openvpn/auth.go b/internal/openvpn/auth.go index b47fe06a..b832d84a 100644 --- a/internal/openvpn/auth.go +++ b/internal/openvpn/auth.go @@ -1,65 +1,56 @@ package openvpn import ( - "io" + "fmt" + "io/ioutil" "os" "strings" ) // WriteAuthFile writes the OpenVPN auth file to disk with the right permissions. func (c *Configurator) WriteAuthFile(user, password string) error { - file, err := os.Open(c.authFilePath) - - if err != nil && !os.IsNotExist(err) { - return err - } - - if os.IsNotExist(err) { - file, err = os.OpenFile(c.authFilePath, os.O_WRONLY|os.O_CREATE, 0400) - if err != nil { - return err - } - _, err = file.WriteString(user + "\n" + password) - if err != nil { - _ = file.Close() - return err - } - err = file.Chown(c.puid, c.pgid) - if err != nil { - _ = file.Close() - return err - } - return file.Close() - } - - data, err := io.ReadAll(file) - if err != nil { - _ = file.Close() - return err - } - if err := file.Close(); err != nil { - return err - } - - lines := strings.Split(string(data), "\n") - if len(lines) > 1 && lines[0] == user && lines[1] == password { - return nil - } - - c.logger.Info("username and password changed in " + c.authFilePath) - file, err = os.OpenFile(c.authFilePath, os.O_TRUNC|os.O_WRONLY, 0400) - if err != nil { - return err - } - _, err = file.WriteString(user + "\n" + password) - if err != nil { - _ = file.Close() - return err - } - err = file.Chown(c.puid, c.pgid) - if err != nil { - _ = file.Close() - return err - } - return file.Close() + content := strings.Join([]string{user, password}, "\n") + return writeIfDifferent(c.authFilePath, content, c.puid, c.pgid) +} + +// WriteAskPassFile writes the OpenVPN askpass file to disk with the right permissions. +func (c *Configurator) WriteAskPassFile(passphrase string) error { + return writeIfDifferent(c.askPassPath, passphrase, c.puid, c.pgid) +} + +func writeIfDifferent(path, content string, puid, pgid int) (err error) { + fileStat, err := os.Stat(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("obtaining file information: %w", err) + } + + const perm = os.FileMode(0400) + var writeData, setChown bool + if os.IsNotExist(err) { + writeData = true + setChown = true + } else { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + writeData = string(data) != content + setChown = fileStat.Mode().Perm() != perm + } + + if writeData { + err = ioutil.WriteFile(path, []byte(content), perm) + if err != nil { + return fmt.Errorf("writing file: %w", err) + } + } + + if setChown { + err = os.Chown(path, puid, pgid) + if err != nil { + return fmt.Errorf("setting file permissions: %w", err) + } + } + + return nil } diff --git a/internal/openvpn/openvpn.go b/internal/openvpn/openvpn.go index e8fdb4cb..4e4b2c5b 100644 --- a/internal/openvpn/openvpn.go +++ b/internal/openvpn/openvpn.go @@ -10,6 +10,7 @@ type Configurator struct { cmder command.RunStarter configPath string authFilePath string + askPassPath string puid, pgid int } @@ -20,6 +21,7 @@ func New(logger Infoer, cmder command.RunStarter, cmder: cmder, configPath: configPath, authFilePath: openvpn.AuthConf, + askPassPath: openvpn.AskPassPath, puid: puid, pgid: pgid, } diff --git a/internal/provider/providers.go b/internal/provider/providers.go index 6d448884..65e6c516 100644 --- a/internal/provider/providers.go +++ b/internal/provider/providers.go @@ -28,6 +28,7 @@ import ( "github.com/qdm12/gluetun/internal/provider/slickvpn" "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" @@ -75,6 +76,7 @@ func NewProviders(storage Storage, timeNow func() time.Time, providers.SlickVPN: slickvpn.New(storage, randSource, client, 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/slickvpn/updater/website.go b/internal/provider/slickvpn/updater/website.go index 15237f3a..b25d0734 100644 --- a/internal/provider/slickvpn/updater/website.go +++ b/internal/provider/slickvpn/updater/website.go @@ -13,7 +13,8 @@ import ( func fetchServers(ctx context.Context, client *http.Client) ( hostToData map[string]serverData, err error) { - rootNode, err := fetchHTML(ctx, client) + const url = "https://www.slickvpn.com/locations/" + rootNode, err := htmlutils.Fetch(ctx, client, url) if err != nil { return nil, fmt.Errorf("fetching HTML code: %w", err) } @@ -26,39 +27,6 @@ func fetchServers(ctx context.Context, client *http.Client) ( return hostToData, nil } -var ErrHTTPStatusCode = errors.New("HTTP status code is not OK") - -func fetchHTML(ctx context.Context, client *http.Client) (rootNode *html.Node, err error) { - const url = "https://www.slickvpn.com/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) - } - - rootNode, err = html.Parse(response.Body) - if err != nil { - _ = response.Body.Close() - return nil, fmt.Errorf("parsing HTML code: %w", err) - } - - err = response.Body.Close() - if err != nil { - return nil, fmt.Errorf("closing response body: %w", err) - } - - return rootNode, nil -} - type serverData struct { ovpnURL string country string diff --git a/internal/provider/slickvpn/updater/website_test.go b/internal/provider/slickvpn/updater/website_test.go index 1c251cbe..b6bb4486 100644 --- a/internal/provider/slickvpn/updater/website_test.go +++ b/internal/provider/slickvpn/updater/website_test.go @@ -104,73 +104,6 @@ func Test_fetchServers(t *testing.T) { } } -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 - rootNode *html.Node - errWrapped error - errMessage string - }{ - "context canceled": { - ctx: canceledCtx, - errWrapped: context.Canceled, - errMessage: `Get "https://www.slickvpn.com/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, - rootNode: parseTestHTML(t, "some body"), - responseBody: ioutil.NopCloser(strings.NewReader("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.slickvpn.com/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 - }), - } - - rootNode, 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.rootNode, rootNode) - }) - } -} - func Test_parseHTML(t *testing.T) { t.Parallel() diff --git a/internal/provider/utils/filtering.go b/internal/provider/utils/filtering.go index 2124b729..eb5ab7af 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 63b27208..3d3ebef6 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 57ad1e66..357f778e 100644 --- a/internal/provider/utils/openvpn.go +++ b/internal/provider/utils/openvpn.go @@ -189,6 +189,13 @@ func OpenVPNConfig(provider OpenVPNProviderSettings, lines.addLines(WrapOpenvpnTLSCrypt(provider.TLSCrypt)) } + if *settings.EncryptedKey != "" { + lines.add("askpass", openvpn.AskPassPath) + keyData, err := extract.PEM([]byte(*settings.EncryptedKey)) + panicOnError(err, "cannot extract PEM encrypted key") + lines.addLines(WrapOpenvpnEncryptedKey(keyData)) + } + if *settings.Cert != "" { certData, err := extract.PEM([]byte(*settings.Cert)) panicOnError(err, "cannot extract OpenVPN certificate") @@ -295,6 +302,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 00000000..d4ee645e --- /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 00000000..126b1fbc --- /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, + // note DES-CBC is not added since it's quite unsecure + Ciphers: []string{openvpn.AES256cbc, openvpn.AES128cbc}, + 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 + } + 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 00000000..7f8d0a29 --- /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/helpers_test.go b/internal/provider/vpnsecure/updater/helpers_test.go new file mode 100644 index 00000000..7025a6b0 --- /dev/null +++ b/internal/provider/vpnsecure/updater/helpers_test.go @@ -0,0 +1,26 @@ +package updater + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/net/html" +) + +func parseTestHTML(t *testing.T, htmlString string) *html.Node { + t.Helper() + rootNode, err := html.Parse(strings.NewReader(htmlString)) + require.NoError(t, err) + return rootNode +} + +func parseTestDataIndexHTML(t *testing.T) *html.Node { + t.Helper() + + data, err := os.ReadFile("testdata/index.html") + require.NoError(t, err) + + return parseTestHTML(t, string(data)) +} diff --git a/internal/provider/vpnsecure/updater/hosttoserver.go b/internal/provider/vpnsecure/updater/hosttoserver.go new file mode 100644 index 00000000..54a03072 --- /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 00000000..1758fab1 --- /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 00000000..18739a63 --- /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, u.warner) + 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/testdata/index.html b/internal/provider/vpnsecure/updater/testdata/index.html new file mode 100644 index 00000000..79858a95 --- /dev/null +++ b/internal/provider/vpnsecure/updater/testdata/index.html @@ -0,0 +1,7345 @@ + + + + + + + + + Locations | VPNSecure.me + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +

+ +

+
+ +
+
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ + + + +
+

+ Encrypted VPN Access World Wide +

+
+ + +
+
+ + + + +
+
+
+ + + + +
+

Our private VPN access servers are monitored. They’re access controlled, and we are the only + ones that operate them — no one else.  They do not permanently store IP addresses, nor do they + store logs. Each server supports all popular protocols, including: OpenVPN, SSH SOCKS, HTTP + Proxy & Smart DNS.  With the very best server locations and low ping times you will always + find a fast server close to you.

+
+ + + + +
+

+ Server Locations and Status +

+
+ + +
+
+ +
+
+
+
+ + +
+ +

Australia

+
+
+ + +
+
+
+ + + + + + + +
+ au1 + up +
+
+
City: Brisbane
+
Region: Queensland
+
Premium: Yes
+ +
+
+ + +
+
+
+ + + + + + + +
+ au2 + up +
+
+
City: Sydney
+
Region: New South Wales
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + +
+ au3 + up +
+
+
City: Sydney
+
Region: New South Wales
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + +
+ au4 + up +
+
+
City: Sydney
+
Region: New South Wales
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Austria

+
+
+ + +
+
+
+ + + + +
+ at1 + up +
+
+
City: Vienna
+
Region: Vienna
+
Premium: Yes
+ +
+
+ + +
+
+
+ + + + +
+ at2 + up +
+
+
City: Vienna
+
Region: Vienna
+
Premium: No
+ +
+
+
+
+ + +
+ +

Brazil

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ br1 + up +
+
+
City: Sao Paulo
+
Region: Sao Paulo
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Belgium

+
+
+ + +
+
+
+ + + + + +
+ be1 + up +
+
+
City: Zaventem
+
Region: Flanders
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ be2 + up +
+
+
City: Brussel
+
Region: Brussels Hoofdstedelijk Gewest
+
Premium: No
+ +
+
+
+
+ + + + + +
+ +

Canada

+
+
+ + +
+
+
+ + + + + + + + + +
+ ca1 + up +
+
+
City: Richmond Hill
+
Region: Ontario
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + + + +
+ ca2 + up +
+
+
City: Richmond Hill
+
Region: Ontario
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + + + +
+ ca3 + up +
+
+
City: Montréal
+
Region: Quebec
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Denmark

+
+
+ + +
+
+
+ + + + + + + + + + +
+ dk1 + up +
+
+
City: Copenhagen
+
Region: Capital Region
+
Premium: Yes
+ +
+
+ + +
+
+
+ + + + + + + + + + +
+ dk2 + up +
+
+
City: Copenhagen
+
Region: Capital Region
+
Premium: Yes
+ +
+
+ + +
+
+
+ + + + + + + + + + +
+ dk3 + up +
+
+
City: Ballerup
+
Region: Capital Region
+
Premium: No
+ +
+
+
+
+ + +
+ +

France

+
+
+ + +
+
+
+ + + + + +
+ fr1 + up +
+
+
City: Paris
+
Region: Île-de-France
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ fr2 + up +
+
+
City: Paris
+
Region: Île-de-France
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ fr3 + up +
+
+
City: Strasbourg
+
Region: Grand Est
+
Premium: No
+ +
+
+
+
+ + +
+ +

Germany

+
+
+ + +
+
+
+ + + + + +
+ de1 + up +
+
+
City: Frankfurt am Main
+
Region: Hesse
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ de2 + up +
+
+
City: Frankfurt am Main
+
Region: Hesse
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ de3 + up +
+
+
City: Frankfurt am Main
+
Region: Hesse
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ de4 + up +
+
+
City: Frankfurt am Main
+
Region: Hesse
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ de5 + up +
+
+
City: Limburg an der Lahn
+
Region: Hesse
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ de6 + up +
+
+
City: Frankfurt am Main
+
Region: Hesse
+
Premium: No
+ +
+
+
+
+ + +
+ +

Hungary

+
+
+ + +
+
+
+ + + + + +
+ hu1 + up +
+
+
City: Budapest
+
Region: Budapest
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

India

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ in1 + up +
+
+
City: Doddaballapura
+
Region: Karnataka
+
Premium: No
+ +
+
+
+
+ + +
+ +

Indonesia

+
+
+ + +
+
+
+ + + + +
+ id1 + up +
+
+
City: Jakarta
+
Region: Special Capital Region of Jakarta
+
Premium: No
+ +
+
+
+
+ + +
+ +

Ireland

+
+
+ + +
+
+
+ + + + + +
+ ie1 + up +
+
+
City: Dublin
+
Region: Dublin City
+
Premium: No
+ +
+
+
+
+ + +
+ +

Israel

+
+
+ + +
+
+
+ + + + + + + + + + + + +
+ il1 + up +
+
+
City: Tel Aviv
+
Region: Tel Aviv
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Italy

+
+
+ + +
+
+
+ + + + + +
+ it1 + up +
+
+
City: Milan
+
Region: Lombardy
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Japan

+
+
+ + +
+
+
+ + + + + + + + + +
+ jp2 + up +
+
+
City: Tokyo
+
Region: Tokyo
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Mexico

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ mx1 + up +
+
+
City: Ampliación San Mateo (Colonia Solidaridad)
+
Region: México
+
Premium: No
+ +
+
+
+
+ + +
+ +

Netherlands

+
+
+ + +
+
+
+ + + + + +
+ nl1 + up +
+
+
City: Haarlem
+
Region: North Holland
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + +
+ nl2 + up +
+
+
City: Naaldwijk
+
Region: South Holland
+
Premium: No
+ +
+
+
+
+ + +
+ +

New Zealand

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ nz1 + up +
+
+
City: Auckland
+
Region: Auckland
+
Premium: No
+ +
+
+
+
+ + +
+ +

Norway

+
+
+ + +
+
+
+ + + + + + + + + + + + +
+ no1 + up +
+
+
City: Oslo
+
Region: Oslo
+
Premium: Yes
+ +
+
+ + +
+
+
+ + + + + + + + + + + + +
+ no2 + up +
+
+
City: Stockholm
+
Region: Stockholm
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Poland

+
+
+ + +
+
+
+ + + + +
+ pl1 + up +
+
+
City: Warsaw
+
Region: Mazovia
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Romania

+
+
+ + +
+
+
+ + + + + +
+ ro1 + up +
+
+
City: Bucharest
+
Region: Bucure?ti
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Russia

+
+
+ + +
+
+
+ + + + + +
+ ru1 + up +
+
+
City: Moscow
+
Region: Moscow
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Singapore

+
+
+ + +
+
+
+ + + + + + + + + + + +
+ sg1 + up +
+
+
City: Singapore
+
Region: Singapore
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

South Africa

+
+
+ + +
+
+
+ + + + + + + + + + + + + +
+ za1 + up +
+
+
City: Cape Town
+
Region: Western Cape
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Spain

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ es2 + up +
+
+
City: Madrid
+
Region: Madrid
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ se1 + up +
+
+
City: Valencia
+
Region: Valencia
+
Premium: No
+ +
+
+
+
+ + +
+ +

Sweden

+
+
+ + +
+
+
+ + + + + + + + + + + + + +
+ se2 + up +
+
+
City: Stockholm
+
Region: Stockholm
+
Premium: Yes
+ +
+
+ + +
+
+
+ + + + + + + + + + + + + +
+ se3 + up +
+
+
City: Stockholm
+
Region: Stockholm
+
Premium: No
+ +
+
+
+
+ + +
+ +

Switzerland

+
+
+ + +
+
+
+ + + + + + + +
+ ch1 + up +
+
+
City: Lausanne
+
Region: Vaud
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + +
+ ch1 + up +
+
+
City: Geneva
+
Region: Geneva
+
Premium: Yes
+ +
+
+ + +
+
+
+ + + + + + + +
+ ch2 + up +
+
+
City: Genève
+
Region: Geneva
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

Ukraine

+
+
+ + +
+
+
+ + + + +
+ ua1 + up +
+
+
City: Kremenchuk
+
Region: Poltavs'ka Oblast'
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

United Arab Emirates

+
+
+ + +
+
+
+ + + + + + + + + + + +
+ ae1 + up +
+
+
City: Mumbai
+
Region: Maharashtra
+
Premium: Yes
+ +
+
+
+
+ + +
+ +

United Kingdom

+
+
+ + +
+
+
+ + + + + + + + + + + +
+ uk2 + up +
+
+
City: London
+
Region: England
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + + + + + +
+ uk3 + up +
+
+
City: Kent
+
Region: England
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + + + + + +
+ uk4 + up +
+
+
City: London
+
Region: England
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + + + + + +
+ uk5 + up +
+
+
City: London
+
Region: England
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + + + + + +
+ uk6 + up +
+
+
City: Harlesden
+
Region: Brent
+
Premium: No
+ +
+
+ + +
+
+
+ + + + + + + + + + + +
+ uk7 + up +
+
+
City: Manchester
+
Region: England
+
Premium: No
+ +
+
+
+
+ + +
+ +

United States

+
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us1 + up +
+
+
City: Secaucus
+
Region: New Jersey
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us10 + up +
+
+
City: New York City
+
Region: New York
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us11 + up +
+
+
City: Los Angeles
+
Region: California
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us12 + up +
+
+
City: Chicago
+
Region: Illinois
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us13 + up +
+
+
City: Los Angeles
+
Region: California
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us14 + up +
+
+
City: Los Angeles
+
Region: California
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us15 + up +
+
+
City: Los Angeles
+
Region: California
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us16 + up +
+
+
City: Chicago
+
Region: Illinois
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us2 + up +
+
+
City: New York City
+
Region: New York
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us3 + up +
+
+
City: Portland
+
Region: Oregon
+
Premium: Yes
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us4 + up +
+
+
City: Chicago
+
Region: Illinois
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us5 + up +
+
+
City: Los Angeles
+
Region: California
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us6 + up +
+
+
City: Los Angeles
+
Region: California
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us7 + up +
+
+
City: Chicago
+
Region: Illinois
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us8 + up +
+
+
City: Atlanta
+
Region: Georgia
+
Premium: No
+ +
+
+ + +
+
+
+ The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ us9 + up +
+
+
City: Atlanta
+
Region: Georgia
+
Premium: No
+ +
+
+
+
+ + +
+ +

Hong Kong

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ hk1 + up +
+
+
City: Hong Kong
+
Region: Central and Western
+
Premium: No
+ +
+
+
+
+ + +
+ +

United States West

+
+
+ + +
+
+
+ us3 + up +
+
+
City: Los Angeles
+
Region: California
+
Premium: Yes
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/provider/vpnsecure/updater/updater.go b/internal/provider/vpnsecure/updater/updater.go new file mode 100644 index 00000000..f15a2f3a --- /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 00000000..2ca77cf0 --- /dev/null +++ b/internal/provider/vpnsecure/updater/website.go @@ -0,0 +1,239 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/common" + htmlutils "github.com/qdm12/gluetun/internal/updater/html" + "golang.org/x/net/html" +) + +func fetchServers(ctx context.Context, client *http.Client, + warner common.Warner) (servers []models.Server, err error) { + const url = "https://www.vpnsecure.me/vpn-locations/" + rootNode, err := htmlutils.Fetch(ctx, client, url) + if err != nil { + return nil, fmt.Errorf("fetching HTML code: %w", err) + } + + servers, warnings, err := parseHTML(rootNode) + for _, warning := range warnings { + warner.Warn(warning) + } + if err != nil { + return nil, fmt.Errorf("parsing HTML code: %w", err) + } + + return servers, nil +} + +var ( + ErrHTMLServersDivNotFound = errors.New("HTML servers container div not found") +) + +const divString = "div" + +func parseHTML(rootNode *html.Node) (servers []models.Server, + warnings []string, err error) { + // Find div container for all servers, searching with BFS. + serversDiv := findServersDiv(rootNode) + if serversDiv == nil { + return nil, nil, htmlutils.WrapError(ErrHTMLServersDivNotFound, rootNode) + } + + for countryNode := serversDiv.FirstChild; countryNode != nil; countryNode = countryNode.NextSibling { + if countryNode.Data != divString { + // empty line(s) and tab(s) + continue + } + + country := findCountry(countryNode) + if country == "" { + warnings = append(warnings, htmlutils.WrapWarning("country not found", countryNode)) + continue + } + + grid := htmlutils.BFS(countryNode, matchGridDiv) + if grid == nil { + warnings = append(warnings, htmlutils.WrapWarning("grid div not found", countryNode)) + continue + } + + gridItems := htmlutils.DirectChildren(grid, matchGridItem) + if len(gridItems) == 0 { + warnings = append(warnings, htmlutils.WrapWarning("no grid item found", grid)) + continue + } + + for _, gridItem := range gridItems { + server, warning := parseHTMLGridItem(gridItem) + if warning != "" { + warnings = append(warnings, warning) + continue + } + + server.Country = country + servers = append(servers, server) + } + } + + return servers, warnings, nil +} + +func parseHTMLGridItem(gridItem *html.Node) ( + server models.Server, warning string) { + gridItemDT := htmlutils.DirectChild(gridItem, matchDT) + if gridItemDT == nil { + return server, htmlutils.WrapWarning("grid item
not found", gridItem) + } + + host := findHost(gridItemDT) + if host == "" { + return server, htmlutils.WrapWarning("host not found", gridItemDT) + } + + status := findStatus(gridItemDT) + if !strings.EqualFold(status, "up") { + warning := fmt.Sprintf("skipping server with host %s which has status %q", host, status) + warning = htmlutils.WrapWarning(warning, gridItemDT) + return server, warning + } + + gridItemDD := htmlutils.DirectChild(gridItem, matchDD) + if gridItemDD == nil { + return server, htmlutils.WrapWarning("grid item dd not found", gridItem) + } + + region := findSpanStrong(gridItemDD, "Region:") + if region == "" { + warning := fmt.Sprintf("region for host %s not found", host) + return server, htmlutils.WrapWarning(warning, gridItemDD) + } + + city := findSpanStrong(gridItemDD, "City:") + if city == "" { + warning := fmt.Sprintf("region for host %s not found", host) + return server, htmlutils.WrapWarning(warning, gridItemDD) + } + + premiumString := findSpanStrong(gridItemDD, "Premium:") + if premiumString == "" { + warning := fmt.Sprintf("premium for host %s not found", host) + return server, htmlutils.WrapWarning(warning, gridItemDD) + } + + return models.Server{ + Region: region, + City: city, + Hostname: host + ".isponeder.com", + Premium: strings.EqualFold(premiumString, "yes"), + }, "" +} + +func findCountry(countryNode *html.Node) (country string) { + for node := countryNode.FirstChild; node != nil; node = node.NextSibling { + if node.Data != "a" { + continue + } + for subNode := node.FirstChild; subNode != nil; subNode = subNode.NextSibling { + if subNode.Data != "h4" { + continue + } + return subNode.FirstChild.Data + } + } + return "" +} + +func findServersDiv(rootNode *html.Node) (serversDiv *html.Node) { + locationsDiv := htmlutils.BFS(rootNode, matchLocationsListDiv) + if locationsDiv == nil { + return nil + } + + return htmlutils.BFS(locationsDiv, matchServersDiv) +} + +func findHost(gridItemDT *html.Node) (host string) { + hostNode := htmlutils.DirectChild(gridItemDT, matchText) + return strings.TrimSpace(hostNode.Data) +} + +func matchText(node *html.Node) (match bool) { + if node.Type != html.TextNode { + return false + } + data := strings.TrimSpace(node.Data) + return data != "" +} + +func findStatus(gridItemDT *html.Node) (status string) { + statusNode := htmlutils.DirectChild(gridItemDT, matchStatusSpan) + return strings.TrimSpace(statusNode.FirstChild.Data) +} + +func matchServersDiv(node *html.Node) (match bool) { + return node != nil && node.Data == divString && + htmlutils.HasClassStrings(node, "blk__i") +} + +func matchLocationsListDiv(node *html.Node) (match bool) { + return node != nil && node.Data == divString && + htmlutils.HasClassStrings(node, "locations-list") +} + +func matchGridDiv(node *html.Node) (match bool) { + return node != nil && node.Data == divString && + htmlutils.HasClassStrings(node, "grid--locations") +} + +func matchGridItem(node *html.Node) (match bool) { + return node != nil && node.Data == "dl" && + htmlutils.HasClassStrings(node, "grid__i") +} + +func matchDT(node *html.Node) (match bool) { + return node != nil && node.Data == "dt" +} + +func matchDD(node *html.Node) (match bool) { + return node != nil && node.Data == "dd" +} + +func matchStatusSpan(node *html.Node) (match bool) { + return node.Data == "span" && htmlutils.HasClassStrings(node, "status") +} + +func findSpanStrong(gridItemDD *html.Node, spanData string) ( + strongValue string) { + spanFound := false + for child := gridItemDD.FirstChild; child != nil; child = child.NextSibling { + if !htmlutils.MatchData("div")(child) { + continue + } + + for subchild := child.FirstChild; subchild != nil; subchild = subchild.NextSibling { + if htmlutils.MatchData("span")(subchild) && subchild.FirstChild.Data == spanData { + spanFound = true + break + } + } + + if !spanFound { + continue + } + + for subchild := child.FirstChild; subchild != nil; subchild = subchild.NextSibling { + if htmlutils.MatchData("strong")(subchild) { + return subchild.FirstChild.Data + } + } + } + + return "" +} diff --git a/internal/provider/vpnsecure/updater/website_test.go b/internal/provider/vpnsecure/updater/website_test.go new file mode 100644 index 00000000..a3771d60 --- /dev/null +++ b/internal/provider/vpnsecure/updater/website_test.go @@ -0,0 +1,235 @@ +package updater + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/provider/common" + "github.com/stretchr/testify/assert" + "golang.org/x/net/html" +) + +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: `fetching HTML code: Get "https://www.vpnsecure.me/vpn-locations/": context canceled`, + }, + "success": { + ctx: context.Background(), + responseStatus: http.StatusOK, + responseBody: ioutil.NopCloser(strings.NewReader(` +
+
+
+ +

Australia

+
+
+
+
+ au1 + up +
+
+
City: City
+
Region: Region
+
Premium: YES
+ +
+
+
+
+
+ `)), + servers: []models.Server{ + { + Country: "Australia", + City: "City", + Region: "Region", + Hostname: "au1.isponeder.com", + Premium: true, + }, + }, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + 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 + }), + } + + warner := common.NewMockWarner(ctrl) + + servers, err := fetchServers(testCase.ctx, client, warner) + + assert.ErrorIs(t, err, testCase.errWrapped) + if testCase.errWrapped != nil { + assert.EqualError(t, err, testCase.errMessage) + } + assert.Equal(t, testCase.servers, servers) + }) + } +} + +func Test_parseHTML(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + rootNode *html.Node + servers []models.Server + warnings []string + errWrapped error + errMessage string + }{ + "empty html": { + rootNode: parseTestHTML(t, ""), + errWrapped: ErrHTMLServersDivNotFound, + errMessage: `HTML servers container div not found: in HTML code: `, + }, + "test data": { + rootNode: parseTestDataIndexHTML(t), + warnings: []string{ + "no grid item found: in HTML code:
\n
", + }, + //nolint:lll + servers: []models.Server{ + {Country: "Australia", Region: "Queensland", City: "Brisbane", Hostname: "au1.isponeder.com", Premium: true}, + {Country: "Australia", Region: "New South Wales", City: "Sydney", Hostname: "au2.isponeder.com"}, + {Country: "Australia", Region: "New South Wales", City: "Sydney", Hostname: "au3.isponeder.com"}, + {Country: "Australia", Region: "New South Wales", City: "Sydney", Hostname: "au4.isponeder.com", Premium: true}, + {Country: "Austria", Region: "Vienna", City: "Vienna", Hostname: "at1.isponeder.com", Premium: true}, + {Country: "Austria", Region: "Vienna", City: "Vienna", Hostname: "at2.isponeder.com"}, + {Country: "Brazil", Region: "Sao Paulo", City: "Sao Paulo", Hostname: "br1.isponeder.com", Premium: true}, + {Country: "Belgium", Region: "Flanders", City: "Zaventem", Hostname: "be1.isponeder.com"}, + {Country: "Belgium", Region: "Brussels Hoofdstedelijk Gewest", City: "Brussel", Hostname: "be2.isponeder.com"}, + {Country: "Canada", Region: "Ontario", City: "Richmond Hill", Hostname: "ca1.isponeder.com"}, + {Country: "Canada", Region: "Ontario", City: "Richmond Hill", Hostname: "ca2.isponeder.com"}, + {Country: "Canada", Region: "Quebec", City: "Montréal", Hostname: "ca3.isponeder.com", Premium: true}, + {Country: "Denmark", Region: "Capital Region", City: "Copenhagen", Hostname: "dk1.isponeder.com", Premium: true}, + {Country: "Denmark", Region: "Capital Region", City: "Copenhagen", Hostname: "dk2.isponeder.com", Premium: true}, + {Country: "Denmark", Region: "Capital Region", City: "Ballerup", Hostname: "dk3.isponeder.com"}, + {Country: "France", Region: "Île-de-France", City: "Paris", Hostname: "fr1.isponeder.com"}, + {Country: "France", Region: "Île-de-France", City: "Paris", Hostname: "fr2.isponeder.com"}, + {Country: "France", Region: "Grand Est", City: "Strasbourg", Hostname: "fr3.isponeder.com"}, + {Country: "Germany", Region: "Hesse", City: "Frankfurt am Main", Hostname: "de1.isponeder.com"}, + {Country: "Germany", Region: "Hesse", City: "Frankfurt am Main", Hostname: "de2.isponeder.com"}, + {Country: "Germany", Region: "Hesse", City: "Frankfurt am Main", Hostname: "de3.isponeder.com"}, + {Country: "Germany", Region: "Hesse", City: "Frankfurt am Main", Hostname: "de4.isponeder.com"}, + {Country: "Germany", Region: "Hesse", City: "Limburg an der Lahn", Hostname: "de5.isponeder.com"}, + {Country: "Germany", Region: "Hesse", City: "Frankfurt am Main", Hostname: "de6.isponeder.com"}, + {Country: "Hungary", Region: "Budapest", City: "Budapest", Hostname: "hu1.isponeder.com", Premium: true}, + {Country: "India", Region: "Karnataka", City: "Doddaballapura", Hostname: "in1.isponeder.com"}, + {Country: "Indonesia", Region: "Special Capital Region of Jakarta", City: "Jakarta", Hostname: "id1.isponeder.com"}, + {Country: "Ireland", Region: "Dublin City", City: "Dublin", Hostname: "ie1.isponeder.com"}, + {Country: "Israel", Region: "Tel Aviv", City: "Tel Aviv", Hostname: "il1.isponeder.com", Premium: true}, + {Country: "Italy", Region: "Lombardy", City: "Milan", Hostname: "it1.isponeder.com", Premium: true}, + {Country: "Japan", Region: "Tokyo", City: "Tokyo", Hostname: "jp2.isponeder.com", Premium: true}, + {Country: "Mexico", Region: "México", City: "Ampliación San Mateo (Colonia Solidaridad)", Hostname: "mx1.isponeder.com"}, + {Country: "Netherlands", Region: "North Holland", City: "Haarlem", Hostname: "nl1.isponeder.com"}, + {Country: "Netherlands", Region: "South Holland", City: "Naaldwijk", Hostname: "nl2.isponeder.com"}, + {Country: "New Zealand", Region: "Auckland", City: "Auckland", Hostname: "nz1.isponeder.com"}, + {Country: "Norway", Region: "Oslo", City: "Oslo", Hostname: "no1.isponeder.com", Premium: true}, + {Country: "Norway", Region: "Stockholm", City: "Stockholm", Hostname: "no2.isponeder.com", Premium: true}, + {Country: "Poland", Region: "Mazovia", City: "Warsaw", Hostname: "pl1.isponeder.com", Premium: true}, + {Country: "Romania", Region: "Bucure?ti", City: "Bucharest", Hostname: "ro1.isponeder.com", Premium: true}, + {Country: "Russia", Region: "Moscow", City: "Moscow", Hostname: "ru1.isponeder.com", Premium: true}, + {Country: "Singapore", Region: "Singapore", City: "Singapore", Hostname: "sg1.isponeder.com", Premium: true}, + {Country: "South Africa", Region: "Western Cape", City: "Cape Town", Hostname: "za1.isponeder.com", Premium: true}, + {Country: "Spain", Region: "Madrid", City: "Madrid", Hostname: "es2.isponeder.com"}, + {Country: "Spain", Region: "Valencia", City: "Valencia", Hostname: "se1.isponeder.com"}, + {Country: "Sweden", Region: "Stockholm", City: "Stockholm", Hostname: "se2.isponeder.com", Premium: true}, + {Country: "Sweden", Region: "Stockholm", City: "Stockholm", Hostname: "se3.isponeder.com"}, + {Country: "Switzerland", Region: "Vaud", City: "Lausanne", Hostname: "ch1.isponeder.com"}, + {Country: "Switzerland", Region: "Geneva", City: "Geneva", Hostname: "ch1.isponeder.com", Premium: true}, + {Country: "Switzerland", Region: "Geneva", City: "Genève", Hostname: "ch2.isponeder.com", Premium: true}, + {Country: "Ukraine", Region: "Poltavs'ka Oblast'", City: "Kremenchuk", Hostname: "ua1.isponeder.com", Premium: true}, + {Country: "United Arab Emirates", Region: "Maharashtra", City: "Mumbai", Hostname: "ae1.isponeder.com", Premium: true}, + {Country: "United Kingdom", Region: "England", City: "London", Hostname: "uk2.isponeder.com"}, + {Country: "United Kingdom", Region: "England", City: "Kent", Hostname: "uk3.isponeder.com"}, + {Country: "United Kingdom", Region: "England", City: "London", Hostname: "uk4.isponeder.com"}, + {Country: "United Kingdom", Region: "England", City: "London", Hostname: "uk5.isponeder.com"}, + {Country: "United Kingdom", Region: "Brent", City: "Harlesden", Hostname: "uk6.isponeder.com"}, + {Country: "United Kingdom", Region: "England", City: "Manchester", Hostname: "uk7.isponeder.com"}, + {Country: "United States", Region: "New Jersey", City: "Secaucus", Hostname: "us1.isponeder.com"}, + {Country: "United States", Region: "New York", City: "New York City", Hostname: "us10.isponeder.com"}, + {Country: "United States", Region: "California", City: "Los Angeles", Hostname: "us11.isponeder.com"}, + {Country: "United States", Region: "Illinois", City: "Chicago", Hostname: "us12.isponeder.com"}, + {Country: "United States", Region: "California", City: "Los Angeles", Hostname: "us13.isponeder.com"}, + {Country: "United States", Region: "California", City: "Los Angeles", Hostname: "us14.isponeder.com"}, + {Country: "United States", Region: "California", City: "Los Angeles", Hostname: "us15.isponeder.com"}, + {Country: "United States", Region: "Illinois", City: "Chicago", Hostname: "us16.isponeder.com"}, + {Country: "United States", Region: "New York", City: "New York City", Hostname: "us2.isponeder.com"}, + {Country: "United States", Region: "Oregon", City: "Portland", Hostname: "us3.isponeder.com", Premium: true}, + {Country: "United States", Region: "Illinois", City: "Chicago", Hostname: "us4.isponeder.com"}, + {Country: "United States", Region: "California", City: "Los Angeles", Hostname: "us5.isponeder.com"}, + {Country: "United States", Region: "California", City: "Los Angeles", Hostname: "us6.isponeder.com"}, + {Country: "United States", Region: "Illinois", City: "Chicago", Hostname: "us7.isponeder.com"}, + {Country: "United States", Region: "Georgia", City: "Atlanta", Hostname: "us8.isponeder.com"}, + {Country: "United States", Region: "Georgia", City: "Atlanta", Hostname: "us9.isponeder.com"}, + {Country: "Hong Kong", Region: "Central and Western", City: "Hong Kong", Hostname: "hk1.isponeder.com"}, + {Country: "United States West", Region: "California", City: "Los Angeles", Hostname: "us3.isponeder.com", Premium: true}, + }, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + servers, warnings, err := parseHTML(testCase.rootNode) + + assert.Equal(t, testCase.servers, servers) + assert.Equal(t, testCase.warnings, warnings) + assert.ErrorIs(t, err, testCase.errWrapped) + if testCase.errWrapped != nil { + assert.EqualError(t, err, testCase.errMessage) + } + }) + } +} diff --git a/internal/storage/formatting.go b/internal/storage/formatting.go index 1fa0e461..ceeb29a6 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/servers.json b/internal/storage/servers.json index f32c7729..1169c215 100644 --- a/internal/storage/servers.json +++ b/internal/storage/servers.json @@ -119755,6 +119755,913 @@ } ] }, + "vpnsecure": { + "version": 1, + "timestamp": 1655858488, + "servers": [ + { + "vpn": "openvpn", + "country": "Australia", + "region": "New South Wales", + "city": "Sydney", + "hostname": "au2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "139.99.131.191" + ] + }, + { + "vpn": "openvpn", + "country": "Australia", + "region": "New South Wales", + "city": "Sydney", + "hostname": "au3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "37.120.234.22" + ] + }, + { + "vpn": "openvpn", + "country": "Australia", + "region": "New South Wales", + "city": "Sydney", + "hostname": "au4.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "217.138.205.151" + ] + }, + { + "vpn": "openvpn", + "country": "Australia", + "region": "Queensland", + "city": "Brisbane", + "hostname": "au1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "51.161.157.216" + ] + }, + { + "vpn": "openvpn", + "country": "Austria", + "region": "Vienna", + "city": "Vienna", + "hostname": "at1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "185.236.202.181" + ] + }, + { + "vpn": "openvpn", + "country": "Austria", + "region": "Vienna", + "city": "Vienna", + "hostname": "at2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "91.151.16.17" + ] + }, + { + "vpn": "openvpn", + "country": "Belgium", + "region": "Brussels Hoofdstedelijk Gewest", + "city": "Brussel", + "hostname": "be2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "37.120.236.166" + ] + }, + { + "vpn": "openvpn", + "country": "Belgium", + "region": "Flanders", + "city": "Zaventem", + "hostname": "be1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "37.120.236.166" + ] + }, + { + "vpn": "openvpn", + "country": "Brazil", + "region": "Sao Paulo", + "city": "Sao Paulo", + "hostname": "br1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "154.16.57.215" + ] + }, + { + "vpn": "openvpn", + "country": "Canada", + "region": "Ontario", + "city": "Richmond Hill", + "hostname": "ca1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "149.56.46.132" + ] + }, + { + "vpn": "openvpn", + "country": "Canada", + "region": "Ontario", + "city": "Richmond Hill", + "hostname": "ca2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.222.50.187" + ] + }, + { + "vpn": "openvpn", + "country": "Canada", + "region": "Quebec", + "city": "Montréal", + "hostname": "ca3.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "67.43.234.50" + ] + }, + { + "vpn": "openvpn", + "country": "Denmark", + "region": "Capital Region", + "city": "Ballerup", + "hostname": "dk3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "37.120.145.132" + ] + }, + { + "vpn": "openvpn", + "country": "Denmark", + "region": "Capital Region", + "city": "Copenhagen", + "hostname": "dk1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "192.36.27.55" + ] + }, + { + "vpn": "openvpn", + "country": "Denmark", + "region": "Capital Region", + "city": "Copenhagen", + "hostname": "dk2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "192.36.27.61" + ] + }, + { + "vpn": "openvpn", + "country": "France", + "region": "Grand Est", + "city": "Strasbourg", + "hostname": "fr3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "151.80.148.41" + ] + }, + { + "vpn": "openvpn", + "country": "France", + "region": "Île-de-France", + "city": "Paris", + "hostname": "fr1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "87.98.158.99" + ] + }, + { + "vpn": "openvpn", + "country": "France", + "region": "Île-de-France", + "city": "Paris", + "hostname": "fr2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "87.98.158.117" + ] + }, + { + "vpn": "openvpn", + "country": "Germany", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.125.201.229" + ] + }, + { + "vpn": "openvpn", + "country": "Germany", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.113.80" + ] + }, + { + "vpn": "openvpn", + "country": "Germany", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.113.82" + ] + }, + { + "vpn": "openvpn", + "country": "Germany", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de4.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.37.144" + ] + }, + { + "vpn": "openvpn", + "country": "Germany", + "region": "Hesse", + "city": "Frankfurt am Main", + "hostname": "de6.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.37.144" + ] + }, + { + "vpn": "openvpn", + "country": "Germany", + "region": "Hesse", + "city": "Limburg an der Lahn", + "hostname": "de5.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.125.183.212" + ] + }, + { + "vpn": "openvpn", + "country": "Hong Kong", + "region": "Central and Western", + "city": "Hong Kong", + "hostname": "hk1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "103.253.43.30" + ] + }, + { + "vpn": "openvpn", + "country": "Hungary", + "region": "Budapest", + "city": "Budapest", + "hostname": "hu1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "194.71.130.93" + ] + }, + { + "vpn": "openvpn", + "country": "India", + "region": "Karnataka", + "city": "Doddaballapura", + "hostname": "in1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "142.93.222.157" + ] + }, + { + "vpn": "openvpn", + "country": "Indonesia", + "region": "Special Capital Region of Jakarta", + "city": "Jakarta", + "hostname": "id1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "45.114.118.84" + ] + }, + { + "vpn": "openvpn", + "country": "Ireland", + "region": "Dublin City", + "city": "Dublin", + "hostname": "ie1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.224.197.60" + ] + }, + { + "vpn": "openvpn", + "country": "Israel", + "region": "Tel Aviv", + "city": "Tel Aviv", + "hostname": "il1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "193.182.144.18" + ] + }, + { + "vpn": "openvpn", + "country": "Italy", + "region": "Lombardy", + "city": "Milan", + "hostname": "it1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "149.154.157.94" + ] + }, + { + "vpn": "openvpn", + "country": "Japan", + "region": "Tokyo", + "city": "Tokyo", + "hostname": "jp2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "194.68.27.45" + ] + }, + { + "vpn": "openvpn", + "country": "Mexico", + "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", + "country": "Netherlands", + "region": "North Holland", + "city": "Haarlem", + "hostname": "nl1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.15.2.92" + ] + }, + { + "vpn": "openvpn", + "country": "Netherlands", + "region": "South Holland", + "city": "Naaldwijk", + "hostname": "nl2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "212.83.133.203" + ] + }, + { + "vpn": "openvpn", + "country": "New Zealand", + "region": "Auckland", + "city": "Auckland", + "hostname": "nz1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.121.168.31" + ] + }, + { + "vpn": "openvpn", + "country": "Norway", + "region": "Oslo", + "city": "Oslo", + "hostname": "no1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "194.68.32.36" + ] + }, + { + "vpn": "openvpn", + "country": "Norway", + "region": "Stockholm", + "city": "Stockholm", + "hostname": "no2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "194.68.32.36" + ] + }, + { + "vpn": "openvpn", + "country": "Poland", + "region": "Mazovia", + "city": "Warsaw", + "hostname": "pl1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "89.207.169.53" + ] + }, + { + "vpn": "openvpn", + "country": "Romania", + "region": "Bucure?ti", + "city": "Bucharest", + "hostname": "ro1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "5.252.178.107" + ] + }, + { + "vpn": "openvpn", + "country": "Russia", + "region": "Moscow", + "city": "Moscow", + "hostname": "ru1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "213.183.56.97" + ] + }, + { + "vpn": "openvpn", + "country": "Singapore", + "region": "Singapore", + "city": "Singapore", + "hostname": "sg1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "139.99.57.42" + ] + }, + { + "vpn": "openvpn", + "country": "South Africa", + "region": "Western Cape", + "city": "Cape Town", + "hostname": "za1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "102.165.60.248" + ] + }, + { + "vpn": "openvpn", + "country": "Spain", + "region": "Madrid", + "city": "Madrid", + "hostname": "es2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "95.85.89.55" + ] + }, + { + "vpn": "openvpn", + "country": "Spain", + "region": "Valencia", + "city": "Valencia", + "hostname": "se1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "79.141.174.55" + ] + }, + { + "vpn": "openvpn", + "country": "Sweden", + "region": "Stockholm", + "city": "Stockholm", + "hostname": "se2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "178.73.210.95" + ] + }, + { + "vpn": "openvpn", + "country": "Sweden", + "region": "Stockholm", + "city": "Stockholm", + "hostname": "se3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.117.89.229" + ] + }, + { + "vpn": "openvpn", + "country": "Switzerland", + "region": "Geneva", + "city": "Geneva", + "hostname": "ch1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "45.90.57.209" + ] + }, + { + "vpn": "openvpn", + "country": "Switzerland", + "region": "Geneva", + "city": "Genève", + "hostname": "ch2.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "45.90.58.5" + ] + }, + { + "vpn": "openvpn", + "country": "Ukraine", + "region": "Poltavs'ka Oblast'", + "city": "Kremenchuk", + "hostname": "ua1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "139.28.36.34" + ] + }, + { + "vpn": "openvpn", + "country": "United Arab Emirates", + "region": "Maharashtra", + "city": "Mumbai", + "hostname": "ae1.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "103.57.251.73" + ] + }, + { + "vpn": "openvpn", + "country": "United Kingdom", + "region": "Brent", + "city": "Harlesden", + "hostname": "uk6.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "45.141.154.190" + ] + }, + { + "vpn": "openvpn", + "country": "United Kingdom", + "region": "England", + "city": "Kent", + "hostname": "uk3.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.62.86.143" + ] + }, + { + "vpn": "openvpn", + "country": "United Kingdom", + "region": "England", + "city": "London", + "hostname": "uk2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.195.221.43" + ] + }, + { + "vpn": "openvpn", + "country": "United Kingdom", + "region": "England", + "city": "London", + "hostname": "uk4.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "185.62.86.142" + ] + }, + { + "vpn": "openvpn", + "country": "United Kingdom", + "region": "England", + "city": "London", + "hostname": "uk5.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "45.141.154.190", + "46.17.63.208" + ] + }, + { + "vpn": "openvpn", + "country": "United Kingdom", + "region": "England", + "city": "Manchester", + "hostname": "uk7.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "77.243.187.81" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "California", + "city": "Los Angeles", + "hostname": "us11.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "51.81.208.46" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "California", + "city": "Los Angeles", + "hostname": "us13.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.13" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "California", + "city": "Los Angeles", + "hostname": "us14.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.14" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "California", + "city": "Los Angeles", + "hostname": "us15.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.10" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "California", + "city": "Los Angeles", + "hostname": "us5.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.11" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "California", + "city": "Los Angeles", + "hostname": "us6.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "167.160.91.12" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "Georgia", + "city": "Atlanta", + "hostname": "us8.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "64.42.181.50" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "Georgia", + "city": "Atlanta", + "hostname": "us9.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "Illinois", + "city": "Chicago", + "hostname": "us12.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "64.42.183.139" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "Illinois", + "city": "Chicago", + "hostname": "us16.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "66.23.205.83" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "Illinois", + "city": "Chicago", + "hostname": "us4.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "Illinois", + "city": "Chicago", + "hostname": "us7.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "New Jersey", + "city": "Secaucus", + "hostname": "us1.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.100.25" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "New York", + "city": "New York City", + "hostname": "us10.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "country": "United States", + "region": "New York", + "city": "New York City", + "hostname": "us2.isponeder.com", + "tcp": true, + "udp": true, + "ips": [ + "135.148.27.95" + ] + }, + { + "vpn": "openvpn", + "country": "United States West", + "region": "California", + "city": "Los Angeles", + "hostname": "us3.isponeder.com", + "tcp": true, + "udp": true, + "premium": true, + "ips": [ + "167.160.91.10" + ] + } + ] + }, "vyprvpn": { "version": 3, "timestamp": 1627008363, diff --git a/internal/updater/html/fetch.go b/internal/updater/html/fetch.go new file mode 100644 index 00000000..86b49e5f --- /dev/null +++ b/internal/updater/html/fetch.go @@ -0,0 +1,43 @@ +package html + +import ( + "context" + "errors" + "fmt" + "net/http" + + "golang.org/x/net/html" +) + +var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code is not OK") + +func Fetch(ctx context.Context, client *http.Client, url string) ( + rootNode *html.Node, err error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating HTTP request: %w", err) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %d %s", ErrHTTPStatusCodeNotOK, + response.StatusCode, response.Status) + } + + rootNode, err = html.Parse(response.Body) + if err != nil { + _ = response.Body.Close() + return nil, fmt.Errorf("parsing HTML code: %w", err) + } + + err = response.Body.Close() + if err != nil { + return nil, fmt.Errorf("closing response body: %w", err) + } + + return rootNode, nil +} diff --git a/internal/updater/html/fetch_test.go b/internal/updater/html/fetch_test.go new file mode 100644 index 00000000..7e29fa79 --- /dev/null +++ b/internal/updater/html/fetch_test.go @@ -0,0 +1,98 @@ +package html + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/html" +) + +func parseTestHTML(t *testing.T, htmlString string) *html.Node { + t.Helper() + rootNode, err := html.Parse(strings.NewReader(htmlString)) + require.NoError(t, err) + return rootNode +} + +type roundTripFunc func(r *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func Test_Fetch(t *testing.T) { + t.Parallel() + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + testCases := map[string]struct { + ctx context.Context + url string + responseStatus int + responseBody io.ReadCloser + rootNode *html.Node + errWrapped error + errMessage string + }{ + "context canceled": { + ctx: canceledCtx, + url: "https://example.com/path", + errWrapped: context.Canceled, + errMessage: `Get "https://example.com/path": context canceled`, + }, + "response status not ok": { + ctx: context.Background(), + url: "https://example.com/path", + responseStatus: http.StatusNotFound, + errWrapped: ErrHTTPStatusCodeNotOK, + errMessage: `HTTP status code is not OK: 404 Not Found`, + }, + "success": { + ctx: context.Background(), + url: "https://example.com/path", + responseStatus: http.StatusOK, + rootNode: parseTestHTML(t, "some body"), + responseBody: ioutil.NopCloser(strings.NewReader("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(), testCase.url) + + 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 + }), + } + + rootNode, err := Fetch(testCase.ctx, client, testCase.url) + + assert.ErrorIs(t, err, testCase.errWrapped) + if testCase.errWrapped != nil { + assert.EqualError(t, err, testCase.errMessage) + } + assert.Equal(t, testCase.rootNode, rootNode) + }) + } +} diff --git a/internal/vpn/interfaces.go b/internal/vpn/interfaces.go index 1aec2dd9..978315b7 100644 --- a/internal/vpn/interfaces.go +++ b/internal/vpn/interfaces.go @@ -29,6 +29,7 @@ type PortForward interface { type OpenVPN interface { WriteConfig(lines []string) error WriteAuthFile(user, password string) error + WriteAskPassFile(passphrase string) error } type Providers interface { diff --git a/internal/vpn/openvpn.go b/internal/vpn/openvpn.go index bb852942..c09a3151 100644 --- a/internal/vpn/openvpn.go +++ b/internal/vpn/openvpn.go @@ -34,6 +34,13 @@ func setupOpenVPN(ctx context.Context, fw Firewall, } } + if *settings.OpenVPN.KeyPassphrase != "" { + err := openvpnConf.WriteAskPassFile(*settings.OpenVPN.KeyPassphrase) + if err != nil { + return nil, "", fmt.Errorf("writing askpass file: %w", err) + } + } + if err := fw.SetVPNConnection(ctx, connection, settings.OpenVPN.Interface); err != nil { return nil, "", fmt.Errorf("failed allowing VPN connection through firewall: %w", err) }