Преглед на файлове

fix(geofile): verify downloaded geo databases against published digests (#6404)

* fix(geofile): verify downloaded geo databases against published digests

UpdateGeofile wrote whatever the three upstreams returned straight into the
Xray asset folder with no integrity check. Xray parses these databases when it
builds its routing matchers, so a corrupted or substituted file takes the core
down at its next start.

The panel already does this for the other artifact it downloads: installXray
checks the release archive against the SHA-256 published in its .dgst sidecar.
The geo databases were the one download that skipped it, even though all three
upstreams publish a <asset>.sha256sum beside every .dat.

Fetch that sidecar, compare it against the bytes that actually arrived, and
stage every file in a temporary folder first, so one bad database installs
nothing rather than leaving the core running databases from two releases.

Match the digest line by base name rather than by the path it records.
Loyalsoldier and runetfreedom write "<hash>  geoip.dat" while chocolate4u
writes "<hash>  release/geoip.dat" -- the path from its own build -- so
`sha256sum --check` semantics fail on a perfectly good download.

Also skip the Xray restart when every upstream answered 304. The conditional
GET was already there, but the restart ran unconditionally and dropped every
client connection on a refresh that changed nothing.

Assisted-by: Claude Code:claude-opus-5 (mostly)

* fix(geofile): pin the release and scope atomicity to one upstream

Four corrections to the digest verification, all from review.

Pin the release. The asset and its .sha256sum were fetched as two independent
requests to releases/latest/download/, so GitHub re-resolved "latest" between
them. These upstreams publish several times a day -- 202609022346, 202609030908
and 202609031849 are three tags from one day -- so a release landing mid-batch
had release N+1's digest checked against release N's bytes, reporting a healthy
upstream as "corrupted or tampered with". Resolve the tag once per upstream from
the redirect GitHub already returns, then fetch body and digest from it. Modeling
the entry as repo + asset rather than an opaque URL is what makes that possible.

Scope atomicity to one upstream. A single failure discarded every verified
download, so one transient 5xx from one of three independent repositories threw
away four good files and re-downloaded tens of MB on the next attempt. The
integrity argument holds for a geoip/geosite pair out of one release; across
repositories it buys nothing. Each upstream now installs or aborts on its own
and errors are collected, as the code did before this feature.

Make the all-or-none test deterministic. It ranged a map, so when the corrupt
entry came first the run returned before the good file was ever requested and
the assertions held trivially -- a coin flip that would also pass against an
implementation installing each file as it verified. Iteration is sorted now, and
the test asserts the good file was actually downloaded first.

Assert which error. The error table checked only that err != nil, so its two
branches could swallow each other's cases; each row now pins the message. Also
trims three comment blocks to the two-line limit.

Assisted-by: Claude Code:claude-opus-5 (mostly)
ilyusha преди 9 часа
родител
ревизия
fc05249e0c
променени са 2 файла, в които са добавени 641 реда и са изтрити 80 реда
  1. 247 80
      internal/web/service/server.go
  2. 394 0
      internal/web/service/server_geofile_test.go

+ 247 - 80
internal/web/service/server.go

@@ -14,6 +14,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"maps"
 	"math"
 	"mime/multipart"
 	stdnet "net"
@@ -21,6 +22,7 @@ import (
 	"net/url"
 	"os"
 	"os/exec"
+	"path"
 	"path/filepath"
 	"regexp"
 	"runtime"
@@ -2181,20 +2183,42 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
 	return matched
 }
 
-func (s *ServerService) UpdateGeofile(fileName string) error {
-	type geofileEntry struct {
-		URL      string
-		FileName string
-	}
-	geofileAllowlist := map[string]geofileEntry{
-		"geoip.dat":      {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
-		"geosite.dat":    {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
-		"geoip_IR.dat":   {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
-		"geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"},
-		"geoip_RU.dat":   {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"},
-		"geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
-	}
+// Repo is the upstream release base and Asset the name it publishes under; all
+// three publish "geoip.dat", so only FileName tells the local copies apart.
+type geofileEntry struct {
+	Repo     string
+	Asset    string
+	FileName string
+}
+
+var geofileAllowlist = map[string]geofileEntry{
+	"geoip.dat":      {"https://github.com/Loyalsoldier/v2ray-rules-dat", "geoip.dat", "geoip.dat"},
+	"geosite.dat":    {"https://github.com/Loyalsoldier/v2ray-rules-dat", "geosite.dat", "geosite.dat"},
+	"geoip_IR.dat":   {"https://github.com/chocolate4u/Iran-v2ray-rules", "geoip.dat", "geoip_IR.dat"},
+	"geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules", "geosite.dat", "geosite_IR.dat"},
+	"geoip_RU.dat":   {"https://github.com/runetfreedom/russia-v2ray-rules-dat", "geoip.dat", "geoip_RU.dat"},
+	"geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat", "geosite.dat", "geosite_RU.dat"},
+}
+
+func (entry geofileEntry) latestURL() string {
+	return entry.Repo + "/releases/latest/download/" + entry.Asset
+}
 
+func (entry geofileEntry) taggedURL(tag string) string {
+	return entry.Repo + "/releases/download/" + tag + "/" + entry.Asset
+}
+
+// stagedGeofile is a verified download waiting to be moved into the asset folder.
+type stagedGeofile struct {
+	destPath  string
+	stagePath string
+}
+
+// restartXrayAfterGeofileUpdate is a seam: tests assert that an update which
+// installed nothing also restarted nothing.
+var restartXrayAfterGeofileUpdate = (*ServerService).RestartXrayService
+
+func (s *ServerService) UpdateGeofile(fileName string) error {
 	// Strict allowlist check to avoid writing uncontrolled files
 	if fileName != "" {
 		if _, ok := geofileAllowlist[fileName]; !ok {
@@ -2202,103 +2226,246 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 		}
 	}
 
+	wanted := geofileAllowlist
+	if fileName != "" {
+		wanted = map[string]geofileEntry{fileName: geofileAllowlist[fileName]}
+	}
+
+	// Atomic per upstream, not across all six: one release's databases belong
+	// together, but a failing repo must not discard another repo's good files.
+	byRepo := make(map[string][]geofileEntry, len(wanted))
+	for _, entry := range wanted {
+		byRepo[entry.Repo] = append(byRepo[entry.Repo], entry)
+	}
+	repos := slices.Sorted(maps.Keys(byRepo))
+
+	binFolder := config.GetBinFolderPath()
+	stageDir, err := os.MkdirTemp(binFolder, "geofile-")
+	if err != nil {
+		return common.NewErrorf("Failed to create staging folder for Geofiles: %v", err)
+	}
+	defer os.RemoveAll(stageDir)
+
 	client := s.settingService.NewProxiedHTTPClient(0)
 
-	downloadFile := func(url, destPath string) error {
-		var req *http.Request
-		req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+	var errorMessages []string
+	installed := 0
+	for _, repo := range repos {
+		entries := byRepo[repo]
+		slices.SortFunc(entries, func(a, b geofileEntry) int { return strings.Compare(a.FileName, b.FileName) })
+
+		staged, err := s.stageGeofileRelease(client, entries, binFolder, stageDir)
 		if err != nil {
-			return common.NewErrorf("Failed to create HTTP request for %s: %v", url, err)
+			errorMessages = append(errorMessages, err.Error())
+			continue
 		}
-
-		var localFileModTime time.Time
-		if fileInfo, err := os.Stat(destPath); err == nil {
-			localFileModTime = fileInfo.ModTime()
-			if !localFileModTime.IsZero() {
-				req.Header.Set("If-Modified-Since", localFileModTime.UTC().Format(http.TimeFormat))
+		for _, file := range staged {
+			if err := os.Rename(file.stagePath, file.destPath); err != nil {
+				errorMessages = append(errorMessages, fmt.Sprintf("Failed to install Geofile %s: %v", file.destPath, err))
+				continue
 			}
+			installed++
 		}
+	}
 
-		resp, err := client.Do(req)
-		if err != nil {
-			return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)
+	// Nothing changed, so there is no reason to restart the core and drop every
+	// client connection.
+	if installed > 0 {
+		if err := restartXrayAfterGeofileUpdate(s); err != nil {
+			errorMessages = append(errorMessages, fmt.Sprintf("Updated Geofiles but Failed to start Xray: %v", err))
 		}
-		defer resp.Body.Close()
+	}
 
-		// Parse Last-Modified header from server
-		var serverModTime time.Time
-		serverModTimeStr := resp.Header.Get("Last-Modified")
-		if serverModTimeStr != "" {
-			parsedTime, err := time.Parse(http.TimeFormat, serverModTimeStr)
-			if err != nil {
-				logger.Warningf("Failed to parse Last-Modified header for %s: %v", url, err)
-			} else {
-				serverModTime = parsedTime
-			}
-		}
+	if len(errorMessages) > 0 {
+		return common.NewErrorf("%s", strings.Join(errorMessages, "\r\n"))
+	}
 
-		// Function to update local file's modification time
-		updateFileModTime := func() {
-			if !serverModTime.IsZero() {
-				if err := os.Chtimes(destPath, serverModTime, serverModTime); err != nil {
-					logger.Warningf("Failed to update modification time for %s: %v", destPath, err)
-				}
-			}
-		}
+	return nil
+}
 
-		// Handle 304 Not Modified
-		if resp.StatusCode == http.StatusNotModified {
-			updateFileModTime()
-			return nil
+// stageGeofileRelease downloads one upstream's databases and verifies each
+// against a digest from the same release, staging all of them or none.
+func (s *ServerService) stageGeofileRelease(client *http.Client, entries []geofileEntry, binFolder, stageDir string) ([]stagedGeofile, error) {
+	// Resolve "latest" once. These upstreams publish several times a day, and a
+	// release landing mid-batch would check one release's digest against another's bytes.
+	tag, err := resolveGeofileTag(client, entries[0].latestURL())
+	if err != nil {
+		return nil, common.NewErrorf("Error resolving Geofile release from %s: %v", entries[0].Repo, err)
+	}
+
+	var staged []stagedGeofile
+	for _, entry := range entries {
+		destPath := filepath.Join(binFolder, entry.FileName)
+		stagePath := filepath.Join(stageDir, entry.FileName)
+		changed, err := s.stageGeofile(client, entry, tag, destPath, stagePath)
+		if err != nil {
+			return nil, common.NewErrorf("Error downloading Geofile '%s': %v", entry.FileName, err)
 		}
+		if changed {
+			staged = append(staged, stagedGeofile{destPath: destPath, stagePath: stagePath})
+		}
+	}
+	return staged, nil
+}
+
+// resolveGeofileTag reads the immutable release tag a `latest` download
+// redirects to, so the asset and its digest cannot come from two releases.
+func resolveGeofileTag(client *http.Client, latestURL string) (string, error) {
+	pinned := *client
+	pinned.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
+
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, latestURL, nil)
+	if err != nil {
+		return "", err
+	}
+	resp, err := pinned.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	_, _ = io.Copy(io.Discard, resp.Body)
 
-		if resp.StatusCode != http.StatusOK {
-			return common.NewErrorf("Failed to download Geofile from %s: received status code %d", url, resp.StatusCode)
+	location := resp.Header.Get("Location")
+	if location == "" {
+		return "", common.NewErrorf("expected a redirect to a tagged release, got HTTP %d", resp.StatusCode)
+	}
+	return geofileTagFromLocation(location)
+}
+
+// geofileTagFromLocation pulls <tag> out of a .../releases/download/<tag>/<asset>
+// redirect target.
+func geofileTagFromLocation(location string) (string, error) {
+	const marker = "/releases/download/"
+	idx := strings.Index(location, marker)
+	if idx < 0 {
+		return "", common.NewErrorf("unexpected release redirect %q", location)
+	}
+	tag, _, found := strings.Cut(location[idx+len(marker):], "/")
+	if !found || tag == "" {
+		return "", common.NewErrorf("unexpected release redirect %q", location)
+	}
+	return tag, nil
+}
+
+// stageGeofile downloads one database into stagePath and checks it against the
+// SHA-256 its upstream publishes. It reports false on 304, staging nothing.
+func (s *ServerService) stageGeofile(client *http.Client, entry geofileEntry, tag, destPath, stagePath string) (bool, error) {
+	assetURL := entry.taggedURL(tag)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, assetURL, nil)
+	if err != nil {
+		return false, common.NewErrorf("Failed to create HTTP request for %s: %v", assetURL, err)
+	}
+
+	if fileInfo, err := os.Stat(destPath); err == nil {
+		if localFileModTime := fileInfo.ModTime(); !localFileModTime.IsZero() {
+			req.Header.Set("If-Modified-Since", localFileModTime.UTC().Format(http.TimeFormat))
 		}
+	}
 
-		file, err := os.Create(destPath)
+	resp, err := client.Do(req)
+	if err != nil {
+		return false, common.NewErrorf("Failed to download Geofile from %s: %v", assetURL, err)
+	}
+	defer resp.Body.Close()
+
+	// Parse Last-Modified header from server
+	var serverModTime time.Time
+	if serverModTimeStr := resp.Header.Get("Last-Modified"); serverModTimeStr != "" {
+		parsedTime, err := time.Parse(http.TimeFormat, serverModTimeStr)
 		if err != nil {
-			return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err)
+			logger.Warningf("Failed to parse Last-Modified header for %s: %v", assetURL, err)
+		} else {
+			serverModTime = parsedTime
 		}
-		defer file.Close()
+	}
 
-		_, err = io.Copy(file, resp.Body)
-		if err != nil {
-			return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err)
+	// The conditional GET above reads this back, so it must survive the rename.
+	setModTime := func(target string) {
+		if !serverModTime.IsZero() {
+			if err := os.Chtimes(target, serverModTime, serverModTime); err != nil {
+				logger.Warningf("Failed to update modification time for %s: %v", target, err)
+			}
 		}
+	}
 
-		updateFileModTime()
-		return nil
+	// Handle 304 Not Modified
+	if resp.StatusCode == http.StatusNotModified {
+		setModTime(destPath)
+		return false, nil
 	}
 
-	var errorMessages []string
+	if resp.StatusCode != http.StatusOK {
+		return false, common.NewErrorf("Failed to download Geofile from %s: received status code %d", assetURL, resp.StatusCode)
+	}
 
-	if fileName == "" {
-		// Download all geofiles
-		for _, entry := range geofileAllowlist {
-			destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
-			if err := downloadFile(entry.URL, destPath); err != nil {
-				errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
-			}
-		}
-	} else {
-		entry := geofileAllowlist[fileName]
-		destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
-		if err := downloadFile(entry.URL, destPath); err != nil {
-			errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
-		}
+	file, err := os.Create(stagePath)
+	if err != nil {
+		return false, common.NewErrorf("Failed to create Geofile %s: %v", stagePath, err)
+	}
+	hasher := sha256.New()
+	if _, err := io.Copy(io.MultiWriter(file, hasher), resp.Body); err != nil {
+		file.Close()
+		return false, common.NewErrorf("Failed to save Geofile %s: %v", stagePath, err)
+	}
+	if err := file.Close(); err != nil {
+		return false, common.NewErrorf("Failed to save Geofile %s: %v", stagePath, err)
 	}
 
-	err := s.RestartXrayService()
+	// TLS protects the transport, not the artifact. Xray parses these databases
+	// when it builds its routing matchers, so a bad one takes the core down.
+	want, err := s.fetchGeofileDigest(client, assetURL+".sha256sum", entry.Asset)
 	if err != nil {
-		errorMessages = append(errorMessages, fmt.Sprintf("Updated Geofile '%s' but Failed to start Xray: %v", fileName, err))
+		return false, err
+	}
+	if got := hex.EncodeToString(hasher.Sum(nil)); !strings.EqualFold(got, want) {
+		return false, common.NewErrorf("does not match the published SHA-256 checksum, so the download is corrupted or has been tampered with (expected %s, got %s)", want, got)
 	}
 
-	if len(errorMessages) > 0 {
-		return common.NewErrorf("%s", strings.Join(errorMessages, "\r\n"))
+	setModTime(stagePath)
+	return true, nil
+}
+
+// fetchGeofileDigest downloads the .sha256sum sidecar published beside a geo
+// database and returns the digest it lists for assetName.
+func (s *ServerService) fetchGeofileDigest(client *http.Client, sumsURL, assetName string) (string, error) {
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, sumsURL, nil)
+	if reqErr != nil {
+		return "", fmt.Errorf("download geofile checksum: %w", reqErr)
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("download geofile checksum: %w", err)
 	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("download geofile checksum: unexpected HTTP %d", resp.StatusCode)
+	}
+	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxXrayDigestBytes))
+	if err != nil {
+		return "", fmt.Errorf("download geofile checksum: %w", err)
+	}
+	return parseGeofileDigest(raw, assetName)
+}
 
-	return nil
+// parseGeofileDigest returns the SHA-256 hex a sidecar lists for assetName,
+// matching on base name since upstreams record "geoip.dat" or "release/geoip.dat".
+func parseGeofileDigest(sums []byte, assetName string) (string, error) {
+	for line := range strings.SplitSeq(string(sums), "\n") {
+		fields := strings.Fields(line)
+		if len(fields) != 2 {
+			continue
+		}
+		// A leading "*" is sha256sum's own binary-mode marker, not part of the name.
+		if path.Base(strings.TrimPrefix(fields[1], "*")) != assetName {
+			continue
+		}
+		digest := strings.ToLower(fields[0])
+		if _, err := hex.DecodeString(digest); err != nil || len(digest) != sha256.Size*2 {
+			return "", fmt.Errorf("geofile checksum: malformed SHA-256 entry for %s", assetName)
+		}
+		return digest, nil
+	}
+	return "", fmt.Errorf("geofile checksum: no SHA-256 entry for %s", assetName)
 }
 
 // parseXrayKeyPairOutput reads the two-line "Label: value" output that xray's

+ 394 - 0
internal/web/service/server_geofile_test.go

@@ -0,0 +1,394 @@
+package service
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+// Loyalsoldier and runetfreedom write "<hash>  geoip.dat"; chocolate4u writes
+// "<hash>  release/geoip.dat", the path from its own build.
+func TestParseGeofileDigest(t *testing.T) {
+	const digest = "0d5d2ba0c5a5c58027fd1347a6afd57c9470799b6bb3cbc274fd4657ed8de382"
+
+	for _, tc := range []struct {
+		name  string
+		sums  string
+		asset string
+		want  string
+	}{
+		{"bare-name", digest + "  geoip.dat\n", "geoip.dat", digest},
+		{"build-path", digest + "  release/geoip.dat\n", "geoip.dat", digest},
+		{"binary-mode-marker", digest + "  *geoip.dat\n", "geoip.dat", digest},
+		{"uppercase-digest", strings.ToUpper(digest) + "  geoip.dat\n", "geoip.dat", digest},
+		{"picks-matching-line", "aaaa  geosite.dat\n" + digest + "  geoip.dat\n", "geoip.dat", digest},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := parseGeofileDigest([]byte(tc.sums), tc.asset)
+			if err != nil {
+				t.Fatalf("parse: %v", err)
+			}
+			if got != tc.want {
+				t.Fatalf("digest = %q, want %q", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestParseGeofileDigest_Errors(t *testing.T) {
+	const digest = "0d5d2ba0c5a5c58027fd1347a6afd57c9470799b6bb3cbc274fd4657ed8de382"
+
+	for _, tc := range []struct {
+		name    string
+		sums    string
+		asset   string
+		wantErr string
+	}{
+		// Accepting this would verify geoip.dat against geosite.dat's digest.
+		{"names-another-asset", digest + "  geosite.dat\n", "geoip.dat", "no SHA-256 entry for geoip.dat"},
+		{"empty", "", "geoip.dat", "no SHA-256 entry for geoip.dat"},
+		{"malformed-short", "deadbeef  geoip.dat\n", "geoip.dat", "malformed SHA-256 entry for geoip.dat"},
+		{"not-hex", strings.Repeat("z", 64) + "  geoip.dat\n", "geoip.dat", "malformed SHA-256 entry for geoip.dat"},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := parseGeofileDigest([]byte(tc.sums), tc.asset)
+			if err == nil {
+				t.Fatalf("%s: expected an error", tc.name)
+			}
+			if !strings.Contains(err.Error(), tc.wantErr) {
+				t.Fatalf("error = %q, want it to contain %q", err, tc.wantErr)
+			}
+		})
+	}
+}
+
+func TestGeofileTagFromLocation(t *testing.T) {
+	got, err := geofileTagFromLocation("https://github.com/o/r/releases/download/202609022346/geoip.dat")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if got != "202609022346" {
+		t.Fatalf("tag = %q, want 202609022346", got)
+	}
+	for _, bad := range []string{
+		"https://github.com/o/r/releases/latest/download/geoip.dat",
+		"https://github.com/o/r/releases/download/202609022346",
+		"",
+	} {
+		if _, err := geofileTagFromLocation(bad); err == nil {
+			t.Fatalf("expected an error for %q", bad)
+		}
+	}
+}
+
+// fakeUpstream serves one repo's release: a `latest` download redirecting to a
+// tagged asset, the asset itself, and its .sha256sum sidecar.
+type fakeUpstream struct {
+	repo    string
+	assets  map[string]string
+	corrupt map[string]bool
+}
+
+// geofileServer mounts every upstream on one test server, mimicking GitHub's
+// `releases/latest/download` -> `releases/download/<tag>` redirect.
+func geofileServer(t *testing.T, ups []fakeUpstream) (*httptest.Server, *sync.Map) {
+	t.Helper()
+
+	hits := &sync.Map{}
+	mux := http.NewServeMux()
+	for _, up := range ups {
+		for asset, body := range up.assets {
+			tagged := "/" + up.repo + "/releases/download/v1/" + asset
+
+			mux.HandleFunc("/"+up.repo+"/releases/latest/download/"+asset, func(w http.ResponseWriter, r *http.Request) {
+				hits.Store("latest:"+up.repo, true)
+				http.Redirect(w, r, tagged, http.StatusFound)
+			})
+			mux.HandleFunc(tagged, func(w http.ResponseWriter, r *http.Request) {
+				hits.Store("body:"+up.repo+"/"+asset, true)
+				_, _ = w.Write([]byte(body))
+			})
+
+			payload := body
+			if up.corrupt[asset] {
+				payload = body + " tampered"
+			}
+			sum := sha256.Sum256([]byte(payload))
+			line := fmt.Sprintf("%s  %s\n", hex.EncodeToString(sum[:]), asset)
+			mux.HandleFunc(tagged+".sha256sum", func(w http.ResponseWriter, r *http.Request) {
+				_, _ = w.Write([]byte(line))
+			})
+		}
+	}
+
+	srv := httptest.NewServer(mux)
+	t.Cleanup(srv.Close)
+	return srv, hits
+}
+
+// geofileTestEnv points the service at a temp asset folder and a throwaway DB.
+func geofileTestEnv(t *testing.T, entries map[string]geofileEntry) string {
+	t.Helper()
+
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	binFolder := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", binFolder)
+
+	originalAllowlist := geofileAllowlist
+	geofileAllowlist = entries
+	t.Cleanup(func() { geofileAllowlist = originalAllowlist })
+
+	return binFolder
+}
+
+func restartStub(t *testing.T, called *bool) {
+	t.Helper()
+	original := restartXrayAfterGeofileUpdate
+	restartXrayAfterGeofileUpdate = func(*ServerService) error {
+		*called = true
+		return nil
+	}
+	t.Cleanup(func() { restartXrayAfterGeofileUpdate = original })
+}
+
+func TestUpdateGeofileInstallsVerifiedFile(t *testing.T) {
+	srv, _ := geofileServer(t, []fakeUpstream{{repo: "a", assets: map[string]string{"geoip.dat": "good geoip payload"}}})
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	if err := (&ServerService{}).UpdateGeofile(""); err != nil {
+		t.Fatalf("UpdateGeofile: %v", err)
+	}
+
+	got, err := os.ReadFile(filepath.Join(binFolder, "geoip.dat"))
+	if err != nil {
+		t.Fatalf("read installed geofile: %v", err)
+	}
+	if string(got) != "good geoip payload" {
+		t.Fatalf("installed content = %q, want %q", got, "good geoip payload")
+	}
+	if !restarted {
+		t.Fatal("a file was installed, so xray should have been restarted")
+	}
+}
+
+func TestUpdateGeofileRejectsDigestMismatch(t *testing.T) {
+	srv, _ := geofileServer(t, []fakeUpstream{{
+		repo:    "a",
+		assets:  map[string]string{"geoip.dat": "good geoip payload"},
+		corrupt: map[string]bool{"geoip.dat": true},
+	}})
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	err := (&ServerService{}).UpdateGeofile("")
+	if err == nil {
+		t.Fatal("expected an error when the download does not match its published digest")
+	}
+	if !strings.Contains(err.Error(), "does not match the published SHA-256 checksum") {
+		t.Fatalf("error = %q, want it to name the checksum mismatch", err)
+	}
+
+	if _, statErr := os.Stat(filepath.Join(binFolder, "geoip.dat")); !os.IsNotExist(statErr) {
+		t.Fatalf("a file failing verification must not be installed (stat: %v)", statErr)
+	}
+	if restarted {
+		t.Fatal("nothing was installed, so xray must not be restarted")
+	}
+}
+
+// Within one upstream the pair installs together. geoip sorts before geosite
+// and is staged first, so a trivially-passing "abort before download" is ruled out.
+func TestUpdateGeofileInstallsNeitherFileOfAFailedUpstream(t *testing.T) {
+	srv, hits := geofileServer(t, []fakeUpstream{{
+		repo:    "a",
+		assets:  map[string]string{"geoip.dat": "good geoip", "geosite.dat": "good geosite"},
+		corrupt: map[string]bool{"geosite.dat": true},
+	}})
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat":   {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+		"geosite.dat": {srv.URL + "/a", "geosite.dat", "geosite.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	if err := (&ServerService{}).UpdateGeofile(""); err == nil {
+		t.Fatal("expected an error when one of the databases fails verification")
+	}
+
+	if _, ok := hits.Load("body:a/geoip.dat"); !ok {
+		t.Fatal("geoip.dat was never downloaded, so this run never exercised staging")
+	}
+	for _, name := range []string{"geoip.dat", "geosite.dat"} {
+		if _, statErr := os.Stat(filepath.Join(binFolder, name)); !os.IsNotExist(statErr) {
+			t.Fatalf("%s was installed even though its sibling failed verification", name)
+		}
+	}
+	if restarted {
+		t.Fatal("nothing was installed, so xray must not be restarted")
+	}
+}
+
+// A broken upstream must not discard a healthy one's verified download.
+func TestUpdateGeofileKeepsGoodUpstreamWhenAnotherFails(t *testing.T) {
+	srv, _ := geofileServer(t, []fakeUpstream{
+		{repo: "aaa", assets: map[string]string{"geoip.dat": "healthy payload"}},
+		{
+			repo:    "zzz",
+			assets:  map[string]string{"geoip.dat": "broken payload"},
+			corrupt: map[string]bool{"geoip.dat": true},
+		},
+	})
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat":    {srv.URL + "/aaa", "geoip.dat", "geoip.dat"},
+		"geoip_RU.dat": {srv.URL + "/zzz", "geoip.dat", "geoip_RU.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	err := (&ServerService{}).UpdateGeofile("")
+	if err == nil {
+		t.Fatal("expected an error naming the failing upstream")
+	}
+	if !strings.Contains(err.Error(), "geoip_RU.dat") {
+		t.Fatalf("error = %q, want it to name geoip_RU.dat", err)
+	}
+
+	got, readErr := os.ReadFile(filepath.Join(binFolder, "geoip.dat"))
+	if readErr != nil {
+		t.Fatalf("the healthy upstream's file must still be installed: %v", readErr)
+	}
+	if string(got) != "healthy payload" {
+		t.Fatalf("installed content = %q, want %q", got, "healthy payload")
+	}
+	if _, statErr := os.Stat(filepath.Join(binFolder, "geoip_RU.dat")); !os.IsNotExist(statErr) {
+		t.Fatal("the failing upstream's file must not be installed")
+	}
+	if !restarted {
+		t.Fatal("a file was installed, so xray should have been restarted")
+	}
+}
+
+// The upstreams publish several times a day. Once `latest` is resolved, the
+// asset and its digest must both come from that release, not from a newer one.
+func TestUpdateGeofileSurvivesReleaseRotation(t *testing.T) {
+	const oldBody = "release one payload"
+	oldSum := sha256.Sum256([]byte(oldBody))
+	newSum := sha256.Sum256([]byte("release two payload"))
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/a/releases/latest/download/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/a/releases/download/v1/geoip.dat", http.StatusFound)
+	})
+	mux.HandleFunc("/a/releases/download/v1/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(oldBody))
+	})
+	mux.HandleFunc("/a/releases/download/v1/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write(fmt.Appendf(nil, "%s  geoip.dat\n", hex.EncodeToString(oldSum[:])))
+	})
+	// "latest" has already moved on to v2. Anything still resolving it gets a
+	// digest for bytes we never downloaded.
+	mux.HandleFunc("/a/releases/latest/download/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write(fmt.Appendf(nil, "%s  geoip.dat\n", hex.EncodeToString(newSum[:])))
+	})
+	srv := httptest.NewServer(mux)
+	t.Cleanup(srv.Close)
+
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	if err := (&ServerService{}).UpdateGeofile(""); err != nil {
+		t.Fatalf("a release landing mid-batch must not look like tampering: %v", err)
+	}
+	if got, err := os.ReadFile(filepath.Join(binFolder, "geoip.dat")); err != nil || string(got) != oldBody {
+		t.Fatalf("installed = %q (err %v), want the pinned release's bytes", got, err)
+	}
+}
+
+func TestUpdateGeofileSkipsRestartWhenNotModified(t *testing.T) {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/a/releases/latest/download/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/a/releases/download/v1/geoip.dat", http.StatusFound)
+	})
+	mux.HandleFunc("/a/releases/download/v1/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("If-Modified-Since") == "" {
+			t.Errorf("expected a conditional GET carrying If-Modified-Since")
+		}
+		w.WriteHeader(http.StatusNotModified)
+	})
+	mux.HandleFunc("/a/releases/download/v1/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
+		t.Error("the sidecar must not be fetched when the asset is unchanged")
+	})
+	srv := httptest.NewServer(mux)
+	t.Cleanup(srv.Close)
+
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+	})
+
+	existing := filepath.Join(binFolder, "geoip.dat")
+	if err := os.WriteFile(existing, []byte("already current"), 0o644); err != nil {
+		t.Fatalf("seed existing geofile: %v", err)
+	}
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	if err := (&ServerService{}).UpdateGeofile(""); err != nil {
+		t.Fatalf("UpdateGeofile: %v", err)
+	}
+
+	if restarted {
+		t.Fatal("a 304 from every upstream must not restart xray and drop client connections")
+	}
+	got, err := os.ReadFile(existing)
+	if err != nil {
+		t.Fatalf("read existing geofile: %v", err)
+	}
+	if string(got) != "already current" {
+		t.Fatalf("existing content = %q, want it left alone", got)
+	}
+}
+
+func TestUpdateGeofileRejectsNameOutsideAllowlist(t *testing.T) {
+	geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {"https://example.invalid", "geoip.dat", "geoip.dat"},
+	})
+
+	err := (&ServerService{}).UpdateGeofile("../../etc/passwd")
+	if err == nil {
+		t.Fatal("expected an error for a name outside the allowlist")
+	}
+	if !strings.Contains(err.Error(), "not in allowlist") {
+		t.Fatalf("error = %q, want it to name the allowlist", err)
+	}
+}