mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-30 19:22:10 +03:00
12d51d7195
Most tests opened a throwaway panel DB with database.InitDB, which runs the full AutoMigrate + seed on an empty file every time: ~230ms, and ~850ms under -race because GORM's reflection-heavy migration is what the detector slows most. internal/web/service does this in ~550 of its 830 tests, so the CI race job spent ~10 of its ~14.6 minutes re-migrating empty databases. internal/database/dbtest.InitDB migrates once per test process, then hands each test its own copy of that file (~130ms under -race) and registers the CloseDB cleanup. The copy then goes through InitDB like a panel restart, so every test still starts from the state a fresh install has. Tests that reopen an existing file, migrate a hand-built legacy DB or target Postgres keep calling database.InitDB. Locally under -race: internal/web/service 626s (last CI run) -> 114s, internal/sub 246s -> 35s.
413 lines
14 KiB
Go
413 lines
14 KiB
Go
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/dbtest"
|
|
)
|
|
|
|
// 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)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestStandardGeodataSources(t *testing.T) {
|
|
want := []GeodataSource{
|
|
{URL: "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", File: "geoip.dat"},
|
|
{URL: "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", File: "geoip_IR.dat"},
|
|
{URL: "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", File: "geoip_RU.dat"},
|
|
{URL: "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", File: "geosite.dat"},
|
|
{URL: "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", File: "geosite_IR.dat"},
|
|
{URL: "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", File: "geosite_RU.dat"},
|
|
}
|
|
|
|
got := StandardGeodataSources()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("sources = %d entries, want %d", len(got), len(want))
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("source %d = %+v, want %+v", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|