server_geofile_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. package service
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "sync"
  12. "testing"
  13. "github.com/mhsanaei/3x-ui/v3/internal/database"
  14. )
  15. // Loyalsoldier and runetfreedom write "<hash> geoip.dat"; chocolate4u writes
  16. // "<hash> release/geoip.dat", the path from its own build.
  17. func TestParseGeofileDigest(t *testing.T) {
  18. const digest = "0d5d2ba0c5a5c58027fd1347a6afd57c9470799b6bb3cbc274fd4657ed8de382"
  19. for _, tc := range []struct {
  20. name string
  21. sums string
  22. asset string
  23. want string
  24. }{
  25. {"bare-name", digest + " geoip.dat\n", "geoip.dat", digest},
  26. {"build-path", digest + " release/geoip.dat\n", "geoip.dat", digest},
  27. {"binary-mode-marker", digest + " *geoip.dat\n", "geoip.dat", digest},
  28. {"uppercase-digest", strings.ToUpper(digest) + " geoip.dat\n", "geoip.dat", digest},
  29. {"picks-matching-line", "aaaa geosite.dat\n" + digest + " geoip.dat\n", "geoip.dat", digest},
  30. } {
  31. t.Run(tc.name, func(t *testing.T) {
  32. got, err := parseGeofileDigest([]byte(tc.sums), tc.asset)
  33. if err != nil {
  34. t.Fatalf("parse: %v", err)
  35. }
  36. if got != tc.want {
  37. t.Fatalf("digest = %q, want %q", got, tc.want)
  38. }
  39. })
  40. }
  41. }
  42. func TestParseGeofileDigest_Errors(t *testing.T) {
  43. const digest = "0d5d2ba0c5a5c58027fd1347a6afd57c9470799b6bb3cbc274fd4657ed8de382"
  44. for _, tc := range []struct {
  45. name string
  46. sums string
  47. asset string
  48. wantErr string
  49. }{
  50. // Accepting this would verify geoip.dat against geosite.dat's digest.
  51. {"names-another-asset", digest + " geosite.dat\n", "geoip.dat", "no SHA-256 entry for geoip.dat"},
  52. {"empty", "", "geoip.dat", "no SHA-256 entry for geoip.dat"},
  53. {"malformed-short", "deadbeef geoip.dat\n", "geoip.dat", "malformed SHA-256 entry for geoip.dat"},
  54. {"not-hex", strings.Repeat("z", 64) + " geoip.dat\n", "geoip.dat", "malformed SHA-256 entry for geoip.dat"},
  55. } {
  56. t.Run(tc.name, func(t *testing.T) {
  57. _, err := parseGeofileDigest([]byte(tc.sums), tc.asset)
  58. if err == nil {
  59. t.Fatalf("%s: expected an error", tc.name)
  60. }
  61. if !strings.Contains(err.Error(), tc.wantErr) {
  62. t.Fatalf("error = %q, want it to contain %q", err, tc.wantErr)
  63. }
  64. })
  65. }
  66. }
  67. func TestGeofileTagFromLocation(t *testing.T) {
  68. got, err := geofileTagFromLocation("https://github.com/o/r/releases/download/202609022346/geoip.dat")
  69. if err != nil {
  70. t.Fatalf("parse: %v", err)
  71. }
  72. if got != "202609022346" {
  73. t.Fatalf("tag = %q, want 202609022346", got)
  74. }
  75. for _, bad := range []string{
  76. "https://github.com/o/r/releases/latest/download/geoip.dat",
  77. "https://github.com/o/r/releases/download/202609022346",
  78. "",
  79. } {
  80. if _, err := geofileTagFromLocation(bad); err == nil {
  81. t.Fatalf("expected an error for %q", bad)
  82. }
  83. }
  84. }
  85. // fakeUpstream serves one repo's release: a `latest` download redirecting to a
  86. // tagged asset, the asset itself, and its .sha256sum sidecar.
  87. type fakeUpstream struct {
  88. repo string
  89. assets map[string]string
  90. corrupt map[string]bool
  91. }
  92. // geofileServer mounts every upstream on one test server, mimicking GitHub's
  93. // `releases/latest/download` -> `releases/download/<tag>` redirect.
  94. func geofileServer(t *testing.T, ups []fakeUpstream) (*httptest.Server, *sync.Map) {
  95. t.Helper()
  96. hits := &sync.Map{}
  97. mux := http.NewServeMux()
  98. for _, up := range ups {
  99. for asset, body := range up.assets {
  100. tagged := "/" + up.repo + "/releases/download/v1/" + asset
  101. mux.HandleFunc("/"+up.repo+"/releases/latest/download/"+asset, func(w http.ResponseWriter, r *http.Request) {
  102. hits.Store("latest:"+up.repo, true)
  103. http.Redirect(w, r, tagged, http.StatusFound)
  104. })
  105. mux.HandleFunc(tagged, func(w http.ResponseWriter, r *http.Request) {
  106. hits.Store("body:"+up.repo+"/"+asset, true)
  107. _, _ = w.Write([]byte(body))
  108. })
  109. payload := body
  110. if up.corrupt[asset] {
  111. payload = body + " tampered"
  112. }
  113. sum := sha256.Sum256([]byte(payload))
  114. line := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset)
  115. mux.HandleFunc(tagged+".sha256sum", func(w http.ResponseWriter, r *http.Request) {
  116. _, _ = w.Write([]byte(line))
  117. })
  118. }
  119. }
  120. srv := httptest.NewServer(mux)
  121. t.Cleanup(srv.Close)
  122. return srv, hits
  123. }
  124. // geofileTestEnv points the service at a temp asset folder and a throwaway DB.
  125. func geofileTestEnv(t *testing.T, entries map[string]geofileEntry) string {
  126. t.Helper()
  127. dbDir := t.TempDir()
  128. t.Setenv("XUI_DB_FOLDER", dbDir)
  129. if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
  130. t.Fatalf("InitDB: %v", err)
  131. }
  132. t.Cleanup(func() { _ = database.CloseDB() })
  133. binFolder := t.TempDir()
  134. t.Setenv("XUI_BIN_FOLDER", binFolder)
  135. originalAllowlist := geofileAllowlist
  136. geofileAllowlist = entries
  137. t.Cleanup(func() { geofileAllowlist = originalAllowlist })
  138. return binFolder
  139. }
  140. func restartStub(t *testing.T, called *bool) {
  141. t.Helper()
  142. original := restartXrayAfterGeofileUpdate
  143. restartXrayAfterGeofileUpdate = func(*ServerService) error {
  144. *called = true
  145. return nil
  146. }
  147. t.Cleanup(func() { restartXrayAfterGeofileUpdate = original })
  148. }
  149. func TestUpdateGeofileInstallsVerifiedFile(t *testing.T) {
  150. srv, _ := geofileServer(t, []fakeUpstream{{repo: "a", assets: map[string]string{"geoip.dat": "good geoip payload"}}})
  151. binFolder := geofileTestEnv(t, map[string]geofileEntry{
  152. "geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
  153. })
  154. var restarted bool
  155. restartStub(t, &restarted)
  156. if err := (&ServerService{}).UpdateGeofile(""); err != nil {
  157. t.Fatalf("UpdateGeofile: %v", err)
  158. }
  159. got, err := os.ReadFile(filepath.Join(binFolder, "geoip.dat"))
  160. if err != nil {
  161. t.Fatalf("read installed geofile: %v", err)
  162. }
  163. if string(got) != "good geoip payload" {
  164. t.Fatalf("installed content = %q, want %q", got, "good geoip payload")
  165. }
  166. if !restarted {
  167. t.Fatal("a file was installed, so xray should have been restarted")
  168. }
  169. }
  170. func TestUpdateGeofileRejectsDigestMismatch(t *testing.T) {
  171. srv, _ := geofileServer(t, []fakeUpstream{{
  172. repo: "a",
  173. assets: map[string]string{"geoip.dat": "good geoip payload"},
  174. corrupt: map[string]bool{"geoip.dat": true},
  175. }})
  176. binFolder := geofileTestEnv(t, map[string]geofileEntry{
  177. "geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
  178. })
  179. var restarted bool
  180. restartStub(t, &restarted)
  181. err := (&ServerService{}).UpdateGeofile("")
  182. if err == nil {
  183. t.Fatal("expected an error when the download does not match its published digest")
  184. }
  185. if !strings.Contains(err.Error(), "does not match the published SHA-256 checksum") {
  186. t.Fatalf("error = %q, want it to name the checksum mismatch", err)
  187. }
  188. if _, statErr := os.Stat(filepath.Join(binFolder, "geoip.dat")); !os.IsNotExist(statErr) {
  189. t.Fatalf("a file failing verification must not be installed (stat: %v)", statErr)
  190. }
  191. if restarted {
  192. t.Fatal("nothing was installed, so xray must not be restarted")
  193. }
  194. }
  195. // Within one upstream the pair installs together. geoip sorts before geosite
  196. // and is staged first, so a trivially-passing "abort before download" is ruled out.
  197. func TestUpdateGeofileInstallsNeitherFileOfAFailedUpstream(t *testing.T) {
  198. srv, hits := geofileServer(t, []fakeUpstream{{
  199. repo: "a",
  200. assets: map[string]string{"geoip.dat": "good geoip", "geosite.dat": "good geosite"},
  201. corrupt: map[string]bool{"geosite.dat": true},
  202. }})
  203. binFolder := geofileTestEnv(t, map[string]geofileEntry{
  204. "geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
  205. "geosite.dat": {srv.URL + "/a", "geosite.dat", "geosite.dat"},
  206. })
  207. var restarted bool
  208. restartStub(t, &restarted)
  209. if err := (&ServerService{}).UpdateGeofile(""); err == nil {
  210. t.Fatal("expected an error when one of the databases fails verification")
  211. }
  212. if _, ok := hits.Load("body:a/geoip.dat"); !ok {
  213. t.Fatal("geoip.dat was never downloaded, so this run never exercised staging")
  214. }
  215. for _, name := range []string{"geoip.dat", "geosite.dat"} {
  216. if _, statErr := os.Stat(filepath.Join(binFolder, name)); !os.IsNotExist(statErr) {
  217. t.Fatalf("%s was installed even though its sibling failed verification", name)
  218. }
  219. }
  220. if restarted {
  221. t.Fatal("nothing was installed, so xray must not be restarted")
  222. }
  223. }
  224. // A broken upstream must not discard a healthy one's verified download.
  225. func TestUpdateGeofileKeepsGoodUpstreamWhenAnotherFails(t *testing.T) {
  226. srv, _ := geofileServer(t, []fakeUpstream{
  227. {repo: "aaa", assets: map[string]string{"geoip.dat": "healthy payload"}},
  228. {
  229. repo: "zzz",
  230. assets: map[string]string{"geoip.dat": "broken payload"},
  231. corrupt: map[string]bool{"geoip.dat": true},
  232. },
  233. })
  234. binFolder := geofileTestEnv(t, map[string]geofileEntry{
  235. "geoip.dat": {srv.URL + "/aaa", "geoip.dat", "geoip.dat"},
  236. "geoip_RU.dat": {srv.URL + "/zzz", "geoip.dat", "geoip_RU.dat"},
  237. })
  238. var restarted bool
  239. restartStub(t, &restarted)
  240. err := (&ServerService{}).UpdateGeofile("")
  241. if err == nil {
  242. t.Fatal("expected an error naming the failing upstream")
  243. }
  244. if !strings.Contains(err.Error(), "geoip_RU.dat") {
  245. t.Fatalf("error = %q, want it to name geoip_RU.dat", err)
  246. }
  247. got, readErr := os.ReadFile(filepath.Join(binFolder, "geoip.dat"))
  248. if readErr != nil {
  249. t.Fatalf("the healthy upstream's file must still be installed: %v", readErr)
  250. }
  251. if string(got) != "healthy payload" {
  252. t.Fatalf("installed content = %q, want %q", got, "healthy payload")
  253. }
  254. if _, statErr := os.Stat(filepath.Join(binFolder, "geoip_RU.dat")); !os.IsNotExist(statErr) {
  255. t.Fatal("the failing upstream's file must not be installed")
  256. }
  257. if !restarted {
  258. t.Fatal("a file was installed, so xray should have been restarted")
  259. }
  260. }
  261. // The upstreams publish several times a day. Once `latest` is resolved, the
  262. // asset and its digest must both come from that release, not from a newer one.
  263. func TestUpdateGeofileSurvivesReleaseRotation(t *testing.T) {
  264. const oldBody = "release one payload"
  265. oldSum := sha256.Sum256([]byte(oldBody))
  266. newSum := sha256.Sum256([]byte("release two payload"))
  267. mux := http.NewServeMux()
  268. mux.HandleFunc("/a/releases/latest/download/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
  269. http.Redirect(w, r, "/a/releases/download/v1/geoip.dat", http.StatusFound)
  270. })
  271. mux.HandleFunc("/a/releases/download/v1/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
  272. _, _ = w.Write([]byte(oldBody))
  273. })
  274. mux.HandleFunc("/a/releases/download/v1/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
  275. _, _ = w.Write(fmt.Appendf(nil, "%s geoip.dat\n", hex.EncodeToString(oldSum[:])))
  276. })
  277. // "latest" has already moved on to v2. Anything still resolving it gets a
  278. // digest for bytes we never downloaded.
  279. mux.HandleFunc("/a/releases/latest/download/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
  280. _, _ = w.Write(fmt.Appendf(nil, "%s geoip.dat\n", hex.EncodeToString(newSum[:])))
  281. })
  282. srv := httptest.NewServer(mux)
  283. t.Cleanup(srv.Close)
  284. binFolder := geofileTestEnv(t, map[string]geofileEntry{
  285. "geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
  286. })
  287. var restarted bool
  288. restartStub(t, &restarted)
  289. if err := (&ServerService{}).UpdateGeofile(""); err != nil {
  290. t.Fatalf("a release landing mid-batch must not look like tampering: %v", err)
  291. }
  292. if got, err := os.ReadFile(filepath.Join(binFolder, "geoip.dat")); err != nil || string(got) != oldBody {
  293. t.Fatalf("installed = %q (err %v), want the pinned release's bytes", got, err)
  294. }
  295. }
  296. func TestUpdateGeofileSkipsRestartWhenNotModified(t *testing.T) {
  297. mux := http.NewServeMux()
  298. mux.HandleFunc("/a/releases/latest/download/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
  299. http.Redirect(w, r, "/a/releases/download/v1/geoip.dat", http.StatusFound)
  300. })
  301. mux.HandleFunc("/a/releases/download/v1/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
  302. if r.Header.Get("If-Modified-Since") == "" {
  303. t.Errorf("expected a conditional GET carrying If-Modified-Since")
  304. }
  305. w.WriteHeader(http.StatusNotModified)
  306. })
  307. mux.HandleFunc("/a/releases/download/v1/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
  308. t.Error("the sidecar must not be fetched when the asset is unchanged")
  309. })
  310. srv := httptest.NewServer(mux)
  311. t.Cleanup(srv.Close)
  312. binFolder := geofileTestEnv(t, map[string]geofileEntry{
  313. "geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
  314. })
  315. existing := filepath.Join(binFolder, "geoip.dat")
  316. if err := os.WriteFile(existing, []byte("already current"), 0o644); err != nil {
  317. t.Fatalf("seed existing geofile: %v", err)
  318. }
  319. var restarted bool
  320. restartStub(t, &restarted)
  321. if err := (&ServerService{}).UpdateGeofile(""); err != nil {
  322. t.Fatalf("UpdateGeofile: %v", err)
  323. }
  324. if restarted {
  325. t.Fatal("a 304 from every upstream must not restart xray and drop client connections")
  326. }
  327. got, err := os.ReadFile(existing)
  328. if err != nil {
  329. t.Fatalf("read existing geofile: %v", err)
  330. }
  331. if string(got) != "already current" {
  332. t.Fatalf("existing content = %q, want it left alone", got)
  333. }
  334. }
  335. func TestUpdateGeofileRejectsNameOutsideAllowlist(t *testing.T) {
  336. geofileTestEnv(t, map[string]geofileEntry{
  337. "geoip.dat": {"https://example.invalid", "geoip.dat", "geoip.dat"},
  338. })
  339. err := (&ServerService{}).UpdateGeofile("../../etc/passwd")
  340. if err == nil {
  341. t.Fatal("expected an error for a name outside the allowlist")
  342. }
  343. if !strings.Contains(err.Error(), "not in allowlist") {
  344. t.Fatalf("error = %q, want it to name the allowlist", err)
  345. }
  346. }