mirror of
https://github.com/qdm12/gluetun.git
synced 2026-07-23 19:06:26 +02:00
d9cc7dcffb
- 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)
301 lines
10 KiB
Go
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
|
|
}
|