feat: add VPNsecure.me support (#848)
- `OPENVPN_ENCRYPTED_KEY` environment variable - `OPENVPN_ENCRYPTED_KEY_SECRETFILE` environment variable - `OPENVPN_KEY_PASSPHRASE` environment variable - `OPENVPN_KEY_PASSPHRASE_SECRETFILE` environment variable - `PREMIUM_ONLY` environment variable - OpenVPN user and password not required for vpnsecure provider
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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{
|
||||
"<key>",
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
|
||||
encryptedKey,
|
||||
"-----END ENCRYPTED PRIVATE KEY-----",
|
||||
"</key>",
|
||||
}
|
||||
}
|
||||
|
||||
func WrapOpenvpnRSAKey(rsaPrivateKey string) (lines []string) {
|
||||
return []string{
|
||||
"<key>",
|
||||
|
||||
14
internal/provider/vpnsecure/connection.go
Normal file
14
internal/provider/vpnsecure/connection.go
Normal file
@@ -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)
|
||||
}
|
||||
26
internal/provider/vpnsecure/openvpnconf.go
Normal file
26
internal/provider/vpnsecure/openvpnconf.go
Normal file
@@ -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)
|
||||
}
|
||||
33
internal/provider/vpnsecure/provider.go
Normal file
33
internal/provider/vpnsecure/provider.go
Normal file
@@ -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
|
||||
}
|
||||
26
internal/provider/vpnsecure/updater/helpers_test.go
Normal file
26
internal/provider/vpnsecure/updater/helpers_test.go
Normal file
@@ -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))
|
||||
}
|
||||
38
internal/provider/vpnsecure/updater/hosttoserver.go
Normal file
38
internal/provider/vpnsecure/updater/hosttoserver.go
Normal file
@@ -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
|
||||
}
|
||||
26
internal/provider/vpnsecure/updater/resolve.go
Normal file
26
internal/provider/vpnsecure/updater/resolve.go
Normal file
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
57
internal/provider/vpnsecure/updater/servers.go
Normal file
57
internal/provider/vpnsecure/updater/servers.go
Normal file
@@ -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
|
||||
}
|
||||
7345
internal/provider/vpnsecure/updater/testdata/index.html
vendored
Normal file
7345
internal/provider/vpnsecure/updater/testdata/index.html
vendored
Normal file
File diff suppressed because one or more lines are too long
22
internal/provider/vpnsecure/updater/updater.go
Normal file
22
internal/provider/vpnsecure/updater/updater.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
239
internal/provider/vpnsecure/updater/website.go
Normal file
239
internal/provider/vpnsecure/updater/website.go
Normal file
@@ -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 <dt> 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 ""
|
||||
}
|
||||
235
internal/provider/vpnsecure/updater/website_test.go
Normal file
235
internal/provider/vpnsecure/updater/website_test.go
Normal file
@@ -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(`
|
||||
<div class="blk blk--white locations-list">
|
||||
<div class="blk__i">
|
||||
<div>
|
||||
<a href="https://www.vpnsecure.me/vpn-locations/australia/">
|
||||
<h4>Australia</h4>
|
||||
</a>
|
||||
<div class="grid grid--3 grid--locations">
|
||||
<dl class="grid__i">
|
||||
<dt>
|
||||
au1
|
||||
<span class="status status--up">up</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<div><span>City:</span> <strong>City</strong></div>
|
||||
<div><span>Region:</span> <strong>Region</strong></div>
|
||||
<div><span>Premium:</span> <strong>YES</strong></div>
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`)),
|
||||
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: <html><head></head><body></body></html>`,
|
||||
},
|
||||
"test data": {
|
||||
rootNode: parseTestDataIndexHTML(t),
|
||||
warnings: []string{
|
||||
"no grid item found: in HTML code: <div class=\"grid grid--3 grid--locations\">\n </div>",
|
||||
},
|
||||
//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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user