Files
gluetun/internal/updater/hma.go
2021-03-05 22:45:54 -05:00

271 lines
6.3 KiB
Go

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
}