diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index f2536b61..75d979e8 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -346,7 +346,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, go unboundLooper.Run(dnsCtx, dnsDone) otherGroupHandler.Add(dnsHandler) - publicIPLooper := publicip.NewLooper(httpClient, + publicIPLooper := publicip.NewLoop(httpClient, logger.NewChild(logging.Settings{Prefix: "ip getter: "}), allSettings.PublicIP, puid, pgid) pubIPHandler, pubIPCtx, pubIPDone := goshutdown.NewGoRoutineHandler( @@ -488,7 +488,7 @@ func routeReadyEvents(ctx context.Context, done chan<- struct{}, buildInfo model restartTickerContext, restartTickerCancel = context.WithCancel(ctx) // Runs the Public IP getter job once - _, _ = publicIPLooper.SetStatus(ctx, constants.Running) + _, _ = publicIPLooper.ApplyStatus(ctx, constants.Running) if versionInformation && first { first = false message, err := versionpkg.GetMessage(ctx, buildInfo, httpClient) diff --git a/internal/publicip/alias.go b/internal/publicip/alias.go new file mode 100644 index 00000000..fe53c8c1 --- /dev/null +++ b/internal/publicip/alias.go @@ -0,0 +1,6 @@ +package publicip + +import "github.com/qdm12/gluetun/internal/publicip/state" + +type Getter = state.PublicIPGetter +type SettingsGetterSetter = state.SettingsGetterSetter diff --git a/internal/publicip/fetch.go b/internal/publicip/fetch.go new file mode 100644 index 00000000..808476d7 --- /dev/null +++ b/internal/publicip/fetch.go @@ -0,0 +1,72 @@ +// Package publicip defines interfaces to get your public IP address. +package publicip + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "strings" +) + +type Fetcher interface { + FetchPublicIP(ctx context.Context) (ip net.IP, err error) +} + +type Fetch struct { + client *http.Client + randIntn func(n int) int +} + +func NewFetch(client *http.Client) *Fetch { + return &Fetch{ + client: client, + randIntn: rand.Intn, + } +} + +var ErrParseIP = errors.New("cannot parse IP address") + +func (f *Fetch) FetchPublicIP(ctx context.Context) (ip net.IP, err error) { + urls := []string{ + "https://ifconfig.me/ip", + "http://ip1.dynupdate.no-ip.com:8245", + "http://ip1.dynupdate.no-ip.com", + "https://api.ipify.org", + "https://diagnostic.opendns.com/myip", + "https://domains.google.com/checkip", + "https://ifconfig.io/ip", + "https://ipinfo.io/ip", + } + url := urls[f.randIntn(len(urls))] + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + response, err := f.client.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w from %s: %s", ErrBadStatusCode, url, response.Status) + } + + content, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrCannotReadBody, err) + } + + s := strings.ReplaceAll(string(content), "\n", "") + ip = net.ParseIP(s) + if ip == nil { + return nil, fmt.Errorf("%w: %s", ErrParseIP, s) + } + return ip, nil +} diff --git a/internal/publicip/helpers.go b/internal/publicip/helpers.go new file mode 100644 index 00000000..5fbb6b7b --- /dev/null +++ b/internal/publicip/helpers.go @@ -0,0 +1,22 @@ +package publicip + +import ( + "context" + "time" +) + +func (l *Loop) logAndWait(ctx context.Context, err error) { + if err != nil { + l.logger.Error(err.Error()) + } + l.logger.Info("retrying in " + l.backoffTime.String()) + timer := time.NewTimer(l.backoffTime) + l.backoffTime *= 2 + select { + case <-timer.C: + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + } +} diff --git a/internal/publicip/loop.go b/internal/publicip/loop.go index 3dabef4f..4846e618 100644 --- a/internal/publicip/loop.go +++ b/internal/publicip/loop.go @@ -1,41 +1,39 @@ package publicip import ( - "context" - "net" "net/http" - "os" - "sync" "time" "github.com/qdm12/gluetun/internal/configuration" "github.com/qdm12/gluetun/internal/constants" + "github.com/qdm12/gluetun/internal/loopstate" "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/publicip/state" "github.com/qdm12/golibs/logging" ) +var _ Looper = (*Loop)(nil) + type Looper interface { - Run(ctx context.Context, done chan<- struct{}) - RunRestartTicker(ctx context.Context, done chan<- struct{}) - GetStatus() (status models.LoopStatus) - SetStatus(ctx context.Context, status models.LoopStatus) ( - outcome string, err error) - GetSettings() (settings configuration.PublicIP) - SetSettings(settings configuration.PublicIP) (outcome string) - GetPublicIP() (publicIP net.IP) + Runner + RestartTickerRunner + loopstate.Getter + loopstate.Applier + SettingsGetterSetter + Getter } -type looper struct { - state state +type Loop struct { + statusManager loopstate.Manager + state state.Manager // Objects - getter IPGetter - client *http.Client - logger logging.Logger + fetcher Fetcher + client *http.Client + logger logging.Logger // Fixed settings puid int pgid int // Internal channels and locks - loopLock sync.Mutex start chan struct{} running chan models.LoopStatus stop chan struct{} @@ -43,22 +41,28 @@ type looper struct { updateTicker chan struct{} backoffTime time.Duration // Mock functions - timeNow func() time.Time - timeSince func(time.Time) time.Duration + timeNow func() time.Time } const defaultBackoffTime = 5 * time.Second -func NewLooper(client *http.Client, logger logging.Logger, - settings configuration.PublicIP, puid, pgid int) Looper { - return &looper{ - state: state{ - status: constants.Stopped, - settings: settings, - }, +func NewLoop(client *http.Client, logger logging.Logger, + settings configuration.PublicIP, puid, pgid int) *Loop { + start := make(chan struct{}) + running := make(chan models.LoopStatus) + stop := make(chan struct{}) + stopped := make(chan struct{}) + updateTicker := make(chan struct{}) + + statusManager := loopstate.New(constants.Stopped, start, running, stop, stopped) + state := state.New(statusManager, settings, updateTicker) + + return &Loop{ + statusManager: statusManager, + state: state, // Objects client: client, - getter: NewIPGetter(client), + fetcher: NewFetch(client), logger: logger, puid: puid, pgid: pgid, @@ -69,151 +73,5 @@ func NewLooper(client *http.Client, logger logging.Logger, updateTicker: make(chan struct{}), backoffTime: defaultBackoffTime, timeNow: time.Now, - timeSince: time.Since, - } -} - -func (l *looper) logAndWait(ctx context.Context, err error) { - if err != nil { - l.logger.Error(err.Error()) - } - l.logger.Info("retrying in " + l.backoffTime.String()) - timer := time.NewTimer(l.backoffTime) - l.backoffTime *= 2 - select { - case <-timer.C: - case <-ctx.Done(): - if !timer.Stop() { - <-timer.C - } - } -} - -func (l *looper) Run(ctx context.Context, done chan<- struct{}) { - defer close(done) - - crashed := false - - select { - case <-l.start: - case <-ctx.Done(): - return - } - - for ctx.Err() == nil { - getCtx, getCancel := context.WithCancel(ctx) - defer getCancel() - - ipCh := make(chan net.IP) - errorCh := make(chan error) - go func() { - ip, err := l.getter.Get(getCtx) - if err != nil { - if getCtx.Err() == nil { - errorCh <- err - } - return - } - ipCh <- ip - }() - - if !crashed { - l.running <- constants.Running - crashed = false - } else { - l.backoffTime = defaultBackoffTime - l.state.setStatusWithLock(constants.Running) - } - - stayHere := true - for stayHere { - select { - case <-ctx.Done(): - getCancel() - close(errorCh) - filepath := l.GetSettings().IPFilepath - l.logger.Info("Removing ip file " + filepath) - if err := os.Remove(filepath); err != nil { - l.logger.Error(err.Error()) - } - return - case <-l.start: - getCancel() - stayHere = false - case <-l.stop: - l.logger.Info("stopping") - getCancel() - <-errorCh - l.stopped <- struct{}{} - case ip := <-ipCh: - getCancel() - l.state.setPublicIP(ip) - - message := "Public IP address is " + ip.String() - result, err := Info(ctx, l.client, ip) - if err != nil { - l.logger.Warn(err.Error()) - } else { - message += " (" + result.Country + ", " + result.Region + ", " + result.City + ")" - } - l.logger.Info(message) - - err = persistPublicIP(l.state.settings.IPFilepath, - ip.String(), l.puid, l.pgid) - if err != nil { - l.logger.Error(err.Error()) - } - l.state.setStatusWithLock(constants.Completed) - case err := <-errorCh: - getCancel() - close(ipCh) - l.state.setStatusWithLock(constants.Crashed) - l.logAndWait(ctx, err) - crashed = true - stayHere = false - } - } - close(errorCh) - } -} - -func (l *looper) RunRestartTicker(ctx context.Context, done chan<- struct{}) { - defer close(done) - timer := time.NewTimer(time.Hour) - timer.Stop() // 1 hour, cannot be a race condition - timerIsStopped := true - if period := l.GetSettings().Period; period > 0 { - timerIsStopped = false - timer.Reset(period) - } - lastTick := time.Unix(0, 0) - for { - select { - case <-ctx.Done(): - if !timerIsStopped && !timer.Stop() { - <-timer.C - } - return - case <-timer.C: - lastTick = l.timeNow() - l.start <- struct{}{} - timer.Reset(l.GetSettings().Period) - case <-l.updateTicker: - if !timerIsStopped && !timer.Stop() { - <-timer.C - } - timerIsStopped = true - period := l.GetSettings().Period - if period == 0 { - continue - } - var waited time.Duration - if lastTick.UnixNano() > 0 { - waited = l.timeSince(lastTick) - } - leftToWait := period - waited - timer.Reset(leftToWait) - timerIsStopped = false - } } } diff --git a/internal/publicip/publicip.go b/internal/publicip/publicip.go index f4b766a5..ef857be4 100644 --- a/internal/publicip/publicip.go +++ b/internal/publicip/publicip.go @@ -1,72 +1,7 @@ -// Package publicip defines interfaces to get your public IP address. package publicip -import ( - "context" - "errors" - "fmt" - "io" - "math/rand" - "net" - "net/http" - "strings" -) +import "net" -type IPGetter interface { - Get(ctx context.Context) (ip net.IP, err error) -} - -type ipGetter struct { - client *http.Client - randIntn func(n int) int -} - -func NewIPGetter(client *http.Client) IPGetter { - return &ipGetter{ - client: client, - randIntn: rand.Intn, - } -} - -var ErrParseIP = errors.New("cannot parse IP address") - -func (i *ipGetter) Get(ctx context.Context) (ip net.IP, err error) { - urls := []string{ - "https://ifconfig.me/ip", - "http://ip1.dynupdate.no-ip.com:8245", - "http://ip1.dynupdate.no-ip.com", - "https://api.ipify.org", - "https://diagnostic.opendns.com/myip", - "https://domains.google.com/checkip", - "https://ifconfig.io/ip", - "https://ipinfo.io/ip", - } - url := urls[i.randIntn(len(urls))] - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - - response, err := i.client.Do(req) - if err != nil { - return nil, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%w from %s: %s", ErrBadStatusCode, url, response.Status) - } - - content, err := io.ReadAll(response.Body) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrCannotReadBody, err) - } - - s := strings.ReplaceAll(string(content), "\n", "") - ip = net.ParseIP(s) - if ip == nil { - return nil, fmt.Errorf("%w: %s", ErrParseIP, s) - } - return ip, nil +func (l *Loop) GetPublicIP() (publicIP net.IP) { + return l.state.GetPublicIP() } diff --git a/internal/publicip/runner.go b/internal/publicip/runner.go new file mode 100644 index 00000000..ae5c77dd --- /dev/null +++ b/internal/publicip/runner.go @@ -0,0 +1,101 @@ +package publicip + +import ( + "context" + "net" + "os" + + "github.com/qdm12/gluetun/internal/constants" +) + +type Runner interface { + Run(ctx context.Context, done chan<- struct{}) +} + +func (l *Loop) Run(ctx context.Context, done chan<- struct{}) { + defer close(done) + + crashed := false + + select { + case <-l.start: + case <-ctx.Done(): + return + } + + for ctx.Err() == nil { + getCtx, getCancel := context.WithCancel(ctx) + defer getCancel() + + ipCh := make(chan net.IP) + errorCh := make(chan error) + go func() { + ip, err := l.fetcher.FetchPublicIP(getCtx) + if err != nil { + if getCtx.Err() == nil { + errorCh <- err + } + return + } + ipCh <- ip + }() + + if !crashed { + l.running <- constants.Running + crashed = false + } else { + l.backoffTime = defaultBackoffTime + l.statusManager.SetStatus(constants.Running) + } + + stayHere := true + for stayHere { + select { + case <-ctx.Done(): + getCancel() + close(errorCh) + filepath := l.state.GetSettings().IPFilepath + l.logger.Info("Removing ip file " + filepath) + if err := os.Remove(filepath); err != nil { + l.logger.Error(err.Error()) + } + return + case <-l.start: + getCancel() + stayHere = false + case <-l.stop: + l.logger.Info("stopping") + getCancel() + <-errorCh + l.stopped <- struct{}{} + case ip := <-ipCh: + getCancel() + l.state.SetPublicIP(ip) + + message := "Public IP address is " + ip.String() + result, err := Info(ctx, l.client, ip) + if err != nil { + l.logger.Warn(err.Error()) + } else { + message += " (" + result.Country + ", " + result.Region + ", " + result.City + ")" + } + l.logger.Info(message) + + filepath := l.state.GetSettings().IPFilepath + err = persistPublicIP(filepath, ip.String(), l.puid, l.pgid) + if err != nil { + l.logger.Error(err.Error()) + } + l.statusManager.SetStatus(constants.Completed) + case err := <-errorCh: + getCancel() + close(ipCh) + l.statusManager.SetStatus(constants.Crashed) + l.logAndWait(ctx, err) + crashed = true + stayHere = false + } + } + close(errorCh) + } +} diff --git a/internal/publicip/settings.go b/internal/publicip/settings.go new file mode 100644 index 00000000..e69fd228 --- /dev/null +++ b/internal/publicip/settings.go @@ -0,0 +1,16 @@ +package publicip + +import ( + "context" + + "github.com/qdm12/gluetun/internal/configuration" +) + +func (l *Loop) GetSettings() (settings configuration.PublicIP) { + return l.state.GetSettings() +} + +func (l *Loop) SetSettings(ctx context.Context, settings configuration.PublicIP) ( + outcome string) { + return l.state.SetSettings(ctx, settings) +} diff --git a/internal/publicip/state.go b/internal/publicip/state.go deleted file mode 100644 index 3bcff774..00000000 --- a/internal/publicip/state.go +++ /dev/null @@ -1,127 +0,0 @@ -package publicip - -import ( - "context" - "errors" - "fmt" - "net" - "reflect" - "sync" - - "github.com/qdm12/gluetun/internal/configuration" - "github.com/qdm12/gluetun/internal/constants" - "github.com/qdm12/gluetun/internal/models" -) - -type state struct { - status models.LoopStatus - settings configuration.PublicIP - ip net.IP - statusMu sync.RWMutex - settingsMu sync.RWMutex - ipMu sync.RWMutex -} - -func (s *state) setStatusWithLock(status models.LoopStatus) { - s.statusMu.Lock() - defer s.statusMu.Unlock() - s.status = status -} - -func (l *looper) GetStatus() (status models.LoopStatus) { - l.state.statusMu.RLock() - defer l.state.statusMu.RUnlock() - return l.state.status -} - -var ErrInvalidStatus = errors.New("invalid status") - -func (l *looper) SetStatus(ctx context.Context, status models.LoopStatus) ( - outcome string, err error) { - l.state.statusMu.Lock() - defer l.state.statusMu.Unlock() - existingStatus := l.state.status - - switch status { - case constants.Running: - switch existingStatus { - case constants.Starting, constants.Running, constants.Stopping, constants.Crashed: - return fmt.Sprintf("already %s", existingStatus), nil - } - l.loopLock.Lock() - defer l.loopLock.Unlock() - l.state.status = constants.Starting - l.state.statusMu.Unlock() - l.start <- struct{}{} - - newStatus := constants.Starting // for canceled context - select { - case <-ctx.Done(): - case newStatus = <-l.running: - } - l.state.statusMu.Lock() - l.state.status = newStatus - return newStatus.String(), nil - case constants.Stopped: - switch existingStatus { - case constants.Stopped, constants.Stopping, constants.Starting, constants.Crashed: - return fmt.Sprintf("already %s", existingStatus), nil - } - l.loopLock.Lock() - defer l.loopLock.Unlock() - l.state.status = constants.Stopping - l.state.statusMu.Unlock() - l.stop <- struct{}{} - - newStatus := constants.Stopping // for canceled context - select { - case <-ctx.Done(): - case <-l.stopped: - newStatus = constants.Stopped - } - l.state.statusMu.Lock() - l.state.status = newStatus - return status.String(), nil - default: - return "", fmt.Errorf("%w: %s: it can only be one of: %s, %s", - ErrInvalidStatus, status, constants.Running, constants.Stopped) - } -} - -func (l *looper) GetSettings() (settings configuration.PublicIP) { - l.state.settingsMu.RLock() - defer l.state.settingsMu.RUnlock() - return l.state.settings -} - -func (l *looper) SetSettings(settings configuration.PublicIP) ( - outcome string) { - l.state.settingsMu.Lock() - defer l.state.settingsMu.Unlock() - settingsUnchanged := reflect.DeepEqual(settings, l.state.settings) - if settingsUnchanged { - return "settings left unchanged" - } - periodChanged := l.state.settings.Period != settings.Period - l.state.settings = settings - if periodChanged { - l.updateTicker <- struct{}{} - // TODO blocking - } - return "settings updated" -} - -func (l *looper) GetPublicIP() (publicIP net.IP) { - l.state.ipMu.RLock() - defer l.state.ipMu.RUnlock() - publicIP = make(net.IP, len(l.state.ip)) - copy(publicIP, l.state.ip) - return publicIP -} - -func (s *state) setPublicIP(publicIP net.IP) { - s.ipMu.Lock() - defer s.ipMu.Unlock() - s.ip = make(net.IP, len(publicIP)) - copy(s.ip, publicIP) -} diff --git a/internal/publicip/state/publicip.go b/internal/publicip/state/publicip.go new file mode 100644 index 00000000..4ae29b06 --- /dev/null +++ b/internal/publicip/state/publicip.go @@ -0,0 +1,29 @@ +package state + +import ( + "net" +) + +type PublicIPGetSetter interface { + PublicIPGetter + SetPublicIP(publicIP net.IP) +} + +type PublicIPGetter interface { + GetPublicIP() (publicIP net.IP) +} + +func (s *State) GetPublicIP() (publicIP net.IP) { + s.publicIPMu.RLock() + defer s.publicIPMu.RUnlock() + publicIP = make(net.IP, len(s.publicIP)) + copy(publicIP, s.publicIP) + return publicIP +} + +func (s *State) SetPublicIP(publicIP net.IP) { + s.settingsMu.Lock() + defer s.settingsMu.Unlock() + s.publicIP = make(net.IP, len(publicIP)) + copy(s.publicIP, publicIP) +} diff --git a/internal/publicip/state/settings.go b/internal/publicip/state/settings.go new file mode 100644 index 00000000..0e4274ad --- /dev/null +++ b/internal/publicip/state/settings.go @@ -0,0 +1,39 @@ +package state + +import ( + "context" + "reflect" + + "github.com/qdm12/gluetun/internal/configuration" +) + +type SettingsGetterSetter interface { + GetSettings() (settings configuration.PublicIP) + SetSettings(ctx context.Context, + settings configuration.PublicIP) (outcome string) +} + +func (s *State) GetSettings() (settings configuration.PublicIP) { + s.settingsMu.RLock() + defer s.settingsMu.RUnlock() + return s.settings +} + +func (s *State) SetSettings(ctx context.Context, settings configuration.PublicIP) ( + outcome string) { + s.settingsMu.Lock() + defer s.settingsMu.Unlock() + + settingsUnchanged := reflect.DeepEqual(s.settings, settings) + if settingsUnchanged { + return "settings left unchanged" + } + + periodChanged := s.settings.Period != settings.Period + s.settings = settings + if periodChanged { + s.updateTicker <- struct{}{} + // TODO blocking + } + return "settings updated" +} diff --git a/internal/publicip/state/state.go b/internal/publicip/state/state.go new file mode 100644 index 00000000..61221309 --- /dev/null +++ b/internal/publicip/state/state.go @@ -0,0 +1,38 @@ +package state + +import ( + "net" + "sync" + + "github.com/qdm12/gluetun/internal/configuration" + "github.com/qdm12/gluetun/internal/loopstate" +) + +var _ Manager = (*State)(nil) + +type Manager interface { + SettingsGetterSetter + PublicIPGetSetter +} + +func New(statusApplier loopstate.Applier, + settings configuration.PublicIP, + updateTicker chan<- struct{}) *State { + return &State{ + statusApplier: statusApplier, + settings: settings, + updateTicker: updateTicker, + } +} + +type State struct { + statusApplier loopstate.Applier + + settings configuration.PublicIP + settingsMu sync.RWMutex + + publicIP net.IP + publicIPMu sync.RWMutex + + updateTicker chan<- struct{} +} diff --git a/internal/publicip/status.go b/internal/publicip/status.go new file mode 100644 index 00000000..0c33f151 --- /dev/null +++ b/internal/publicip/status.go @@ -0,0 +1,16 @@ +package publicip + +import ( + "context" + + "github.com/qdm12/gluetun/internal/models" +) + +func (l *Loop) GetStatus() (status models.LoopStatus) { + return l.statusManager.GetStatus() +} + +func (l *Loop) ApplyStatus(ctx context.Context, status models.LoopStatus) ( + outcome string, err error) { + return l.statusManager.ApplyStatus(ctx, status) +} diff --git a/internal/publicip/ticker.go b/internal/publicip/ticker.go new file mode 100644 index 00000000..8c93d7f0 --- /dev/null +++ b/internal/publicip/ticker.go @@ -0,0 +1,51 @@ +package publicip + +import ( + "context" + "time" +) + +type RestartTickerRunner interface { + RunRestartTicker(ctx context.Context, done chan<- struct{}) +} + +func (l *Loop) RunRestartTicker(ctx context.Context, done chan<- struct{}) { + defer close(done) + timer := time.NewTimer(time.Hour) + timer.Stop() // 1 hour, cannot be a race condition + timerIsStopped := true + if period := l.state.GetSettings().Period; period > 0 { + timerIsStopped = false + timer.Reset(period) + } + lastTick := time.Unix(0, 0) + for { + select { + case <-ctx.Done(): + if !timerIsStopped && !timer.Stop() { + <-timer.C + } + return + case <-timer.C: + lastTick = l.timeNow() + l.start <- struct{}{} + timer.Reset(l.state.GetSettings().Period) + case <-l.updateTicker: + if !timerIsStopped && !timer.Stop() { + <-timer.C + } + timerIsStopped = true + period := l.state.GetSettings().Period + if period == 0 { + continue + } + var waited time.Duration + if lastTick.UnixNano() > 0 { + waited = l.timeNow().Sub(lastTick) + } + leftToWait := period - waited + timer.Reset(leftToWait) + timerIsStopped = false + } + } +}