package updaters import ( "context" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "slices" "strings" "time" "github.com/qdm12/dns/v2/pkg/doh" dnsprovider "github.com/qdm12/dns/v2/pkg/provider" "github.com/qdm12/gluetun-servers/pkg/models" publicipapi "github.com/qdm12/gluetun-servers/pkg/publicip/api" "github.com/qdm12/gluetun-servers/pkg/updaters/common" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/airvpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/cyberghost" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/expressvpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/fastestvpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/giganews" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/hidemyass" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/ipvanish" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/ivpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/mullvad" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/nordvpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/perfectprivacy" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/privado" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/privateinternetaccess" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/privatevpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/protonvpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/purevpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/slickvpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/surfshark" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/torguard" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/vpnsecure" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/vpnunlimited" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/vyprvpn" "github.com/qdm12/gluetun-servers/pkg/updaters/providers/windscribe" "github.com/qdm12/gluetun-servers/pkg/updaters/resolver" "github.com/qdm12/gluetun-servers/pkg/updaters/unzip" "github.com/qdm12/gosettings" ) // UpdateAllSettings contains the configuration for the [UpdateAll] function. type UpdateAllSettings struct { // OutputPath is the directory where the provider JSON files will be written. // It defaults to the current directory if left unset. OutputPath *string // ProtonEmail is the email for the ProtonVPN account, which is required // to update ProtonVPN servers. ProtonEmail *string // ProtonPassword is the password for the ProtonVPN account, which is required // to update ProtonVPN servers. ProtonPassword *string // IpinfoToken is the API token for the IPInfo public IP API. // If not provided, the IP fetcher will still work but may be // subject to stricter rate limits for ipinfo.io. IpinfoToken *string // MinServers is a map of provider name to minimum number of servers required for a successful update. // If a provider has fewer servers than the specified minimum, the update will be considered a failure // for that provider. If the provider name is not found in this map, it is assumed that there is no // minimum server requirement for that provider. MinServers map[string]uint } func (s *UpdateAllSettings) setDefaults() { s.OutputPath = gosettings.DefaultPointer(s.OutputPath, "") s.ProtonEmail = gosettings.DefaultPointer(s.ProtonEmail, "") s.ProtonPassword = gosettings.DefaultPointer(s.ProtonPassword, "") s.IpinfoToken = gosettings.DefaultPointer(s.IpinfoToken, "") if s.MinServers == nil { s.MinServers = make(map[string]uint) } } var ( errProtonEmailRequired = errors.New("proton email is required for updating ProtonVPN servers") errProtonPasswordRequired = errors.New("proton password is required for updating ProtonVPN servers") errMinServersProviderNotFound = errors.New("provider name in MinServers not found in list of all providers") ) func (s *UpdateAllSettings) validate() error { switch { case *s.ProtonEmail == "": return fmt.Errorf("%w", errProtonEmailRequired) case *s.ProtonPassword == "": return fmt.Errorf("%w", errProtonPasswordRequired) } allProviders := ListAllNames() for providerName := range s.MinServers { if !slices.Contains(allProviders, providerName) { return fmt.Errorf("%w: %s", errMinServersProviderNotFound, providerName) } } return nil } // ListAllNames returns a list of all provider names supported by [UpdateAll]. // It is exported notably to validate from the gluetun repository if it matches // the list of providers in the internal/constants/providers package. func ListAllNames() []string { fetchers := [...]interface { Name() string }{ &airvpn.Updater{}, &cyberghost.Updater{}, &expressvpn.Updater{}, &fastestvpn.Updater{}, &giganews.Updater{}, &hidemyass.Updater{}, &ipvanish.Updater{}, &ivpn.Updater{}, &mullvad.Updater{}, &nordvpn.Updater{}, &perfectprivacy.Updater{}, &privado.Updater{}, &privateinternetaccess.Updater{}, &privatevpn.Updater{}, &protonvpn.Updater{}, &purevpn.Updater{}, &slickvpn.Updater{}, &surfshark.Updater{}, &torguard.Updater{}, &vpnsecure.Updater{}, &vpnunlimited.Updater{}, &vyprvpn.Updater{}, &windscribe.Updater{}, } names := make([]string, len(fetchers)) for i, fetcher := range fetchers { names[i] = fetcher.Name() } return names } type Logger interface { Warn(message string) } var errUpdateAllFailed = errors.New("update failed for one or more providers") // UpdateAll fetches server data for all providers in parallel and writes each // provider's data as .json into the output path specified. // Errors for individual providers are collected and returned as a combined error. func UpdateAll(ctx context.Context, client *http.Client, logger Logger, settings UpdateAllSettings) error { settings.setDefaults() err := settings.validate() if err != nil { return fmt.Errorf("validating settings: %w", err) } const permission = 0o755 err = os.MkdirAll(*settings.OutputPath, permission) if err != nil { return fmt.Errorf("creating output directory: %w", err) } dohDialer, err := doh.New(doh.Settings{ UpstreamResolvers: []dnsprovider.Provider{ dnsprovider.Cloudflare(), dnsprovider.Google(), }, }) if err != nil { return fmt.Errorf("creating updater DoH dialer: %w", err) } parallelResolver := resolver.NewParallelResolver(dohDialer) unzipper := unzip.New(client) ipFetcher, err := buildIPFetcher(client, logger, *settings.IpinfoToken) if err != nil { return fmt.Errorf("creating IP fetcher: %w", err) } fetchers := buildFetchers(client, parallelResolver, unzipper, ipFetcher, logger, *settings.ProtonEmail, *settings.ProtonPassword) results := make(chan error) allProviders := ListAllNames() for _, providerName := range allProviders { go func(providerName string) { fetcher := fetchers[providerName] minServers := settings.MinServers[providerName] err := fetchAndWrite(ctx, providerName, fetcher, minServers, *settings.OutputPath) if err != nil { err = fmt.Errorf("provider %s: %w", providerName, err) } results <- err }(providerName) } errs := make([]string, 0, len(allProviders)) for range allProviders { err := <-results if err != nil { errs = append(errs, err.Error()) } } if len(errs) > 0 { return fmt.Errorf("%w:\n%s", errUpdateAllFailed, strings.Join(errs, "\n")) } return nil } func buildIPFetcher(client *http.Client, logger Logger, ipinfoToken string) ( fetcher *publicipapi.ResilientFetcher, err error, ) { nameTokenPairs := []publicipapi.NameToken{ {Name: string(publicipapi.IPInfo), Token: ipinfoToken}, {Name: string(publicipapi.IP2Location)}, {Name: string(publicipapi.IfConfigCo)}, } fetchers, err := publicipapi.New(nameTokenPairs, client) if err != nil { return nil, fmt.Errorf("creating public IP fetchers: %w", err) } fetcher = publicipapi.NewResilient(fetchers, logger) return fetcher, nil } func buildFetchers(client *http.Client, parallelResolver common.ParallelResolver, unzipper common.Unzipper, ipFetcher common.IPFetcher, logger Logger, protonEmail, protonPassword string, ) map[string]common.Fetcher { fetchers := [...]common.Fetcher{ airvpn.New(client), cyberghost.New(parallelResolver, logger), expressvpn.New(unzipper, logger, parallelResolver), fastestvpn.New(client, logger, parallelResolver), giganews.New(unzipper, logger, parallelResolver), hidemyass.New(client, logger, parallelResolver), ipvanish.New(unzipper, logger, parallelResolver), ivpn.New(client, logger, parallelResolver), mullvad.New(client), nordvpn.New(client, logger), perfectprivacy.New(unzipper, logger), privado.New(client, logger), privateinternetaccess.New(client), privatevpn.New(unzipper, logger, parallelResolver), protonvpn.New(client, logger, protonEmail, protonPassword), purevpn.New(ipFetcher, unzipper, logger, parallelResolver), slickvpn.New(client, logger, parallelResolver), surfshark.New(client, unzipper, logger, parallelResolver), torguard.New(unzipper, logger, parallelResolver), vpnsecure.New(client, logger, parallelResolver), vpnunlimited.New(unzipper, logger, parallelResolver), vyprvpn.New(unzipper, logger, parallelResolver), windscribe.New(client, logger), } nameToFetcher := make(map[string]common.Fetcher, len(fetchers)) for _, fetcher := range fetchers { nameToFetcher[fetcher.Name()] = fetcher } return nameToFetcher } func fetchAndWrite(ctx context.Context, providerName string, fetcher common.Fetcher, minServers uint, outputDirPath string, ) error { filename := strings.ToLower(strings.ReplaceAll(providerName, " ", "")) + ".json" destinationPath := filepath.Join(outputDirPath, filename) const permission = 0o644 file, err := os.OpenFile(destinationPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, permission) if err != nil { return fmt.Errorf("opening output file: %w", err) } servers, err := fetcher.FetchServers(ctx, int(minServers)) //nolint:gosec if err != nil { _ = file.Close() _ = os.Remove(destinationPath) return fmt.Errorf("fetching servers: %w", err) } data := models.Servers{ Version: fetcher.Version(), Timestamp: time.Now().Unix(), Servers: servers, } encoder := json.NewEncoder(file) encoder.SetIndent("", " ") err = encoder.Encode(data) if err != nil { _ = file.Close() return fmt.Errorf("encoding servers to JSON: %w", err) } err = file.Close() if err != nil { return fmt.Errorf("closing output file: %w", err) } return nil }