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