Repository: syncthing/syncthing (language: go)

Issue:
lib/nat: panic: runtime error: index out of range
```
INFO: syncthing v1.27.5 "Gold Grasshopper" (go1.22.1 linux-amd64) debian@github.syncthing.net 2024-03-25 03:45:14 UTC [noupgrade]
Panic at 2024-04-08T14:59:35+02:00
panic: runtime error: index out of range [0] with length 0 [recovered]
	panic: runtime error: index out of range [0] with length 0

goroutine 77 [running]:
github.com/thejerf/suture/v4.(*Supervisor).runService.func2.2()
	github.com/thejerf/suture/v4@v4.0.2/supervisor.go:561 +0xb7
panic({0xf88620?, 0xc0004405a0?})
	runtime/panic.go:770 +0x132
github.com/syncthing/syncthing/lib/nat.(*Service).verifyExistingLocked(0xc00038e5b0, {0x1398080, 0xc000034b40}, 0xc0002bc480, 0xc00278af00, 0x1)
	github.com/syncthing/syncthing/lib/nat/service.go:261 +0xa85
github.com/syncthing/syncthing/lib/nat.(*Service).updateMapping(0xc00038e5b0, {0x1398080, 0xc000034b40}, 0xc0002bc480, 0xc00278af00, 0x1)
	github.com/syncthing/syncthing/lib/nat/service.go:216 +0x134
github.com/syncthing/syncthing/lib/nat.(*Service).process(0xc00038e5b0, {0x1398080, 0xc000034b40})
	github.com/syncthing/syncthing/lib/nat/service.go:149 +0x4d5
github.com/syncthing/syncthing/lib/nat.(*Service).Serve(0xc00038e5b0, {0x1398080, 0xc000034b40})
	github.com/syncthing/syncthing/lib/nat/service.go:96 +0x26f
github.com/thejerf/suture/v4.(*Supervisor).runService.func2()
	github.com/thejerf/suture/v4@v4.0.2/supervisor.go:565 +0xe6
created by github.com/thejerf/suture/v4.(*Supervisor).runService in goroutine 69
	github.com/thejerf/suture/v4@v4.0.2/supervisor.go:539 +0x18b
```

//@calmh


Discussion / hints:

Fix patch:
diff --git a/Dockerfile.strelaypoolsrv b/Dockerfile.strelaypoolsrv
index f7e2760e777..a0ad1fd6dde 100644
--- a/Dockerfile.strelaypoolsrv
+++ b/Dockerfile.strelaypoolsrv
@@ -11,14 +11,6 @@ LABEL org.opencontainers.image.authors="The Syncthing Project" \
 
 EXPOSE 8080
 
-RUN apk add --no-cache ca-certificates su-exec curl
-ENV PUID=1000 PGID=1000 MAXMIND_KEY=
-
-RUN mkdir /var/strelaypoolsrv && chown 1000 /var/strelaypoolsrv
-USER 1000
-
 COPY strelaypoolsrv-linux-${TARGETARCH} /bin/strelaypoolsrv
-COPY script/strelaypoolsrv-entrypoint.sh /bin/entrypoint.sh
 
-WORKDIR /var/strelaypoolsrv
-ENTRYPOINT ["/bin/entrypoint.sh", "/bin/strelaypoolsrv", "-listen", ":8080"]
+ENTRYPOINT ["/bin/strelaypoolsrv", "-listen", ":8080"]
diff --git a/cmd/strelaypoolsrv/main.go b/cmd/strelaypoolsrv/main.go
index 6d574e3e88d..6f1e559ef8f 100644
--- a/cmd/strelaypoolsrv/main.go
+++ b/cmd/strelaypoolsrv/main.go
@@ -21,12 +21,12 @@ import (
 	"time"
 
 	lru "github.com/hashicorp/golang-lru/v2"
-	"github.com/oschwald/geoip2-golang"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 	"github.com/syncthing/syncthing/cmd/strelaypoolsrv/auto"
 	"github.com/syncthing/syncthing/lib/assets"
 	_ "github.com/syncthing/syncthing/lib/automaxprocs"
+	"github.com/syncthing/syncthing/lib/geoip"
 	"github.com/syncthing/syncthing/lib/httpcache"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/rand"
@@ -100,11 +100,12 @@ var (
 	debug             bool
 	permRelaysFile    string
 	ipHeader          string
-	geoipPath         string
 	proto             string
 	statsRefresh      = time.Minute
 	requestQueueLen   = 64
 	requestProcessors = 8
+	geoipLicenseKey   = os.Getenv("GEOIP_LICENSE_KEY")
+	geoipAccountID, _ = strconv.Atoi(os.Getenv("GEOIP_ACCOUNT_ID"))
 
 	requests chan request
 
@@ -130,34 +131,38 @@ func main() {
 	flag.StringVar(&permRelaysFile, "perm-relays", "", "Path to list of permanent relays")
 	flag.StringVar(&knownRelaysFile, "known-relays", knownRelaysFile, "Path to list of current relays")
 	flag.StringVar(&ipHeader, "ip-header", "", "Name of header which holds clients ip:port. Only meaningful when running behind a reverse proxy.")
-	flag.StringVar(&geoipPath, "geoip", "GeoLite2-City.mmdb", "Path to GeoLite2-City database")
 	flag.StringVar(&proto, "protocol", "tcp", "Protocol used for listening. 'tcp' for IPv4 and IPv6, 'tcp4' for IPv4, 'tcp6' for IPv6")
 	flag.DurationVar(&statsRefresh, "stats-refresh", statsRefresh, "Interval at which to refresh relay stats")
 	flag.IntVar(&requestQueueLen, "request-queue", requestQueueLen, "Queue length for incoming test requests")
 	flag.IntVar(&requestProcessors, "request-processors", requestProcessors, "Number of request processor routines")
+	flag.StringVar(&geoipLicenseKey, "geoip-license-key", geoipLicenseKey, "License key for GeoIP database")
 
 	flag.Parse()
 
 	requests = make(chan request, requestQueueLen)
+	geoip, err := geoip.NewGeoLite2CityProvider(context.Background(), geoipAccountID, geoipLicenseKey, os.TempDir())
+	if err != nil {
+		log.Fatalln("Failed to create GeoIP provider:", err)
+	}
+	go geoip.Serve(context.TODO())
 
 	var listener net.Listener
-	var err error
 
 	if permRelaysFile != "" {
-		permanentRelays = loadRelays(permRelaysFile)
+		permanentRelays = loadRelays(permRelaysFile, geoip)
 	}
 
 	testCert = createTestCertificate()
 
 	for i := 0; i < requestProcessors; i++ {
-		go requestProcessor()
+		go requestProcessor(geoip)
 	}
 
 	// Load relays from cache in the background.
 	// Load them in a serial fashion to make sure any genuine requests
 	// are not dropped.
 	go func() {
-		for _, relay := range loadRelays(knownRelaysFile) {
+		for _, relay := range loadRelays(knownRelaysFile, geoip) {
 			resultChan := make(chan result)
 			requests <- request{relay, resultChan, nil}
 			result := <-resultChan
@@ -425,19 +430,19 @@ func handlePostRequest(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func requestProcessor() {
+func requestProcessor(geoip *geoip.Provider) {
 	for request := range requests {
 		if request.queueTimer != nil {
 			request.queueTimer.ObserveDuration()
 		}
 
 		timer := prometheus.NewTimer(relayTestActionsSeconds.WithLabelValues("test"))
-		handleRelayTest(request)
+		handleRelayTest(request, geoip)
 		timer.ObserveDuration()
 	}
 }
 
-func handleRelayTest(request request) {
+func handleRelayTest(request request, geoip *geoip.Provider) {
 	if debug {
 		log.Println("Request for", request.relay)
 	}
@@ -450,7 +455,7 @@ func handleRelayTest(request request) {
 	}
 
 	stats := fetchStats(request.relay)
-	location := getLocation(request.relay.uri.Host)
+	location := getLocation(request.relay.uri.Host, geoip)
 
 	mut.Lock()
 	if stats != nil {
@@ -523,7 +528,7 @@ func evict(relay *relay) func() {
 	}
 }
 
-func loadRelays(file string) []*relay {
+func loadRelays(file string, geoip *geoip.Provider) []*relay {
 	content, err := os.ReadFile(file)
 	if err != nil {
 		log.Println("Failed to load relays: " + err.Error())
@@ -547,7 +552,7 @@ func loadRelays(file string) []*relay {
 
 		relays = append(relays, &relay{
 			URL:      line,
-			Location: getLocation(uri.Host),
+			Location: getLocation(uri.Host, geoip),
 			uri:      uri,
 		})
 		if debug {
@@ -580,21 +585,16 @@ func createTestCertificate() tls.Certificate {
 	return cert
 }
 
-func getLocation(host string) location {
+func getLocation(host string, geoip *geoip.Provider) location {
 	timer := prometheus.NewTimer(locationLookupSeconds)
 	defer timer.ObserveDuration()
-	db, err := geoip2.Open(geoipPath)
-	if err != nil {
-		return location{}
-	}
-	defer db.Close()
 
 	addr, err := net.ResolveTCPAddr("tcp", host)
 	if err != nil {
 		return location{}
 	}
 
-	city, err := db.City(addr.IP)
+	city, err := geoip.City(addr.IP)
 	if err != nil {
 		return location{}
 	}
diff --git a/cmd/ursrv/serve/serve.go b/cmd/ursrv/serve/serve.go
index b7d51db3b51..14d3123b121 100644
--- a/cmd/ursrv/serve/serve.go
+++ b/cmd/ursrv/serve/serve.go
@@ -8,6 +8,7 @@ package serve
 
 import (
 	"bytes"
+	"context"
 	"database/sql"
 	"embed"
 	"encoding/json"
@@ -17,6 +18,7 @@ import (
 	"log"
 	"net"
 	"net/http"
+	"os"
 	"regexp"
 	"sort"
 	"strconv"
@@ -26,20 +28,21 @@ import (
 	"unicode"
 
 	_ "github.com/lib/pq" // PostgreSQL driver
-	"github.com/oschwald/geoip2-golang"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 
+	"github.com/syncthing/syncthing/lib/geoip"
 	"github.com/syncthing/syncthing/lib/upgrade"
 	"github.com/syncthing/syncthing/lib/ur/contract"
 )
 
 type CLI struct {
-	Debug     bool   `env:"UR_DEBUG"`
-	DBConn    string `env:"UR_DB_URL" default:"postgres://user:password@localhost/ur?sslmode=disable"`
-	Listen    string `env:"UR_LISTEN" default:"0.0.0.0:8080"`
-	GeoIPPath string `env:"UR_GEOIP" default:"GeoLite2-City.mmdb"`
+	Debug           bool   `env:"UR_DEBUG"`
+	DBConn          string `env:"UR_DB_URL" default:"postgres://user:password@localhost/ur?sslmode=disable"`
+	Listen          string `env:"UR_LISTEN" default:"0.0.0.0:8080"`
+	GeoIPLicenseKey string `env:"UR_GEOIP_LICENSE_KEY"`
+	GeoIPAccountID  int    `env:"UR_GEOIP_ACCOUNT_ID"`
 }
 
 //go:embed static
@@ -189,10 +192,16 @@ func (cli *CLI) Run() error {
 		log.Fatalln("listen:", err)
 	}
 
+	geoip, err := geoip.NewGeoLite2CityProvider(context.Background(), cli.GeoIPAccountID, cli.GeoIPLicenseKey, os.TempDir())
+	if err != nil {
+		log.Fatalln("geoip:", err)
+	}
+	go geoip.Serve(context.TODO())
+
 	srv := &server{
-		db:        db,
-		debug:     cli.Debug,
-		geoIPPath: cli.GeoIPPath,
+		db:    db,
+		debug: cli.Debug,
+		geoip: geoip,
 	}
 	http.HandleFunc("/", srv.rootHandler)
 	http.HandleFunc("/newdata", srv.newDataHandler)
@@ -213,9 +222,9 @@ func (cli *CLI) Run() error {
 }
 
 type server struct {
-	debug     bool
-	db        *sql.DB
-	geoIPPath string
+	debug bool
+	db    *sql.DB
+	geoip *geoip.Provider
 
 	cacheMut        sync.Mutex
 	cachedIndex     []byte
@@ -238,7 +247,7 @@ func (s *server) cacheRefresher() {
 }
 
 func (s *server) refreshCacheLocked() error {
-	rep := getReport(s.db, s.geoIPPath)
+	rep := getReport(s.db, s.geoip)
 	buf := new(bytes.Buffer)
 	err := tpl.Execute(buf, rep)
 	if err != nil {
@@ -492,15 +501,7 @@ type weightedLocation struct {
 	Weight int `json:"weight"`
 }
 
-func getReport(db *sql.DB, geoIPPath string) map[string]interface{} {
-	geoip, err := geoip2.Open(geoIPPath)
-	if err != nil {
-		log.Println("opening geoip db", err)
-		geoip = nil
-	} else {
-		defer geoip.Close()
-	}
-
+func getReport(db *sql.DB, geoip *geoip.Provider) map[string]interface{} {
 	nodes := 0
 	countriesTotal := 0
 	var versions []string
diff --git a/go.mod b/go.mod
index d630181c02d..226734b7876 100644
--- a/go.mod
+++ b/go.mod
@@ -24,6 +24,7 @@ require (
 	github.com/lib/pq v1.10.9
 	github.com/maruel/panicparse/v2 v2.3.1
 	github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1
+	github.com/maxmind/geoipupdate/v6 v6.1.0
 	github.com/minio/sha256-simd v1.0.1
 	github.com/miscreant/miscreant.go v0.0.0-20200214223636-26d376326b75
 	github.com/oschwald/geoip2-golang v1.9.0
@@ -52,6 +53,7 @@ require (
 	github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
 	github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/cenkalti/backoff/v4 v4.2.1 // indirect
 	github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
@@ -60,13 +62,13 @@ require (
 	github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
+	github.com/gofrs/flock v0.8.1 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/pprof v0.0.0-20240402174815-29b9bb013b0f // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/go-multierror v1.1.1 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.7 // indirect
-	github.com/kr/text v0.2.0 // indirect
 	github.com/nxadm/tail v1.4.11 // indirect
 	github.com/onsi/ginkgo/v2 v2.17.1 // indirect
 	github.com/onsi/gomega v1.31.1 // indirect
diff --git a/go.sum b/go.sum
index d0b7768497c..77d6cce00e7 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@ github.com/calmh/xdr v1.1.0 h1:U/Dd4CXNLoo8EiQ4ulJUXkgO1/EyQLgDKLgpY1SOoJE=
 github.com/calmh/xdr v1.1.0/go.mod h1:E8sz2ByAdXC8MbANf1LCRYzedSnnc+/sXXJs/PVqoeg=
 github.com/ccding/go-stun v0.1.4 h1:lC0co3Q3vjAuu2Jz098WivVPBPbemYFqbwE1syoka4M=
 github.com/ccding/go-stun v0.1.4/go.mod h1:cCZjJ1J3WFSJV6Wj8Y9Di8JMTsEXh6uv2eNmLzKaUeM=
+github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
+github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s=
 github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -34,7 +36,6 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
 github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
 github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
 github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -62,6 +63,8 @@ github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW
 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
+github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
+github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -131,6 +134,8 @@ github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1 h1:NicmruxkeqHjDv03SfSxqmaLuisddudfP3h5wdXFbhM=
 github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1/go.mod h1:eyp4DdUJAKkr9tvxR3jWhw2mDK7CWABMG5r9uyaKC7I=
+github.com/maxmind/geoipupdate/v6 v6.1.0 h1:sdtTHzzQNJlXF5+fd/EoPTucRHyMonYt/Cok8xzzfqA=
+github.com/maxmind/geoipupdate/v6 v6.1.0/go.mod h1:cZYCDzfMzTY4v6dKRdV7KTB6SStxtn3yFkiJ1btTGGc=
 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
diff --git a/lib/geoip/geoip.go b/lib/geoip/geoip.go
new file mode 100644
index 00000000000..3400587e0b5
--- /dev/null
+++ b/lib/geoip/geoip.go
@@ -0,0 +1,124 @@
+// Copyright (C) 2024 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+// Package geoip provides an automatically updating MaxMind GeoIP2 database
+// provider.
+package geoip
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+
+	"github.com/maxmind/geoipupdate/v6/pkg/geoipupdate"
+	"github.com/oschwald/geoip2-golang"
+)
+
+type Provider struct {
+	edition         string
+	accountID       int
+	licenseKey      string
+	refreshInterval time.Duration
+	directory       string
+
+	mut          sync.Mutex
+	currentDBDir string
+	db           *geoip2.Reader
+}
+
+// NewGeoLite2CityProvider returns a new GeoIP2 database provider for the
+// GeoLite2-City database. The database will be stored in the given
+// directory (which should exist) and refreshed every 7 days.
+func NewGeoLite2CityProvider(ctx context.Context, accountID int, licenseKey string, directory string) (*Provider, error) {
+	p := &Provider{
+		edition:         "GeoLite2-City",
+		accountID:       accountID,
+		licenseKey:      licenseKey,
+		refreshInterval: 7 * 24 * time.Hour,
+		directory:       directory,
+	}
+
+	if err := p.download(ctx); err != nil {
+		return nil, err
+	}
+
+	return p, nil
+}
+
+func (p *Provider) City(ip net.IP) (*geoip2.City, error) {
+	p.mut.Lock()
+	defer p.mut.Unlock()
+
+	if p.db == nil {
+		return nil, errors.New("database not open")
+	}
+
+	return p.db.City(ip)
+}
+
+// Serve downloads the GeoIP2 database and keeps it up to date. It will return
+// when the context is canceled.
+func (p *Provider) Serve(ctx context.Context) error {
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+
+		case <-time.After(p.refreshInterval):
+			if err := p.download(ctx); err != nil {
+				return err
+			}
+		}
+	}
+}
+
+func (p *Provider) download(ctx context.Context) error {
+	newSubdir, err := os.MkdirTemp(p.directory, "geoipupdate")
+	if err != nil {
+		return fmt.Errorf("download: %w", err)
+	}
+
+	cfg := &geoipupdate.Config{
+		URL:               "https://updates.maxmind.com",
+		DatabaseDirectory: newSubdir,
+		LockFile:          filepath.Join(newSubdir, "geoipupdate.lock"),
+		RetryFor:          5 * time.Minute,
+		Parallelism:       1,
+		AccountID:         p.accountID,
+		LicenseKey:        p.licenseKey,
+		EditionIDs:        []string{p.edition},
+	}
+
+	if err := geoipupdate.NewClient(cfg).Run(ctx); err != nil {
+		return fmt.Errorf("download: %w", err)
+	}
+
+	dbPath := filepath.Join(newSubdir, p.edition+".mmdb")
+	db, err := geoip2.Open(dbPath)
+	if err != nil {
+		return fmt.Errorf("open downloaded db: %w", err)
+	}
+
+	p.mut.Lock()
+	prevDBDir := p.currentDBDir
+	if p.db != nil {
+		p.db.Close()
+	}
+	p.currentDBDir = newSubdir
+	p.db = db
+	p.mut.Unlock()
+
+	if prevDBDir != "" {
+		_ = os.RemoveAll(p.currentDBDir)
+	}
+
+	return nil
+}
diff --git a/script/strelaypoolsrv-entrypoint.sh b/script/strelaypoolsrv-entrypoint.sh
deleted file mode 100755
index 945c77d5b00..00000000000
--- a/script/strelaypoolsrv-entrypoint.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/sh
-
-set -eu
-
-if [ "$MAXMIND_KEY" != "" ] ; then
-	curl "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=${MAXMIND_KEY}&suffix=tar.gz" \
-	| tar --strip-components 1 -zxv
-fi
-
-exec "$@"


Test patch:
diff --git a/lib/geoip/geoip_test.go b/lib/geoip/geoip_test.go
new file mode 100644
index 00000000000..bf22c1c363f
--- /dev/null
+++ b/lib/geoip/geoip_test.go
@@ -0,0 +1,36 @@
+// Copyright (C) 2024 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package geoip
+
+import (
+	"context"
+	"net"
+	"os"
+	"strconv"
+	"testing"
+)
+
+func TestDownloadAndOpen(t *testing.T) {
+	acctID, _ := strconv.Atoi(os.Getenv("GEOIP_ACCOUNT_ID"))
+	if acctID == 0 {
+		t.Skip("No account ID set")
+	}
+	license := os.Getenv("GEOIP_LICENSE_KEY")
+	if license == "" {
+		t.Skip("No license key set")
+	}
+
+	p, err := NewGeoLite2CityProvider(context.Background(), acctID, license, t.TempDir())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, err = p.City(net.ParseIP("8.8.8.8"))
+	if err != nil {
+		t.Fatal(err)
+	}
+}