Files
gluetun/gluetun-servers/pkg/updaters/updateall.go
T
Quentin McGaw d9cc7dcffb refactor(storage): new storage file structure
- new directory structure containing manifest.json and one json file per provider, by default.
- the manifest.json file can specify a filepath for each vpn provider
- each vpn provider json data file can contain the `"preferred": true` field to enforce it is used even if outdated, unless there is a version mismatch
- `STORAGE_SERVERS_DIRECTORY_PATH` replaces `STORAGE_FILEPATH` (which is now a migration source only). It sets the directory where server manifest and per-provider JSON files are stored (default: `/gluetun/servers/`).
- First-run migration: On startup, gluetun checks for the old /gluetun/servers.json file; if found and no new manifest exists, it automatically migrates all data to /gluetun/servers/ directory structure
- Silent fallback: If legacy file isn't found, uses the new directory path normally
- Legacy cleanup: After successful migration, attempts to remove the old fat JSON file (logs warning only if removal fails, e.g., read-only bind mounts)
2026-05-11 04:53:04 +00:00

301 lines
10 KiB
Go

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 <provider-name>.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
}