Feature: Hide My Ass VPN provider support (#401)
This commit is contained in:
@@ -3,6 +3,7 @@ package updater
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
ErrUnmarshalResponseBody = errors.New("cannot unmarshal response body")
|
||||
ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
ErrUnmarshalResponseBody = errors.New("cannot unmarshal response body")
|
||||
ErrUpdateServerInformation = errors.New("failed updating server information")
|
||||
)
|
||||
|
||||
270
internal/updater/hma.go
Normal file
270
internal/updater/hma.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
func (u *updater) updateHideMyAss(ctx context.Context) (err error) {
|
||||
servers, warnings, err := findHideMyAssServers(ctx, u.client, u.lookupIP)
|
||||
if u.options.CLI {
|
||||
for _, warning := range warnings {
|
||||
u.logger.Warn("HideMyAss: %s", warning)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: HideMyAss: %s", ErrUpdateServerInformation, err)
|
||||
}
|
||||
if u.options.Stdout {
|
||||
u.println(stringifyHideMyAssServers(servers))
|
||||
}
|
||||
u.servers.HideMyAss.Timestamp = u.timeNow().Unix()
|
||||
u.servers.HideMyAss.Servers = servers
|
||||
return nil
|
||||
}
|
||||
|
||||
func findHideMyAssServers(ctx context.Context, client *http.Client, lookupIP lookupIPFunc) (
|
||||
servers []models.HideMyAssServer, warnings []string, err error) {
|
||||
TCPhostToURL, err := findHideMyAssHostToURLForProto(ctx, client, "TCP")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
UDPhostToURL, err := findHideMyAssHostToURLForProto(ctx, client, "UDP")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
uniqueHosts := make(map[string]struct{}, len(TCPhostToURL))
|
||||
for host := range TCPhostToURL {
|
||||
uniqueHosts[host] = struct{}{}
|
||||
}
|
||||
for host := range UDPhostToURL {
|
||||
uniqueHosts[host] = struct{}{}
|
||||
}
|
||||
|
||||
hosts := make([]string, len(uniqueHosts))
|
||||
i := 0
|
||||
for host := range uniqueHosts {
|
||||
hosts[i] = host
|
||||
i++
|
||||
}
|
||||
|
||||
const failOnErr = false
|
||||
const resolveRepetition = 5
|
||||
const timeBetween = 2 * time.Second
|
||||
hostToIPs, warnings, _ := parallelResolve(ctx, lookupIP, hosts, resolveRepetition, timeBetween, failOnErr)
|
||||
|
||||
servers = make([]models.HideMyAssServer, 0, len(hostToIPs))
|
||||
for host, IPs := range hostToIPs {
|
||||
tcpURL, tcp := TCPhostToURL[host]
|
||||
udpURL, udp := UDPhostToURL[host]
|
||||
|
||||
var url, protocol string
|
||||
if tcp {
|
||||
url = tcpURL
|
||||
protocol = "TCP"
|
||||
} else if udp {
|
||||
url = udpURL
|
||||
protocol = "UDP"
|
||||
}
|
||||
country, region, city := parseHideMyAssURL(url, protocol)
|
||||
|
||||
server := models.HideMyAssServer{
|
||||
Country: country,
|
||||
Region: region,
|
||||
City: city,
|
||||
Hostname: host,
|
||||
IPs: IPs,
|
||||
TCP: tcp,
|
||||
UDP: udp,
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
|
||||
sort.Slice(servers, func(i, j int) bool {
|
||||
return servers[i].Country+servers[i].Region+servers[i].City+servers[i].Hostname <
|
||||
servers[j].Country+servers[j].Region+servers[j].City+servers[j].Hostname
|
||||
})
|
||||
|
||||
return servers, warnings, nil
|
||||
}
|
||||
|
||||
func findHideMyAssHostToURLForProto(ctx context.Context, client *http.Client, protocol string) (
|
||||
hostToURL map[string]string, err error) {
|
||||
indexURL := "https://vpn.hidemyass.com/vpn-config/" + strings.ToUpper(protocol) + "/"
|
||||
|
||||
urls, err := fetchHideMyAssHTTPIndex(ctx, client, indexURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fetchMultiOvpnFiles(ctx, client, urls)
|
||||
}
|
||||
|
||||
func parseHideMyAssURL(url, protocol string) (country, region, city string) {
|
||||
lastSlashIndex := strings.LastIndex(url, "/")
|
||||
url = url[lastSlashIndex+1:]
|
||||
|
||||
suffix := "." + strings.ToUpper(protocol) + ".ovpn"
|
||||
url = strings.TrimSuffix(url, suffix)
|
||||
|
||||
parts := strings.Split(url, ".")
|
||||
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
country = parts[0]
|
||||
return country, "", ""
|
||||
case 2: //nolint:gomnd
|
||||
country = parts[0]
|
||||
city = parts[1]
|
||||
default:
|
||||
country = parts[0]
|
||||
region = parts[1]
|
||||
city = parts[2]
|
||||
}
|
||||
|
||||
return camelCaseToWords(country), camelCaseToWords(region), camelCaseToWords(city)
|
||||
}
|
||||
|
||||
func camelCaseToWords(camelCase string) (words string) {
|
||||
wasLowerCase := false
|
||||
for _, r := range camelCase {
|
||||
if wasLowerCase && unicode.IsUpper(r) {
|
||||
words += " "
|
||||
}
|
||||
wasLowerCase = unicode.IsLower(r)
|
||||
words += string(r)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
var hideMyAssIndexRegex = regexp.MustCompile(`<a[ ]+href=".+\.ovpn">.+\.ovpn</a>`)
|
||||
|
||||
func fetchHideMyAssHTTPIndex(ctx context.Context, client *http.Client, indexURL string) (urls []string, err error) {
|
||||
htmlCode, err := fetchFile(ctx, client, indexURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(indexURL, "/") {
|
||||
indexURL += "/"
|
||||
}
|
||||
|
||||
lines := strings.Split(string(htmlCode), "\n")
|
||||
for _, line := range lines {
|
||||
found := hideMyAssIndexRegex.FindString(line)
|
||||
if len(found) == 0 {
|
||||
continue
|
||||
}
|
||||
const prefix = `.ovpn">`
|
||||
const suffix = `</a>`
|
||||
startIndex := strings.Index(found, prefix) + len(prefix)
|
||||
endIndex := strings.Index(found, suffix)
|
||||
filename := found[startIndex:endIndex]
|
||||
url := indexURL + filename
|
||||
if !strings.HasSuffix(url, ".ovpn") {
|
||||
continue
|
||||
}
|
||||
urls = append(urls, url)
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func fetchMultiOvpnFiles(ctx context.Context, client *http.Client, urls []string) (
|
||||
hostToURL map[string]string, err error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
hostToURL = make(map[string]string, len(urls))
|
||||
|
||||
type Result struct {
|
||||
url string
|
||||
host string
|
||||
}
|
||||
results := make(chan Result)
|
||||
errors := make(chan error)
|
||||
for _, url := range urls {
|
||||
go func(url string) {
|
||||
host, err := fetchOvpnFile(ctx, client, url)
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("%w: for %s", err, url)
|
||||
return
|
||||
}
|
||||
results <- Result{
|
||||
url: url,
|
||||
host: host,
|
||||
}
|
||||
}(url)
|
||||
}
|
||||
|
||||
for range urls {
|
||||
select {
|
||||
case newErr := <-errors:
|
||||
if err == nil { // only assign to the first error
|
||||
err = newErr
|
||||
cancel() // stop other operations, this will trigger other errors we ignore
|
||||
}
|
||||
case result := <-results:
|
||||
hostToURL[result.host] = result.url
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hostToURL, nil
|
||||
}
|
||||
|
||||
func fetchOvpnFile(ctx context.Context, client *http.Client, url string) (hostname string, err error) {
|
||||
b, err := fetchFile(ctx, client, url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
const rejectIP = true
|
||||
const rejectDomain = false
|
||||
hosts := extractRemoteHostsFromOpenvpn(b, rejectIP, rejectDomain)
|
||||
if len(hosts) == 0 {
|
||||
return "", errRemoteHostNotFound
|
||||
}
|
||||
|
||||
return hosts[0], nil
|
||||
}
|
||||
|
||||
func fetchFile(ctx context.Context, client *http.Client, url string) (b []byte, err error) {
|
||||
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
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
return io.ReadAll(response.Body)
|
||||
}
|
||||
|
||||
func stringifyHideMyAssServers(servers []models.HideMyAssServer) (s string) {
|
||||
s = "func HideMyAssServers() []models.HideMyAssServer {\n"
|
||||
s += " return []models.HideMyAssServer{\n"
|
||||
for _, server := range servers {
|
||||
s += " " + server.String() + ",\n"
|
||||
}
|
||||
s += " }\n"
|
||||
s += "}"
|
||||
return s
|
||||
}
|
||||
@@ -60,6 +60,16 @@ func (u *updater) UpdateServers(ctx context.Context) (allServers models.AllServe
|
||||
}
|
||||
}
|
||||
|
||||
if u.options.HideMyAss {
|
||||
u.logger.Info("updating HideMyAss servers...")
|
||||
if err := u.updateHideMyAss(ctx); err != nil {
|
||||
u.logger.Error(err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return allServers, err
|
||||
}
|
||||
}
|
||||
|
||||
if u.options.Mullvad {
|
||||
u.logger.Info("updating Mullvad servers...")
|
||||
if err := u.updateMullvad(ctx); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user