1
0

geodata_test.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. package controller
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "net/http/httptest"
  6. "net/netip"
  7. "net/url"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. "github.com/gin-gonic/gin"
  13. "github.com/op/go-logging"
  14. xraygeodata "github.com/xtls/xray-core/common/geodata"
  15. "google.golang.org/protobuf/proto"
  16. xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
  17. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  18. "github.com/mhsanaei/3x-ui/v3/internal/xray/geodata"
  19. )
  20. func newGeodataEngine(t *testing.T) *gin.Engine {
  21. t.Helper()
  22. xuilogger.InitLogger(logging.ERROR)
  23. gin.SetMode(gin.TestMode)
  24. dir := t.TempDir()
  25. t.Setenv("XUI_BIN_FOLDER", dir)
  26. writeGeositeDB(t, dir)
  27. writeGeoipDB(t, dir)
  28. engine := gin.New()
  29. NewXraySettingController(engine.Group("/panel/api"))
  30. return engine
  31. }
  32. func writeGeositeDB(t *testing.T, dir string) {
  33. t.Helper()
  34. data, err := proto.Marshal(&xraygeodata.GeoSiteList{Entry: []*xraygeodata.GeoSite{
  35. {Code: "google", Domain: []*xraygeodata.Domain{
  36. {Type: xraygeodata.Domain_Domain, Value: "google.com"},
  37. {Type: xraygeodata.Domain_Full, Value: "ads.google.com", Attribute: []*xraygeodata.Domain_Attribute{
  38. {Key: "ads", TypedValue: &xraygeodata.Domain_Attribute_BoolValue{BoolValue: true}},
  39. }},
  40. }},
  41. {Code: "cn", Domain: []*xraygeodata.Domain{{Type: xraygeodata.Domain_Domain, Value: "baidu.com"}}},
  42. }})
  43. if err != nil {
  44. t.Fatalf("marshal geosite: %v", err)
  45. }
  46. if err := os.WriteFile(filepath.Join(dir, "geosite.dat"), data, 0o644); err != nil {
  47. t.Fatalf("write geosite.dat: %v", err)
  48. }
  49. }
  50. func writeGeoipDB(t *testing.T, dir string) {
  51. t.Helper()
  52. prefix := netip.MustParsePrefix("10.0.0.0/8")
  53. data, err := proto.Marshal(&xraygeodata.GeoIPList{Entry: []*xraygeodata.GeoIP{
  54. {Code: "private", Cidr: []*xraygeodata.CIDR{{Ip: prefix.Addr().AsSlice(), Prefix: uint32(prefix.Bits())}}},
  55. }})
  56. if err != nil {
  57. t.Fatalf("marshal geoip: %v", err)
  58. }
  59. if err := os.WriteFile(filepath.Join(dir, "geoip.dat"), data, 0o644); err != nil {
  60. t.Fatalf("write geoip.dat: %v", err)
  61. }
  62. }
  63. type geodataEnvelope struct {
  64. Success bool `json:"success"`
  65. Msg string `json:"msg"`
  66. Obj json.RawMessage `json:"obj"`
  67. }
  68. func doGeodataGet(t *testing.T, engine *gin.Engine, path string) geodataEnvelope {
  69. t.Helper()
  70. return doGeodataReq(t, engine, httptest.NewRequest(http.MethodGet, path, nil))
  71. }
  72. func doGeodataPost(t *testing.T, engine *gin.Engine, path string, form url.Values) geodataEnvelope {
  73. t.Helper()
  74. req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
  75. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  76. return doGeodataReq(t, engine, req)
  77. }
  78. func doGeodataReq(t *testing.T, engine *gin.Engine, req *http.Request) geodataEnvelope {
  79. t.Helper()
  80. w := httptest.NewRecorder()
  81. engine.ServeHTTP(w, req)
  82. if w.Code != http.StatusOK {
  83. t.Fatalf("%s %s: status %d, body=%s", req.Method, req.URL, w.Code, w.Body.String())
  84. }
  85. var env geodataEnvelope
  86. if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
  87. t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
  88. }
  89. return env
  90. }
  91. func TestGeodataFiles(t *testing.T) {
  92. engine := newGeodataEngine(t)
  93. env := doGeodataGet(t, engine, "/panel/api/xray/geodata/files")
  94. if !env.Success {
  95. t.Fatalf("files not successful: %s", env.Msg)
  96. }
  97. var files []geodata.GeoFile
  98. if err := json.Unmarshal(env.Obj, &files); err != nil {
  99. t.Fatalf("decode files: %v", err)
  100. }
  101. if len(files) != 2 {
  102. t.Fatalf("files = %+v, want 2 entries", files)
  103. }
  104. byName := make(map[string]geodata.GeoFile, len(files))
  105. for _, file := range files {
  106. byName[file.Name] = file
  107. }
  108. if got := byName["geosite.dat"]; got.Kind != geodata.KindSite || got.Categories != 2 {
  109. t.Errorf("geosite.dat = %+v, want kind site with 2 categories", got)
  110. }
  111. if got := byName["geoip.dat"]; got.Kind != geodata.KindIP || got.Categories != 1 {
  112. t.Errorf("geoip.dat = %+v, want kind ip with 1 category", got)
  113. }
  114. }
  115. func TestGeodataCategoriesAndEntries(t *testing.T) {
  116. engine := newGeodataEngine(t)
  117. env := doGeodataGet(t, engine, "/panel/api/xray/geodata/categories?file=geosite.dat&q=goo&limit=10")
  118. var categories geodata.GeoCategoryPage
  119. if err := json.Unmarshal(env.Obj, &categories); err != nil {
  120. t.Fatalf("decode categories: %v", err)
  121. }
  122. if categories.Total != 1 || categories.Items[0].Code != "google" {
  123. t.Fatalf("categories = %+v, want only google", categories)
  124. }
  125. env = doGeodataGet(t, engine, "/panel/api/xray/geodata/entries?file=geosite.dat&code=google&limit=1&offset=1")
  126. var entries geodata.GeoEntryPage
  127. if err := json.Unmarshal(env.Obj, &entries); err != nil {
  128. t.Fatalf("decode entries: %v", err)
  129. }
  130. if entries.Total != 2 {
  131. t.Errorf("entries total = %d, want 2", entries.Total)
  132. }
  133. if len(entries.Items) != 1 || entries.Items[0].Value != "ads.google.com" || entries.Items[0].Kind != "full" {
  134. t.Errorf("entries items = %+v, want the second entry ads.google.com", entries.Items)
  135. }
  136. }
  137. func TestGeodataRejectsBadRequests(t *testing.T) {
  138. engine := newGeodataEngine(t)
  139. tests := []struct {
  140. name string
  141. path string
  142. }{
  143. {name: "missing code", path: "/panel/api/xray/geodata/entries?file=geosite.dat"},
  144. {name: "unknown category", path: "/panel/api/xray/geodata/entries?file=geosite.dat&code=nope"},
  145. {name: "path traversal", path: "/panel/api/xray/geodata/categories?file=../../etc/passwd.dat"},
  146. {name: "non dat file", path: "/panel/api/xray/geodata/categories?file=x-ui.db"},
  147. }
  148. for _, tt := range tests {
  149. t.Run(tt.name, func(t *testing.T) {
  150. if env := doGeodataGet(t, engine, tt.path); env.Success {
  151. t.Errorf("request succeeded, want failure: %s", env.Obj)
  152. }
  153. })
  154. }
  155. }
  156. func TestGeodataValidate(t *testing.T) {
  157. engine := newGeodataEngine(t)
  158. tests := []struct {
  159. name string
  160. kind string
  161. tokens string
  162. wantTokens []string
  163. wantReason string
  164. }{
  165. {name: "known categories pass", kind: "domain", tokens: "geosite:google,geosite:cn,google.com"},
  166. {name: "attribute filter passes", kind: "domain", tokens: "geosite:google@ads"},
  167. {
  168. name: "attribute the category does not carry",
  169. kind: "domain",
  170. tokens: "geosite:google@typo",
  171. wantTokens: []string{"geosite:google@typo"},
  172. wantReason: "attributeMissing",
  173. },
  174. {
  175. name: "empty attribute is a syntax error",
  176. kind: "domain",
  177. tokens: "geosite:google@",
  178. wantTokens: []string{"geosite:google@"},
  179. wantReason: "syntax",
  180. },
  181. {
  182. name: "missing category",
  183. kind: "domain",
  184. tokens: "geosite:google,geosite:blabla",
  185. wantTokens: []string{"geosite:blabla"},
  186. wantReason: "categoryMissing",
  187. },
  188. {
  189. name: "missing database",
  190. kind: "domain",
  191. tokens: "ext:absent.dat:corp",
  192. wantTokens: []string{"ext:absent.dat:corp"},
  193. wantReason: "fileMissing",
  194. },
  195. {
  196. name: "a geoip token in a domain field is reported",
  197. kind: "domain",
  198. tokens: "geoip:cn",
  199. wantTokens: []string{"geoip:cn"},
  200. wantReason: "wrongKind",
  201. },
  202. {name: "plain cidr passes", kind: "ip", tokens: "10.0.0.0/8,geoip:private"},
  203. {
  204. name: "missing ip category",
  205. kind: "ip",
  206. tokens: "geoip:nowhere",
  207. wantTokens: []string{"geoip:nowhere"},
  208. wantReason: "categoryMissing",
  209. },
  210. }
  211. for _, tt := range tests {
  212. t.Run(tt.name, func(t *testing.T) {
  213. env := doGeodataPost(t, engine, "/panel/api/xray/geodata/validate", url.Values{
  214. "kind": {tt.kind},
  215. "tokens": {tt.tokens},
  216. })
  217. if !env.Success {
  218. t.Fatalf("validate not successful: %s", env.Msg)
  219. }
  220. var issues []service.GeodataTokenIssue
  221. if err := json.Unmarshal(env.Obj, &issues); err != nil {
  222. t.Fatalf("decode issues: %v", err)
  223. }
  224. if len(issues) != len(tt.wantTokens) {
  225. t.Fatalf("issues = %+v, want %d", issues, len(tt.wantTokens))
  226. }
  227. for i, wantToken := range tt.wantTokens {
  228. if issues[i].Token != wantToken {
  229. t.Errorf("issue %d token = %q, want %q", i, issues[i].Token, wantToken)
  230. }
  231. if issues[i].Reason != tt.wantReason {
  232. t.Errorf("issue %d reason = %q, want %q", i, issues[i].Reason, tt.wantReason)
  233. }
  234. }
  235. })
  236. }
  237. }
  238. func TestGeodataFollowsXrayAssetLocation(t *testing.T) {
  239. engine := newGeodataEngine(t)
  240. shared := t.TempDir()
  241. writeGeositeDB(t, shared)
  242. t.Setenv("XRAY_LOCATION_ASSET", shared)
  243. env := doGeodataGet(t, engine, "/panel/api/xray/geodata/files")
  244. var files []geodata.GeoFile
  245. if err := json.Unmarshal(env.Obj, &files); err != nil {
  246. t.Fatalf("decode files: %v", err)
  247. }
  248. if len(files) != 1 || files[0].Name != "geosite.dat" {
  249. t.Fatalf("files = %+v, want only the database from XRAY_LOCATION_ASSET", files)
  250. }
  251. if files[0].Categories != 2 {
  252. t.Errorf("categories = %d, want 2 — the shared asset folder should be read", files[0].Categories)
  253. }
  254. }