chore(updater): move updater packages to pkg/updaters/<name>

This commit is contained in:
Quentin McGaw
2026-04-23 03:47:57 +00:00
parent 628b0a22e2
commit d96752c734
164 changed files with 732 additions and 343 deletions
+2 -2
View File
@@ -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),
}
}
-66
View File
@@ -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
}
-101
View File
@@ -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,
}
}
+1
View File
@@ -18,6 +18,7 @@ var (
type Fetcher interface {
FetchServers(ctx context.Context, minServers int) (servers []models.Server, err error)
Version() uint16
}
type ParallelResolver interface {
+2 -2
View File
@@ -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,
}
}
+2 -2
View File
@@ -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),
}
}
-60
View File
@@ -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,
}
}
+2 -2
View File
@@ -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,
}
}
+2 -2
View File
@@ -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),
}
}
-128
View File
@@ -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,
}
}
+2 -2
View File
@@ -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,
}
}
+2 -2
View File
@@ -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
}
}
+2 -2
View File
@@ -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,
}
}
+2 -2
View File
@@ -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),
}
}
-63
View File
@@ -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)
}
})
}
}
-28
View File
@@ -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)
}
-110
View File
@@ -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)
}
})
}
}
-23
View File
@@ -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,
}
}
+2 -2
View File
@@ -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),
}
}
-58
View File
@@ -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
}
-32
View File
@@ -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,
}
}
+2 -2
View File
@@ -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),
}
}
-44
View File
@@ -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
}
-199
View File
@@ -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)
}
-29
View File
@@ -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,
}
}
+2 -2
View File
@@ -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,
}
}
+2 -2
View File
@@ -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,
}
}
+2 -2
View File
@@ -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,
}
}
+2 -2
View File
@@ -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),
}
}
-638
View File
@@ -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