diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 43775bb8c..401adaba2 100644 --- a/internal/web/service/server.go +++ b/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,96 +2226,54 @@ 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) - if err != nil { - return common.NewErrorf("Failed to create HTTP request for %s: %v", url, err) - } - - 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)) - } - } - - resp, err := client.Do(req) - if err != nil { - return common.NewErrorf("Failed to download Geofile from %s: %v", url, 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 - } - } - - // 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) - } - } - } - - // Handle 304 Not Modified - if resp.StatusCode == http.StatusNotModified { - updateFileModTime() - return nil - } - - if resp.StatusCode != http.StatusOK { - return common.NewErrorf("Failed to download Geofile from %s: received status code %d", url, resp.StatusCode) - } - - file, err := os.Create(destPath) - if err != nil { - return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err) - } - defer file.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err) - } - - updateFileModTime() - return 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) }) - 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)) - } + staged, err := s.stageGeofileRelease(client, entries, binFolder, stageDir) + if err != nil { + errorMessages = append(errorMessages, err.Error()) + continue } - } 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)) + 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++ } } - err := s.RestartXrayService() - if err != nil { - errorMessages = append(errorMessages, fmt.Sprintf("Updated Geofile '%s' but Failed to start Xray: %v", fileName, 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)) + } } if len(errorMessages) > 0 { @@ -2301,6 +2283,191 @@ func (s *ServerService) UpdateGeofile(fileName string) error { 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) + + 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 out of a .../releases/download// +// 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)) + } + } + + 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 { + logger.Warningf("Failed to parse Last-Modified header for %s: %v", assetURL, err) + } else { + serverModTime = parsedTime + } + } + + // 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) + } + } + } + + // Handle 304 Not Modified + if resp.StatusCode == http.StatusNotModified { + setModTime(destPath) + return false, nil + } + + if resp.StatusCode != http.StatusOK { + return false, common.NewErrorf("Failed to download Geofile from %s: received status code %d", assetURL, resp.StatusCode) + } + + 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) + } + + // 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 { + 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) + } + + 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) +} + +// 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 // key-generation subcommands (x25519, mldsa65, mlkem768) print and returns the // two values. Short or label-less output yields an error instead of panicking diff --git a/internal/web/service/server_geofile_test.go b/internal/web/service/server_geofile_test.go new file mode 100644 index 000000000..14c91c8cf --- /dev/null +++ b/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 " geoip.dat"; chocolate4u writes +// " 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/` 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) + } +}