mirror of
https://github.com/qdm12/gluetun.git
synced 2026-05-09 20:29:23 +02:00
chore(updater): move updater packages to pkg/updaters/<name>
This commit is contained in:
@@ -5,8 +5,8 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/airvpn/updater"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/airvpn"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -21,7 +21,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client),
|
||||
Fetcher: airvpn.New(client),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type apiData struct {
|
||||
Servers []apiServer `json:"servers"`
|
||||
}
|
||||
|
||||
type apiServer struct {
|
||||
PublicName string `json:"public_name"`
|
||||
CountryName string `json:"country_name"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Location string `json:"location"`
|
||||
Continent string `json:"continent"`
|
||||
IPv4In1 netip.Addr `json:"ip_v4_in1"`
|
||||
IPv4In2 netip.Addr `json:"ip_v4_in2"`
|
||||
IPv4In3 netip.Addr `json:"ip_v4_in3"`
|
||||
IPv4In4 netip.Addr `json:"ip_v4_in4"`
|
||||
IPv6In1 netip.Addr `json:"ip_v6_in1"`
|
||||
IPv6In2 netip.Addr `json:"ip_v6_in2"`
|
||||
IPv6In3 netip.Addr `json:"ip_v6_in3"`
|
||||
IPv6In4 netip.Addr `json:"ip_v6_in4"`
|
||||
Health string `json:"health"`
|
||||
}
|
||||
|
||||
func fetchAPI(ctx context.Context, client *http.Client) (
|
||||
data apiData, err error,
|
||||
) {
|
||||
const url = "https://airvpn.org/api/status/"
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return data, fmt.Errorf("creating HTTP request: %w", err)
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return data, fmt.Errorf("doing HTTP request: %w", err)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_ = response.Body.Close()
|
||||
return data, fmt.Errorf("%w: %d %s",
|
||||
common.ErrHTTPStatusCodeNotOK, response.StatusCode, response.Status)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
_ = response.Body.Close()
|
||||
return data, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return data, fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"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,
|
||||
) {
|
||||
data, err := fetchAPI(ctx, u.client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching API: %w", err)
|
||||
}
|
||||
|
||||
// every API server model has:
|
||||
// - Wireguard server using IPv4In1
|
||||
// - Wiregard server using IPv6In1
|
||||
// - OpenVPN TCP+UDP+SSH+SSL server with tls-auth using IPv4In1 and IPv6In1
|
||||
// - OpenVPN TCP+UDP+SSH+SSL server with tls-auth using IPv4In2 and IPv6In2
|
||||
// - OpenVPN TCP+UDP+SSH+SSL server with tls-crypt using IPv4In3 and IPv6In3
|
||||
// - OpenVPN TCP+UDP+SSH+SSL server with tls-crypt using IPv6In4 and IPv6In4
|
||||
const numberOfServersPerAPIServer = 1 + // Wireguard server using IPv4In1
|
||||
1 + // Wiregard server using IPv6In1
|
||||
4 + // OpenVPN TCP server with tls-auth using IPv4In3, IPv6In3, IPv4In4, IPv6In4
|
||||
4 // OpenVPN UDP server with tls-auth using IPv4In3, IPv6In3, IPv4In4, IPv6In4
|
||||
projectedNumberOfServers := numberOfServersPerAPIServer * len(data.Servers)
|
||||
|
||||
if projectedNumberOfServers < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, projectedNumberOfServers, minServers)
|
||||
}
|
||||
|
||||
servers = make([]models.Server, 0, projectedNumberOfServers)
|
||||
for _, apiServer := range data.Servers {
|
||||
if apiServer.Health != "ok" {
|
||||
continue
|
||||
}
|
||||
|
||||
city := strings.ReplaceAll(apiServer.Location, ", ", " ")
|
||||
city = strings.ReplaceAll(city, ",", "")
|
||||
baseServer := models.Server{
|
||||
ServerName: apiServer.PublicName,
|
||||
Country: apiServer.CountryName,
|
||||
City: city,
|
||||
Region: apiServer.Continent,
|
||||
}
|
||||
|
||||
baseWireguardServer := baseServer
|
||||
baseWireguardServer.VPN = vpn.Wireguard
|
||||
baseWireguardServer.WgPubKey = "PyLCXAQT8KkM4T+dUsOQfn+Ub3pGxfGlxkIApuig+hk="
|
||||
|
||||
ipv4WireguadServer := baseWireguardServer
|
||||
ipv4WireguadServer.IPs = []netip.Addr{apiServer.IPv4In1}
|
||||
ipv4WireguadServer.Hostname = apiServer.CountryCode + ".vpn.airdns.org"
|
||||
servers = append(servers, ipv4WireguadServer)
|
||||
|
||||
ipv6WireguadServer := baseWireguardServer
|
||||
ipv6WireguadServer.IPs = []netip.Addr{apiServer.IPv6In1}
|
||||
ipv6WireguadServer.Hostname = apiServer.CountryCode + ".ipv6.vpn.airdns.org"
|
||||
servers = append(servers, ipv6WireguadServer)
|
||||
|
||||
baseOpenVPNServer := baseServer
|
||||
baseOpenVPNServer.VPN = vpn.OpenVPN
|
||||
baseOpenVPNServer.UDP = true
|
||||
baseOpenVPNServer.TCP = true
|
||||
|
||||
// Ignore IPs 1 and 2 since tls-crypt is superior to tls-auth really.
|
||||
|
||||
ipv4In3OpenVPNServer := baseOpenVPNServer
|
||||
ipv4In3OpenVPNServer.IPs = []netip.Addr{apiServer.IPv4In3}
|
||||
ipv4In3OpenVPNServer.Hostname = apiServer.CountryCode + "3.vpn.airdns.org"
|
||||
servers = append(servers, ipv4In3OpenVPNServer)
|
||||
|
||||
ipv6In3OpenVPNServer := baseOpenVPNServer
|
||||
ipv6In3OpenVPNServer.IPs = []netip.Addr{apiServer.IPv6In3}
|
||||
ipv6In3OpenVPNServer.Hostname = apiServer.CountryCode + "3.ipv6.vpn.airdns.org"
|
||||
servers = append(servers, ipv6In3OpenVPNServer)
|
||||
|
||||
ipv4In4OpenVPNServer := baseOpenVPNServer
|
||||
ipv4In4OpenVPNServer.IPs = []netip.Addr{apiServer.IPv4In4}
|
||||
ipv4In4OpenVPNServer.Hostname = apiServer.CountryCode + "4.vpn.airdns.org"
|
||||
servers = append(servers, ipv4In4OpenVPNServer)
|
||||
|
||||
ipv6In4OpenVPNServer := baseOpenVPNServer
|
||||
ipv6In4OpenVPNServer.IPs = []netip.Addr{apiServer.IPv6In4}
|
||||
ipv6In4OpenVPNServer.Hostname = apiServer.CountryCode + "4.ipv6.vpn.airdns.org"
|
||||
servers = append(servers, ipv6In4OpenVPNServer)
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(client *http.Client) *Updater {
|
||||
return &Updater{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ var (
|
||||
|
||||
type Fetcher interface {
|
||||
FetchServers(ctx context.Context, minServers int) (servers []models.Server, err error)
|
||||
Version() uint16
|
||||
}
|
||||
|
||||
type ParallelResolver interface {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/cyberghost/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/cyberghost"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -20,7 +20,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(parallelResolver, updaterWarner),
|
||||
Fetcher: cyberghost.New(parallelResolver, updaterWarner),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
package updater
|
||||
|
||||
import "github.com/qdm12/gluetun/internal/constants"
|
||||
|
||||
func getGroupIDToProtocol() map[string]string {
|
||||
return map[string]string{
|
||||
"87-1": constants.UDP, // Premium UDP
|
||||
"87-8": constants.UDP, // NoSpy UDP
|
||||
"87-19": constants.UDP, // Gaming UDP
|
||||
"97-1": constants.TCP, // Premium TCP
|
||||
"97-8": constants.TCP, // NoSpy TCP
|
||||
"97-19": constants.TCP, // Gaming TCP
|
||||
}
|
||||
}
|
||||
|
||||
func getSubdomainToRegion() map[string]string {
|
||||
return map[string]string{
|
||||
"af": "Afghanistan",
|
||||
"ax": "Aland Islands",
|
||||
"al": "Albania",
|
||||
"dz": "Algeria",
|
||||
"as": "American Samoa",
|
||||
"ad": "Andorra",
|
||||
"ao": "Angola",
|
||||
"ai": "Anguilla",
|
||||
"aq": "Antarctica",
|
||||
"ag": "Antigua and Barbuda",
|
||||
"ar": "Argentina",
|
||||
"am": "Armenia",
|
||||
"aw": "Aruba",
|
||||
"au": "Australia",
|
||||
"at": "Austria",
|
||||
"az": "Azerbaijan",
|
||||
"bs": "Bahamas",
|
||||
"bh": "Bahrain",
|
||||
"bd": "Bangladesh",
|
||||
"bb": "Barbados",
|
||||
"by": "Belarus",
|
||||
"be": "Belgium",
|
||||
"bz": "Belize",
|
||||
"bj": "Benin",
|
||||
"bm": "Bermuda",
|
||||
"bt": "Bhutan",
|
||||
"bo": "Bolivia",
|
||||
"bq": "Bonaire",
|
||||
"ba": "Bosnia and Herzegovina",
|
||||
"bw": "Botswana",
|
||||
"bv": "Bouvet Island",
|
||||
"br": "Brazil",
|
||||
"io": "British Indian Ocean Territory",
|
||||
"vg": "British Virgin Islands",
|
||||
"bn": "Brunei Darussalam",
|
||||
"bg": "Bulgaria",
|
||||
"bf": "Burkina Faso",
|
||||
"bi": "Burundi",
|
||||
"kh": "Cambodia",
|
||||
"cm": "Cameroon",
|
||||
"ca": "Canada",
|
||||
"cv": "Cape Verde",
|
||||
"ky": "Cayman Islands",
|
||||
"cf": "Central African Republic",
|
||||
"td": "Chad",
|
||||
"cl": "Chile",
|
||||
"cn": "China",
|
||||
"cx": "Christmas Island",
|
||||
"cc": "Cocos Islands",
|
||||
"co": "Colombia",
|
||||
"km": "Comoros",
|
||||
"cg": "Congo",
|
||||
"ck": "Cook Islands",
|
||||
"cr": "Costa Rica",
|
||||
"ci": "Cote d'Ivoire",
|
||||
"hr": "Croatia",
|
||||
"cu": "Cuba",
|
||||
"cw": "Curacao",
|
||||
"cy": "Cyprus",
|
||||
"cz": "Czech Republic",
|
||||
"cd": "Democratic Republic of the Congo",
|
||||
"dk": "Denmark",
|
||||
"dj": "Djibouti",
|
||||
"dm": "Dominica",
|
||||
"do": "Dominican Republic",
|
||||
"ec": "Ecuador",
|
||||
"eg": "Egypt",
|
||||
"sv": "El Salvador",
|
||||
"gq": "Equatorial Guinea",
|
||||
"er": "Eritrea",
|
||||
"ee": "Estonia",
|
||||
"et": "Ethiopia",
|
||||
"fk": "Falkland Islands",
|
||||
"fo": "Faroe Islands",
|
||||
"fj": "Fiji",
|
||||
"fi": "Finland",
|
||||
"fr": "France",
|
||||
"gf": "French Guiana",
|
||||
"pf": "French Polynesia",
|
||||
"tf": "French Southern Territories",
|
||||
"ga": "Gabon",
|
||||
"gm": "Gambia",
|
||||
"ge": "Georgia",
|
||||
"de": "Germany",
|
||||
"gh": "Ghana",
|
||||
"gi": "Gibraltar",
|
||||
"gr": "Greece",
|
||||
"gl": "Greenland",
|
||||
"gd": "Grenada",
|
||||
"gp": "Guadeloupe",
|
||||
"gu": "Guam",
|
||||
"gt": "Guatemala",
|
||||
"gg": "Guernsey",
|
||||
"gw": "Guinea-Bissau",
|
||||
"gn": "Guinea",
|
||||
"gy": "Guyana",
|
||||
"ht": "Haiti",
|
||||
"hm": "Heard Island and McDonald Islands",
|
||||
"hn": "Honduras",
|
||||
"hk": "Hong Kong",
|
||||
"hu": "Hungary",
|
||||
"is": "Iceland",
|
||||
"in": "India",
|
||||
"id": "Indonesia",
|
||||
"ir": "Iran",
|
||||
"iq": "Iraq",
|
||||
"ie": "Ireland",
|
||||
"im": "Isle of Man",
|
||||
"il": "Israel",
|
||||
"it": "Italy",
|
||||
"jm": "Jamaica",
|
||||
"jp": "Japan",
|
||||
"je": "Jersey",
|
||||
"jo": "Jordan",
|
||||
"kz": "Kazakhstan",
|
||||
"ke": "Kenya",
|
||||
"ki": "Kiribati",
|
||||
"kr": "Korea",
|
||||
"kw": "Kuwait",
|
||||
"kg": "Kyrgyzstan",
|
||||
"la": "Lao People's Democratic Republic",
|
||||
"lv": "Latvia",
|
||||
"lb": "Lebanon",
|
||||
"ls": "Lesotho",
|
||||
"lr": "Liberia",
|
||||
"ly": "Libya",
|
||||
"li": "Liechtenstein",
|
||||
"lt": "Lithuania",
|
||||
"lu": "Luxembourg",
|
||||
"mo": "Macao",
|
||||
"mk": "Macedonia",
|
||||
"mg": "Madagascar",
|
||||
"mw": "Malawi",
|
||||
"my": "Malaysia",
|
||||
"mv": "Maldives",
|
||||
"ml": "Mali",
|
||||
"mt": "Malta",
|
||||
"mh": "Marshall Islands",
|
||||
"mq": "Martinique",
|
||||
"mr": "Mauritania",
|
||||
"mu": "Mauritius",
|
||||
"yt": "Mayotte",
|
||||
"mx": "Mexico",
|
||||
"fm": "Micronesia",
|
||||
"md": "Moldova",
|
||||
"mc": "Monaco",
|
||||
"mn": "Mongolia",
|
||||
"me": "Montenegro",
|
||||
"ms": "Montserrat",
|
||||
"ma": "Morocco",
|
||||
"mz": "Mozambique",
|
||||
"mm": "Myanmar",
|
||||
"na": "Namibia",
|
||||
"nr": "Nauru",
|
||||
"np": "Nepal",
|
||||
"nl": "Netherlands",
|
||||
"nc": "New Caledonia",
|
||||
"nz": "New Zealand",
|
||||
"ni": "Nicaragua",
|
||||
"ne": "Niger",
|
||||
"ng": "Nigeria",
|
||||
"nu": "Niue",
|
||||
"nf": "Norfolk Island",
|
||||
"mp": "Northern Mariana Islands",
|
||||
"no": "Norway",
|
||||
"om": "Oman",
|
||||
"pk": "Pakistan",
|
||||
"pw": "Palau",
|
||||
"ps": "Palestine, State of",
|
||||
"pa": "Panama",
|
||||
"pg": "Papua New Guinea",
|
||||
"py": "Paraguay",
|
||||
"pe": "Peru",
|
||||
"ph": "Philippines",
|
||||
"pn": "Pitcairn",
|
||||
"pl": "Poland",
|
||||
"pt": "Portugal",
|
||||
"pr": "Puerto Rico",
|
||||
"qa": "Qatar",
|
||||
"re": "Reunion",
|
||||
"ro": "Romania",
|
||||
"ru": "Russian Federation",
|
||||
"rw": "Rwanda",
|
||||
"bl": "Saint Barthelemy",
|
||||
"sh": "Saint Helena",
|
||||
"kn": "Saint Kitts and Nevis",
|
||||
"lc": "Saint Lucia",
|
||||
"mf": "Saint Martin",
|
||||
"pm": "Saint Pierre and Miquelon",
|
||||
"vc": "Saint Vincent and the Grenadines",
|
||||
"ws": "Samoa",
|
||||
"sm": "San Marino",
|
||||
"st": "Sao Tome and Principe",
|
||||
"sa": "Saudi Arabia",
|
||||
"sn": "Senegal",
|
||||
"rs": "Serbia",
|
||||
"sc": "Seychelles",
|
||||
"sl": "Sierra Leone",
|
||||
"sg": "Singapore",
|
||||
"sx": "Sint Maarten",
|
||||
"sk": "Slovakia",
|
||||
"si": "Slovenia",
|
||||
"sb": "Solomon Islands",
|
||||
"so": "Somalia",
|
||||
"za": "South Africa",
|
||||
"gs": "South Georgia and the South Sandwich Islands",
|
||||
"ss": "South Sudan",
|
||||
"es": "Spain",
|
||||
"lk": "Sri Lanka",
|
||||
"sd": "Sudan",
|
||||
"sr": "Suriname",
|
||||
"sj": "Svalbard and Jan Mayen",
|
||||
"sz": "Swaziland",
|
||||
"se": "Sweden",
|
||||
"ch": "Switzerland",
|
||||
"sy": "Syrian Arab Republic",
|
||||
"tw": "Taiwan",
|
||||
"tj": "Tajikistan",
|
||||
"tz": "Tanzania",
|
||||
"th": "Thailand",
|
||||
"tl": "Timor-Leste",
|
||||
"tg": "Togo",
|
||||
"tk": "Tokelau",
|
||||
"to": "Tonga",
|
||||
"tt": "Trinidad and Tobago",
|
||||
"tn": "Tunisia",
|
||||
"tr": "Turkey",
|
||||
"tm": "Turkmenistan",
|
||||
"tc": "Turks and Caicos Islands",
|
||||
"tv": "Tuvalu",
|
||||
"ug": "Uganda",
|
||||
"ua": "Ukraine",
|
||||
"ae": "United Arab Emirates",
|
||||
"gb": "United Kingdom",
|
||||
"um": "United States Minor Outlying Islands",
|
||||
"us": "United States",
|
||||
"uy": "Uruguay",
|
||||
"vi": "US Virgin Islands",
|
||||
"uz": "Uzbekistan",
|
||||
"vu": "Vanuatu",
|
||||
"va": "Vatican City State",
|
||||
"ve": "Venezuela",
|
||||
"vn": "Vietnam",
|
||||
"wf": "Wallis and Futuna",
|
||||
"eh": "Western Sahara",
|
||||
"ye": "Yemen",
|
||||
"zm": "Zambia",
|
||||
"zw": "Zimbabwe",
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package updater
|
||||
|
||||
func mergeCountryCodes(base, extend map[string]string) (merged map[string]string) {
|
||||
merged = make(map[string]string, len(base))
|
||||
for countryCode, region := range base {
|
||||
merged[countryCode] = region
|
||||
}
|
||||
for countryCode := range base {
|
||||
delete(extend, countryCode)
|
||||
}
|
||||
for countryCode, region := range extend {
|
||||
merged[countryCode] = region
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants"
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type hostToServer map[string]models.Server
|
||||
|
||||
func getPossibleServers() (possibleServers hostToServer) {
|
||||
groupIDToProtocol := getGroupIDToProtocol()
|
||||
|
||||
cyberghostCountryCodes := getSubdomainToRegion()
|
||||
allCountryCodes := constants.CountryCodes()
|
||||
possibleCountryCodes := mergeCountryCodes(cyberghostCountryCodes, allCountryCodes)
|
||||
|
||||
n := len(groupIDToProtocol) * len(possibleCountryCodes)
|
||||
|
||||
possibleServers = make(hostToServer, n) // key is the host
|
||||
|
||||
for groupID, protocol := range groupIDToProtocol {
|
||||
for countryCode, country := range possibleCountryCodes {
|
||||
const domain = "cg-dialup.net"
|
||||
possibleHost := groupID + "-" + countryCode + "." + domain
|
||||
possibleServer := models.Server{
|
||||
VPN: vpn.OpenVPN,
|
||||
Hostname: possibleHost,
|
||||
Country: country,
|
||||
TCP: protocol == constants.TCP,
|
||||
UDP: protocol == constants.UDP,
|
||||
}
|
||||
possibleServers[possibleHost] = possibleServer
|
||||
}
|
||||
}
|
||||
|
||||
return possibleServers
|
||||
}
|
||||
|
||||
func (hts hostToServer) hostsSlice() (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][]netip.Addr) {
|
||||
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) toSlice() (servers []models.Server) {
|
||||
servers = make([]models.Server, 0, len(hts))
|
||||
for _, server := range hts {
|
||||
servers = append(servers, server)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 1
|
||||
maxDuration = 20 * time.Second
|
||||
betweenDuration = time.Second
|
||||
maxNoNew = 4
|
||||
maxFails = 10
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
BetweenDuration: betweenDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"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,
|
||||
) {
|
||||
possibleServers := getPossibleServers()
|
||||
|
||||
possibleHosts := possibleServers.hostsSlice()
|
||||
resolveSettings := parallelResolverSettings(possibleHosts)
|
||||
hostToIPs, warnings, err := u.parallelResolver.Resolve(ctx, resolveSettings)
|
||||
for _, warning := range warnings {
|
||||
if strings.HasSuffix(warning, "no such host") {
|
||||
continue // ignore no such host 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)
|
||||
}
|
||||
|
||||
possibleServers.adaptWithIPs(hostToIPs)
|
||||
|
||||
servers = possibleServers.toSlice()
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
parallelResolver common.ParallelResolver
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(parallelResolver common.ParallelResolver, warner common.Warner) *Updater {
|
||||
return &Updater{
|
||||
parallelResolver: parallelResolver,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/example/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/example"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -23,7 +23,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(updaterWarner, unzipper, client, parallelResolver),
|
||||
Fetcher: example.New(updaterWarner, unzipper, client, parallelResolver),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var errHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
|
||||
type apiData struct {
|
||||
Servers []apiServer `json:"servers"`
|
||||
}
|
||||
|
||||
type apiServer struct {
|
||||
OpenVPNHostname string `json:"openvpn_hostname"`
|
||||
WireguardHostname string `json:"wireguard_hostname"`
|
||||
Country string `json:"country"`
|
||||
Region string `json:"region"`
|
||||
City string `json:"city"`
|
||||
WgPubKey string `json:"wg_public_key"`
|
||||
}
|
||||
|
||||
func fetchAPI(ctx context.Context, client *http.Client) (
|
||||
data apiData, err error,
|
||||
) {
|
||||
// TODO: adapt this URL and the structures above to match the real
|
||||
// API models you have.
|
||||
const url = "https://example.com/servers"
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_ = response.Body.Close()
|
||||
return data, fmt.Errorf("%w: %d %s",
|
||||
errHTTPStatusCodeNotOK, response.StatusCode, response.Status)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
_ = response.Body.Close()
|
||||
return data, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return data, fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
// TODO: remove this file if the parallel resolver is not used
|
||||
// by the updater.
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
// TODO: adapt these constant values below to make the resolution
|
||||
// as fast and as reliable as possible.
|
||||
const (
|
||||
maxFailRatio = 0.1
|
||||
maxDuration = 20 * time.Second
|
||||
betweenDuration = time.Second
|
||||
maxNoNew = 2
|
||||
maxFails = 2
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
BetweenDuration: betweenDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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,
|
||||
) {
|
||||
// FetchServers obtains information for each VPN server
|
||||
// for the VPN service provider.
|
||||
//
|
||||
// You should aim at obtaining as much information as possible
|
||||
// for each server, such as their location information.
|
||||
// Required fields for each server are:
|
||||
// - the `VPN` protocol string field
|
||||
// - the `Hostname` string field
|
||||
// - the `IPs` IP slice field
|
||||
// - have one network protocol set, either `TCP` or `UDP`
|
||||
// - If `VPN` is `wireguard`, the `WgPubKey` field to be set
|
||||
//
|
||||
// The information obtention can be done in different ways
|
||||
// or by combining ways, depending on how the provider exposes
|
||||
// this information. Some common ones are listed below:
|
||||
//
|
||||
// - you can use u.client to fetch structured (usually JSON)
|
||||
// data of the servers from an HTTP API endpoint of the provider.
|
||||
// Example in: `internal/provider/mullvad/updater`
|
||||
// - you can use u.unzipper to download, unzip and parse a zip
|
||||
// file of OpenVPN configuration files.
|
||||
// Example in: `internal/provider/fastestvpn/updater`
|
||||
// - you can use u.parallelResolver to resolve all hostnames
|
||||
// found in parallel to obtain their corresponding IP addresses.
|
||||
// Example in: `internal/provider/fastestvpn/updater`
|
||||
//
|
||||
// The following is an example code which fetches server
|
||||
// information from an HTTP API endpoint of the provider,
|
||||
// and then resolves in parallel all hostnames to get their
|
||||
// IP addresses. You should pay attention to the following:
|
||||
// - we check multiple times we have enough servers
|
||||
// before continuing processing.
|
||||
// - hosts are deduplicated to reduce parallel resolution
|
||||
// load.
|
||||
// - servers are sorted at the end.
|
||||
//
|
||||
// Once you are done, please check all the TODO comments
|
||||
// in this package and address them.
|
||||
data, err := fetchAPI(ctx, u.client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching API: %w", err)
|
||||
}
|
||||
|
||||
uniqueHosts := make(map[string]struct{}, len(data.Servers))
|
||||
|
||||
for _, serverData := range data.Servers {
|
||||
if serverData.OpenVPNHostname != "" {
|
||||
uniqueHosts[serverData.OpenVPNHostname] = struct{}{}
|
||||
}
|
||||
|
||||
if serverData.WireguardHostname != "" {
|
||||
uniqueHosts[serverData.WireguardHostname] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(uniqueHosts) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(uniqueHosts), minServers)
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(uniqueHosts))
|
||||
for host := range uniqueHosts {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
|
||||
resolveSettings := parallelResolverSettings(hosts)
|
||||
hostToIPs, warnings, err := u.parallelResolver.Resolve(ctx, resolveSettings)
|
||||
for _, warning := range warnings {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving hosts: %w", err)
|
||||
}
|
||||
|
||||
if len(hostToIPs) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(servers), minServers)
|
||||
}
|
||||
|
||||
maxServers := 2 * len(data.Servers) //nolint:mnd
|
||||
servers = make([]models.Server, 0, maxServers)
|
||||
for _, serverData := range data.Servers {
|
||||
baseServer := models.Server{
|
||||
Country: serverData.Country,
|
||||
Region: serverData.Region,
|
||||
City: serverData.City,
|
||||
WgPubKey: serverData.WgPubKey,
|
||||
}
|
||||
if serverData.OpenVPNHostname != "" {
|
||||
openvpnServer := baseServer
|
||||
openvpnServer.VPN = vpn.OpenVPN
|
||||
openvpnServer.UDP = true
|
||||
openvpnServer.TCP = true
|
||||
openvpnServer.Hostname = serverData.OpenVPNHostname
|
||||
openvpnServer.IPs = hostToIPs[serverData.OpenVPNHostname]
|
||||
servers = append(servers, openvpnServer)
|
||||
}
|
||||
if serverData.WireguardHostname != "" {
|
||||
wireguardServer := baseServer
|
||||
wireguardServer.VPN = vpn.Wireguard
|
||||
wireguardServer.Hostname = serverData.WireguardHostname
|
||||
wireguardServer.IPs = hostToIPs[serverData.WireguardHostname]
|
||||
servers = append(servers, wireguardServer)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
// TODO: remove fields not used by the updater
|
||||
client *http.Client
|
||||
unzipper common.Unzipper
|
||||
parallelResolver common.ParallelResolver
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(warner common.Warner, unzipper common.Unzipper,
|
||||
client *http.Client, parallelResolver common.ParallelResolver,
|
||||
) *Updater {
|
||||
// TODO: remove arguments not used by the updater
|
||||
return &Updater{
|
||||
client: client,
|
||||
unzipper: unzipper,
|
||||
parallelResolver: parallelResolver,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/expressvpn/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/expressvpn"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -21,7 +21,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(unzipper, updaterWarner, parallelResolver),
|
||||
Fetcher: expressvpn.New(unzipper, updaterWarner, parallelResolver),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
func hardcodedServers() (servers []models.Server) {
|
||||
return []models.Server{
|
||||
{Country: "Albania", Hostname: "albania-ca-version-2.expressnetw.com"},
|
||||
{Country: "Algeria", Hostname: "algeria-ca-version-2.expressnetw.com"},
|
||||
{Country: "Andorra", Hostname: "andorra-ca-version-2.expressnetw.com"},
|
||||
{Country: "Argentina", Hostname: "argentina-ca-version-2.expressnetw.com"},
|
||||
{Country: "Armenia", Hostname: "armenia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Australia", City: "Adelaide", Hostname: "australia-adelaide--ca-version-2.expressnetw.com"},
|
||||
{Country: "Australia", City: "Brisbane", Hostname: "australia-brisbane-ca-version-2.expressnetw.com"},
|
||||
{Country: "Australia", City: "Melbourne", Hostname: "australia-melbourne-ca-version-2.expressnetw.com"},
|
||||
{Country: "Australia", City: "Perth", Hostname: "australia-perth-ca-version-2.expressnetw.com"},
|
||||
{Country: "Australia", City: "Sydney", Hostname: "australia-sydney-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Australia", City: "Sydney", Hostname: "australia-sydney-ca-version-2.expressnetw.com"},
|
||||
{Country: "Australia", City: "Woolloomooloo", Hostname: "australia-woolloomooloo-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Austria", Hostname: "austria-ca-version-2.expressnetw.com"},
|
||||
{Country: "Azerbaijan", Hostname: "azerbaijan-ca-version-2.expressnetw.com"},
|
||||
{Country: "Bahamas", Hostname: "bahamas-ca-version-2.expressnetw.com"},
|
||||
{Country: "Bangladesh", Hostname: "bangladesh-ca-version-2.expressnetw.com"},
|
||||
{Country: "Belarus", Hostname: "belarus-ca-version-2.expressnetw.com"},
|
||||
{Country: "Belgium", Hostname: "belgium-ca-version-2.expressnetw.com"},
|
||||
{Country: "Bermuda", Hostname: "bermuda-ca-version-2.expressnetw.com"},
|
||||
{Country: "Bhutan", Hostname: "bhutan-ca-version-2.expressnetw.com"},
|
||||
{Country: "Bolivia", Hostname: "bolivia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Bosnia and Herzegovina", Hostname: "bosniaandherzegovina-ca-version-2.expressnetw.com"},
|
||||
{Country: "Brazil", Hostname: "brazil-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Brazil", Hostname: "brazil-ca-version-2.expressnetw.com"},
|
||||
{Country: "Brunei", Hostname: "brunei-ca-version-2.expressnetw.com"},
|
||||
{Country: "Bulgaria", Hostname: "bulgaria-ca-version-2.expressnetw.com"},
|
||||
{Country: "Cambodia", Hostname: "cambodia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Canada", City: "Montreal", Hostname: "canada-montreal-ca-version-2.expressnetw.com"},
|
||||
{Country: "Canada", City: "Toronto", Hostname: "canada-toronto-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Canada", City: "Toronto", Hostname: "canada-toronto-ca-version-2.expressnetw.com"},
|
||||
{Country: "Cayman Islands", Hostname: "caymanislands-ca-version-2.expressnetw.com"},
|
||||
{Country: "Chile", Hostname: "chile-ca-version-2.expressnetw.com"},
|
||||
{Country: "Colombia", Hostname: "colombia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Costa Rica", Hostname: "costarica-ca-version-2.expressnetw.com"},
|
||||
{Country: "Croatia", Hostname: "croatia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Cuba", Hostname: "cuba-ca-version-2.expressnetw.com"},
|
||||
{Country: "Cyprus", Hostname: "cyprus-ca-version-2.expressnetw.com"},
|
||||
{Country: "Czech Republic", Hostname: "czechrepublic-ca-version-2.expressnetw.com"},
|
||||
{Country: "Denmark", Hostname: "denmark-ca-version-2.expressnetw.com"},
|
||||
{Country: "Dominican Republic", Hostname: "dominicanrepublic-ca-version-2.expressnetw.com"},
|
||||
{Country: "Ecuador", Hostname: "ecuador-ca-version-2.expressnetw.com"},
|
||||
{Country: "Egypt", Hostname: "egypt-ca-version-2.expressnetw.com"},
|
||||
{Country: "Estonia", Hostname: "estonia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Finland", Hostname: "finland-ca-version-2.expressnetw.com"},
|
||||
{Country: "France", City: "Alsace", Hostname: "france-alsace-ca-version-2.expressnetw.com"},
|
||||
{Country: "France", City: "Marseille", Hostname: "france-marseille-ca-version-2.expressnetw.com"},
|
||||
{Country: "France", City: "Paris", Hostname: "france-paris-1-ca-version-2.expressnetw.com"},
|
||||
{Country: "France", City: "Paris", Hostname: "france-paris-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "France", City: "Strasbourg", Hostname: "france-strasbourg-ca-version-2.expressnetw.com"},
|
||||
{Country: "Georgia", Hostname: "georgia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Germany", City: "Frankfurt", Hostname: "germany-darmstadt-ca-version-2.expressnetw.com"},
|
||||
{Country: "Germany", City: "Frankfurt", Hostname: "germany-frankfurt-1-ca-version-2.expressnetw.com"},
|
||||
{Country: "Germany", City: "Nuremberg", Hostname: "germany-nuremberg-ca-version-2.expressnetw.com"},
|
||||
{Country: "Ghana", Hostname: "ghana-ca-version-2.expressnetw.com"},
|
||||
{Country: "Greece", Hostname: "greece-ca-version-2.expressnetw.com"},
|
||||
{Country: "Guam", Hostname: "guam-ca-version-2.expressnetw.com"},
|
||||
{Country: "Guatemala", Hostname: "guatemala-ca-version-2.expressnetw.com"},
|
||||
{Country: "Honduras", Hostname: "honduras-ca-version-2.expressnetw.com"},
|
||||
{Country: "Hong Kong", Hostname: "hongkong-1-ca-version-2.expressnetw.com"},
|
||||
{Country: "Hong Kong", Hostname: "hongkong-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Hungary", Hostname: "hungary-ca-version-2.expressnetw.com"},
|
||||
{Country: "Iceland", Hostname: "iceland-ca-version-2.expressnetw.com"},
|
||||
{Country: "India (via Singapore)", Hostname: "india-sg-ca-version-2.expressnetw.com"},
|
||||
{Country: "India (via UK)", Hostname: "india-uk-ca-version-2.expressnetw.com"},
|
||||
{Country: "Indonesia", Hostname: "indonesia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Ireland", Hostname: "ireland-ca-version-2.expressnetw.com"},
|
||||
{Country: "Isle of Man", Hostname: "isleofman-ca-version-2.expressnetw.com"},
|
||||
{Country: "Israel", Hostname: "israel-ca-version-2.expressnetw.com"},
|
||||
{Country: "Italy", City: "Cosenza", Hostname: "italy-cosenza-ca-version-2.expressnetw.com"},
|
||||
{Country: "Italy", City: "Milan", Hostname: "italy-milan-ca-version-2.expressnetw.com"},
|
||||
{Country: "Italy", City: "Naples", Hostname: "italy-naples-ca-version-2.expressnetw.com"},
|
||||
{Country: "Jamaica", Hostname: "jamaica-ca-version-2.expressnetw.com"},
|
||||
{Country: "Japan", City: "Osaka", Hostname: "japan-osaka-ca-version-2.expressnetw.com"},
|
||||
{Country: "Japan", City: "Shibuya", Hostname: "japan-shibuya-ca-version-2.expressnetw.com"},
|
||||
{Country: "Japan", City: "Tokyo", Hostname: "japan-tokyo-ca-version-2.expressnetw.com"},
|
||||
{Country: "Japan", City: "Yokohama", Hostname: "japan-yokohama-ca-version-2.expressnetw.com"},
|
||||
{Country: "Jersey", Hostname: "jersey-ca-version-2.expressnetw.com"},
|
||||
{Country: "Kazakhstan", Hostname: "kazakhstan-ca-version-2.expressnetw.com"},
|
||||
{Country: "Kenya", Hostname: "kenya-ca-version-2.expressnetw.com"},
|
||||
{Country: "Laos", Hostname: "laos-ca-version-2.expressnetw.com"},
|
||||
{Country: "Latvia", Hostname: "latvia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Lebanon", Hostname: "lebanon-ca-version-2.expressnetw.com"},
|
||||
{Country: "Liechtenstein", Hostname: "liechtenstein-ca-version-2.expressnetw.com"},
|
||||
{Country: "Lithuania", Hostname: "lithuania-ca-version-2.expressnetw.com"},
|
||||
{Country: "Luxembourg", Hostname: "luxembourg-ca-version-2.expressnetw.com"},
|
||||
{Country: "Macau", Hostname: "macau-ca-version-2.expressnetw.com"},
|
||||
{Country: "Malaysia", Hostname: "malaysia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Malta", Hostname: "malta-ca-version-2.expressnetw.com"},
|
||||
{Country: "Mexico", Hostname: "mexico-ca-version-2.expressnetw.com"},
|
||||
{Country: "Moldova", Hostname: "moldova-ca-version-2.expressnetw.com"},
|
||||
{Country: "Monaco", Hostname: "monaco-ca-version-2.expressnetw.com"},
|
||||
{Country: "Mongolia", Hostname: "mongolia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Montenegro", Hostname: "montenegro-ca-version-2.expressnetw.com"},
|
||||
{Country: "Morocco", Hostname: "morocco-ca-version-2.expressnetw.com"},
|
||||
{Country: "Myanmar", Hostname: "myanmar-ca-version-2.expressnetw.com"},
|
||||
{Country: "Nepal", Hostname: "nepal-ca-version-2.expressnetw.com"},
|
||||
{Country: "Netherlands", City: "Amsterdam", Hostname: "netherlands-amsterdam-ca-version-2.expressnetw.com"},
|
||||
{Country: "Netherlands", City: "Rotterdam", Hostname: "netherlands-rotterdam-ca-version-2.expressnetw.com"},
|
||||
{Country: "Netherlands", City: "The Hague", Hostname: "netherlands-thehague-ca-version-2.expressnetw.com"},
|
||||
{Country: "New Zealand", Hostname: "newzealand-ca-version-2.expressnetw.com"},
|
||||
{Country: "North Macedonia", Hostname: "macedonia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Norway", Hostname: "norway-ca-version-2.expressnetw.com"},
|
||||
{Country: "Panama", Hostname: "panama-ca-version-2.expressnetw.com"},
|
||||
{Country: "Peru", Hostname: "peru-ca-version-2.expressnetw.com"},
|
||||
{Country: "Philippines (via Singapore)", Hostname: "ph-via-sing-ca-version-2.expressnetw.com"},
|
||||
{Country: "Poland", Hostname: "poland-ca-version-2.expressnetw.com"},
|
||||
{Country: "Portugal", Hostname: "portugal-ca-version-2.expressnetw.com"},
|
||||
{Country: "Puerto Rico", Hostname: "puertorico-ca-version-2.expressnetw.com"},
|
||||
{Country: "Romania", Hostname: "romania-ca-version-2.expressnetw.com"},
|
||||
{Country: "Serbia", Hostname: "serbia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Singapore", City: "CBD", Hostname: "singapore-cbd-ca-version-2.expressnetw.com"},
|
||||
{Country: "Singapore", City: "Jurong", Hostname: "singapore-jurong-ca-version-2.expressnetw.com"},
|
||||
{Country: "Singapore", City: "Marina Bay", Hostname: "singapore-marinabay-ca-version-2.expressnetw.com"},
|
||||
{Country: "Slovakia", Hostname: "slovakia-ca-version-2.expressnetw.com"},
|
||||
{Country: "Slovenia", Hostname: "slovenia-ca-version-2.expressnetw.com"},
|
||||
{Country: "South Africa", Hostname: "southafrica-ca-version-2.expressnetw.com"},
|
||||
{Country: "South Korea", Hostname: "southkorea2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Spain", City: "Barcelona", Hostname: "spain-barcelona-ca-version-2.expressnetw.com"},
|
||||
{Country: "Spain", City: "Barcelona", Hostname: "spain-barcelona2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Spain", City: "Madrid", Hostname: "spain-ca-version-2.expressnetw.com"},
|
||||
{Country: "Sri Lanka", Hostname: "srilanka-ca-version-2.expressnetw.com"},
|
||||
{Country: "Sweden", Hostname: "sweden-ca-version-2.expressnetw.com"},
|
||||
{Country: "Sweden", Hostname: "sweden2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Switzerland", Hostname: "switzerland-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "Switzerland", Hostname: "switzerland-ca-version-2.expressnetw.com"},
|
||||
{Country: "Taiwan", Hostname: "taiwan-3-ca-version-2.expressnetw.com"},
|
||||
{Country: "Thailand", Hostname: "thailand-ca-version-2.expressnetw.com"},
|
||||
{Country: "Trinidad and Tobago", Hostname: "trinidadandtobago-ca-version-2.expressnetw.com"},
|
||||
{Country: "Turkey", Hostname: "turkey-ca-version-2.expressnetw.com"},
|
||||
{Country: "UK", City: "Docklands", Hostname: "uk-1-docklands-ca-version-2.expressnetw.com"},
|
||||
{Country: "UK", City: "East London", Hostname: "uk-east-london-ca-version-2.expressnetw.com"},
|
||||
{Country: "UK", City: "London", Hostname: "uk-london-ca-version-2.expressnetw.com"},
|
||||
{Country: "UK", City: "Midlands", Hostname: "uk-midlands-ca-version-2.expressnetw.com"},
|
||||
{Country: "UK", City: "Tottenham", Hostname: "uk-tottenham-ca-version-2.expressnetw.com"},
|
||||
{Country: "UK", City: "Wembley", Hostname: "uk-wembley-ca-version-2.expressnetw.com"},
|
||||
{Country: "Ukraine", Hostname: "ukraine-ca-version-2.expressnetw.com"},
|
||||
{Country: "Uruguay", Hostname: "uruguay-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Albuquerque", Hostname: "usa-albuquerque-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Atlanta", Hostname: "usa-atlanta-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Boston", Hostname: "us-boston-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Chicago", Hostname: "usa-chicago-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Dallas", Hostname: "usa-dallas-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Denver", Hostname: "usa-denver-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Houston", Hostname: "usa-houston-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Jackson", Hostname: "us-jackson-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Lincoln Park", Hostname: "usa-lincolnpark-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Little Rock", Hostname: "us-littlerock-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-3-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Los Angeles", Hostname: "usa-losangeles5-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Miami", Hostname: "usa-miami-2-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Miami", Hostname: "usa-miami-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "New Jersey", Hostname: "usa-newjersey-1-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "New Jersey", Hostname: "usa-newjersey-3-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "New Jersey", Hostname: "usa-newjersey2-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "New Orleans", Hostname: "us-neworleans-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "New York", Hostname: "usa-newyork-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Oklahoma City", Hostname: "us-oklahoma-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Phoenix", Hostname: "usa-phoenix-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Salt Lake City", Hostname: "usa-saltlakecity-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "San Francisco", Hostname: "usa-sanfrancisco-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Santa Monica", Hostname: "usa-santa-monica-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Seattle", Hostname: "usa-seattle-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Tampa", Hostname: "usa-tampa-1-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Washington DC", Hostname: "usa-washingtondc-ca-version-2.expressnetw.com"},
|
||||
{Country: "USA", City: "Wichita", Hostname: "us-wichita-ca-version-2.expressnetw.com"},
|
||||
{Country: "Uzbekistan", Hostname: "uzbekistan-ca-version-2.expressnetw.com"},
|
||||
{Country: "Venezuela", Hostname: "venezuela-ca-version-2.expressnetw.com"},
|
||||
{Country: "Vietnam", Hostname: "vietnam-ca-version-2.expressnetw.com"},
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 0.4
|
||||
maxNoNew = 1
|
||||
maxFails = 4
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: time.Second,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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 = hardcodedServers()
|
||||
|
||||
hosts := make([]string, len(servers))
|
||||
for i := range servers {
|
||||
hosts[i] = servers[i].Hostname
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
i := 0
|
||||
for _, server := range servers {
|
||||
hostname := server.Hostname
|
||||
server.IPs = hostToIPs[hostname]
|
||||
if len(server.IPs) == 0 {
|
||||
continue
|
||||
}
|
||||
server.VPN = vpn.OpenVPN
|
||||
server.UDP = true // no TCP support
|
||||
servers[i] = server
|
||||
i++
|
||||
}
|
||||
servers = servers[:i]
|
||||
|
||||
if len(servers) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(servers), minServers)
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
unzipper common.Unzipper
|
||||
parallelResolver common.ParallelResolver
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(unzipper common.Unzipper, warner common.Warner,
|
||||
parallelResolver common.ParallelResolver,
|
||||
) *Updater {
|
||||
return &Updater{
|
||||
unzipper: unzipper,
|
||||
parallelResolver: parallelResolver,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/fastestvpn/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/fastestvpn"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -22,7 +22,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client, updaterWarner, parallelResolver),
|
||||
Fetcher: fastestvpn.New(client, updaterWarner, parallelResolver),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type apiServer struct {
|
||||
country string
|
||||
city string
|
||||
hostname string
|
||||
}
|
||||
|
||||
var ErrDataMalformed = errors.New("data is malformed")
|
||||
|
||||
const apiURL = "https://support.fastestvpn.com/wp-admin/admin-ajax.php"
|
||||
|
||||
// The API URL and requests are shamelessly taken from network operations
|
||||
// done on the page https://support.fastestvpn.com/vpn-servers/
|
||||
func fetchAPIServers(ctx context.Context, client *http.Client, protocol string) (
|
||||
servers []apiServer, err error,
|
||||
) {
|
||||
form := url.Values{
|
||||
"action": []string{"vpn_servers"},
|
||||
"protocol": []string{protocol},
|
||||
}
|
||||
body := strings.NewReader(form.Encode())
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
// request.Header.Set("User-Agent", "curl/8.9.0")
|
||||
// request.Header.Set("Accept", "*/*")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_ = response.Body.Close()
|
||||
return nil, fmt.Errorf("%w: %d", common.ErrHTTPStatusCodeNotOK, response.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
_ = response.Body.Close()
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
const usualMaxNumber = 100
|
||||
servers = make([]apiServer, 0, usualMaxNumber)
|
||||
|
||||
for {
|
||||
trBlock := getNextTRBlock(data)
|
||||
if trBlock == nil {
|
||||
break
|
||||
}
|
||||
data = data[len(trBlock):]
|
||||
|
||||
var server apiServer
|
||||
|
||||
const numberOfTDBlocks = 3
|
||||
for i := range numberOfTDBlocks {
|
||||
tdBlock := getNextTDBlock(trBlock)
|
||||
if tdBlock == nil {
|
||||
return nil, fmt.Errorf("%w: expected 3 <td> blocks in <tr> block %q",
|
||||
ErrDataMalformed, string(trBlock))
|
||||
}
|
||||
trBlock = trBlock[len(tdBlock):]
|
||||
|
||||
const startToken, endToken = "<td>", "</td>"
|
||||
tdBlockData := string(tdBlock[len(startToken) : len(tdBlock)-len(endToken)])
|
||||
const countryIndex, cityIndex, hostnameIndex = 0, 1, 2
|
||||
switch i {
|
||||
case countryIndex:
|
||||
server.country = tdBlockData
|
||||
case cityIndex:
|
||||
server.city = tdBlockData
|
||||
case hostnameIndex:
|
||||
server.hostname = tdBlockData
|
||||
}
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func getNextTRBlock(data []byte) (trBlock []byte) {
|
||||
const startToken, endToken = "<tr>", "</tr>"
|
||||
return getNextBlock(data, startToken, endToken)
|
||||
}
|
||||
|
||||
func getNextTDBlock(data []byte) (tdBlock []byte) {
|
||||
const startToken, endToken = "<td>", "</td>"
|
||||
return getNextBlock(data, startToken, endToken)
|
||||
}
|
||||
|
||||
func getNextBlock(data []byte, startToken, endToken string) (nextBlock []byte) {
|
||||
i := bytes.Index(data, []byte(startToken))
|
||||
if i == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nextBlock = data[i:]
|
||||
i = bytes.Index(nextBlock[len(startToken):], []byte(endToken))
|
||||
if i == -1 {
|
||||
return nil
|
||||
}
|
||||
nextBlock = nextBlock[:i+len(startToken)+len(endToken)]
|
||||
return nextBlock
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"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_fechAPIServers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errTest := errors.New("test error")
|
||||
|
||||
testCases := map[string]struct {
|
||||
ctx context.Context
|
||||
protocol string
|
||||
requestBody string
|
||||
responseStatus int
|
||||
responseBody io.ReadCloser
|
||||
transportErr error
|
||||
servers []apiServer
|
||||
errWrapped error
|
||||
errMessage string
|
||||
}{
|
||||
"transport_error": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusOK,
|
||||
transportErr: errTest,
|
||||
errWrapped: errTest,
|
||||
errMessage: `sending request: Post ` +
|
||||
`"https://support.fastestvpn.com/wp-admin/admin-ajax.php": ` +
|
||||
`test error`,
|
||||
},
|
||||
"not_found_status_code": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusNotFound,
|
||||
errWrapped: common.ErrHTTPStatusCodeNotOK,
|
||||
errMessage: "HTTP status code not OK: 404",
|
||||
},
|
||||
"empty_data": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader("")),
|
||||
servers: []apiServer{},
|
||||
},
|
||||
"single_server": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader(
|
||||
"irrelevant<tr><td>Australia</td><td>Sydney</td>" +
|
||||
"<td>au-stream.jumptoserver.com</td></tr>irrelevant")),
|
||||
servers: []apiServer{
|
||||
{country: "Australia", city: "Sydney", hostname: "au-stream.jumptoserver.com"},
|
||||
},
|
||||
},
|
||||
"two_servers": {
|
||||
ctx: context.Background(),
|
||||
protocol: "tcp",
|
||||
requestBody: "action=vpn_servers&protocol=tcp",
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader(
|
||||
"<tr><td>Australia</td><td>Sydney</td><td>au-stream.jumptoserver.com</td></tr>" +
|
||||
"<tr><td>Australia</td><td>Sydney</td><td>au-01.jumptoserver.com</td></tr>")),
|
||||
servers: []apiServer{
|
||||
{country: "Australia", city: "Sydney", hostname: "au-stream.jumptoserver.com"},
|
||||
{country: "Australia", city: "Sydney", hostname: "au-01.jumptoserver.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
assert.Equal(t, apiURL, r.URL.String())
|
||||
requestBody, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testCase.requestBody, string(requestBody))
|
||||
if testCase.transportErr != nil {
|
||||
return nil, testCase.transportErr
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: testCase.responseStatus,
|
||||
Body: testCase.responseBody,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
entries, err := fetchAPIServers(testCase.ctx, client, testCase.protocol)
|
||||
|
||||
assert.ErrorIs(t, err, testCase.errWrapped)
|
||||
if testCase.errWrapped != nil {
|
||||
assert.EqualError(t, err, testCase.errMessage)
|
||||
}
|
||||
assert.Equal(t, testCase.servers, entries)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getNextBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := map[string]struct {
|
||||
data string
|
||||
startToken string
|
||||
endToken string
|
||||
nextBlock []byte
|
||||
}{
|
||||
"empty_data": {
|
||||
startToken: "<a>",
|
||||
endToken: "</a>",
|
||||
},
|
||||
"start_token_not_found": {
|
||||
data: "test</a>",
|
||||
startToken: "<a>",
|
||||
endToken: "</a>",
|
||||
},
|
||||
"end_token_not_found": {
|
||||
data: "<a>test",
|
||||
startToken: "<a>",
|
||||
endToken: "</a>",
|
||||
},
|
||||
"block_found": {
|
||||
data: "xy<a>test</a><a>test2</a>zx",
|
||||
startToken: "<a>",
|
||||
endToken: "</a>",
|
||||
nextBlock: []byte("<a>test</a>"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nextBlock := getNextBlock([]byte(testCase.data), testCase.startToken, testCase.endToken)
|
||||
|
||||
assert.Equal(t, testCase.nextBlock, nextBlock)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type hostToServerData map[string]serverData
|
||||
|
||||
type serverData struct {
|
||||
openvpn bool
|
||||
wireguard bool
|
||||
country string
|
||||
city string
|
||||
openvpnUDP bool
|
||||
openvpnTCP bool
|
||||
ips []netip.Addr
|
||||
}
|
||||
|
||||
func (hts hostToServerData) add(host, vpnType, country, city string, tcp, udp bool) {
|
||||
serverData, ok := hts[host]
|
||||
switch vpnType {
|
||||
case vpn.OpenVPN:
|
||||
serverData.openvpn = true
|
||||
serverData.openvpnTCP = serverData.openvpnTCP || tcp
|
||||
serverData.openvpnUDP = serverData.openvpnUDP || udp
|
||||
case vpn.Wireguard:
|
||||
serverData.wireguard = true
|
||||
default:
|
||||
panic("protocol not supported")
|
||||
}
|
||||
|
||||
if !ok {
|
||||
serverData.country = country
|
||||
serverData.city = city
|
||||
} else if city != "" {
|
||||
// some servers are listed without the city although
|
||||
// they are also listed with the city described, so update
|
||||
// the city field.
|
||||
serverData.city = city
|
||||
}
|
||||
|
||||
hts[host] = serverData
|
||||
}
|
||||
|
||||
func (hts hostToServerData) toHostsSlice() (hosts []string) {
|
||||
hosts = make([]string, 0, len(hts))
|
||||
for host := range hts {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func (hts hostToServerData) adaptWithIPs(hostToIPs map[string][]netip.Addr) {
|
||||
for host, serverData := range hts {
|
||||
ips := hostToIPs[host]
|
||||
if len(ips) == 0 {
|
||||
delete(hts, host)
|
||||
continue
|
||||
}
|
||||
serverData.ips = ips
|
||||
hts[host] = serverData
|
||||
}
|
||||
}
|
||||
|
||||
func (hts hostToServerData) toServersSlice() (servers []models.Server) {
|
||||
servers = make([]models.Server, 0, 2*len(hts)) //nolint:mnd
|
||||
for hostname, serverData := range hts {
|
||||
baseServer := models.Server{
|
||||
Hostname: hostname,
|
||||
Country: serverData.country,
|
||||
City: serverData.city,
|
||||
IPs: serverData.ips,
|
||||
}
|
||||
if serverData.openvpn {
|
||||
openvpnServer := baseServer
|
||||
openvpnServer.VPN = vpn.OpenVPN
|
||||
openvpnServer.TCP = serverData.openvpnTCP
|
||||
openvpnServer.UDP = serverData.openvpnUDP
|
||||
servers = append(servers, openvpnServer)
|
||||
}
|
||||
if serverData.wireguard {
|
||||
wireguardServer := baseServer
|
||||
wireguardServer.VPN = vpn.Wireguard
|
||||
const wireguardPublicKey = "658QxufMbjOTmB61Z7f+c7Rjg7oqWLnepTalqBERjF0="
|
||||
wireguardServer.WgPubKey = wireguardPublicKey
|
||||
servers = append(servers, wireguardServer)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 0.1
|
||||
maxNoNew = 1
|
||||
maxFails = 4
|
||||
maxDuration = 3 * time.Second
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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,
|
||||
) {
|
||||
protocols := []string{"ikev2", "tcp", "udp"}
|
||||
hts := make(hostToServerData)
|
||||
|
||||
for _, protocol := range protocols {
|
||||
apiServers, err := fetchAPIServers(ctx, u.client, protocol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching %s servers from API: %w", protocol, err)
|
||||
}
|
||||
for _, apiServer := range apiServers {
|
||||
// all hostnames from the protocols TCP, UDP and IKEV2 support Wireguard
|
||||
// per https://github.com/qdm12/gluetun-wiki/issues/76#issuecomment-2125420536
|
||||
const wgTCP, wgUDP = false, false // ignored
|
||||
hts.add(apiServer.hostname, vpn.Wireguard, apiServer.country, apiServer.city, wgTCP, wgUDP)
|
||||
|
||||
tcp := protocol == "tcp"
|
||||
udp := protocol == "udp"
|
||||
if !tcp && !udp { // not an OpenVPN protocol, for example ikev2
|
||||
continue
|
||||
}
|
||||
hts.add(apiServer.hostname, vpn.OpenVPN, apiServer.country, apiServer.city, tcp, udp)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
hts.adaptWithIPs(hostToIPs)
|
||||
|
||||
servers = hts.toServersSlice()
|
||||
|
||||
if len(servers) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(servers), minServers)
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/giganews/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/giganews"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -21,7 +21,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(unzipper, updaterWarner, parallelResolver),
|
||||
Fetcher: giganews.New(unzipper, updaterWarner, parallelResolver),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var errNotOvpnExt = errors.New("filename does not have the openvpn file extension")
|
||||
|
||||
func parseFilename(fileName string) (
|
||||
region string, err error,
|
||||
) {
|
||||
const suffix = ".ovpn"
|
||||
if !strings.HasSuffix(fileName, suffix) {
|
||||
return "", fmt.Errorf("%w: %s", errNotOvpnExt, fileName)
|
||||
}
|
||||
|
||||
region = strings.TrimSuffix(fileName, suffix)
|
||||
region = strings.ReplaceAll(region, " - ", " ")
|
||||
return region, nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type hostToServer map[string]models.Server
|
||||
|
||||
func (hts hostToServer) add(host, region string, tcp, udp bool) {
|
||||
server, ok := hts[host]
|
||||
if !ok {
|
||||
server.VPN = vpn.OpenVPN
|
||||
server.Hostname = host
|
||||
server.Region = region
|
||||
}
|
||||
if tcp {
|
||||
server.TCP = true
|
||||
}
|
||||
if udp {
|
||||
server.UDP = true
|
||||
}
|
||||
hts[host] = 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][]netip.Addr) {
|
||||
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
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 0.1
|
||||
maxDuration = 5 * time.Second
|
||||
betweenDuration = time.Second
|
||||
maxNoNew = 2
|
||||
maxFails = 2
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
BetweenDuration: betweenDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/updater/openvpn"
|
||||
)
|
||||
|
||||
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
|
||||
servers []models.Server, err error,
|
||||
) {
|
||||
const url = "https://support.vyprvpn.com/hc/article_attachments/360052617332/Vypr_OpenVPN_20200320.zip"
|
||||
contents, err := u.unzipper.FetchAndExtract(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if len(contents) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(contents), minServers)
|
||||
}
|
||||
|
||||
hts := make(hostToServer)
|
||||
|
||||
for fileName, content := range contents {
|
||||
if !strings.HasSuffix(fileName, ".ovpn") {
|
||||
continue // not an OpenVPN file
|
||||
}
|
||||
|
||||
host, warning, err := openvpn.ExtractHost(content)
|
||||
if warning != "" {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
if err != nil {
|
||||
// treat error as warning and go to next file
|
||||
u.warner.Warn(err.Error() + " in " + fileName)
|
||||
continue
|
||||
}
|
||||
host = strings.ReplaceAll(host, "vyprvpn.com", "vpn.giganews.com")
|
||||
|
||||
tcp, udp, err := openvpn.ExtractProto(content)
|
||||
if err != nil {
|
||||
// treat error as warning and go to next file
|
||||
u.warner.Warn(err.Error() + " in " + fileName)
|
||||
continue
|
||||
}
|
||||
|
||||
region, err := parseFilename(fileName)
|
||||
if err != nil {
|
||||
// treat error as warning and go to next file
|
||||
u.warner.Warn(err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
hts.add(host, region, tcp, udp)
|
||||
}
|
||||
|
||||
if len(hts) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(hts), minServers)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
unzipper common.Unzipper
|
||||
parallelResolver common.ParallelResolver
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(unzipper common.Unzipper, warner common.Warner,
|
||||
parallelResolver common.ParallelResolver,
|
||||
) *Updater {
|
||||
return &Updater{
|
||||
unzipper: unzipper,
|
||||
parallelResolver: parallelResolver,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/hidemyass/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/hidemyass"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -22,7 +22,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client, updaterWarner, parallelResolver),
|
||||
Fetcher: hidemyass.New(client, updaterWarner, parallelResolver),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package updater
|
||||
|
||||
func getUniqueHosts(tcpHostToURL, udpHostToURL map[string]string) (
|
||||
hosts []string,
|
||||
) {
|
||||
uniqueHosts := make(map[string]struct{}, len(tcpHostToURL))
|
||||
for host := range tcpHostToURL {
|
||||
uniqueHosts[host] = struct{}{}
|
||||
}
|
||||
for host := range udpHostToURL {
|
||||
uniqueHosts[host] = struct{}{}
|
||||
}
|
||||
|
||||
hosts = make([]string, 0, len(uniqueHosts))
|
||||
for host := range uniqueHosts {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/openvpn"
|
||||
)
|
||||
|
||||
func getAllHostToURL(ctx context.Context, client *http.Client) (
|
||||
tcpHostToURL, udpHostToURL map[string]string, err error,
|
||||
) {
|
||||
tcpHostToURL, err = getHostToURL(ctx, client, "TCP")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
udpHostToURL, err = getHostToURL(ctx, client, "UDP")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return tcpHostToURL, udpHostToURL, nil
|
||||
}
|
||||
|
||||
func getHostToURL(ctx context.Context, client *http.Client, protocol string) (
|
||||
hostToURL map[string]string, err error,
|
||||
) {
|
||||
const baseURL = "https://vpn.hidemyass.com/vpn-config"
|
||||
indexURL := baseURL + "/" + strings.ToUpper(protocol) + "/"
|
||||
|
||||
urls, err := fetchIndex(ctx, client, indexURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
const failEarly = true
|
||||
hostToURL, errors := openvpn.FetchMultiFiles(ctx, client, urls, failEarly)
|
||||
if len(errors) > 0 {
|
||||
return nil, errors[0]
|
||||
}
|
||||
|
||||
return hostToURL, nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var indexOpenvpnLinksRegex = regexp.MustCompile(`<a[ ]+href=".+\.ovpn">.+\.ovpn</a>`)
|
||||
|
||||
func fetchIndex(ctx context.Context, client *http.Client, indexURL string) (
|
||||
openvpnURLs []string, err error,
|
||||
) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
htmlCode, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(indexURL, "/") {
|
||||
indexURL += "/"
|
||||
}
|
||||
|
||||
lines := strings.Split(string(htmlCode), "\n")
|
||||
for _, line := range lines {
|
||||
found := indexOpenvpnLinksRegex.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]
|
||||
openvpnURL := indexURL + filename
|
||||
if !strings.HasSuffix(openvpnURL, ".ovpn") {
|
||||
continue
|
||||
}
|
||||
openvpnURLs = append(openvpnURLs, openvpnURL)
|
||||
}
|
||||
|
||||
return openvpnURLs, nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 0.1
|
||||
maxDuration = 15 * time.Second
|
||||
betweenDuration = 2 * time.Second
|
||||
maxNoNew = 2
|
||||
maxFails = 2
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
BetweenDuration: betweenDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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,
|
||||
) {
|
||||
tcpHostToURL, udpHostToURL, err := getAllHostToURL(ctx, u.client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hosts := getUniqueHosts(tcpHostToURL, udpHostToURL)
|
||||
|
||||
if len(hosts) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(hosts), minServers)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
servers = make([]models.Server, 0, len(hostToIPs))
|
||||
for host, IPs := range hostToIPs {
|
||||
tcpURL, tcp := tcpHostToURL[host]
|
||||
udpURL, udp := udpHostToURL[host]
|
||||
|
||||
// These two are only used to extract the country, region and city.
|
||||
var url, protocol string
|
||||
if tcp {
|
||||
url = tcpURL
|
||||
protocol = "TCP"
|
||||
} else if udp {
|
||||
url = udpURL
|
||||
protocol = "UDP"
|
||||
}
|
||||
country, region, city := parseOpenvpnURL(url, protocol)
|
||||
|
||||
server := models.Server{
|
||||
VPN: vpn.OpenVPN,
|
||||
Country: country,
|
||||
Region: region,
|
||||
City: city,
|
||||
Hostname: host,
|
||||
IPs: IPs,
|
||||
TCP: tcp,
|
||||
UDP: udp,
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func parseOpenvpnURL(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:mnd
|
||||
country = parts[0]
|
||||
city = parts[1]
|
||||
default:
|
||||
country = parts[0]
|
||||
region = parts[1]
|
||||
city = parts[2]
|
||||
}
|
||||
|
||||
country = camelCaseToWords(country)
|
||||
region = camelCaseToWords(region)
|
||||
city = camelCaseToWords(city)
|
||||
|
||||
country = mutateSpecialCountryCases(country)
|
||||
|
||||
return country, region, 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
|
||||
}
|
||||
|
||||
func mutateSpecialCountryCases(country string) string {
|
||||
switch country {
|
||||
case "Coted`Ivoire":
|
||||
return "Cote d'Ivoire"
|
||||
default:
|
||||
return country
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/ipvanish/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/ipvanish"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -21,7 +21,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(unzipper, updaterWarner, parallelResolver),
|
||||
Fetcher: ipvanish.New(unzipper, updaterWarner, parallelResolver),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
var errCountryCodeUnknown = errors.New("country code is unknown")
|
||||
|
||||
func parseFilename(fileName, hostname string, titleCaser cases.Caser) (
|
||||
country, city string, err error,
|
||||
) {
|
||||
const prefix = "ipvanish-"
|
||||
s := strings.TrimPrefix(fileName, prefix)
|
||||
|
||||
const ext = ".ovpn"
|
||||
host := strings.Split(hostname, ".")[0]
|
||||
suffix := "-" + host + ext
|
||||
s = strings.TrimSuffix(s, suffix)
|
||||
|
||||
parts := strings.Split(s, "-")
|
||||
|
||||
countryCodes := constants.CountryCodes()
|
||||
countryCode := strings.ToLower(parts[0])
|
||||
country, ok := countryCodes[countryCode]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("%w: %s", errCountryCodeUnknown, countryCode)
|
||||
}
|
||||
country = titleCaser.String(country)
|
||||
|
||||
if len(parts) > 1 {
|
||||
city = strings.Join(parts[1:], " ")
|
||||
city = titleCaser.String(city)
|
||||
}
|
||||
|
||||
return country, city, nil
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func Test_parseFilename(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := map[string]struct {
|
||||
fileName string
|
||||
hostname string
|
||||
country string
|
||||
city string
|
||||
err error
|
||||
}{
|
||||
"unknown country code": {
|
||||
fileName: "ipvanish-unknown-host.ovpn",
|
||||
hostname: "host.ipvanish.com",
|
||||
err: errors.New("country code is unknown: unknown"),
|
||||
},
|
||||
"country code only": {
|
||||
fileName: "ipvanish-ca-host.ovpn",
|
||||
hostname: "host.ipvanish.com",
|
||||
country: "Canada",
|
||||
},
|
||||
"country code and city": {
|
||||
fileName: "ipvanish-ca-sao-paulo-host.ovpn",
|
||||
hostname: "host.ipvanish.com",
|
||||
country: "Canada",
|
||||
city: "Sao Paulo",
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
titleCaser := cases.Title(language.English)
|
||||
country, city, err := parseFilename(testCase.fileName, testCase.hostname, titleCaser)
|
||||
|
||||
if testCase.err != nil {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, testCase.err.Error(), err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, testCase.country, country)
|
||||
assert.Equal(t, testCase.city, city)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type hostToServer map[string]models.Server
|
||||
|
||||
func (hts hostToServer) add(host, country, city string, tcp, udp bool) {
|
||||
server, ok := hts[host]
|
||||
if !ok {
|
||||
server.VPN = vpn.OpenVPN
|
||||
server.Hostname = host
|
||||
server.Country = country
|
||||
server.City = city
|
||||
}
|
||||
if tcp {
|
||||
server.TCP = tcp
|
||||
}
|
||||
if udp {
|
||||
server.UDP = udp
|
||||
}
|
||||
hts[host] = server
|
||||
}
|
||||
|
||||
func (hts hostToServer) toHostsSlice() (hosts []string) {
|
||||
hosts = make([]string, 0, len(hts))
|
||||
for host := range hts {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
sort.Slice(hosts, func(i, j int) bool {
|
||||
return hosts[i] < hosts[j]
|
||||
})
|
||||
return hosts
|
||||
}
|
||||
|
||||
func (hts hostToServer) adaptWithIPs(hostToIPs map[string][]netip.Addr) {
|
||||
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
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_hostToServer_add(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := map[string]struct {
|
||||
initialHTS hostToServer
|
||||
host string
|
||||
country string
|
||||
city string
|
||||
tcp bool
|
||||
udp bool
|
||||
expectedHTS hostToServer
|
||||
}{
|
||||
"empty host to server": {
|
||||
initialHTS: hostToServer{},
|
||||
host: "host",
|
||||
country: "country",
|
||||
city: "city",
|
||||
tcp: true,
|
||||
udp: true,
|
||||
expectedHTS: hostToServer{
|
||||
"host": {
|
||||
VPN: vpn.OpenVPN,
|
||||
Hostname: "host",
|
||||
Country: "country",
|
||||
City: "city",
|
||||
TCP: true,
|
||||
UDP: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"add server": {
|
||||
initialHTS: hostToServer{
|
||||
"existing host": {},
|
||||
},
|
||||
host: "host",
|
||||
country: "country",
|
||||
city: "city",
|
||||
tcp: true,
|
||||
udp: true,
|
||||
expectedHTS: hostToServer{
|
||||
"existing host": {},
|
||||
"host": models.Server{
|
||||
VPN: vpn.OpenVPN,
|
||||
Hostname: "host",
|
||||
Country: "country",
|
||||
City: "city",
|
||||
TCP: true,
|
||||
UDP: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"extend existing server": {
|
||||
initialHTS: hostToServer{
|
||||
"host": models.Server{
|
||||
VPN: vpn.OpenVPN,
|
||||
Hostname: "host",
|
||||
Country: "country",
|
||||
City: "city",
|
||||
TCP: true,
|
||||
},
|
||||
},
|
||||
host: "host",
|
||||
country: "country",
|
||||
city: "city",
|
||||
tcp: false,
|
||||
udp: true,
|
||||
expectedHTS: hostToServer{
|
||||
"host": models.Server{
|
||||
VPN: vpn.OpenVPN,
|
||||
Hostname: "host",
|
||||
Country: "country",
|
||||
City: "city",
|
||||
TCP: true,
|
||||
UDP: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCase.initialHTS.add(testCase.host, testCase.country, testCase.city, testCase.tcp, testCase.udp)
|
||||
assert.Equal(t, testCase.expectedHTS, testCase.initialHTS)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_hostToServer_toHostsSlice(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := map[string]struct {
|
||||
hts hostToServer
|
||||
hosts []string
|
||||
}{
|
||||
"empty host to server": {
|
||||
hts: hostToServer{},
|
||||
hosts: []string{},
|
||||
},
|
||||
"single host": {
|
||||
hts: hostToServer{
|
||||
"A": {},
|
||||
},
|
||||
hosts: []string{"A"},
|
||||
},
|
||||
"multiple hosts": {
|
||||
hts: hostToServer{
|
||||
"A": {},
|
||||
"B": {},
|
||||
},
|
||||
hosts: []string{"A", "B"},
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hosts := testCase.hts.toHostsSlice()
|
||||
assert.ElementsMatch(t, testCase.hosts, hosts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_hostToServer_adaptWithIPs(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := map[string]struct {
|
||||
initialHTS hostToServer
|
||||
hostToIPs map[string][]netip.Addr
|
||||
expectedHTS hostToServer
|
||||
}{
|
||||
"create server": {
|
||||
initialHTS: hostToServer{},
|
||||
hostToIPs: map[string][]netip.Addr{
|
||||
"A": {netip.AddrFrom4([4]byte{1, 2, 3, 4})},
|
||||
},
|
||||
expectedHTS: hostToServer{
|
||||
"A": models.Server{
|
||||
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
|
||||
},
|
||||
},
|
||||
},
|
||||
"add IPs to existing server": {
|
||||
initialHTS: hostToServer{
|
||||
"A": models.Server{
|
||||
Country: "country",
|
||||
},
|
||||
},
|
||||
hostToIPs: map[string][]netip.Addr{
|
||||
"A": {netip.AddrFrom4([4]byte{1, 2, 3, 4})},
|
||||
},
|
||||
expectedHTS: hostToServer{
|
||||
"A": models.Server{
|
||||
Country: "country",
|
||||
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
|
||||
},
|
||||
},
|
||||
},
|
||||
"remove server without IP": {
|
||||
initialHTS: hostToServer{
|
||||
"A": models.Server{
|
||||
Country: "country",
|
||||
},
|
||||
},
|
||||
hostToIPs: map[string][]netip.Addr{},
|
||||
expectedHTS: hostToServer{},
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCase.initialHTS.adaptWithIPs(testCase.hostToIPs)
|
||||
assert.Equal(t, testCase.expectedHTS, testCase.initialHTS)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_hostToServer_toServersSlice(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := map[string]struct {
|
||||
hts hostToServer
|
||||
servers []models.Server
|
||||
}{
|
||||
"empty host to server": {
|
||||
hts: hostToServer{},
|
||||
servers: []models.Server{},
|
||||
},
|
||||
"multiple servers": {
|
||||
hts: hostToServer{
|
||||
"A": {Country: "A"},
|
||||
"B": {Country: "B"},
|
||||
},
|
||||
servers: []models.Server{
|
||||
{Country: "A"},
|
||||
{Country: "B"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
servers := testCase.hts.toServersSlice()
|
||||
assert.ElementsMatch(t, testCase.servers, servers)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 0.1
|
||||
maxDuration = 20 * time.Second
|
||||
betweenDuration = time.Second
|
||||
maxNoNew = 2
|
||||
maxFails = 2
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
BetweenDuration: betweenDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/updater/openvpn"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
|
||||
servers []models.Server, err error,
|
||||
) {
|
||||
const url = "https://configs.ipvanish.com/openvpn/v2.6.0-0/configs.zip"
|
||||
contents, err := u.unzipper.FetchAndExtract(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if len(contents) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(contents), minServers)
|
||||
}
|
||||
|
||||
hts := make(hostToServer)
|
||||
|
||||
titleCaser := cases.Title(language.English)
|
||||
for fileName, content := range contents {
|
||||
if !strings.HasSuffix(fileName, ".ovpn") {
|
||||
continue // not an OpenVPN file
|
||||
}
|
||||
|
||||
tcp, udp, err := openvpn.ExtractProto(content)
|
||||
if err != nil {
|
||||
// treat error as warning and go to next file
|
||||
u.warner.Warn(err.Error() + " in " + fileName)
|
||||
continue
|
||||
}
|
||||
|
||||
hostname, warning, err := openvpn.ExtractHost(content)
|
||||
if warning != "" {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
if err != nil {
|
||||
// treat error as warning and go to next file
|
||||
u.warner.Warn(err.Error() + " in " + fileName)
|
||||
continue
|
||||
}
|
||||
|
||||
country, city, err := parseFilename(fileName, hostname, titleCaser)
|
||||
if err != nil {
|
||||
// treat error as warning and go to next file
|
||||
u.warner.Warn(err.Error() + " in " + fileName)
|
||||
continue
|
||||
}
|
||||
|
||||
hts.add(hostname, country, city, tcp, udp)
|
||||
}
|
||||
|
||||
if len(hts) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(hts), minServers)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_Updater_GetServers(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := map[string]struct {
|
||||
// Inputs
|
||||
minServers int
|
||||
|
||||
// Mocks
|
||||
warnerBuilder func(ctrl *gomock.Controller) common.Warner
|
||||
|
||||
// Unzip
|
||||
unzipContents map[string][]byte
|
||||
unzipErr error
|
||||
|
||||
// Resolution
|
||||
expectResolve bool
|
||||
resolverSettings resolver.ParallelSettings
|
||||
hostToIPs map[string][]netip.Addr
|
||||
resolveWarnings []string
|
||||
resolveErr error
|
||||
|
||||
// Output
|
||||
servers []models.Server
|
||||
err error
|
||||
}{
|
||||
"unzipper error": {
|
||||
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
|
||||
unzipErr: errors.New("dummy"),
|
||||
err: errors.New("dummy"),
|
||||
},
|
||||
"not enough unzip contents": {
|
||||
minServers: 1,
|
||||
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
|
||||
unzipContents: map[string][]byte{},
|
||||
err: errors.New("not enough servers found: 0 and expected at least 1"),
|
||||
},
|
||||
"no openvpn file": {
|
||||
minServers: 1,
|
||||
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
|
||||
unzipContents: map[string][]byte{"somefile.txt": {}},
|
||||
err: errors.New("not enough servers found: 0 and expected at least 1"),
|
||||
},
|
||||
"invalid proto": {
|
||||
minServers: 1,
|
||||
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
|
||||
warner := common.NewMockWarner(ctrl)
|
||||
warner.EXPECT().Warn("unknown protocol: invalid in badproto.ovpn")
|
||||
return warner
|
||||
},
|
||||
unzipContents: map[string][]byte{"badproto.ovpn": []byte(`proto invalid`)},
|
||||
err: errors.New("not enough servers found: 0 and expected at least 1"),
|
||||
},
|
||||
"no host": {
|
||||
minServers: 1,
|
||||
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
|
||||
warner := common.NewMockWarner(ctrl)
|
||||
warner.EXPECT().Warn("remote host not found in nohost.ovpn")
|
||||
return warner
|
||||
},
|
||||
unzipContents: map[string][]byte{"nohost.ovpn": []byte(``)},
|
||||
err: errors.New("not enough servers found: 0 and expected at least 1"),
|
||||
},
|
||||
"multiple hosts": {
|
||||
minServers: 1,
|
||||
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
|
||||
warner := common.NewMockWarner(ctrl)
|
||||
warner.EXPECT().Warn("only using the first host \"hosta\" and discarding 1 other hosts")
|
||||
return warner
|
||||
},
|
||||
unzipContents: map[string][]byte{
|
||||
"ipvanish-CA-City-A-hosta.ovpn": []byte("remote hosta\nremote hostb"),
|
||||
},
|
||||
expectResolve: true,
|
||||
resolverSettings: resolver.ParallelSettings{
|
||||
Hosts: []string{"hosta"},
|
||||
MaxFailRatio: 0.1,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: 20 * time.Second,
|
||||
BetweenDuration: time.Second,
|
||||
MaxNoNew: 2,
|
||||
MaxFails: 2,
|
||||
SortIPs: true,
|
||||
},
|
||||
},
|
||||
err: errors.New("not enough servers found: 0 and expected at least 1"),
|
||||
},
|
||||
"resolve error": {
|
||||
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
|
||||
warner := common.NewMockWarner(ctrl)
|
||||
warner.EXPECT().Warn("resolve warning")
|
||||
return warner
|
||||
},
|
||||
unzipContents: map[string][]byte{
|
||||
"ipvanish-CA-City-A-hosta.ovpn": []byte("remote hosta"),
|
||||
},
|
||||
expectResolve: true,
|
||||
resolverSettings: resolver.ParallelSettings{
|
||||
Hosts: []string{"hosta"},
|
||||
MaxFailRatio: 0.1,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: 20 * time.Second,
|
||||
BetweenDuration: time.Second,
|
||||
MaxNoNew: 2,
|
||||
MaxFails: 2,
|
||||
SortIPs: true,
|
||||
},
|
||||
},
|
||||
resolveWarnings: []string{"resolve warning"},
|
||||
resolveErr: errors.New("dummy"),
|
||||
err: errors.New("dummy"),
|
||||
},
|
||||
"filename parsing error": {
|
||||
minServers: 1,
|
||||
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
|
||||
warner := common.NewMockWarner(ctrl)
|
||||
warner.EXPECT().Warn("country code is unknown: unknown in ipvanish-unknown-City-A-hosta.ovpn")
|
||||
return warner
|
||||
},
|
||||
unzipContents: map[string][]byte{
|
||||
"ipvanish-unknown-City-A-hosta.ovpn": []byte("remote hosta"),
|
||||
},
|
||||
err: errors.New("not enough servers found: 0 and expected at least 1"),
|
||||
},
|
||||
"success": {
|
||||
minServers: 1,
|
||||
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
|
||||
warner := common.NewMockWarner(ctrl)
|
||||
warner.EXPECT().Warn("resolve warning")
|
||||
return warner
|
||||
},
|
||||
unzipContents: map[string][]byte{
|
||||
"ipvanish-CA-City-A-hosta.ovpn": []byte("remote hosta"),
|
||||
"ipvanish-LU-City-B-hostb.ovpn": []byte("remote hostb"),
|
||||
},
|
||||
expectResolve: true,
|
||||
resolverSettings: resolver.ParallelSettings{
|
||||
Hosts: []string{"hosta", "hostb"},
|
||||
MaxFailRatio: 0.1,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: 20 * time.Second,
|
||||
BetweenDuration: time.Second,
|
||||
MaxNoNew: 2,
|
||||
MaxFails: 2,
|
||||
SortIPs: true,
|
||||
},
|
||||
},
|
||||
hostToIPs: map[string][]netip.Addr{
|
||||
"hosta": {netip.AddrFrom4([4]byte{1, 1, 1, 1}), netip.AddrFrom4([4]byte{2, 2, 2, 2})},
|
||||
"hostb": {netip.AddrFrom4([4]byte{3, 3, 3, 3}), netip.AddrFrom4([4]byte{4, 4, 4, 4})},
|
||||
},
|
||||
resolveWarnings: []string{"resolve warning"},
|
||||
servers: []models.Server{
|
||||
{
|
||||
VPN: vpn.OpenVPN,
|
||||
Country: "Canada",
|
||||
City: "City A",
|
||||
Hostname: "hosta",
|
||||
UDP: true,
|
||||
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1}), netip.AddrFrom4([4]byte{2, 2, 2, 2})},
|
||||
},
|
||||
{
|
||||
VPN: vpn.OpenVPN,
|
||||
Country: "Luxembourg",
|
||||
City: "City B",
|
||||
Hostname: "hostb",
|
||||
UDP: true,
|
||||
IPs: []netip.Addr{netip.AddrFrom4([4]byte{3, 3, 3, 3}), netip.AddrFrom4([4]byte{4, 4, 4, 4})},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
unzipper := common.NewMockUnzipper(ctrl)
|
||||
const zipURL = "https://configs.ipvanish.com/openvpn/v2.6.0-0/configs.zip"
|
||||
unzipper.EXPECT().FetchAndExtract(ctx, zipURL).
|
||||
Return(testCase.unzipContents, testCase.unzipErr)
|
||||
|
||||
parallelResolver := common.NewMockParallelResolver(ctrl)
|
||||
if testCase.expectResolve {
|
||||
parallelResolver.EXPECT().Resolve(ctx, testCase.resolverSettings).
|
||||
Return(testCase.hostToIPs, testCase.resolveWarnings, testCase.resolveErr)
|
||||
}
|
||||
|
||||
updater := &Updater{
|
||||
unzipper: unzipper,
|
||||
warner: testCase.warnerBuilder(ctrl),
|
||||
parallelResolver: parallelResolver,
|
||||
}
|
||||
|
||||
servers, err := updater.FetchServers(ctx, testCase.minServers)
|
||||
|
||||
assert.Equal(t, testCase.servers, servers)
|
||||
if testCase.err != nil {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, testCase.err.Error(), err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
unzipper common.Unzipper
|
||||
warner common.Warner
|
||||
parallelResolver common.ParallelResolver
|
||||
}
|
||||
|
||||
func New(unzipper common.Unzipper, warner common.Warner,
|
||||
parallelResolver common.ParallelResolver,
|
||||
) *Updater {
|
||||
return &Updater{
|
||||
unzipper: unzipper,
|
||||
warner: warner,
|
||||
parallelResolver: parallelResolver,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/ivpn/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/ivpn"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -22,7 +22,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client, updaterWarner, parallelResolver),
|
||||
Fetcher: ivpn.New(client, updaterWarner, parallelResolver),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var errHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
|
||||
type apiData struct {
|
||||
Servers []apiServer `json:"servers"`
|
||||
}
|
||||
|
||||
type apiServer struct {
|
||||
Hostnames apiHostnames `json:"hostnames"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
ISP string `json:"isp"`
|
||||
WgPubKey string `json:"wg_public_key"`
|
||||
}
|
||||
|
||||
type apiHostnames struct {
|
||||
OpenVPN string `json:"openvpn"`
|
||||
Wireguard string `json:"wireguard"`
|
||||
}
|
||||
|
||||
func fetchAPI(ctx context.Context, client *http.Client) (
|
||||
data apiData, err error,
|
||||
) {
|
||||
const url = "https://api.ivpn.net/v4/servers/stats"
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_ = response.Body.Close()
|
||||
return data, fmt.Errorf("%w: %d %s",
|
||||
errHTTPStatusCodeNotOK, response.StatusCode, response.Status)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
_ = response.Body.Close()
|
||||
return data, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return data, fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_fetchAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := map[string]struct {
|
||||
responseStatus int
|
||||
responseBody io.ReadCloser
|
||||
data apiData
|
||||
err error
|
||||
}{
|
||||
"http response status not ok": {
|
||||
responseStatus: http.StatusNoContent,
|
||||
err: errors.New("HTTP status code not OK: 204 No Content"),
|
||||
},
|
||||
"nil body": {
|
||||
responseStatus: http.StatusOK,
|
||||
err: errors.New("decoding response body: EOF"),
|
||||
},
|
||||
"no server": {
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader(`{}`)),
|
||||
},
|
||||
"success": {
|
||||
responseStatus: http.StatusOK,
|
||||
responseBody: io.NopCloser(strings.NewReader(`{"servers":[
|
||||
{"country":"Country1","city":"City A","isp":"xyz","is_active":true,"hostnames":{"openvpn":"hosta"}},
|
||||
{"country":"Country2","city":"City B","isp":"abc","is_active":false,"hostnames":{"openvpn":"hostb"}}
|
||||
]}`)),
|
||||
data: apiData{
|
||||
Servers: []apiServer{
|
||||
{
|
||||
Country: "Country1",
|
||||
City: "City A",
|
||||
IsActive: true,
|
||||
ISP: "xyz",
|
||||
Hostnames: apiHostnames{
|
||||
OpenVPN: "hosta",
|
||||
},
|
||||
},
|
||||
{
|
||||
Country: "Country2",
|
||||
City: "City B",
|
||||
ISP: "abc",
|
||||
Hostnames: apiHostnames{
|
||||
OpenVPN: "hostb",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
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://api.ivpn.net/v4/servers/stats")
|
||||
return &http.Response{
|
||||
StatusCode: testCase.responseStatus,
|
||||
Status: http.StatusText(testCase.responseStatus),
|
||||
Body: testCase.responseBody,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
data, err := fetchAPI(ctx, client)
|
||||
|
||||
assert.Equal(t, testCase.data, data)
|
||||
if testCase.err != nil {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, testCase.err.Error(), err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 0.1
|
||||
maxDuration = 20 * time.Second
|
||||
betweenDuration = time.Second
|
||||
maxNoNew = 2
|
||||
maxFails = 2
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
BetweenDuration: betweenDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package updater
|
||||
|
||||
import "net/http"
|
||||
|
||||
type roundTripFunc func(r *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"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,
|
||||
) {
|
||||
data, err := fetchAPI(ctx, u.client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching API: %w", err)
|
||||
}
|
||||
|
||||
hosts := make(map[string]struct{}, len(data.Servers))
|
||||
|
||||
for _, serverData := range data.Servers {
|
||||
openVPNHost := serverData.Hostnames.OpenVPN
|
||||
if openVPNHost != "" {
|
||||
hosts[openVPNHost] = struct{}{}
|
||||
}
|
||||
|
||||
wireguardHost := serverData.Hostnames.Wireguard
|
||||
if wireguardHost != "" {
|
||||
hosts[wireguardHost] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(hosts) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(hosts), minServers)
|
||||
}
|
||||
|
||||
hostsSlice := make(sort.StringSlice, 0, len(hosts))
|
||||
for host := range hosts {
|
||||
hostsSlice = append(hostsSlice, host)
|
||||
}
|
||||
hostsSlice.Sort() // for predictable unit tests
|
||||
|
||||
resolveSettings := parallelResolverSettings(hostsSlice)
|
||||
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)
|
||||
}
|
||||
|
||||
servers = make([]models.Server, 0, len(hostToIPs))
|
||||
for _, serverData := range data.Servers {
|
||||
city, region := parseCity(serverData.City)
|
||||
server := models.Server{
|
||||
Country: serverData.Country,
|
||||
City: city,
|
||||
Region: region,
|
||||
ISP: serverData.ISP,
|
||||
}
|
||||
|
||||
openVPNHostname := serverData.Hostnames.OpenVPN
|
||||
wireguardHostname := serverData.Hostnames.Wireguard
|
||||
if openVPNHostname == "" && wireguardHostname == "" {
|
||||
warning := fmt.Sprintf("server data %v has no OpenVPN nor Wireguard hostname", serverData)
|
||||
warnings = append(warnings, warning)
|
||||
continue
|
||||
}
|
||||
|
||||
if openVPNHostname != "" {
|
||||
openVPNServer := server
|
||||
openVPNServer.Hostname = openVPNHostname
|
||||
openVPNServer.VPN = vpn.OpenVPN
|
||||
openVPNServer.UDP = true
|
||||
openVPNServer.TCP = true
|
||||
openVPNServer.IPs = hostToIPs[openVPNHostname]
|
||||
servers = append(servers, openVPNServer)
|
||||
}
|
||||
|
||||
if wireguardHostname != "" {
|
||||
wireguardServer := server
|
||||
wireguardServer.Hostname = wireguardHostname
|
||||
wireguardServer.VPN = vpn.Wireguard
|
||||
wireguardServer.IPs = hostToIPs[wireguardHostname]
|
||||
wireguardServer.WgPubKey = serverData.WgPubKey
|
||||
servers = append(servers, wireguardServer)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func parseCity(city string) (parsedCity, region string) {
|
||||
commaIndex := strings.Index(city, ", ")
|
||||
if commaIndex == -1 {
|
||||
return city, ""
|
||||
}
|
||||
return city[:commaIndex], city[commaIndex+2:]
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_Updater_GetServers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := map[string]struct {
|
||||
// Inputs
|
||||
minServers int
|
||||
|
||||
// Mocks
|
||||
warnerBuilder func(ctrl *gomock.Controller) common.Warner
|
||||
|
||||
// From API
|
||||
responseBody string
|
||||
responseStatus int
|
||||
|
||||
// Resolution
|
||||
expectResolve bool
|
||||
resolveSettings resolver.ParallelSettings
|
||||
hostToIPs map[string][]netip.Addr
|
||||
resolveWarnings []string
|
||||
resolveErr error
|
||||
|
||||
// Output
|
||||
servers []models.Server
|
||||
err error
|
||||
}{
|
||||
"http response error": {
|
||||
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
|
||||
responseStatus: http.StatusNoContent,
|
||||
err: errors.New("fetching API: HTTP status code not OK: 204 No Content"),
|
||||
},
|
||||
"resolve error": {
|
||||
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
|
||||
warner := common.NewMockWarner(ctrl)
|
||||
warner.EXPECT().Warn("resolve warning")
|
||||
return warner
|
||||
},
|
||||
responseBody: `{"servers":[
|
||||
{"hostnames":{"openvpn":"hosta"}}
|
||||
]}`,
|
||||
responseStatus: http.StatusOK,
|
||||
expectResolve: true,
|
||||
resolveSettings: resolver.ParallelSettings{
|
||||
Hosts: []string{"hosta"},
|
||||
MaxFailRatio: 0.1,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: 20 * time.Second,
|
||||
BetweenDuration: time.Second,
|
||||
MaxNoNew: 2,
|
||||
MaxFails: 2,
|
||||
SortIPs: true,
|
||||
},
|
||||
},
|
||||
resolveWarnings: []string{"resolve warning"},
|
||||
resolveErr: errors.New("dummy"),
|
||||
err: errors.New("dummy"),
|
||||
},
|
||||
"not enough servers": {
|
||||
minServers: 2,
|
||||
warnerBuilder: func(_ *gomock.Controller) common.Warner { return nil },
|
||||
responseBody: `{"servers":[
|
||||
{"hostnames":{"openvpn":"hosta"}}
|
||||
]}`,
|
||||
responseStatus: http.StatusOK,
|
||||
err: errors.New("not enough servers found: 1 and expected at least 2"),
|
||||
},
|
||||
"success": {
|
||||
minServers: 1,
|
||||
warnerBuilder: func(ctrl *gomock.Controller) common.Warner {
|
||||
warner := common.NewMockWarner(ctrl)
|
||||
warner.EXPECT().Warn("resolve warning")
|
||||
return warner
|
||||
},
|
||||
responseBody: `{"servers":[
|
||||
{"country":"Country1","city":"City A","hostnames":{"openvpn":"hosta"}},
|
||||
{"country":"Country2","city":"City B","hostnames":{"openvpn":"hostb"},"wg_public_key":"xyz"},
|
||||
{"country":"Country3","city":"City C","hostnames":{"wireguard":"hostc"},"wg_public_key":"xyz"}
|
||||
]}`,
|
||||
responseStatus: http.StatusOK,
|
||||
expectResolve: true,
|
||||
resolveSettings: resolver.ParallelSettings{
|
||||
Hosts: []string{"hosta", "hostb", "hostc"},
|
||||
MaxFailRatio: 0.1,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: 20 * time.Second,
|
||||
BetweenDuration: time.Second,
|
||||
MaxNoNew: 2,
|
||||
MaxFails: 2,
|
||||
SortIPs: true,
|
||||
},
|
||||
},
|
||||
hostToIPs: map[string][]netip.Addr{
|
||||
"hosta": {netip.AddrFrom4([4]byte{1, 1, 1, 1}), netip.AddrFrom4([4]byte{2, 2, 2, 2})},
|
||||
"hostb": {netip.AddrFrom4([4]byte{3, 3, 3, 3}), netip.AddrFrom4([4]byte{4, 4, 4, 4})},
|
||||
"hostc": {netip.AddrFrom4([4]byte{5, 5, 5, 5}), netip.AddrFrom4([4]byte{6, 6, 6, 6})},
|
||||
},
|
||||
resolveWarnings: []string{"resolve warning"},
|
||||
servers: []models.Server{
|
||||
{
|
||||
VPN: vpn.OpenVPN, Country: "Country1",
|
||||
City: "City A", Hostname: "hosta", TCP: true, UDP: true,
|
||||
IPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1}), netip.AddrFrom4([4]byte{2, 2, 2, 2})},
|
||||
},
|
||||
{
|
||||
VPN: vpn.OpenVPN, Country: "Country2",
|
||||
City: "City B", Hostname: "hostb", TCP: true, UDP: true,
|
||||
IPs: []netip.Addr{netip.AddrFrom4([4]byte{3, 3, 3, 3}), netip.AddrFrom4([4]byte{4, 4, 4, 4})},
|
||||
},
|
||||
{
|
||||
VPN: vpn.Wireguard,
|
||||
Country: "Country3", City: "City C",
|
||||
Hostname: "hostc",
|
||||
WgPubKey: "xyz",
|
||||
IPs: []netip.Addr{netip.AddrFrom4([4]byte{5, 5, 5, 5}), netip.AddrFrom4([4]byte{6, 6, 6, 6})},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
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://api.ivpn.net/v4/servers/stats")
|
||||
return &http.Response{
|
||||
StatusCode: testCase.responseStatus,
|
||||
Status: http.StatusText(testCase.responseStatus),
|
||||
Body: io.NopCloser(strings.NewReader(testCase.responseBody)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
parallelResolver := common.NewMockParallelResolver(ctrl)
|
||||
if testCase.expectResolve {
|
||||
parallelResolver.EXPECT().Resolve(ctx, testCase.resolveSettings).
|
||||
Return(testCase.hostToIPs, testCase.resolveWarnings, testCase.resolveErr)
|
||||
}
|
||||
|
||||
warner := testCase.warnerBuilder(ctrl)
|
||||
|
||||
updater := &Updater{
|
||||
client: client,
|
||||
parallelResolver: parallelResolver,
|
||||
warner: warner,
|
||||
}
|
||||
|
||||
servers, err := updater.FetchServers(ctx, testCase.minServers)
|
||||
|
||||
assert.Equal(t, testCase.servers, servers)
|
||||
if testCase.err != nil {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, testCase.err.Error(), err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/mullvad/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/mullvad"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -21,7 +21,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client),
|
||||
Fetcher: mullvad.New(client),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
ErrDecodeResponseBody = errors.New("failed decoding response body")
|
||||
)
|
||||
|
||||
type serverData struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Country string `json:"country_name"`
|
||||
City string `json:"city_name"`
|
||||
Active bool `json:"active"`
|
||||
Owned bool `json:"owned"`
|
||||
Provider string `json:"provider"`
|
||||
IPv4 string `json:"ipv4_addr_in"`
|
||||
IPv6 string `json:"ipv6_addr_in"`
|
||||
Type string `json:"type"`
|
||||
PubKey string `json:"pubkey"` // Wireguard public key
|
||||
}
|
||||
|
||||
func fetchAPI(ctx context.Context, client *http.Client) (data []serverData, err error) {
|
||||
const url = "https://api.mullvad.net/www/relays/all/"
|
||||
|
||||
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()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %d %s", ErrHTTPStatusCodeNotOK,
|
||||
response.StatusCode, response.Status)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrDecodeResponseBody, err)
|
||||
}
|
||||
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type hostToServer map[string]models.Server
|
||||
|
||||
var (
|
||||
ErrNoIP = errors.New("no IP address for VPN server")
|
||||
ErrIPIsNotV4 = errors.New("IP address is not IPv4")
|
||||
ErrIPIsNotV6 = errors.New("IP address is not IPv6")
|
||||
ErrVPNTypeNotSupported = errors.New("VPN type not supported")
|
||||
)
|
||||
|
||||
func (hts hostToServer) add(data serverData) (err error) {
|
||||
if !data.Active {
|
||||
return nil
|
||||
}
|
||||
|
||||
if data.IPv4 == "" && data.IPv6 == "" {
|
||||
return fmt.Errorf("%w", ErrNoIP)
|
||||
}
|
||||
|
||||
server, ok := hts[data.Hostname]
|
||||
if ok { // API returns a server per hostname at most
|
||||
return nil
|
||||
}
|
||||
|
||||
switch data.Type {
|
||||
case "wireguard":
|
||||
server.VPN = vpn.Wireguard
|
||||
case "bridge":
|
||||
// ignore bridge servers
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%w: %s", ErrVPNTypeNotSupported, data.Type)
|
||||
}
|
||||
|
||||
if data.IPv4 != "" {
|
||||
ipv4, err := netip.ParseAddr(data.IPv4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing IPv4 address: %w", err)
|
||||
} else if !ipv4.Is4() {
|
||||
return fmt.Errorf("%w: %s", ErrIPIsNotV4, data.IPv4)
|
||||
}
|
||||
server.IPs = append(server.IPs, ipv4)
|
||||
}
|
||||
|
||||
if data.IPv6 != "" {
|
||||
ipv6, err := netip.ParseAddr(data.IPv6)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing IPv6 address: %w", err)
|
||||
} else if !ipv6.Is6() {
|
||||
return fmt.Errorf("%w: %s", ErrIPIsNotV6, data.IPv6)
|
||||
}
|
||||
server.IPs = append(server.IPs, ipv6)
|
||||
}
|
||||
|
||||
server.Country = data.Country
|
||||
server.City = strings.ReplaceAll(data.City, ",", "")
|
||||
server.Hostname = data.Hostname
|
||||
server.ISP = data.Provider
|
||||
server.Owned = data.Owned
|
||||
server.WgPubKey = data.PubKey
|
||||
|
||||
hts[data.Hostname] = server
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hts hostToServer) toServersSlice() (servers []models.Server) {
|
||||
servers = make([]models.Server, 0, len(hts))
|
||||
for _, server := range hts {
|
||||
server.IPs = uniqueSortedIPs(server.IPs)
|
||||
servers = append(servers, server)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func uniqueSortedIPs(ips []netip.Addr) []netip.Addr {
|
||||
uniqueIPs := make(map[string]struct{}, len(ips))
|
||||
for _, ip := range ips {
|
||||
key := ip.String()
|
||||
uniqueIPs[key] = struct{}{}
|
||||
}
|
||||
|
||||
ips = make([]netip.Addr, 0, len(uniqueIPs))
|
||||
for key := range uniqueIPs {
|
||||
ip, err := netip.ParseAddr(key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if ip.Is4In6() {
|
||||
ip = netip.AddrFrom4(ip.As4())
|
||||
}
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
|
||||
sort.Slice(ips, func(i, j int) bool {
|
||||
return ips[i].Compare(ips[j]) < 0
|
||||
})
|
||||
|
||||
return ips
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_uniqueSortedIPs(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := map[string]struct {
|
||||
inputIPs []netip.Addr
|
||||
outputIPs []netip.Addr
|
||||
}{
|
||||
"nil": {
|
||||
inputIPs: nil,
|
||||
outputIPs: []netip.Addr{},
|
||||
},
|
||||
"empty": {
|
||||
inputIPs: []netip.Addr{},
|
||||
outputIPs: []netip.Addr{},
|
||||
},
|
||||
"single IPv4": {
|
||||
inputIPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1})},
|
||||
outputIPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1})},
|
||||
},
|
||||
"two IPv4s": {
|
||||
inputIPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 2, 1}), netip.AddrFrom4([4]byte{1, 1, 1, 1})},
|
||||
outputIPs: []netip.Addr{netip.AddrFrom4([4]byte{1, 1, 1, 1}), netip.AddrFrom4([4]byte{1, 1, 2, 1})},
|
||||
},
|
||||
}
|
||||
for name, testCase := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
outputIPs := uniqueSortedIPs(testCase.inputIPs)
|
||||
assert.Equal(t, testCase.outputIPs, outputIPs)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"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,
|
||||
) {
|
||||
data, err := fetchAPI(ctx, u.client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hts := make(hostToServer)
|
||||
for _, serverData := range data {
|
||||
if err := hts.add(serverData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(hts) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(hts), minServers)
|
||||
}
|
||||
|
||||
servers = hts.toServersSlice()
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(client *http.Client) *Updater {
|
||||
return &Updater{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/nordvpn/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/nordvpn"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -21,7 +21,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client, updaterWarner),
|
||||
Fetcher: nordvpn.New(client, updaterWarner),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
|
||||
func fetchAPI(ctx context.Context, client *http.Client,
|
||||
limit uint,
|
||||
) (data serversData, err error) {
|
||||
url := "https://api.nordvpn.com/v2/servers"
|
||||
url += fmt.Sprintf("?limit=%d", limit) // 0 means no limit
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return serversData{}, err
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return serversData{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return serversData{}, fmt.Errorf("%w: %s", ErrHTTPStatusCodeNotOK, response.Status)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
return serversData{}, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return serversData{}, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Check out the JSON data from https://api.nordvpn.com/v2/servers?limit=10
|
||||
type serversData struct {
|
||||
Servers []serverData `json:"servers"`
|
||||
Groups []groupData `json:"groups"`
|
||||
Services []serviceData `json:"services"`
|
||||
Locations []locationData `json:"locations"`
|
||||
Technologies []technologyData `json:"technologies"`
|
||||
}
|
||||
|
||||
type serverData struct {
|
||||
// Name is the server name, for example 'Poland #128'
|
||||
Name string `json:"name"`
|
||||
// Stations is, it seems, the entry IP address.
|
||||
// However it is ignored in favor of the 'ips' entry field.
|
||||
Station netip.Addr `json:"station"`
|
||||
// IPv6Station is mostly empty, so we ignore it for now.
|
||||
IPv6Station netip.Addr `json:"station_ipv6"`
|
||||
// Hostname is the server hostname, for example 'pl128.nordvpn.com'
|
||||
Hostname string `json:"hostname"`
|
||||
// Status is the server status, for example 'online'
|
||||
Status string `json:"status"`
|
||||
// Locations is the list of location IDs for the server.
|
||||
// Only the first location is taken into account for now.
|
||||
LocationIDs []uint32 `json:"location_ids"`
|
||||
Technologies []struct {
|
||||
ID uint32 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Metadata []struct {
|
||||
// Name can notably be 'public_key'.
|
||||
Name string `json:"name"`
|
||||
// Value can notably the Wireguard public key value.
|
||||
Value string `json:"value"`
|
||||
} `json:"metadata"`
|
||||
} `json:"technologies"`
|
||||
GroupIDs []uint32 `json:"group_ids"`
|
||||
ServiceIDs []uint32 `json:"service_ids"`
|
||||
// IPs is the list of IP addresses for the server.
|
||||
IPs []struct {
|
||||
// Type can notably be 'entry'.
|
||||
Type string `json:"type"`
|
||||
IP struct {
|
||||
IP netip.Addr `json:"ip"`
|
||||
} `json:"ip"`
|
||||
} `json:"ips"`
|
||||
}
|
||||
|
||||
type groupData struct {
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"` // "Europe", "Standard VPN servers", etc.
|
||||
Type struct {
|
||||
Identifier string `json:"identifier"` // 'regions', 'legacy_group_category', etc.
|
||||
} `json:"type"`
|
||||
}
|
||||
|
||||
type serviceData struct {
|
||||
ID uint32 `json:"id"`
|
||||
Identifier string `json:"identifier"` // 'vpn', 'proxy', etc.
|
||||
}
|
||||
|
||||
type locationData struct {
|
||||
ID uint32 `json:"id"`
|
||||
Country struct {
|
||||
Name string `json:"name"` // for example "Poland"
|
||||
City struct {
|
||||
Name string `json:"name"` // for example "Warsaw"
|
||||
} `json:"city"`
|
||||
} `json:"country"`
|
||||
}
|
||||
|
||||
type technologyData struct {
|
||||
ID uint32 `json:"id"`
|
||||
// Identifier is the technology identifier name and relevant values are:
|
||||
// 'openvpn_udp', 'openvpn_tcp', 'openvpn_dedicated_udp',
|
||||
// 'openvpn_dedicated_tcp' and 'wireguard_udp'
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
func (s serversData) idToData() (
|
||||
groups map[uint32]groupData,
|
||||
services map[uint32]serviceData,
|
||||
locations map[uint32]locationData,
|
||||
technologies map[uint32]technologyData,
|
||||
) {
|
||||
groups = make(map[uint32]groupData, len(s.Groups))
|
||||
for _, group := range s.Groups {
|
||||
if group.Type.Identifier == "regions" { //nolint:goconst
|
||||
group.Title = strings.ReplaceAll(group.Title, ",", "")
|
||||
}
|
||||
groups[group.ID] = group
|
||||
}
|
||||
|
||||
services = make(map[uint32]serviceData, len(s.Services))
|
||||
for _, service := range s.Services {
|
||||
services[service.ID] = service
|
||||
}
|
||||
|
||||
locations = make(map[uint32]locationData, len(s.Locations))
|
||||
for _, location := range s.Locations {
|
||||
locations[location.ID] = location
|
||||
}
|
||||
|
||||
technologies = make(map[uint32]technologyData, len(s.Technologies))
|
||||
for _, technology := range s.Technologies {
|
||||
technologies[technology.ID] = technology
|
||||
}
|
||||
|
||||
return groups, services, locations, technologies
|
||||
}
|
||||
|
||||
func (s *serverData) region(groups map[uint32]groupData) (region string) {
|
||||
for _, groupID := range s.GroupIDs {
|
||||
group, ok := groups[groupID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if group.Type.Identifier == "regions" {
|
||||
return group.Title
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *serverData) hasVPNService(services map[uint32]serviceData) (ok bool) {
|
||||
for _, serviceID := range s.ServiceIDs {
|
||||
service, ok := services[serviceID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if service.Identifier == "vpn" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// categories returns the list of categories for the server.
|
||||
func (s *serverData) categories(groups map[uint32]groupData) (categories []string) {
|
||||
categories = make([]string, 0, len(s.GroupIDs))
|
||||
for _, groupID := range s.GroupIDs {
|
||||
data, ok := groups[groupID]
|
||||
if !ok || data.Type.Identifier == "regions" {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, data.Title)
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
// ips returns the list of IP addresses for the server.
|
||||
func (s *serverData) ips() (ips []netip.Addr) {
|
||||
ips = make([]netip.Addr, 0, len(s.IPs))
|
||||
for _, ipObject := range s.IPs {
|
||||
if ipObject.Type != "entry" {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ipObject.IP.IP)
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
var (
|
||||
ErrWireguardPublicKeyMalformed = errors.New("wireguard public key is malformed")
|
||||
ErrWireguardPublicKeyNotFound = errors.New("wireguard public key not found")
|
||||
)
|
||||
|
||||
// wireguardPublicKey returns the Wireguard public key for the server.
|
||||
func (s *serverData) wireguardPublicKey(technologies map[uint32]technologyData) (
|
||||
wgPubKey string, err error,
|
||||
) {
|
||||
for _, technology := range s.Technologies {
|
||||
data, ok := technologies[technology.ID]
|
||||
if !ok || data.Identifier != "wireguard_udp" {
|
||||
continue
|
||||
}
|
||||
for _, metadata := range technology.Metadata {
|
||||
if metadata.Name != "public_key" {
|
||||
continue
|
||||
}
|
||||
wgPubKey = metadata.Value
|
||||
_, err = base64.StdEncoding.DecodeString(wgPubKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s cannot be decoded: %s",
|
||||
ErrWireguardPublicKeyMalformed, wgPubKey, err)
|
||||
}
|
||||
return metadata.Value, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w", ErrWireguardPublicKeyNotFound)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoIDInServerName = errors.New("no ID in server name")
|
||||
ErrInvalidIDInServerName = errors.New("invalid ID in server name")
|
||||
)
|
||||
|
||||
func parseServerName(serverName string) (number uint16, err error) {
|
||||
i := strings.IndexRune(serverName, '#')
|
||||
if i < 0 {
|
||||
return 0, fmt.Errorf("%w: %s", ErrNoIDInServerName, serverName)
|
||||
}
|
||||
|
||||
idString := serverName[i+1:]
|
||||
idUint64, err := strconv.ParseUint(idString, 10, 16)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: %s", ErrInvalidIDInServerName, serverName)
|
||||
}
|
||||
|
||||
number = uint16(idUint64)
|
||||
return number, nil
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
var ErrNotIPv4 = errors.New("IP address is not IPv4")
|
||||
|
||||
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
|
||||
servers []models.Server, err error,
|
||||
) {
|
||||
const limit = 0
|
||||
data, err := fetchAPI(ctx, u.client, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
servers = make([]models.Server, 0, len(data.Servers))
|
||||
groups, services, locations, technologies := data.idToData()
|
||||
|
||||
for _, jsonServer := range data.Servers {
|
||||
newServers, warnings := extractServers(jsonServer, groups, services, locations, technologies)
|
||||
for _, warning := range warnings {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
servers = append(servers, newServers...)
|
||||
}
|
||||
|
||||
if len(servers) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(servers), minServers)
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func extractServers(jsonServer serverData, groups map[uint32]groupData,
|
||||
services map[uint32]serviceData, locations map[uint32]locationData,
|
||||
technologies map[uint32]technologyData) (servers []models.Server,
|
||||
warnings []string,
|
||||
) {
|
||||
ignoreReason := ""
|
||||
switch {
|
||||
case jsonServer.Status != "online":
|
||||
ignoreReason = "status is " + jsonServer.Status
|
||||
case len(jsonServer.LocationIDs) == 0:
|
||||
ignoreReason = "no location"
|
||||
case len(jsonServer.IPs) == 0:
|
||||
ignoreReason = "no IP address"
|
||||
case !jsonServer.hasVPNService(services):
|
||||
ignoreReason = "no VPN service"
|
||||
}
|
||||
if ignoreReason != "" {
|
||||
warning := fmt.Sprintf("ignoring server %s: %s", jsonServer.Name, ignoreReason)
|
||||
return nil, []string{warning}
|
||||
}
|
||||
|
||||
location, ok := locations[jsonServer.LocationIDs[0]]
|
||||
if !ok {
|
||||
warning := fmt.Sprintf("location with id %d not found in %v",
|
||||
jsonServer.LocationIDs[0], locations)
|
||||
return nil, []string{warning}
|
||||
}
|
||||
|
||||
region := jsonServer.region(groups)
|
||||
if region == "" {
|
||||
warning := fmt.Sprintf("no region found for server %s", jsonServer.Name)
|
||||
return nil, []string{warning}
|
||||
}
|
||||
|
||||
server := models.Server{
|
||||
Country: location.Country.Name,
|
||||
Region: region,
|
||||
City: location.Country.City.Name,
|
||||
Categories: jsonServer.categories(groups),
|
||||
Hostname: jsonServer.Hostname,
|
||||
IPs: jsonServer.ips(),
|
||||
}
|
||||
|
||||
number, err := parseServerName(jsonServer.Name)
|
||||
switch {
|
||||
case errors.Is(err, ErrNoIDInServerName):
|
||||
warning := fmt.Sprintf("%s - leaving server number as 0", err)
|
||||
warnings = append(warnings, warning)
|
||||
case err != nil:
|
||||
warning := fmt.Sprintf("failed parsing server name: %s", err)
|
||||
return nil, []string{warning}
|
||||
default: // no error
|
||||
server.Number = number
|
||||
}
|
||||
|
||||
var wireguardFound, openvpnFound bool
|
||||
wireguardServer := server
|
||||
wireguardServer.VPN = vpn.Wireguard
|
||||
openVPNServer := server // accumulate UDP+TCP technologies
|
||||
openVPNServer.VPN = vpn.OpenVPN
|
||||
|
||||
for _, technology := range jsonServer.Technologies {
|
||||
if technology.Status != "online" {
|
||||
continue
|
||||
}
|
||||
technologyData, ok := technologies[technology.ID]
|
||||
if !ok {
|
||||
warning := fmt.Sprintf("technology with id %d not found in %v",
|
||||
technology.ID, technologies)
|
||||
warnings = append(warnings, warning)
|
||||
continue
|
||||
}
|
||||
|
||||
switch technologyData.Identifier {
|
||||
case "openvpn_udp", "openvpn_dedicated_udp":
|
||||
openvpnFound = true
|
||||
openVPNServer.UDP = true
|
||||
case "openvpn_tcp", "openvpn_dedicated_tcp":
|
||||
openvpnFound = true
|
||||
openVPNServer.TCP = true
|
||||
case "wireguard_udp":
|
||||
wireguardFound = true
|
||||
wireguardServer.WgPubKey, err = jsonServer.wireguardPublicKey(technologies)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("ignoring Wireguard server %s: %s", jsonServer.Name, err)
|
||||
warnings = append(warnings, warning)
|
||||
wireguardFound = false
|
||||
continue
|
||||
}
|
||||
default: // Ignore other technologies
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const maxServers = 2
|
||||
servers = make([]models.Server, 0, maxServers)
|
||||
if openvpnFound {
|
||||
servers = append(servers, openVPNServer)
|
||||
}
|
||||
if wireguardFound {
|
||||
servers = append(servers, wireguardServer)
|
||||
}
|
||||
|
||||
return servers, warnings
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
client *http.Client
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(client *http.Client, warner common.Warner) *Updater {
|
||||
return &Updater{
|
||||
client: client,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/perfectprivacy/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/perfectprivacy"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -20,7 +20,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(unzipper, updaterWarner),
|
||||
Fetcher: perfectprivacy.New(unzipper, updaterWarner),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type cityToServer map[string]models.Server
|
||||
|
||||
func (cts cityToServer) add(city string, ips []netip.Addr) {
|
||||
server, ok := cts[city]
|
||||
if !ok {
|
||||
server.VPN = vpn.OpenVPN
|
||||
server.City = city
|
||||
server.IPs = ips
|
||||
server.TCP = true
|
||||
server.UDP = true
|
||||
} else {
|
||||
// Do not insert duplicate IP addresses
|
||||
existingIPs := make(map[string]struct{}, len(server.IPs))
|
||||
for _, ip := range server.IPs {
|
||||
existingIPs[ip.String()] = struct{}{}
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
ipString := ip.String()
|
||||
_, ok := existingIPs[ipString]
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
existingIPs[ipString] = struct{}{}
|
||||
server.IPs = append(server.IPs, ip)
|
||||
}
|
||||
}
|
||||
|
||||
cts[city] = server
|
||||
}
|
||||
|
||||
func (cts cityToServer) toServersSlice() (servers []models.Server) {
|
||||
servers = make([]models.Server, 0, len(cts))
|
||||
for _, server := range cts {
|
||||
servers = append(servers, server)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func parseFilename(fileName string) (city string) {
|
||||
const suffix = ".conf"
|
||||
s := strings.TrimSuffix(fileName, suffix)
|
||||
|
||||
for i, r := range s {
|
||||
if unicode.IsDigit(r) {
|
||||
s = s[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/updater/openvpn"
|
||||
)
|
||||
|
||||
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
|
||||
servers []models.Server, err error,
|
||||
) {
|
||||
zipURL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: "www.perfect-privacy.com",
|
||||
Path: "/downloads/openvpn/get",
|
||||
}
|
||||
values := make(url.Values)
|
||||
values.Set("system", "linux")
|
||||
values.Set("scope", "server")
|
||||
values.Set("filetype", "zip")
|
||||
values.Set("protocol", "udp") // all support both TCP and UDP
|
||||
zipURL.RawQuery = values.Encode()
|
||||
|
||||
contents, err := u.unzipper.FetchAndExtract(ctx, zipURL.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cts := make(cityToServer)
|
||||
|
||||
for fileName, content := range contents {
|
||||
err := addServerFromOvpn(cts, fileName, content)
|
||||
if err != nil {
|
||||
u.warner.Warn(err.Error() + " in " + fileName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cts) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(cts), minServers)
|
||||
}
|
||||
|
||||
servers = cts.toServersSlice()
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func addServerFromOvpn(cts cityToServer,
|
||||
fileName string, content []byte,
|
||||
) (err error) {
|
||||
if !strings.HasSuffix(fileName, ".conf") {
|
||||
return nil // not an OpenVPN file
|
||||
}
|
||||
|
||||
ips, err := openvpn.ExtractIPs(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
city := parseFilename(fileName)
|
||||
|
||||
cts.add(city, ips)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
unzipper common.Unzipper
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(unzipper common.Unzipper, warner common.Warner) *Updater {
|
||||
return &Updater{
|
||||
unzipper: unzipper,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/privado/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/privado"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -21,7 +21,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client, updaterWarner),
|
||||
Fetcher: privado.New(client, updaterWarner),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"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,
|
||||
) {
|
||||
const url = "https://privadovpn.com/apps/servers_export.json"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
response, err := u.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Servers []struct {
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP netip.Addr `json:"ip"`
|
||||
} `json:"servers"`
|
||||
}
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
err = decoder.Decode(&data)
|
||||
if err != nil {
|
||||
_ = response.Body.Close()
|
||||
return nil, fmt.Errorf("decoding JSON response: %w", err)
|
||||
}
|
||||
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
if len(data.Servers) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(data.Servers), minServers)
|
||||
}
|
||||
|
||||
servers = make([]models.Server, len(data.Servers))
|
||||
for i, server := range data.Servers {
|
||||
servers[i] = models.Server{
|
||||
VPN: vpn.OpenVPN,
|
||||
Country: server.Country,
|
||||
City: server.City,
|
||||
Hostname: server.Hostname,
|
||||
IPs: []netip.Addr{server.IP},
|
||||
UDP: true,
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
client *http.Client
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(client *http.Client, warner common.Warner) *Updater {
|
||||
return &Updater{
|
||||
client: client,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/privateinternetaccess/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/privateinternetaccess"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -30,7 +30,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
timeNow: timeNow,
|
||||
randSource: randSource,
|
||||
portForwardPath: jsonPortForwardPath,
|
||||
Fetcher: updater.New(client),
|
||||
Fetcher: privateinternetaccess.New(client),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
|
||||
type apiData struct {
|
||||
Regions []regionData `json:"regions"`
|
||||
}
|
||||
|
||||
type regionData struct {
|
||||
Name string `json:"name"`
|
||||
DNS string `json:"dns"`
|
||||
PortForward bool `json:"port_forward"`
|
||||
Offline bool `json:"offline"`
|
||||
Servers struct {
|
||||
UDP []serverData `json:"ovpnudp"`
|
||||
TCP []serverData `json:"ovpntcp"`
|
||||
} `json:"servers"`
|
||||
}
|
||||
|
||||
type serverData struct {
|
||||
IP netip.Addr `json:"ip"`
|
||||
CN string `json:"cn"`
|
||||
}
|
||||
|
||||
func fetchAPI(ctx context.Context, client *http.Client) (
|
||||
data apiData, err error,
|
||||
) {
|
||||
const url = "https://serverlist.piaservers.net/vpninfo/servers/v7"
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return data, fmt.Errorf("%w: %d %s", ErrHTTPStatusCodeNotOK,
|
||||
response.StatusCode, response.Status)
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
// remove key/signature at the bottom
|
||||
i := bytes.IndexRune(b, '\n')
|
||||
b = b[:i]
|
||||
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type nameToServer map[string]models.Server
|
||||
|
||||
func (nts nameToServer) add(name, hostname, region string,
|
||||
tcp, udp, portForward bool, ip netip.Addr,
|
||||
) (change bool) {
|
||||
server, ok := nts[name]
|
||||
if !ok {
|
||||
change = true
|
||||
server.VPN = vpn.OpenVPN
|
||||
server.ServerName = name
|
||||
server.Hostname = hostname
|
||||
server.Region = region
|
||||
server.PortForward = portForward
|
||||
}
|
||||
|
||||
if !server.TCP && tcp {
|
||||
change = true
|
||||
server.TCP = tcp
|
||||
}
|
||||
if !server.UDP && udp {
|
||||
change = true
|
||||
server.UDP = udp
|
||||
}
|
||||
|
||||
ipFound := false
|
||||
for _, existingIP := range server.IPs {
|
||||
if ip == existingIP {
|
||||
ipFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !ipFound {
|
||||
change = true
|
||||
server.IPs = append(server.IPs, ip)
|
||||
}
|
||||
|
||||
nts[name] = server
|
||||
|
||||
return change
|
||||
}
|
||||
|
||||
func (nts nameToServer) toServersSlice() (servers []models.Server) {
|
||||
servers = make([]models.Server, 0, len(nts))
|
||||
for _, server := range nts {
|
||||
servers = append(servers, server)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"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,
|
||||
) {
|
||||
nts := make(nameToServer)
|
||||
|
||||
noChangeCounter := 0
|
||||
const maxNoChange = 10
|
||||
const betweenDuration = 200 * time.Millisecond
|
||||
const maxDuration = time.Minute
|
||||
|
||||
maxTimer := time.NewTimer(maxDuration)
|
||||
|
||||
for {
|
||||
data, err := fetchAPI(ctx, u.client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
change := addData(data.Regions, nts)
|
||||
|
||||
if !change {
|
||||
noChangeCounter++
|
||||
if noChangeCounter == maxNoChange {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
noChangeCounter = 0
|
||||
}
|
||||
|
||||
timer := time.NewTimer(betweenDuration)
|
||||
maxTimeout := false
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
if !maxTimer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
case <-maxTimer.C:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
maxTimeout = true
|
||||
}
|
||||
|
||||
if maxTimeout {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
servers = nts.toServersSlice()
|
||||
|
||||
if len(servers) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(servers), minServers)
|
||||
}
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func addData(regions []regionData, nts nameToServer) (change bool) {
|
||||
for _, region := range regions {
|
||||
if region.Offline {
|
||||
continue
|
||||
}
|
||||
for _, server := range region.Servers.UDP {
|
||||
const tcp, udp = false, true
|
||||
if nts.add(server.CN, region.DNS, region.Name, tcp, udp, region.PortForward, server.IP) {
|
||||
change = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, server := range region.Servers.TCP {
|
||||
const tcp, udp = true, false
|
||||
if nts.add(server.CN, region.DNS, region.Name, tcp, udp, region.PortForward, server.IP) {
|
||||
change = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return change
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(client *http.Client) *Updater {
|
||||
return &Updater{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/privatevpn/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/privatevpn"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -21,7 +21,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(unzipper, updaterWarner, parallelResolver),
|
||||
Fetcher: privatevpn.New(unzipper, updaterWarner, parallelResolver),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package updater
|
||||
|
||||
import "strings"
|
||||
|
||||
func codeToCountry(countryCode string, countryCodes map[string]string) (
|
||||
country string, warning string,
|
||||
) {
|
||||
countryCode = strings.ToLower(countryCode)
|
||||
country, ok := countryCodes[countryCode]
|
||||
if !ok {
|
||||
warning = "unknown country code: " + countryCode
|
||||
country = countryCode
|
||||
}
|
||||
return country, warning
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var trailingNumber = regexp.MustCompile(` [0-9]+$`)
|
||||
|
||||
var (
|
||||
errBadPrefix = errors.New("bad prefix in file name")
|
||||
errBadSuffix = errors.New("bad suffix in file name")
|
||||
errNotEnoughParts = errors.New("not enough parts in file name")
|
||||
)
|
||||
|
||||
func parseFilename(fileName string) (
|
||||
countryCode, city string, err error,
|
||||
) {
|
||||
fileName = strings.ReplaceAll(fileName, " ", "") // remove spaces
|
||||
|
||||
const prefix = "PrivateVPN-"
|
||||
if !strings.HasPrefix(fileName, prefix) {
|
||||
return "", "", fmt.Errorf("%w: %s", errBadPrefix, fileName)
|
||||
}
|
||||
s := strings.TrimPrefix(fileName, prefix)
|
||||
|
||||
const tcpSuffix = "-TUN-443.ovpn"
|
||||
const udpSuffix = "-TUN-1194.ovpn"
|
||||
switch {
|
||||
case strings.HasSuffix(fileName, tcpSuffix):
|
||||
s = strings.TrimSuffix(s, tcpSuffix)
|
||||
case strings.HasSuffix(fileName, udpSuffix):
|
||||
s = strings.TrimSuffix(s, udpSuffix)
|
||||
default:
|
||||
return "", "", fmt.Errorf("%w: %s", errBadSuffix, fileName)
|
||||
}
|
||||
|
||||
s = trailingNumber.ReplaceAllString(s, "")
|
||||
|
||||
parts := strings.Split(s, "-")
|
||||
const minParts = 2
|
||||
if len(parts) < minParts {
|
||||
return "", "", fmt.Errorf("%w: %s", errNotEnoughParts, fileName)
|
||||
}
|
||||
countryCode, city = parts[0], parts[1]
|
||||
|
||||
countryCode = strings.ToLower(countryCode)
|
||||
if countryCode == "co" && strings.HasPrefix(city, "Bogot") {
|
||||
city = "Bogota"
|
||||
}
|
||||
|
||||
return countryCode, city, nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type hostToServer map[string]models.Server
|
||||
|
||||
// TODO check if server supports TCP and UDP.
|
||||
func (hts hostToServer) add(host, country, city string) {
|
||||
server, ok := hts[host]
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
server.VPN = vpn.OpenVPN
|
||||
server.Hostname = host
|
||||
server.Country = country
|
||||
server.City = city
|
||||
server.UDP = true
|
||||
server.TCP = true
|
||||
hts[host] = 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][]netip.Addr) {
|
||||
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
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/updater/resolver"
|
||||
)
|
||||
|
||||
func parallelResolverSettings(hosts []string) (settings resolver.ParallelSettings) {
|
||||
const (
|
||||
maxFailRatio = 0.1
|
||||
maxDuration = 6 * time.Second
|
||||
betweenDuration = time.Second
|
||||
maxNoNew = 2
|
||||
maxFails = 2
|
||||
)
|
||||
return resolver.ParallelSettings{
|
||||
Hosts: hosts,
|
||||
MaxFailRatio: maxFailRatio,
|
||||
Repeat: resolver.RepeatSettings{
|
||||
MaxDuration: maxDuration,
|
||||
BetweenDuration: betweenDuration,
|
||||
MaxNoNew: maxNoNew,
|
||||
MaxFails: maxFails,
|
||||
SortIPs: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants"
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/updater/openvpn"
|
||||
)
|
||||
|
||||
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
|
||||
servers []models.Server, err error,
|
||||
) {
|
||||
const url = "https://privatevpn.com/client/PrivateVPN-TUN.zip"
|
||||
contents, err := u.unzipper.FetchAndExtract(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if len(contents) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(contents), minServers)
|
||||
}
|
||||
|
||||
countryCodes := constants.CountryCodes()
|
||||
|
||||
hts := make(hostToServer)
|
||||
noHostnameServers := make([]models.Server, 0, 1) // there is only one for now
|
||||
|
||||
for fileName, content := range contents {
|
||||
if !strings.HasSuffix(fileName, ".ovpn") {
|
||||
continue // not an OpenVPN file
|
||||
}
|
||||
|
||||
countryCode, city, err := parseFilename(fileName)
|
||||
if err != nil {
|
||||
// treat error as warning and go to next file
|
||||
u.warner.Warn(err.Error() + " in " + fileName)
|
||||
continue
|
||||
}
|
||||
|
||||
country, warning := codeToCountry(countryCode, countryCodes)
|
||||
if warning != "" {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
|
||||
host, warning, err := openvpn.ExtractHost(content)
|
||||
if warning != "" {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
if err == nil { // found host
|
||||
hts.add(host, country, city)
|
||||
continue
|
||||
}
|
||||
|
||||
ips, extractIPErr := openvpn.ExtractIPs(content)
|
||||
if warning != "" {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
if extractIPErr != nil {
|
||||
// treat extract host error as warning and go to next file
|
||||
u.warner.Warn(extractIPErr.Error() + " in " + fileName)
|
||||
continue
|
||||
}
|
||||
server := models.Server{
|
||||
VPN: vpn.OpenVPN,
|
||||
Country: country,
|
||||
City: city,
|
||||
IPs: ips,
|
||||
UDP: true,
|
||||
TCP: true,
|
||||
}
|
||||
noHostnameServers = append(noHostnameServers, server)
|
||||
}
|
||||
|
||||
if len(noHostnameServers)+len(hts) < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(servers)+len(hts), minServers)
|
||||
}
|
||||
|
||||
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(noHostnameServers)+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()
|
||||
servers = append(servers, noHostnameServers...)
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
unzipper common.Unzipper
|
||||
parallelResolver common.ParallelResolver
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(unzipper common.Unzipper, warner common.Warner,
|
||||
parallelResolver common.ParallelResolver,
|
||||
) *Updater {
|
||||
return &Updater{
|
||||
unzipper: unzipper,
|
||||
parallelResolver: parallelResolver,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/providers"
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/provider/protonvpn/updater"
|
||||
"github.com/qdm12/gluetun/pkg/updaters/protonvpn"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@@ -23,7 +23,7 @@ func New(storage common.Storage, randSource rand.Source,
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client, updaterWarner, email, password),
|
||||
Fetcher: protonvpn.New(client, updaterWarner, email, password),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,638 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
srp "github.com/ProtonMail/go-srp"
|
||||
)
|
||||
|
||||
// apiClient is a minimal Proton v4 API client which can handle all the
|
||||
// oddities of Proton's authentication flow they want to keep hidden
|
||||
// from the public.
|
||||
type apiClient struct {
|
||||
apiURLBase string
|
||||
httpClient *http.Client
|
||||
appVersion string
|
||||
userAgent string
|
||||
generator *rand.ChaCha8
|
||||
}
|
||||
|
||||
// newAPIClient returns an [apiClient] with sane defaults matching Proton's
|
||||
// insane expectations.
|
||||
func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClient, err error) {
|
||||
var seed [32]byte
|
||||
_, _ = crand.Read(seed[:])
|
||||
generator := rand.NewChaCha8(seed)
|
||||
|
||||
// Pick a random user agent from this list. Because I'm not going to tell
|
||||
// Proton shit on where all these funny requests are coming from, given their
|
||||
// unhelpfulness in figuring out their authentication flow.
|
||||
userAgents := [...]string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
||||
}
|
||||
userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))]
|
||||
|
||||
appVersion, err := getMostRecentStableTag(ctx, httpClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting most recent version for proton app: %w", err)
|
||||
}
|
||||
|
||||
return &apiClient{
|
||||
apiURLBase: "https://account.proton.me/api",
|
||||
httpClient: httpClient,
|
||||
appVersion: appVersion,
|
||||
userAgent: userAgent,
|
||||
generator: generator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var ErrCodeNotSuccess = errors.New("response code is not success")
|
||||
|
||||
// setHeaders sets the minimal necessary headers for Proton API requests
|
||||
// to succeed without being blocked by their "security" measures.
|
||||
// See for example [getMostRecentStableTag] on how the app version must
|
||||
// be set to a recent version or they block your request. "SeCuRiTy"...
|
||||
func (c *apiClient) setHeaders(request *http.Request, cookie cookie) {
|
||||
request.Header.Set("Cookie", cookie.String())
|
||||
request.Header.Set("User-Agent", c.userAgent)
|
||||
request.Header.Set("x-pm-appversion", c.appVersion)
|
||||
request.Header.Set("x-pm-locale", "en_US")
|
||||
request.Header.Set("x-pm-uid", cookie.uid)
|
||||
}
|
||||
|
||||
// authenticate performs the full Proton authentication flow
|
||||
// to obtain an authenticated cookie (uid, token and session ID).
|
||||
func (c *apiClient) authenticate(ctx context.Context, email, password string,
|
||||
) (authCookie cookie, err error) {
|
||||
sessionID, err := c.getSessionID(ctx)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("getting session ID: %w", err)
|
||||
}
|
||||
|
||||
tokenType, accessToken, refreshToken, uid, err := c.getUnauthSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("getting unauthenticated session data: %w", err)
|
||||
}
|
||||
|
||||
cookieToken, err := c.cookieToken(ctx, sessionID, tokenType, accessToken, refreshToken, uid)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("getting cookie token: %w", err)
|
||||
}
|
||||
|
||||
unauthCookie := cookie{
|
||||
uid: uid,
|
||||
token: cookieToken,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64,
|
||||
srpSessionHex, version, err := c.authInfo(ctx, email, unauthCookie)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("getting auth information: %w", err)
|
||||
}
|
||||
|
||||
// Prepare SRP proof generator using Proton's official SRP parameters and hashing.
|
||||
srpAuth, err := srp.NewAuth(version, username, []byte(password),
|
||||
saltBase64, modulusPGPClearSigned, serverEphemeralBase64)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("initializing SRP auth: %w", err)
|
||||
}
|
||||
|
||||
// Generate SRP proofs (A, M1) with the usual 2048-bit modulus.
|
||||
const modulusBits = 2048
|
||||
proofs, err := srpAuth.GenerateProofs(modulusBits)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("generating SRP proofs: %w", err)
|
||||
}
|
||||
|
||||
authCookie, err = c.auth(ctx, unauthCookie, email, srpSessionHex, proofs)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("authentifying: %w", err)
|
||||
}
|
||||
|
||||
return authCookie, nil
|
||||
}
|
||||
|
||||
var ErrSessionIDNotFound = errors.New("session ID not found in cookies")
|
||||
|
||||
func (c *apiClient) getSessionID(ctx context.Context) (sessionID string, err error) {
|
||||
const url = "https://account.proton.me/vpn"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
for _, cookie := range response.Cookies() {
|
||||
if cookie.Name == "Session-Id" {
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w", ErrSessionIDNotFound)
|
||||
}
|
||||
|
||||
var ErrDataFieldMissing = errors.New("data field missing in response")
|
||||
|
||||
func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) (
|
||||
tokenType, accessToken, refreshToken, uid string, err error,
|
||||
) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/auth/v4/sessions", nil)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
unauthCookie := cookie{
|
||||
sessionID: sessionID,
|
||||
}
|
||||
c.setHeaders(request, unauthCookie)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("reading response body: %w", err)
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return "", "", "", "", buildError(response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Code uint `json:"Code"` // 1000 on success
|
||||
AccessToken string `json:"AccessToken"` // 32-chars lowercase and digits
|
||||
RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits
|
||||
TokenType string `json:"TokenType"` // "Bearer"
|
||||
Scopes []string `json:"Scopes"` // should be [] for our usage
|
||||
UID string `json:"UID"` // 32-chars lowercase and digits
|
||||
LocalID uint `json:"LocalID"` // 0 in my case
|
||||
}
|
||||
|
||||
err = json.Unmarshal(responseBody, &data)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
const successCode = 1000
|
||||
switch {
|
||||
case data.Code != successCode:
|
||||
return "", "", "", "", fmt.Errorf("%w: expected %d got %d",
|
||||
ErrCodeNotSuccess, successCode, data.Code)
|
||||
case data.AccessToken == "":
|
||||
return "", "", "", "", fmt.Errorf("%w: access token is empty", ErrDataFieldMissing)
|
||||
case data.RefreshToken == "":
|
||||
return "", "", "", "", fmt.Errorf("%w: refresh token is empty", ErrDataFieldMissing)
|
||||
case data.TokenType == "":
|
||||
return "", "", "", "", fmt.Errorf("%w: token type is empty", ErrDataFieldMissing)
|
||||
case data.UID == "":
|
||||
return "", "", "", "", fmt.Errorf("%w: UID is empty", ErrDataFieldMissing)
|
||||
}
|
||||
// Ignore Scopes and LocalID fields, we don't use them.
|
||||
|
||||
return data.TokenType, data.AccessToken, data.RefreshToken, data.UID, nil
|
||||
}
|
||||
|
||||
var ErrUIDMismatch = errors.New("UID in response does not match request UID")
|
||||
|
||||
func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, accessToken,
|
||||
refreshToken, uid string,
|
||||
) (cookieToken string, err error) {
|
||||
type requestBodySchema struct {
|
||||
GrantType string `json:"GrantType"` // "refresh_token"
|
||||
Persistent uint `json:"Persistent"` // 0
|
||||
RedirectURI string `json:"RedirectURI"` // "https://protonmail.com"
|
||||
RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits
|
||||
ResponseType string `json:"ResponseType"` // "token"
|
||||
State string `json:"State"` // 24-chars letters and digits
|
||||
UID string `json:"UID"` // 32-chars lowercase and digits
|
||||
}
|
||||
requestBody := requestBodySchema{
|
||||
GrantType: "refresh_token",
|
||||
Persistent: 0,
|
||||
RedirectURI: "https://protonmail.com",
|
||||
RefreshToken: refreshToken,
|
||||
ResponseType: "token",
|
||||
State: generateLettersDigits(c.generator, 24), //nolint:mnd
|
||||
UID: uid,
|
||||
}
|
||||
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
encoder := json.NewEncoder(buffer)
|
||||
if err := encoder.Encode(requestBody); err != nil {
|
||||
return "", fmt.Errorf("encoding request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/cookies", buffer)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
unauthCookie := cookie{
|
||||
uid: uid,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
c.setHeaders(request, unauthCookie)
|
||||
request.Header.Set("Authorization", tokenType+" "+accessToken)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading response body: %w", err)
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return "", buildError(response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var cookies struct {
|
||||
Code uint `json:"Code"` // 1000 on success
|
||||
UID string `json:"UID"` // should match request UID
|
||||
LocalID uint `json:"LocalID"` // 0
|
||||
RefreshCounter uint `json:"RefreshCounter"` // 1
|
||||
}
|
||||
err = json.Unmarshal(responseBody, &cookies)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
const successCode = 1000
|
||||
switch {
|
||||
case cookies.Code != successCode:
|
||||
return "", fmt.Errorf("%w: expected %d got %d",
|
||||
ErrCodeNotSuccess, successCode, cookies.Code)
|
||||
case cookies.UID != requestBody.UID:
|
||||
return "", fmt.Errorf("%w: expected %s got %s",
|
||||
ErrUIDMismatch, requestBody.UID, cookies.UID)
|
||||
}
|
||||
// Ignore LocalID and RefreshCounter fields, we don't use them.
|
||||
|
||||
for _, cookie := range response.Cookies() {
|
||||
if cookie.Name == "AUTH-"+uid {
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w", ErrAuthCookieNotFound)
|
||||
}
|
||||
|
||||
var ErrUsernameDoesNotExist = errors.New("username does not exist")
|
||||
|
||||
// authInfo fetches SRP parameters for the account.
|
||||
func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie cookie) (
|
||||
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string,
|
||||
version int, err error,
|
||||
) {
|
||||
type requestBodySchema struct {
|
||||
Intent string `json:"Intent"` // "Proton"
|
||||
Username string `json:"Username"`
|
||||
}
|
||||
requestBody := requestBodySchema{
|
||||
Intent: "Proton",
|
||||
Username: email,
|
||||
}
|
||||
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
encoder := json.NewEncoder(buffer)
|
||||
if err := encoder.Encode(requestBody); err != nil {
|
||||
return "", "", "", "", "", 0, fmt.Errorf("encoding request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/info", buffer)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", 0, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
c.setHeaders(request, unauthCookie)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", 0, fmt.Errorf("reading response body: %w", err)
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return "", "", "", "", "", 0, buildError(response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var info struct {
|
||||
Code uint `json:"Code"` // 1000 on success
|
||||
Modulus string `json:"Modulus"` // PGP clearsigned modulus string
|
||||
ServerEphemeral string `json:"ServerEphemeral"` // base64
|
||||
Version *uint `json:"Version,omitempty"` // 4 as of 2025-10-26
|
||||
Salt string `json:"Salt"` // base64
|
||||
SRPSession string `json:"SRPSession"` // hexadecimal
|
||||
Username string `json:"Username"` // user without @domain.com. Mine has its first letter capitalized.
|
||||
}
|
||||
err = json.Unmarshal(responseBody, &info)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", 0, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
const successCode = 1000
|
||||
switch {
|
||||
case info.Code != successCode:
|
||||
return "", "", "", "", "", 0, fmt.Errorf("%w: expected %d got %d",
|
||||
ErrCodeNotSuccess, successCode, info.Code)
|
||||
case info.Modulus == "":
|
||||
return "", "", "", "", "", 0, fmt.Errorf("%w: modulus is empty", ErrDataFieldMissing)
|
||||
case info.ServerEphemeral == "":
|
||||
return "", "", "", "", "", 0, fmt.Errorf("%w: server ephemeral is empty", ErrDataFieldMissing)
|
||||
case info.Salt == "":
|
||||
return "", "", "", "", "", 0, fmt.Errorf("%w (salt data field is empty)", ErrUsernameDoesNotExist)
|
||||
case info.SRPSession == "":
|
||||
return "", "", "", "", "", 0, fmt.Errorf("%w: SRP session is empty", ErrDataFieldMissing)
|
||||
case info.Username == "":
|
||||
return "", "", "", "", "", 0, fmt.Errorf("%w: username is empty", ErrDataFieldMissing)
|
||||
case info.Version == nil:
|
||||
return "", "", "", "", "", 0, fmt.Errorf("%w: version is missing", ErrDataFieldMissing)
|
||||
}
|
||||
|
||||
version = int(*info.Version) //nolint:gosec
|
||||
return info.Username, info.Modulus, info.ServerEphemeral, info.Salt,
|
||||
info.SRPSession, version, nil
|
||||
}
|
||||
|
||||
type cookie struct {
|
||||
uid string
|
||||
token string
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (c *cookie) String() string {
|
||||
s := ""
|
||||
if c.token != "" {
|
||||
s += fmt.Sprintf("AUTH-%s=%s; ", c.uid, c.token)
|
||||
}
|
||||
if c.sessionID != "" {
|
||||
s += fmt.Sprintf("Session-Id=%s; ", c.sessionID)
|
||||
}
|
||||
if c.token != "" {
|
||||
s += "Tag=default; iaas=W10; Domain=proton.me; Feature=VPNDashboard:A"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrServerProofNotValid indicates the M2 from the server didn't match the expected proof.
|
||||
ErrServerProofNotValid = errors.New("server proof from server is not valid")
|
||||
ErrVPNScopeNotFound = errors.New("VPN scope not found in scopes")
|
||||
ErrTwoFANotSupported = errors.New("two factor authentication not supported in this client")
|
||||
ErrAuthCookieNotFound = errors.New("auth cookie not found")
|
||||
)
|
||||
|
||||
// auth performs the SRP proof submission (and optionally TOTP) to obtain tokens.
|
||||
func (c *apiClient) auth(ctx context.Context, unauthCookie cookie,
|
||||
username, srpSession string, proofs *srp.Proofs,
|
||||
) (authCookie cookie, err error) {
|
||||
clientEphemeral := base64.StdEncoding.EncodeToString(proofs.ClientEphemeral)
|
||||
clientProof := base64.StdEncoding.EncodeToString(proofs.ClientProof)
|
||||
|
||||
type requestBodySchema struct {
|
||||
ClientEphemeral string `json:"ClientEphemeral"` // base64(A)
|
||||
ClientProof string `json:"ClientProof"` // base64(M1)
|
||||
Payload map[string]string `json:"Payload,omitempty"` // not sure
|
||||
SRPSession string `json:"SRPSession"` // hexadecimal
|
||||
Username string `json:"Username"` // user@protonmail.com
|
||||
}
|
||||
requestBody := requestBodySchema{
|
||||
ClientEphemeral: clientEphemeral,
|
||||
ClientProof: clientProof,
|
||||
SRPSession: srpSession,
|
||||
Username: username,
|
||||
}
|
||||
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
encoder := json.NewEncoder(buffer)
|
||||
if err := encoder.Encode(requestBody); err != nil {
|
||||
return cookie{}, fmt.Errorf("encoding request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth", buffer)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
c.setHeaders(request, unauthCookie)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return cookie{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("reading response body: %w", err)
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return cookie{}, buildError(response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
type twoFAStatus uint
|
||||
//nolint:unused
|
||||
const (
|
||||
twoFADisabled twoFAStatus = iota
|
||||
twoFAHasTOTP
|
||||
twoFAHasFIDO2
|
||||
twoFAHasFIDO2AndTOTP
|
||||
)
|
||||
type twoFAInfo struct {
|
||||
Enabled twoFAStatus `json:"Enabled"`
|
||||
FIDO2 struct {
|
||||
AuthenticationOptions any `json:"AuthenticationOptions"`
|
||||
RegisteredKeys []any `json:"RegisteredKeys"`
|
||||
} `json:"FIDO2"`
|
||||
TOTP uint `json:"TOTP"`
|
||||
}
|
||||
|
||||
var auth struct {
|
||||
Code uint `json:"Code"` // 1000 on success
|
||||
LocalID uint `json:"LocalID"` // 7 in my case
|
||||
Scopes []string `json:"Scopes"` // this should contain "vpn". Same as `Scope` field value.
|
||||
UID string `json:"UID"` // same as `Uid` field value
|
||||
UserID string `json:"UserID"` // base64
|
||||
EventID string `json:"EventID"` // base64
|
||||
PasswordMode uint `json:"PasswordMode"` // 1 in my case
|
||||
ServerProof string `json:"ServerProof"` // base64(M2)
|
||||
TwoFactor uint `json:"TwoFactor"` // 0 if 2FA not required
|
||||
TwoFA twoFAInfo `json:"2FA"`
|
||||
TemporaryPassword uint `json:"TemporaryPassword"` // 0 in my case
|
||||
}
|
||||
|
||||
err = json.Unmarshal(responseBody, &auth)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
m2, err := base64.StdEncoding.DecodeString(auth.ServerProof)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("decoding server proof: %w", err)
|
||||
}
|
||||
if !bytes.Equal(m2, proofs.ExpectedServerProof) {
|
||||
return cookie{}, fmt.Errorf("%w: expected %x got %x",
|
||||
ErrServerProofNotValid, proofs.ExpectedServerProof, m2)
|
||||
}
|
||||
|
||||
const successCode = 1000
|
||||
switch {
|
||||
case auth.Code != successCode:
|
||||
return cookie{}, fmt.Errorf("%w: expected %d got %d",
|
||||
ErrCodeNotSuccess, successCode, auth.Code)
|
||||
case auth.UID != unauthCookie.uid:
|
||||
return cookie{}, fmt.Errorf("%w: expected %s got %s",
|
||||
ErrUIDMismatch, unauthCookie.uid, auth.UID)
|
||||
case auth.TwoFactor != 0:
|
||||
return cookie{}, fmt.Errorf("%w", ErrTwoFANotSupported)
|
||||
case !slices.Contains(auth.Scopes, "vpn"):
|
||||
return cookie{}, fmt.Errorf("%w: in %v", ErrVPNScopeNotFound, auth.Scopes)
|
||||
}
|
||||
|
||||
for _, setCookieHeader := range response.Header.Values("Set-Cookie") {
|
||||
parts := strings.Split(setCookieHeader, ";")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "AUTH-"+unauthCookie.uid+"=") {
|
||||
authCookie = unauthCookie
|
||||
authCookie.token = strings.TrimPrefix(part, "AUTH-"+unauthCookie.uid+"=")
|
||||
return authCookie, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cookie{}, fmt.Errorf("%w: in HTTP headers %s",
|
||||
ErrAuthCookieNotFound, httpHeadersToString(response.Header))
|
||||
}
|
||||
|
||||
// generateLettersDigits mimicing Proton's own random string generator:
|
||||
// https://github.com/ProtonMail/WebClients/blob/e4d7e4ab9babe15b79a131960185f9f8275512cd/packages/utils/generateLettersDigits.ts
|
||||
func generateLettersDigits(rng *rand.ChaCha8, length uint) string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
return generateFromCharset(rng, length, charset)
|
||||
}
|
||||
|
||||
func generateFromCharset(rng *rand.ChaCha8, length uint, charset string) string {
|
||||
result := make([]byte, length)
|
||||
randomBytes := make([]byte, length)
|
||||
_, _ = rng.Read(randomBytes)
|
||||
for i := range length {
|
||||
result[i] = charset[int(randomBytes[i])%len(charset)]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func httpHeadersToString(headers http.Header) string {
|
||||
var builder strings.Builder
|
||||
first := true
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
if !first {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("%s: %s", key, value))
|
||||
first = false
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
type apiData struct {
|
||||
LogicalServers []logicalServer `json:"LogicalServers"`
|
||||
}
|
||||
|
||||
type logicalServer struct {
|
||||
Name string `json:"Name"`
|
||||
ExitCountry string `json:"ExitCountry"`
|
||||
Region *string `json:"Region"`
|
||||
City *string `json:"City"`
|
||||
Servers []physicalServer `json:"Servers"`
|
||||
Features uint16 `json:"Features"`
|
||||
Tier *uint8 `json:"Tier,omitempty"`
|
||||
}
|
||||
|
||||
type physicalServer struct {
|
||||
EntryIP netip.Addr `json:"EntryIP"`
|
||||
ExitIP netip.Addr `json:"ExitIP"`
|
||||
Domain string `json:"Domain"`
|
||||
Status uint8 `json:"Status"`
|
||||
X25519PublicKey string `json:"X25519PublicKey"`
|
||||
}
|
||||
|
||||
func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) (
|
||||
data apiData, err error,
|
||||
) {
|
||||
const url = "https://account.proton.me/api/vpn/logicals"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
c.setHeaders(request, cookie)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(response.Body)
|
||||
return data, buildError(response.StatusCode, b)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
return data, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
|
||||
func buildError(httpCode int, body []byte) error {
|
||||
prettyCode := http.StatusText(httpCode)
|
||||
var protonError struct {
|
||||
Code *int `json:"Code,omitempty"`
|
||||
Error *string `json:"Error,omitempty"`
|
||||
Details map[string]string `json:"Details"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
err := decoder.Decode(&protonError)
|
||||
if err != nil || protonError.Error == nil || protonError.Code == nil {
|
||||
return fmt.Errorf("%w: %s: %s",
|
||||
ErrHTTPStatusCodeNotOK, prettyCode, body)
|
||||
}
|
||||
|
||||
details := make([]string, 0, len(protonError.Details))
|
||||
for key, value := range protonError.Details {
|
||||
details = append(details, fmt.Sprintf("%s: %s", key, value))
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s: %s (code %d with details: %s)",
|
||||
ErrHTTPStatusCodeNotOK, prettyCode, *protonError.Error, *protonError.Code, strings.Join(details, ", "))
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package updater
|
||||
|
||||
import "strings"
|
||||
|
||||
func codeToCountry(countryCode string, countryCodes map[string]string) (
|
||||
country string, warning string,
|
||||
) {
|
||||
countryCode = strings.ToLower(countryCode)
|
||||
country, ok := countryCodes[countryCode]
|
||||
if !ok {
|
||||
warning = "unknown country code: " + countryCode
|
||||
country = countryCode
|
||||
}
|
||||
return country, warning
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants/vpn"
|
||||
"github.com/qdm12/gluetun/internal/models"
|
||||
)
|
||||
|
||||
type ipToServers map[string][2]models.Server // first server is OpenVPN, second is Wireguard.
|
||||
|
||||
type features struct {
|
||||
secureCore bool
|
||||
tor bool
|
||||
p2p bool
|
||||
stream bool
|
||||
}
|
||||
|
||||
func (its ipToServers) add(country, region, city, name, hostname, wgPubKey string,
|
||||
free bool, entryIP netip.Addr, features features,
|
||||
) {
|
||||
key := entryIP.String()
|
||||
|
||||
servers, ok := its[key]
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
|
||||
baseServer := models.Server{
|
||||
Country: country,
|
||||
Region: region,
|
||||
City: city,
|
||||
ServerName: name,
|
||||
Hostname: hostname,
|
||||
Free: free,
|
||||
SecureCore: features.secureCore,
|
||||
Tor: features.tor,
|
||||
PortForward: features.p2p,
|
||||
Stream: features.stream,
|
||||
IPs: []netip.Addr{entryIP},
|
||||
}
|
||||
openvpnServer := baseServer
|
||||
openvpnServer.VPN = vpn.OpenVPN
|
||||
openvpnServer.UDP = true
|
||||
openvpnServer.TCP = true
|
||||
servers[0] = openvpnServer
|
||||
wireguardServer := baseServer
|
||||
wireguardServer.VPN = vpn.Wireguard
|
||||
wireguardServer.WgPubKey = wgPubKey
|
||||
servers[1] = wireguardServer
|
||||
its[key] = servers
|
||||
}
|
||||
|
||||
func (its ipToServers) toServersSlice() (serversSlice []models.Server) {
|
||||
const vpnProtocols = 2
|
||||
serversSlice = make([]models.Server, 0, vpnProtocols*len(its))
|
||||
for _, servers := range its {
|
||||
serversSlice = append(serversSlice, servers[0], servers[1])
|
||||
}
|
||||
return serversSlice
|
||||
}
|
||||
|
||||
func (its ipToServers) numberOfServers() int {
|
||||
const serversPerIP = 2
|
||||
return len(its) * serversPerIP
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/constants"
|
||||
"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,
|
||||
) {
|
||||
switch {
|
||||
case u.email == "":
|
||||
return nil, fmt.Errorf("%w: email is empty", common.ErrCredentialsMissing)
|
||||
case u.password == "":
|
||||
return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing)
|
||||
}
|
||||
|
||||
apiClient, err := newAPIClient(ctx, u.client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating API client: %w", err)
|
||||
}
|
||||
|
||||
cookie, err := apiClient.authenticate(ctx, u.email, u.password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authentifying with Proton: %w", err)
|
||||
}
|
||||
|
||||
data, err := apiClient.fetchServers(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching logical servers: %w", err)
|
||||
}
|
||||
|
||||
countryCodes := constants.CountryCodes()
|
||||
|
||||
var count int
|
||||
for _, logicalServer := range data.LogicalServers {
|
||||
count += len(logicalServer.Servers)
|
||||
}
|
||||
|
||||
if count < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, count, minServers)
|
||||
}
|
||||
|
||||
ipToServer := make(ipToServers, count)
|
||||
for _, logicalServer := range data.LogicalServers {
|
||||
region := getStringValue(logicalServer.Region)
|
||||
city := getStringValue(logicalServer.City)
|
||||
// TODO v4 remove `name` field because of
|
||||
// https://github.com/qdm12/gluetun/issues/1018#issuecomment-1151750179
|
||||
name := logicalServer.Name
|
||||
|
||||
//nolint:lll
|
||||
// See https://github.com/ProtonVPN/protonvpn-nm-lib/blob/31d5f99fbc89274e4e977a11e7432c0eab5a3ef8/protonvpn_nm_lib/enums.py#L44-L49
|
||||
featuresBits := logicalServer.Features
|
||||
features := features{
|
||||
secureCore: featuresBits&1 != 0,
|
||||
tor: featuresBits&2 != 0,
|
||||
p2p: featuresBits&4 != 0,
|
||||
stream: featuresBits&8 != 0,
|
||||
// ipv6: featuresBits&16 != 0, - unused.
|
||||
}
|
||||
|
||||
//nolint:lll
|
||||
// See https://github.com/ProtonVPN/protonvpn-nm-lib/blob/31d5f99fbc89274e4e977a11e7432c0eab5a3ef8/protonvpn_nm_lib/enums.py#L56-L62
|
||||
free := false
|
||||
if logicalServer.Tier == nil {
|
||||
u.warner.Warn("tier field not set for server " + logicalServer.Name)
|
||||
} else if *logicalServer.Tier == 0 {
|
||||
free = true
|
||||
}
|
||||
|
||||
for _, physicalServer := range logicalServer.Servers {
|
||||
if physicalServer.Status == 0 { // disabled so skip server
|
||||
u.warner.Warn("ignoring server " + physicalServer.Domain + " with status 0")
|
||||
continue
|
||||
}
|
||||
|
||||
hostname := physicalServer.Domain
|
||||
entryIP := physicalServer.EntryIP
|
||||
wgPubKey := physicalServer.X25519PublicKey
|
||||
|
||||
// Note: for multi-hop use the server name or hostname
|
||||
// instead of the country
|
||||
countryCode := logicalServer.ExitCountry
|
||||
country, warning := codeToCountry(countryCode, countryCodes)
|
||||
if warning != "" {
|
||||
u.warner.Warn(warning)
|
||||
}
|
||||
|
||||
ipToServer.add(country, region, city, name, hostname, wgPubKey, free, entryIP, features)
|
||||
}
|
||||
}
|
||||
|
||||
if ipToServer.numberOfServers() < minServers {
|
||||
return nil, fmt.Errorf("%w: %d and expected at least %d",
|
||||
common.ErrNotEnoughServers, len(ipToServer), minServers)
|
||||
}
|
||||
|
||||
servers = ipToServer.toServersSlice()
|
||||
|
||||
sort.Sort(models.SortableServers(servers))
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func getStringValue(ptr *string) string {
|
||||
if ptr == nil {
|
||||
return ""
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
client *http.Client
|
||||
email string
|
||||
password string
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(client *http.Client, warner common.Warner, email, password string) *Updater {
|
||||
return &Updater{
|
||||
client: client,
|
||||
email: email,
|
||||
password: password,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// getMostRecentStableTag finds the most recent proton-account stable tag version,
|
||||
// in order to use it in the x-pm-appversion http request header. Because if we do
|
||||
// fall behind on versioning, Proton doesn't like it because they like to create
|
||||
// complications where there is no need for it. Hence this function.
|
||||
func getMostRecentStableTag(ctx context.Context, client *http.Client) (version string, err error) {
|
||||
page := 1
|
||||
regexVersion := regexp.MustCompile(`^proton-account@(\d+\.\d+\.\d+\.\d+)$`)
|
||||
for ctx.Err() == nil {
|
||||
// Define a timeout since the default client has a large timeout and we don't
|
||||
// want to wait too long.
|
||||
const timeout = 5 * time.Second
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
url := "https://api.github.com/repos/ProtonMail/WebClients/tags?per_page=30&page=" + fmt.Sprint(page)
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
request.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%w: %s: %s", ErrHTTPStatusCodeNotOK, response.Status, data)
|
||||
}
|
||||
|
||||
var tags []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err = json.Unmarshal(data, &tags)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decoding JSON response: %w", err)
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if !regexVersion.MatchString(tag.Name) {
|
||||
continue
|
||||
}
|
||||
version := "web-account@" + strings.TrimPrefix(tag.Name, "proton-account@")
|
||||
return version, nil
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w (queried %d pages)", context.Canceled, page)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user