diff --git a/.github/workflows/closed-issue.yml b/.github/workflows/closed-issue.yml index 13b16aa0..1829498b 100644 --- a/.github/workflows/closed-issue.yml +++ b/.github/workflows/closed-issue.yml @@ -18,5 +18,8 @@ jobs: If you think this is *still unresolved* and have **more information** to bring, please either re-open this issue or create another issue. + ❤️😠 temporarily help the Gluetun community and fight the AI slop scam website `gluetun[dot]com` by setting `BORINGPOLL_GLUETUNCOM=on` on the latest image. + See [the option in the wiki for more information](https://github.com/qdm12/gluetun-wiki/blob/main/setup/options/others.md) + This is an automated comment setup because @qdm12 is the sole maintainer of this project which became too popular to monitor closed issues. diff --git a/Dockerfile b/Dockerfile index 72d24eee..b1836831 100644 --- a/Dockerfile +++ b/Dockerfile @@ -231,6 +231,7 @@ ENV VPN_SERVICE_PROVIDER=pia \ PPROF_HTTP_SERVER_ADDRESS=":6060" \ # Extras VERSION_INFORMATION=on \ + BORINGPOLL_GLUETUNCOM=off \ TZ= \ PUID=1000 \ PGID=1000 diff --git a/README.md b/README.md index d2b81579..5a5e1c57 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ ⚠️ This and [gluetun-wiki](https://github.com/qdm12/gluetun-wiki) are the only websites for Gluetun, other websites claiming to be official are scams ⚠️ +💁 You can optionally set `BORINGPOLL_GLUETUNCOM=on` to... [poll](./internal/boringpoll/boringpoll.go) that **scammy AI slop** website every few minutes so it costs them too much to keep it up. My gentle email reminders to take it down are being grossly ignored 🤷 This would make me very happy and serve this community. + Lightweight swiss-army-knife-like VPN client to multiple VPN service providers ![Title image](https://raw.githubusercontent.com/qdm12/gluetun/master/title.svg) diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index c222a632..9ec9e82d 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -17,6 +17,7 @@ import ( _ "github.com/breml/rootcerts" "github.com/qdm12/gluetun/internal/alpine" + "github.com/qdm12/gluetun/internal/boringpoll" "github.com/qdm12/gluetun/internal/cli" "github.com/qdm12/gluetun/internal/command" "github.com/qdm12/gluetun/internal/configuration/settings" @@ -168,7 +169,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, defer fmt.Println(gluetunLogo) - announcementExp, err := time.Parse(time.RFC3339, "2026-04-01T00:00:00Z") + announcementExp, err := time.Parse(time.RFC3339, "2026-04-30T00:00:00Z") if err != nil { return err } @@ -179,7 +180,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, Version: buildInfo.Version, Commit: buildInfo.Commit, Created: buildInfo.Created, - Announcement: "All control server routes are now private by default", + Announcement: "Set BORINGPOLL_GLUETUNCOM=on to help combat AI slop and shutdown that scam website", AnnounceExp: announcementExp, // Sponsor information PaypalUser: "qmcgaw", @@ -441,11 +442,14 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, httpClient, unzipper, parallelResolver, publicIPLooper.Fetcher(), openvpnFileExtractor, allSettings.Updater) + boringPollLogger := logger.New(log.SetComponent("boring poll")) + boringPoll := boringpoll.New(httpClient, boringPollLogger, allSettings.BoringPoll) + vpnLogger := logger.New(log.SetComponent("vpn")) vpnLooper := vpn.NewLoop(allSettings.VPN, ipv6Supported, allSettings.Firewall.VPNInputPorts, - providers, storage, allSettings.Health, healthChecker, healthcheckServer, ovpnConf, netLinker, firewallConf, - routingConf, portForwardLooper, cmder, publicIPLooper, dnsLooper, vpnLogger, httpClient, - buildInfo, *allSettings.Version.Enabled) + providers, storage, boringPoll, allSettings.Health, healthChecker, healthcheckServer, + ovpnConf, netLinker, firewallConf, routingConf, portForwardLooper, cmder, publicIPLooper, + dnsLooper, vpnLogger, httpClient, buildInfo, *allSettings.Version.Enabled) vpnHandler, vpnCtx, vpnDone := goshutdown.NewGoRoutineHandler( "vpn", goroutine.OptionTimeout(time.Second)) go vpnLooper.Run(vpnCtx, vpnDone) diff --git a/internal/boringpoll/boringpoll.go b/internal/boringpoll/boringpoll.go new file mode 100644 index 00000000..4d9d7093 --- /dev/null +++ b/internal/boringpoll/boringpoll.go @@ -0,0 +1,188 @@ +package boringpoll + +import ( + "context" + "fmt" + "io" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/qdm12/gluetun/internal/configuration/settings" +) + +type BoringPoll struct { + // Injected dependencies + client *http.Client + logger Logger + + // Internal state + urlToData map[string]*urlData + + // Internal signals and channels + cancel context.CancelFunc + done <-chan struct{} + mutex sync.Mutex +} + +type urlData struct{} + +func New(client *http.Client, logger Logger, settings settings.BoringPoll) *BoringPoll { + urlToData := make(map[string]*urlData) + if *settings.GluetunCom { + urlToData["https://gluetun.com/wp-json"] = &urlData{} + } + return &BoringPoll{ + client: client, + logger: logger, + urlToData: urlToData, + } +} + +func (b *BoringPoll) Start() (runError <-chan error, err error) { + b.mutex.Lock() + defer b.mutex.Unlock() + + if len(b.urlToData) == 0 { + return nil, nil //nolint:nilnil + } + + const minPeriod = time.Minute + const maxPeriod = 5 * time.Minute + const logEveryBytes = 100 * 1000 * 1000 // 100 IEC MB + + var ready, done sync.WaitGroup + ready.Add(len(b.urlToData)) + done.Add(len(b.urlToData)) + ctx, cancel := context.WithCancel(context.Background()) + b.cancel = cancel + for url := range b.urlToData { + go func(url string) { + defer done.Done() + + b.logger.Infof("running against %s periodically between %s and %s "+ + "and will log every %s downloaded", + url, minPeriod, maxPeriod, byteCountSI(logEveryBytes)) + totalDownloaded := uint64(0) + lastDownloaded := uint64(0) + consecutiveFails := 0 + const maxConsecutiveErrs = 3 + const coolDownTimeout = time.Hour + timer := time.NewTimer(time.Hour) + var err error + + ready.Done() + for { + timeout := minPeriod + time.Duration(rand.Int63n(int64(maxPeriod-minPeriod))) //nolint:gosec + if consecutiveFails >= maxConsecutiveErrs { + b.logger.Debugf("pausing poll to %s for %s due to %d consecutive errors, last error: %s", + url, coolDownTimeout, consecutiveFails, err) + timeout = coolDownTimeout + } + timer.Reset(timeout) + select { + case <-ctx.Done(): + timer.Stop() + totalDownloaded += lastDownloaded + if totalDownloaded > 0 { + b.logger.Infof("stopping poll to %s, downloaded %s!", url, byteCountSI(totalDownloaded)) + } + return + case <-timer.C: + } + + n, err := fetchURL(ctx, b.client, url) + if err != nil { + consecutiveFails++ + continue + } + consecutiveFails = 0 + totalDownloaded += uint64(n) //nolint:gosec + lastDownloaded += uint64(n) //nolint:gosec + if lastDownloaded >= logEveryBytes { + b.logger.Infof("thanks for helping! Downloaded %s from %s!", + byteCountSI(totalDownloaded), url) + lastDownloaded = 0 + } + } + }(url) + } + return nil, nil //nolint:nilnil +} + +func fetchURL(ctx context.Context, client *http.Client, url string) (downloaded int64, err error) { + const requestTimeout = 10 * time.Second + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + cancel() + return 0, err + } + request.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") + request.Header.Set("Pragma", "no-cache") + request.Header.Set("Expires", "0") + request.Header.Set("User-Agent", getRandomUserAgent()) + + response, err := client.Do(request) + if err != nil { + return 0, err + } + downloaded, err = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + if err != nil { + return 0, err + } + return downloaded, nil +} + +func getRandomUserAgent() string { + //nolint:lll + userAgents := [...]string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 14; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0", + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + } + return userAgents[rand.Intn(len(userAgents))] //nolint:gosec +} + +func (b *BoringPoll) Stop() error { + b.mutex.Lock() + defer b.mutex.Unlock() + + if b.cancel == nil { + return nil + } + b.cancel() + <-b.done + b.cancel = nil + b.done = nil + return nil +} + +func byteCountSI(b uint64) string { + const unit = 1000 + if b < unit { + return fmt.Sprintf("%dB", b) + } + + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "kMGTPE"[exp]) +} diff --git a/internal/boringpoll/interfaces.go b/internal/boringpoll/interfaces.go new file mode 100644 index 00000000..7a1ed2c3 --- /dev/null +++ b/internal/boringpoll/interfaces.go @@ -0,0 +1,6 @@ +package boringpoll + +type Logger interface { + Infof(format string, args ...any) + Debugf(format string, args ...any) +} diff --git a/internal/configuration/settings/boringpoll.go b/internal/configuration/settings/boringpoll.go new file mode 100644 index 00000000..cec4e42f --- /dev/null +++ b/internal/configuration/settings/boringpoll.go @@ -0,0 +1,51 @@ +package settings + +import ( + "github.com/qdm12/gosettings" + "github.com/qdm12/gosettings/reader" + "github.com/qdm12/gotree" +) + +type BoringPoll struct { + GluetunCom *bool +} + +func (b BoringPoll) validate() error { + return nil +} + +func (b BoringPoll) Copy() BoringPoll { + return BoringPoll{ + GluetunCom: gosettings.CopyPointer(b.GluetunCom), + } +} + +func (b *BoringPoll) overrideWith(other BoringPoll) { + b.GluetunCom = gosettings.OverrideWithPointer(b.GluetunCom, other.GluetunCom) +} + +func (b *BoringPoll) setDefaults() { + b.GluetunCom = gosettings.DefaultPointer(b.GluetunCom, false) +} + +func (b BoringPoll) String() string { + return b.toLinesNode().String() +} + +func (b BoringPoll) toLinesNode() *gotree.Node { + if !*b.GluetunCom { + return nil + } + + node := gotree.New("Boring-poll settings:") + node.Append("gluetun.com: on") + return node +} + +func (b *BoringPoll) read(r *reader.Reader) (err error) { + b.GluetunCom, err = r.BoolPtr("BORINGPOLL_GLUETUNCOM") + if err != nil { + return err + } + return nil +} diff --git a/internal/configuration/settings/settings.go b/internal/configuration/settings/settings.go index 091c4a4d..beff08f3 100644 --- a/internal/configuration/settings/settings.go +++ b/internal/configuration/settings/settings.go @@ -28,6 +28,7 @@ type Settings struct { Version Version VPN VPN Pprof pprof.Settings + BoringPoll BoringPoll } type FilterChoicesGetter interface { @@ -57,6 +58,7 @@ func (s *Settings) Validate(filterChoicesGetter FilterChoicesGetter, ipv6Support "VPN": func() error { return s.VPN.Validate(filterChoicesGetter, ipv6Supported, warner) }, + "boring poll": s.BoringPoll.validate, } for name, validation := range nameToValidation { @@ -85,6 +87,7 @@ func (s *Settings) copy() (copied Settings) { Version: s.Version.copy(), VPN: s.VPN.Copy(), Pprof: s.Pprof.Copy(), + BoringPoll: s.BoringPoll.Copy(), } } @@ -106,6 +109,7 @@ func (s *Settings) OverrideWith(other Settings, patchedSettings.Version.overrideWith(other.Version) patchedSettings.VPN.OverrideWith(other.VPN) patchedSettings.Pprof.OverrideWith(other.Pprof) + patchedSettings.BoringPoll.overrideWith(other.BoringPoll) err = patchedSettings.Validate(filterChoicesGetter, ipv6Supported, warner) if err != nil { return err @@ -129,6 +133,7 @@ func (s *Settings) SetDefaults() { s.VPN.setDefaults() s.Updater.SetDefaults(s.VPN.Provider.Name) s.Pprof.SetDefaults() + s.BoringPoll.setDefaults() } func (s Settings) String() string { @@ -152,6 +157,7 @@ func (s Settings) toLinesNode() (node *gotree.Node) { node.AppendNode(s.Updater.toLinesNode()) node.AppendNode(s.Version.toLinesNode()) node.AppendNode(s.Pprof.ToLinesNode()) + node.AppendNode(s.BoringPoll.toLinesNode()) return node } @@ -209,6 +215,7 @@ func (s *Settings) Read(r *reader.Reader, warner Warner) (err error) { "version": s.Version.read, "VPN": s.VPN.read, "profiling": s.Pprof.Read, + "boring poll": s.BoringPoll.read, } for name, read := range readFunctions { diff --git a/internal/vpn/cleanup.go b/internal/vpn/cleanup.go index 2b5c0919..0b7db1a6 100644 --- a/internal/vpn/cleanup.go +++ b/internal/vpn/cleanup.go @@ -25,4 +25,9 @@ func (l *Loop) cleanup() { l.logger.Error("stopping port forwarding: " + err.Error()) } } + + err = l.boringPoll.Stop() + if err != nil { + l.logger.Error("stopping boring poll: " + err.Error()) + } } diff --git a/internal/vpn/interfaces.go b/internal/vpn/interfaces.go index 3238c2e2..4189fe62 100644 --- a/internal/vpn/interfaces.go +++ b/internal/vpn/interfaces.go @@ -115,3 +115,8 @@ type HealthChecker interface { type HealthServer interface { SetError(err error) } + +type Service interface { + Start() (runError <-chan error, err error) + Stop() error +} diff --git a/internal/vpn/loop.go b/internal/vpn/loop.go index c83ad11a..e28cc804 100644 --- a/internal/vpn/loop.go +++ b/internal/vpn/loop.go @@ -33,6 +33,7 @@ type Loop struct { portForward PortForward publicip PublicIPLoop dnsLooper DNSLoop + boringPoll Service // Other objects starter CmdStarter // for OpenVPN logger log.LoggerInterface @@ -52,9 +53,9 @@ const ( ) func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint16, - providers Providers, storage Storage, healthSettings settings.Health, - healthChecker HealthChecker, healthServer HealthServer, openvpnConf OpenVPN, - netLinker NetLinker, fw Firewall, routing Routing, + providers Providers, storage Storage, boringPoll Service, + healthSettings settings.Health, healthChecker HealthChecker, healthServer HealthServer, + openvpnConf OpenVPN, netLinker NetLinker, fw Firewall, routing Routing, portForward PortForward, starter CmdStarter, publicip PublicIPLoop, dnsLooper DNSLoop, logger log.LoggerInterface, client *http.Client, @@ -80,6 +81,7 @@ func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint1 versionInfo: versionInfo, ipv6Supported: ipv6Supported, vpnInputPorts: vpnInputPorts, + boringPoll: boringPoll, openvpnConf: openvpnConf, netLinker: netLinker, fw: fw, diff --git a/internal/vpn/tunnelup.go b/internal/vpn/tunnelup.go index 7bc5550f..a8eb31a2 100644 --- a/internal/vpn/tunnelup.go +++ b/internal/vpn/tunnelup.go @@ -119,6 +119,11 @@ func (l *Loop) onTunnelUp(ctx, loopCtx context.Context, data tunnelUpData) { if err != nil { l.logger.Error(err.Error()) } + + _, err = l.boringPoll.Start() + if err != nil { + l.logger.Error("cannot start boring poll: " + err.Error()) + } } func (l *Loop) collectHealthErrors(ctx, loopCtx context.Context, healthErrCh <-chan error) {