Updater loop with period and http route (#240)
* Updater loop with period and http route * Using DNS over TLS to update servers * Better logging * Remove goroutines for cyberghost updater * Respects context for servers update (quite slow overall) * Increase shutdown grace period to 5 seconds * Update announcement * Add log lines for each provider update start
This commit is contained in:
@@ -260,6 +260,7 @@ That one is important if you want to connect to the container from your LAN for
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `PUBLICIP_PERIOD` | `12h` | Valid duration | Period to check for public IP address. Set to `0` to disable. |
|
| `PUBLICIP_PERIOD` | `12h` | Valid duration | Period to check for public IP address. Set to `0` to disable. |
|
||||||
| `VERSION_INFORMATION` | `on` | `on`, `off` | Logs a message indicating if a newer version is available once the VPN is connected |
|
| `VERSION_INFORMATION` | `on` | `on`, `off` | Logs a message indicating if a newer version is available once the VPN is connected |
|
||||||
|
| `UPDATER_PERIOD` | `0` | Valid duration string such as `24h` | Period to update all VPN servers information in memory and to /gluetun/servers.json. Set to `0` to disable. This does a burst of DNS over TLS requests, which may be blocked if you set `BLOCK_MALICIOUS=on` for example. |
|
||||||
|
|
||||||
## Connect to it
|
## Connect to it
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/qdm12/gluetun/internal/shadowsocks"
|
"github.com/qdm12/gluetun/internal/shadowsocks"
|
||||||
"github.com/qdm12/gluetun/internal/storage"
|
"github.com/qdm12/gluetun/internal/storage"
|
||||||
"github.com/qdm12/gluetun/internal/tinyproxy"
|
"github.com/qdm12/gluetun/internal/tinyproxy"
|
||||||
|
"github.com/qdm12/gluetun/internal/updater"
|
||||||
versionpkg "github.com/qdm12/gluetun/internal/version"
|
versionpkg "github.com/qdm12/gluetun/internal/version"
|
||||||
"github.com/qdm12/golibs/command"
|
"github.com/qdm12/golibs/command"
|
||||||
"github.com/qdm12/golibs/files"
|
"github.com/qdm12/golibs/files"
|
||||||
@@ -70,6 +71,7 @@ func _main(background context.Context, args []string) int { //nolint:gocognit,go
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
logger := createLogger()
|
logger := createLogger()
|
||||||
|
|
||||||
|
httpClient := &http.Client{Timeout: 15 * time.Second}
|
||||||
client := network.NewClient(15 * time.Second)
|
client := network.NewClient(15 * time.Second)
|
||||||
// Create configurators
|
// Create configurators
|
||||||
fileManager := files.NewFileManager()
|
fileManager := files.NewFileManager()
|
||||||
@@ -195,6 +197,12 @@ func _main(background context.Context, args []string) int { //nolint:gocognit,go
|
|||||||
// wait for restartOpenvpn
|
// wait for restartOpenvpn
|
||||||
go openvpnLooper.Run(ctx, wg)
|
go openvpnLooper.Run(ctx, wg)
|
||||||
|
|
||||||
|
updaterOptions := updater.NewOptions("127.0.0.1")
|
||||||
|
updaterLooper := updater.NewLooper(updaterOptions, allSettings.UpdaterPeriod, allServers, storage, openvpnLooper.SetAllServers, httpClient, logger)
|
||||||
|
wg.Add(1)
|
||||||
|
// wait for updaterLooper.Restart() or its ticket launched with RunRestartTicker
|
||||||
|
go updaterLooper.Run(ctx, wg)
|
||||||
|
|
||||||
unboundLooper := dns.NewLooper(dnsConf, allSettings.DNS, logger, streamMerger, uid, gid)
|
unboundLooper := dns.NewLooper(dnsConf, allSettings.DNS, logger, streamMerger, uid, gid)
|
||||||
restartUnbound := unboundLooper.Restart
|
restartUnbound := unboundLooper.Restart
|
||||||
// wait for restartUnbound
|
// wait for restartUnbound
|
||||||
@@ -226,8 +234,7 @@ func _main(background context.Context, args []string) int { //nolint:gocognit,go
|
|||||||
if !allSettings.VersionInformation {
|
if !allSettings.VersionInformation {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
message, err := versionpkg.GetMessage(version, commit, httpClient)
|
||||||
message, err := versionpkg.GetMessage(version, commit, client)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
return
|
return
|
||||||
@@ -246,12 +253,14 @@ func _main(background context.Context, args []string) int { //nolint:gocognit,go
|
|||||||
restartTickerCancel()
|
restartTickerCancel()
|
||||||
restartTickerContext, restartTickerCancel = context.WithCancel(ctx)
|
restartTickerContext, restartTickerCancel = context.WithCancel(ctx)
|
||||||
go unboundLooper.RunRestartTicker(restartTickerContext)
|
go unboundLooper.RunRestartTicker(restartTickerContext)
|
||||||
|
go updaterLooper.RunRestartTicker(ctx)
|
||||||
onConnected(allSettings, logger, routingConf, portForward, restartUnbound, restartPublicIP, versionInformation)
|
onConnected(allSettings, logger, routingConf, portForward, restartUnbound, restartPublicIP, versionInformation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
httpServer := server.New("0.0.0.0:8000", logger, restartOpenvpn, restartUnbound, getOpenvpnSettings, getPortForwarded)
|
httpServer := server.New("0.0.0.0:8000", logger, restartOpenvpn, restartUnbound, updaterLooper.Restart,
|
||||||
|
getOpenvpnSettings, getPortForwarded)
|
||||||
go httpServer.Run(ctx, wg)
|
go httpServer.Run(ctx, wg)
|
||||||
|
|
||||||
// Start openvpn for the first time
|
// Start openvpn for the first time
|
||||||
@@ -283,7 +292,8 @@ func _main(background context.Context, args []string) int { //nolint:gocognit,go
|
|||||||
shutdownErrorsCount++
|
shutdownErrorsCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
waiting, waited := context.WithTimeout(context.Background(), time.Second)
|
const shutdownGracePeriod = 5 * time.Second
|
||||||
|
waiting, waited := context.WithTimeout(context.Background(), shutdownGracePeriod)
|
||||||
go func() {
|
go func() {
|
||||||
defer waited()
|
defer waited()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -90,9 +90,10 @@ func OpenvpnConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Update(args []string) error {
|
func Update(args []string) error {
|
||||||
var options updater.Options
|
options := updater.Options{CLI: true}
|
||||||
|
var flushToFile bool
|
||||||
flagSet := flag.NewFlagSet("update", flag.ExitOnError)
|
flagSet := flag.NewFlagSet("update", flag.ExitOnError)
|
||||||
flagSet.BoolVar(&options.File, "file", false, "Write results to /gluetun/servers.json (for end users)")
|
flagSet.BoolVar(&flushToFile, "file", true, "Write results to /gluetun/servers.json (for end users)")
|
||||||
flagSet.BoolVar(&options.Stdout, "stdout", false, "Write results to console to modify the program (for maintainers)")
|
flagSet.BoolVar(&options.Stdout, "stdout", false, "Write results to console to modify the program (for maintainers)")
|
||||||
flagSet.StringVar(&options.DNSAddress, "dns", "1.1.1.1", "DNS resolver address to use")
|
flagSet.StringVar(&options.DNSAddress, "dns", "1.1.1.1", "DNS resolver address to use")
|
||||||
flagSet.BoolVar(&options.Cyberghost, "cyberghost", false, "Update Cyberghost servers")
|
flagSet.BoolVar(&options.Cyberghost, "cyberghost", false, "Update Cyberghost servers")
|
||||||
@@ -110,15 +111,27 @@ func Update(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !options.File && !options.Stdout {
|
if !flushToFile && !options.Stdout {
|
||||||
return fmt.Errorf("at least one of -file or -stdout must be specified")
|
return fmt.Errorf("at least one of -file or -stdout must be specified")
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
storage := storage.New(logger)
|
storage := storage.New(logger)
|
||||||
updater := updater.New(options, storage, httpClient)
|
const writeSync = false
|
||||||
if err := updater.UpdateServers(ctx); err != nil {
|
currentServers, err := storage.SyncServers(constants.GetAllServers(), writeSync)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot update servers: %w", err)
|
||||||
|
}
|
||||||
|
updater := updater.New(options, httpClient, currentServers, logger)
|
||||||
|
allServers, err := updater.UpdateServers(ctx)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if flushToFile {
|
||||||
|
if err := storage.FlushToFile(allServers); err != nil {
|
||||||
|
return fmt.Errorf("cannot update servers: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package constants
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Announcement is a message announcement
|
// Announcement is a message announcement
|
||||||
Announcement = "Persistent server IP addresses at /gluetun/servers.json, please BIND MOUNT"
|
Announcement = "Update servers information see https://github.com/qdm12/gluetun/wiki/Update-servers-information"
|
||||||
// AnnouncementExpiration is the expiration date of the announcement in format yyyy-mm-dd
|
// AnnouncementExpiration is the expiration date of the announcement in format yyyy-mm-dd
|
||||||
AnnouncementExpiration = "2020-09-30"
|
AnnouncementExpiration = "2020-10-10"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Looper interface {
|
|||||||
GetSettings() (settings settings.OpenVPN)
|
GetSettings() (settings settings.OpenVPN)
|
||||||
SetSettings(settings settings.OpenVPN)
|
SetSettings(settings settings.OpenVPN)
|
||||||
GetPortForwarded() (portForwarded uint16)
|
GetPortForwarded() (portForwarded uint16)
|
||||||
|
SetAllServers(allServers models.AllServers)
|
||||||
}
|
}
|
||||||
|
|
||||||
type looper struct {
|
type looper struct {
|
||||||
@@ -33,10 +34,11 @@ type looper struct {
|
|||||||
settingsMutex sync.RWMutex
|
settingsMutex sync.RWMutex
|
||||||
portForwarded uint16
|
portForwarded uint16
|
||||||
portForwardedMutex sync.RWMutex
|
portForwardedMutex sync.RWMutex
|
||||||
|
allServers models.AllServers
|
||||||
|
allServersMutex sync.RWMutex
|
||||||
// Fixed parameters
|
// Fixed parameters
|
||||||
uid int
|
uid int
|
||||||
gid int
|
gid int
|
||||||
allServers models.AllServers
|
|
||||||
// Configurators
|
// Configurators
|
||||||
conf Configurator
|
conf Configurator
|
||||||
fw firewall.Configurator
|
fw firewall.Configurator
|
||||||
@@ -89,6 +91,12 @@ func (l *looper) SetSettings(settings settings.OpenVPN) {
|
|||||||
l.settings = settings
|
l.settings = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *looper) SetAllServers(allServers models.AllServers) {
|
||||||
|
l.allServersMutex.Lock()
|
||||||
|
defer l.allServersMutex.Unlock()
|
||||||
|
l.allServers = allServers
|
||||||
|
}
|
||||||
|
|
||||||
func (l *looper) Run(ctx context.Context, wg *sync.WaitGroup) {
|
func (l *looper) Run(ctx context.Context, wg *sync.WaitGroup) {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -101,7 +109,9 @@ func (l *looper) Run(ctx context.Context, wg *sync.WaitGroup) {
|
|||||||
|
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
settings := l.GetSettings()
|
settings := l.GetSettings()
|
||||||
|
l.allServersMutex.RLock()
|
||||||
providerConf := provider.New(l.provider, l.allServers)
|
providerConf := provider.New(l.provider, l.allServers)
|
||||||
|
l.allServersMutex.RUnlock()
|
||||||
connections, err := providerConf.GetOpenVPNConnections(settings.Provider.ServerSelection)
|
connections, err := providerConf.GetOpenVPNConnections(settings.Provider.ServerSelection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.logger.Error(err)
|
l.logger.Error(err)
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ type Reader interface {
|
|||||||
GetPublicIPPeriod() (period time.Duration, err error)
|
GetPublicIPPeriod() (period time.Duration, err error)
|
||||||
|
|
||||||
GetVersionInformation() (enabled bool, err error)
|
GetVersionInformation() (enabled bool, err error)
|
||||||
|
|
||||||
|
GetUpdaterPeriod() (period time.Duration, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type reader struct {
|
type reader struct {
|
||||||
|
|||||||
17
internal/params/updater.go
Normal file
17
internal/params/updater.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package params
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libparams "github.com/qdm12/golibs/params"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetUpdaterPeriod obtains the period to fetch the servers information when the tunnel is up.
|
||||||
|
// Set to 0 to disable
|
||||||
|
func (r *reader) GetUpdaterPeriod() (period time.Duration, err error) {
|
||||||
|
s, err := r.envParams.GetEnv("UPDATER_PERIOD", libparams.Default("0"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return time.ParseDuration(s)
|
||||||
|
}
|
||||||
@@ -21,18 +21,20 @@ type server struct {
|
|||||||
logger logging.Logger
|
logger logging.Logger
|
||||||
restartOpenvpn func()
|
restartOpenvpn func()
|
||||||
restartUnbound func()
|
restartUnbound func()
|
||||||
|
restartUpdater func()
|
||||||
getOpenvpnSettings func() settings.OpenVPN
|
getOpenvpnSettings func() settings.OpenVPN
|
||||||
getPortForwarded func() uint16
|
getPortForwarded func() uint16
|
||||||
lookupIP func(host string) ([]net.IP, error)
|
lookupIP func(host string) ([]net.IP, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(address string, logger logging.Logger, restartOpenvpn, restartUnbound func(),
|
func New(address string, logger logging.Logger, restartOpenvpn, restartUnbound, restartUpdater func(),
|
||||||
getOpenvpnSettings func() settings.OpenVPN, getPortForwarded func() uint16) Server {
|
getOpenvpnSettings func() settings.OpenVPN, getPortForwarded func() uint16) Server {
|
||||||
return &server{
|
return &server{
|
||||||
address: address,
|
address: address,
|
||||||
logger: logger.WithPrefix("http server: "),
|
logger: logger.WithPrefix("http server: "),
|
||||||
restartOpenvpn: restartOpenvpn,
|
restartOpenvpn: restartOpenvpn,
|
||||||
restartUnbound: restartUnbound,
|
restartUnbound: restartUnbound,
|
||||||
|
restartUpdater: restartUpdater,
|
||||||
getOpenvpnSettings: getOpenvpnSettings,
|
getOpenvpnSettings: getOpenvpnSettings,
|
||||||
getPortForwarded: getPortForwarded,
|
getPortForwarded: getPortForwarded,
|
||||||
lookupIP: net.LookupIP,
|
lookupIP: net.LookupIP,
|
||||||
@@ -76,6 +78,8 @@ func (s *server) makeHandler() http.HandlerFunc {
|
|||||||
s.handleGetOpenvpnSettings(w)
|
s.handleGetOpenvpnSettings(w)
|
||||||
case "/health":
|
case "/health":
|
||||||
s.handleHealth(w)
|
s.handleHealth(w)
|
||||||
|
case "/updater/restart":
|
||||||
|
s.restartUpdater()
|
||||||
default:
|
default:
|
||||||
routeDoesNotExist(s.logger, w, r)
|
routeDoesNotExist(s.logger, w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package settings
|
package settings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ type Settings struct {
|
|||||||
TinyProxy TinyProxy
|
TinyProxy TinyProxy
|
||||||
ShadowSocks ShadowSocks
|
ShadowSocks ShadowSocks
|
||||||
PublicIPPeriod time.Duration
|
PublicIPPeriod time.Duration
|
||||||
|
UpdaterPeriod time.Duration
|
||||||
VersionInformation bool
|
VersionInformation bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +33,10 @@ func (s *Settings) String() string {
|
|||||||
if s.VersionInformation {
|
if s.VersionInformation {
|
||||||
versionInformation = enabled
|
versionInformation = enabled
|
||||||
}
|
}
|
||||||
|
updaterLine := "Updater: disabled"
|
||||||
|
if s.UpdaterPeriod > 0 {
|
||||||
|
updaterLine = fmt.Sprintf("Updater period: %s", s.UpdaterPeriod)
|
||||||
|
}
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
"Settings summary below:",
|
"Settings summary below:",
|
||||||
s.OpenVPN.String(),
|
s.OpenVPN.String(),
|
||||||
@@ -39,8 +45,9 @@ func (s *Settings) String() string {
|
|||||||
s.Firewall.String(),
|
s.Firewall.String(),
|
||||||
s.TinyProxy.String(),
|
s.TinyProxy.String(),
|
||||||
s.ShadowSocks.String(),
|
s.ShadowSocks.String(),
|
||||||
"Public IP check period: " + s.PublicIPPeriod.String(),
|
"Public IP check period: " + s.PublicIPPeriod.String(), // TODO print disabled if 0
|
||||||
"Version information: " + versionInformation,
|
"Version information: " + versionInformation,
|
||||||
|
updaterLine,
|
||||||
"", // new line at the end
|
"", // new line at the end
|
||||||
}, "\n")
|
}, "\n")
|
||||||
}
|
}
|
||||||
@@ -84,5 +91,9 @@ func GetAllSettings(paramsReader params.Reader) (settings Settings, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return settings, err
|
return settings, err
|
||||||
}
|
}
|
||||||
|
settings.UpdaterPeriod, err = paramsReader.GetUpdaterPeriod()
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,53 +8,46 @@ import (
|
|||||||
"github.com/qdm12/gluetun/internal/models"
|
"github.com/qdm12/gluetun/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (u *updater) updateCyberghost(ctx context.Context) {
|
func (u *updater) updateCyberghost(ctx context.Context) (err error) {
|
||||||
servers := findCyberghostServers(ctx, u.lookupIP)
|
servers, err := findCyberghostServers(ctx, u.lookupIP)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if u.options.Stdout {
|
if u.options.Stdout {
|
||||||
u.println(stringifyCyberghostServers(servers))
|
u.println(stringifyCyberghostServers(servers))
|
||||||
}
|
}
|
||||||
u.servers.Cyberghost.Timestamp = u.timeNow().Unix()
|
u.servers.Cyberghost.Timestamp = u.timeNow().Unix()
|
||||||
u.servers.Cyberghost.Servers = servers
|
u.servers.Cyberghost.Servers = servers
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findCyberghostServers(ctx context.Context, lookupIP lookupIPFunc) (servers []models.CyberghostServer) {
|
func findCyberghostServers(ctx context.Context, lookupIP lookupIPFunc) (servers []models.CyberghostServer, err error) {
|
||||||
groups := getCyberghostGroups()
|
groups := getCyberghostGroups()
|
||||||
allCountryCodes := getCountryCodes()
|
allCountryCodes := getCountryCodes()
|
||||||
cyberghostCountryCodes := getCyberghostSubdomainToRegion()
|
cyberghostCountryCodes := getCyberghostSubdomainToRegion()
|
||||||
possibleCountryCodes := mergeCountryCodes(cyberghostCountryCodes, allCountryCodes)
|
possibleCountryCodes := mergeCountryCodes(cyberghostCountryCodes, allCountryCodes)
|
||||||
|
|
||||||
resultsChannel := make(chan models.CyberghostServer)
|
|
||||||
const maxGoroutines = 10
|
|
||||||
guard := make(chan struct{}, maxGoroutines)
|
|
||||||
for groupID, groupName := range groups {
|
for groupID, groupName := range groups {
|
||||||
for countryCode, region := range possibleCountryCodes {
|
for countryCode, region := range possibleCountryCodes {
|
||||||
go func(groupName, groupID, region, countryCode string) {
|
if err := ctx.Err(); err != nil {
|
||||||
host := fmt.Sprintf("%s-%s.cg-dialup.net", groupID, countryCode)
|
return nil, err
|
||||||
guard <- struct{}{}
|
}
|
||||||
IPs, err := resolveRepeat(ctx, lookupIP, host, 2)
|
host := fmt.Sprintf("%s-%s.cg-dialup.net", groupID, countryCode)
|
||||||
if err != nil {
|
IPs, err := resolveRepeat(ctx, lookupIP, host, 2)
|
||||||
IPs = nil
|
if err != nil || len(IPs) == 0 {
|
||||||
}
|
continue
|
||||||
<-guard
|
}
|
||||||
resultsChannel <- models.CyberghostServer{
|
servers = append(servers, models.CyberghostServer{
|
||||||
Region: region,
|
Region: region,
|
||||||
Group: groupName,
|
Group: groupName,
|
||||||
IPs: IPs,
|
IPs: IPs,
|
||||||
}
|
})
|
||||||
}(groupName, groupID, region, countryCode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := 0; i < len(groups)*len(possibleCountryCodes); i++ {
|
|
||||||
server := <-resultsChannel
|
|
||||||
if server.IPs == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
servers = append(servers, server)
|
|
||||||
}
|
|
||||||
sort.Slice(servers, func(i, j int) bool {
|
sort.Slice(servers, func(i, j int) bool {
|
||||||
return servers[i].Region < servers[j].Region
|
return servers[i].Region < servers[j].Region
|
||||||
})
|
})
|
||||||
return servers
|
return servers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:goconst
|
//nolint:goconst
|
||||||
|
|||||||
146
internal/updater/loop.go
Normal file
146
internal/updater/loop.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/qdm12/gluetun/internal/models"
|
||||||
|
"github.com/qdm12/gluetun/internal/storage"
|
||||||
|
"github.com/qdm12/golibs/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Looper interface {
|
||||||
|
Run(ctx context.Context, wg *sync.WaitGroup)
|
||||||
|
RunRestartTicker(ctx context.Context)
|
||||||
|
Restart()
|
||||||
|
Stop()
|
||||||
|
GetPeriod() (period time.Duration)
|
||||||
|
SetPeriod(period time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
type looper struct {
|
||||||
|
period time.Duration
|
||||||
|
periodMutex sync.RWMutex
|
||||||
|
updater Updater
|
||||||
|
storage storage.Storage
|
||||||
|
setAllServers func(allServers models.AllServers)
|
||||||
|
logger logging.Logger
|
||||||
|
restart chan struct{}
|
||||||
|
stop chan struct{}
|
||||||
|
updateTicker chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLooper(options Options, period time.Duration, currentServers models.AllServers,
|
||||||
|
storage storage.Storage, setAllServers func(allServers models.AllServers),
|
||||||
|
client *http.Client, logger logging.Logger) Looper {
|
||||||
|
loggerWithPrefix := logger.WithPrefix("updater: ")
|
||||||
|
return &looper{
|
||||||
|
period: period,
|
||||||
|
updater: New(options, client, currentServers, loggerWithPrefix),
|
||||||
|
storage: storage,
|
||||||
|
setAllServers: setAllServers,
|
||||||
|
logger: loggerWithPrefix,
|
||||||
|
restart: make(chan struct{}),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
updateTicker: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *looper) Restart() { l.restart <- struct{}{} }
|
||||||
|
func (l *looper) Stop() { l.stop <- struct{}{} }
|
||||||
|
|
||||||
|
func (l *looper) GetPeriod() (period time.Duration) {
|
||||||
|
l.periodMutex.RLock()
|
||||||
|
defer l.periodMutex.RUnlock()
|
||||||
|
return l.period
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *looper) SetPeriod(period time.Duration) {
|
||||||
|
l.periodMutex.Lock()
|
||||||
|
l.period = period
|
||||||
|
l.periodMutex.Unlock()
|
||||||
|
l.updateTicker <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *looper) logAndWait(ctx context.Context, err error) {
|
||||||
|
l.logger.Error(err)
|
||||||
|
l.logger.Info("retrying in 5 minutes")
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||||
|
defer cancel() // just for the linter
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *looper) Run(ctx context.Context, wg *sync.WaitGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case <-l.restart:
|
||||||
|
l.logger.Info("starting...")
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer l.logger.Warn("loop exited")
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
for !enabled {
|
||||||
|
// wait for a signal to re-enable
|
||||||
|
select {
|
||||||
|
case <-l.stop:
|
||||||
|
l.logger.Info("already disabled")
|
||||||
|
case <-l.restart:
|
||||||
|
enabled = true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled and has a period set
|
||||||
|
|
||||||
|
servers, err := l.updater.UpdateServers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.logAndWait(ctx, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l.setAllServers(servers)
|
||||||
|
if err := l.storage.FlushToFile(servers); err != nil {
|
||||||
|
l.logger.Error(err)
|
||||||
|
}
|
||||||
|
l.logger.Info("Updated servers information")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-l.restart: // triggered restart
|
||||||
|
case <-l.stop:
|
||||||
|
enabled = false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *looper) RunRestartTicker(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
period := l.GetPeriod()
|
||||||
|
if period > 0 {
|
||||||
|
ticker = time.NewTicker(period)
|
||||||
|
} else {
|
||||||
|
ticker.Stop()
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
ticker.Stop()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
l.restart <- struct{}{}
|
||||||
|
case <-l.updateTicker:
|
||||||
|
ticker.Stop()
|
||||||
|
ticker = time.NewTicker(l.GetPeriod())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ import (
|
|||||||
|
|
||||||
func (u *updater) updateNordvpn() (err error) {
|
func (u *updater) updateNordvpn() (err error) {
|
||||||
servers, warnings, err := findNordvpnServers(u.httpGet)
|
servers, warnings, err := findNordvpnServers(u.httpGet)
|
||||||
for _, warning := range warnings {
|
if u.options.CLI {
|
||||||
u.println(warning)
|
for _, warning := range warnings {
|
||||||
|
u.logger.Warn("Nordvpn: %s", warning)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot update Nordvpn servers: %w", err)
|
return fmt.Errorf("cannot update Nordvpn servers: %w", err)
|
||||||
|
|||||||
@@ -10,7 +10,24 @@ type Options struct {
|
|||||||
Surfshark bool
|
Surfshark bool
|
||||||
Vyprvpn bool
|
Vyprvpn bool
|
||||||
Windscribe bool
|
Windscribe bool
|
||||||
File bool // update JSON file (user side)
|
Stdout bool // in order to update constants file (maintainer side)
|
||||||
Stdout bool // update constants file (maintainer side)
|
CLI bool
|
||||||
DNSAddress string
|
DNSAddress string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewOptions(dnsAddress string) Options {
|
||||||
|
return Options{
|
||||||
|
Cyberghost: true,
|
||||||
|
Mullvad: true,
|
||||||
|
Nordvpn: true,
|
||||||
|
PIA: true,
|
||||||
|
PIAold: true,
|
||||||
|
Purevpn: true,
|
||||||
|
Surfshark: true,
|
||||||
|
Vyprvpn: true,
|
||||||
|
Windscribe: true,
|
||||||
|
Stdout: false,
|
||||||
|
CLI: false,
|
||||||
|
DNSAddress: dnsAddress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ func (u *updater) updatePIAOld(ctx context.Context) (err error) {
|
|||||||
}
|
}
|
||||||
servers := make([]models.PIAServer, 0, len(contents))
|
servers := make([]models.PIAServer, 0, len(contents))
|
||||||
for fileName, content := range contents {
|
for fileName, content := range contents {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
remoteLines := extractRemoteLinesFromOpenvpn(content)
|
remoteLines := extractRemoteLinesFromOpenvpn(content)
|
||||||
if len(remoteLines) == 0 {
|
if len(remoteLines) == 0 {
|
||||||
return fmt.Errorf("cannot find any remote lines in %s", fileName)
|
return fmt.Errorf("cannot find any remote lines in %s", fileName)
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import (
|
|||||||
|
|
||||||
func (u *updater) updatePurevpn(ctx context.Context) (err error) {
|
func (u *updater) updatePurevpn(ctx context.Context) (err error) {
|
||||||
servers, warnings, err := findPurevpnServers(ctx, u.httpGet, u.lookupIP)
|
servers, warnings, err := findPurevpnServers(ctx, u.httpGet, u.lookupIP)
|
||||||
for _, warning := range warnings {
|
if u.options.CLI {
|
||||||
u.println(warning)
|
for _, warning := range warnings {
|
||||||
|
u.logger.Warn("PureVPN: %s", warning)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot update Purevpn servers: %w", err)
|
return fmt.Errorf("cannot update Purevpn servers: %w", err)
|
||||||
@@ -76,6 +78,9 @@ func findPurevpnServers(ctx context.Context, httpGet httpGetFunc, lookupIP looku
|
|||||||
return data[i].Region < data[j].Region
|
return data[i].Region < data[j].Region
|
||||||
})
|
})
|
||||||
for _, jsonServer := range data {
|
for _, jsonServer := range data {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, warnings, err
|
||||||
|
}
|
||||||
if jsonServer.UDP == "" && jsonServer.TCP == "" {
|
if jsonServer.UDP == "" && jsonServer.TCP == "" {
|
||||||
warnings = append(warnings, fmt.Sprintf("server %s %s %s does not support TCP and UDP for openvpn", jsonServer.Region, jsonServer.Country, jsonServer.City))
|
warnings = append(warnings, fmt.Sprintf("server %s %s %s does not support TCP and UDP for openvpn", jsonServer.Region, jsonServer.Country, jsonServer.City))
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ func findSurfsharkServers(ctx context.Context, lookupIP lookupIPFunc) (servers [
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for fileName, content := range contents {
|
for fileName, content := range contents {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if strings.HasSuffix(fileName, "_tcp.ovpn") {
|
if strings.HasSuffix(fileName, "_tcp.ovpn") {
|
||||||
continue // only parse UDP files
|
continue // only parse UDP files
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,110 +6,138 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/qdm12/gluetun/internal/constants"
|
|
||||||
"github.com/qdm12/gluetun/internal/models"
|
"github.com/qdm12/gluetun/internal/models"
|
||||||
"github.com/qdm12/gluetun/internal/storage"
|
"github.com/qdm12/golibs/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Updater interface {
|
type Updater interface {
|
||||||
UpdateServers(ctx context.Context) error
|
UpdateServers(ctx context.Context) (allServers models.AllServers, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type updater struct {
|
type updater struct {
|
||||||
// configuration
|
// configuration
|
||||||
options Options
|
options Options
|
||||||
storage storage.Storage
|
|
||||||
|
|
||||||
// state
|
// state
|
||||||
servers models.AllServers
|
servers models.AllServers
|
||||||
|
|
||||||
// Functions for tests
|
// Functions for tests
|
||||||
|
logger logging.Logger
|
||||||
timeNow func() time.Time
|
timeNow func() time.Time
|
||||||
println func(s string)
|
println func(s string)
|
||||||
httpGet httpGetFunc
|
httpGet httpGetFunc
|
||||||
lookupIP lookupIPFunc
|
lookupIP lookupIPFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(options Options, storage storage.Storage, httpClient *http.Client) Updater {
|
func New(options Options, httpClient *http.Client, currentServers models.AllServers, logger logging.Logger) Updater {
|
||||||
if len(options.DNSAddress) == 0 {
|
if len(options.DNSAddress) == 0 {
|
||||||
options.DNSAddress = "1.1.1.1"
|
options.DNSAddress = "1.1.1.1"
|
||||||
}
|
}
|
||||||
resolver := newResolver(options.DNSAddress)
|
resolver := newResolver(options.DNSAddress)
|
||||||
return &updater{
|
return &updater{
|
||||||
storage: storage,
|
logger: logger,
|
||||||
timeNow: time.Now,
|
timeNow: time.Now,
|
||||||
println: func(s string) { fmt.Println(s) },
|
println: func(s string) { fmt.Println(s) },
|
||||||
httpGet: httpClient.Get,
|
httpGet: httpClient.Get,
|
||||||
lookupIP: newLookupIP(resolver),
|
lookupIP: newLookupIP(resolver),
|
||||||
options: options,
|
options: options,
|
||||||
|
servers: currentServers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO parallelize DNS resolution
|
// TODO parallelize DNS resolution
|
||||||
func (u *updater) UpdateServers(ctx context.Context) (err error) {
|
func (u *updater) UpdateServers(ctx context.Context) (allServers models.AllServers, err error) { //nolint:gocognit
|
||||||
const writeSync = false
|
|
||||||
u.servers, err = u.storage.SyncServers(constants.GetAllServers(), writeSync)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot update servers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.options.Cyberghost {
|
if u.options.Cyberghost {
|
||||||
u.updateCyberghost(ctx)
|
u.logger.Info("updating Cyberghost servers...")
|
||||||
|
if err := u.updateCyberghost(ctx); err != nil {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return allServers, ctxErr
|
||||||
|
}
|
||||||
|
u.logger.Error(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.options.Mullvad {
|
if u.options.Mullvad {
|
||||||
|
u.logger.Info("updating Mullvad servers...")
|
||||||
if err := u.updateMullvad(); err != nil {
|
if err := u.updateMullvad(); err != nil {
|
||||||
return err
|
u.logger.Error(err)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return allServers, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.options.Nordvpn {
|
if u.options.Nordvpn {
|
||||||
// TODO support servers offering only TCP or only UDP
|
// TODO support servers offering only TCP or only UDP
|
||||||
|
u.logger.Info("updating NordVPN servers...")
|
||||||
if err := u.updateNordvpn(); err != nil {
|
if err := u.updateNordvpn(); err != nil {
|
||||||
return err
|
u.logger.Error(err)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return allServers, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.options.PIA {
|
if u.options.PIA {
|
||||||
|
u.logger.Info("updating Private Internet Access (v4) servers...")
|
||||||
if err := u.updatePIA(); err != nil {
|
if err := u.updatePIA(); err != nil {
|
||||||
return err
|
u.logger.Error(err)
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return allServers, ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.options.PIAold {
|
if u.options.PIAold {
|
||||||
|
u.logger.Info("updating Private Internet Access old (v3) servers...")
|
||||||
if err := u.updatePIAOld(ctx); err != nil {
|
if err := u.updatePIAOld(ctx); err != nil {
|
||||||
return err
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return allServers, ctxErr
|
||||||
|
}
|
||||||
|
u.logger.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.options.Purevpn {
|
if u.options.Purevpn {
|
||||||
|
u.logger.Info("updating PureVPN servers...")
|
||||||
// TODO support servers offering only TCP or only UDP
|
// TODO support servers offering only TCP or only UDP
|
||||||
if err := u.updatePurevpn(ctx); err != nil {
|
if err := u.updatePurevpn(ctx); err != nil {
|
||||||
return err
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return allServers, ctxErr
|
||||||
|
}
|
||||||
|
u.logger.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.options.Surfshark {
|
if u.options.Surfshark {
|
||||||
|
u.logger.Info("updating Surfshark servers...")
|
||||||
if err := u.updateSurfshark(ctx); err != nil {
|
if err := u.updateSurfshark(ctx); err != nil {
|
||||||
return err
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return allServers, ctxErr
|
||||||
|
}
|
||||||
|
u.logger.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.options.Vyprvpn {
|
if u.options.Vyprvpn {
|
||||||
|
u.logger.Info("updating Vyprvpn servers...")
|
||||||
if err := u.updateVyprvpn(ctx); err != nil {
|
if err := u.updateVyprvpn(ctx); err != nil {
|
||||||
return err
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return allServers, ctxErr
|
||||||
|
}
|
||||||
|
u.logger.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.options.Windscribe {
|
if u.options.Windscribe {
|
||||||
u.updateWindscribe(ctx)
|
u.logger.Info("updating Windscribe servers...")
|
||||||
}
|
if err := u.updateWindscribe(ctx); err != nil {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
if u.options.File {
|
return allServers, ctxErr
|
||||||
if err := u.storage.FlushToFile(u.servers); err != nil {
|
}
|
||||||
return fmt.Errorf("cannot update servers: %w", err)
|
u.logger.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return u.servers, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ func findVyprvpnServers(ctx context.Context, lookupIP lookupIPFunc) (servers []m
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for fileName, content := range contents {
|
for fileName, content := range contents {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
remoteLines := extractRemoteLinesFromOpenvpn(content)
|
remoteLines := extractRemoteLinesFromOpenvpn(content)
|
||||||
if len(remoteLines) == 0 {
|
if len(remoteLines) == 0 {
|
||||||
return nil, fmt.Errorf("cannot find any remote lines in %s", fileName)
|
return nil, fmt.Errorf("cannot find any remote lines in %s", fileName)
|
||||||
|
|||||||
@@ -2,26 +2,34 @@ package updater
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/qdm12/gluetun/internal/models"
|
"github.com/qdm12/gluetun/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (u *updater) updateWindscribe(ctx context.Context) {
|
func (u *updater) updateWindscribe(ctx context.Context) (err error) {
|
||||||
servers := findWindscribeServers(ctx, u.lookupIP)
|
servers, err := findWindscribeServers(ctx, u.lookupIP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot update Windscribe servers: %w", err)
|
||||||
|
}
|
||||||
if u.options.Stdout {
|
if u.options.Stdout {
|
||||||
u.println(stringifyWindscribeServers(servers))
|
u.println(stringifyWindscribeServers(servers))
|
||||||
}
|
}
|
||||||
u.servers.Windscribe.Timestamp = u.timeNow().Unix()
|
u.servers.Windscribe.Timestamp = u.timeNow().Unix()
|
||||||
u.servers.Windscribe.Servers = servers
|
u.servers.Windscribe.Servers = servers
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findWindscribeServers(ctx context.Context, lookupIP lookupIPFunc) (servers []models.WindscribeServer) {
|
func findWindscribeServers(ctx context.Context, lookupIP lookupIPFunc) (servers []models.WindscribeServer, err error) {
|
||||||
allCountryCodes := getCountryCodes()
|
allCountryCodes := getCountryCodes()
|
||||||
windscribeCountryCodes := getWindscribeSubdomainToRegion()
|
windscribeCountryCodes := getWindscribeSubdomainToRegion()
|
||||||
possibleCountryCodes := mergeCountryCodes(windscribeCountryCodes, allCountryCodes)
|
possibleCountryCodes := mergeCountryCodes(windscribeCountryCodes, allCountryCodes)
|
||||||
const domain = "windscribe.com"
|
const domain = "windscribe.com"
|
||||||
for countryCode, region := range possibleCountryCodes {
|
for countryCode, region := range possibleCountryCodes {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
host := countryCode + "." + domain
|
host := countryCode + "." + domain
|
||||||
ips, err := resolveRepeat(ctx, lookupIP, host, 2)
|
ips, err := resolveRepeat(ctx, lookupIP, host, 2)
|
||||||
if err != nil || len(ips) == 0 {
|
if err != nil || len(ips) == 0 {
|
||||||
@@ -35,7 +43,7 @@ func findWindscribeServers(ctx context.Context, lookupIP lookupIPFunc) (servers
|
|||||||
sort.Slice(servers, func(i, j int) bool {
|
sort.Slice(servers, func(i, j int) bool {
|
||||||
return servers[i].Region < servers[j].Region
|
return servers[i].Region < servers[j].Region
|
||||||
})
|
})
|
||||||
return servers
|
return servers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeCountryCodes(base, extend map[string]string) (merged map[string]string) {
|
func mergeCountryCodes(base, extend map[string]string) (merged map[string]string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user