1
0

happ_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/rand"
  6. "crypto/rsa"
  7. "encoding/base64"
  8. "errors"
  9. "os"
  10. "path/filepath"
  11. "regexp"
  12. "strconv"
  13. "strings"
  14. "sync"
  15. "testing"
  16. "golang.org/x/crypto/chacha20poly1305"
  17. "github.com/mhsanaei/3x-ui/v3/internal/database"
  18. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  19. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  20. )
  21. func initHappTestDB(t *testing.T) {
  22. t.Helper()
  23. dbDir := t.TempDir()
  24. t.Setenv("XUI_DB_FOLDER", dbDir)
  25. t.Setenv("XUI_BIN_FOLDER", dbDir)
  26. if err := os.WriteFile(filepath.Join(dbDir, "config.json"), []byte(`{"log":{}}`), 0o600); err != nil {
  27. t.Fatalf("write Xray config: %v", err)
  28. }
  29. if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
  30. t.Fatalf("InitDB: %v", err)
  31. }
  32. t.Cleanup(func() { _ = database.CloseDB() })
  33. }
  34. func seedHappClient(t *testing.T, subID string) *model.ClientRecord {
  35. t.Helper()
  36. client := &model.ClientRecord{Email: "happ@test", SubID: subID, Enable: true}
  37. if err := database.GetDB().Create(client).Error; err != nil {
  38. t.Fatalf("seed client: %v", err)
  39. }
  40. return client
  41. }
  42. func configureHappSubscription(t *testing.T, enabled bool, subURI string) {
  43. t.Helper()
  44. settings := &SettingService{}
  45. for key, value := range map[string]string{
  46. "subEnable": "false",
  47. "subURI": subURI,
  48. "subPath": "/sub/",
  49. "subPort": "80",
  50. "subDomain": "",
  51. } {
  52. if key == "subEnable" && enabled {
  53. value = "true"
  54. }
  55. if err := settings.saveSetting(key, value); err != nil {
  56. t.Fatalf("save %s: %v", key, err)
  57. }
  58. }
  59. }
  60. func configureHappLinkGate(t *testing.T, enabled bool) {
  61. t.Helper()
  62. if err := (&SettingService{}).saveSetting("happLinkEnable", strconv.FormatBool(enabled)); err != nil {
  63. t.Fatalf("save happLinkEnable: %v", err)
  64. }
  65. }
  66. var syntheticHappKey = sync.OnceValues(func() (*rsa.PrivateKey, error) {
  67. return rsa.GenerateKey(rand.Reader, 4096)
  68. })
  69. func newLocalHappTestService(t *testing.T) (*HappService, *rsa.PrivateKey) {
  70. t.Helper()
  71. key, err := syntheticHappKey()
  72. if err != nil {
  73. t.Fatal(err)
  74. }
  75. svc := NewHappService(&ClientService{}, &SettingService{})
  76. svc.encrypt = func(source string) (string, error) { return encryptHappSource(source, &key.PublicKey) }
  77. return svc, key
  78. }
  79. func decryptHappTestLink(t *testing.T, link string, key *rsa.PrivateKey) string {
  80. t.Helper()
  81. return decodeHappTestLink(t, link, key).source
  82. }
  83. type happTestDecoded struct {
  84. source string
  85. key []byte
  86. nonce []byte
  87. }
  88. func decodeHappTestLink(t *testing.T, link string, key *rsa.PrivateKey) happTestDecoded {
  89. t.Helper()
  90. const prefix = "happ://crypt5/"
  91. if !strings.HasPrefix(link, prefix) {
  92. t.Fatal("unexpected Happ protocol")
  93. }
  94. payload := []byte(link[len(prefix):])
  95. // Independent inverse indexing catches encoder swap errors without sharing its helpers.
  96. frame := append([]byte{}, payload...)
  97. for i := 0; i+4 <= len(payload); i += 4 {
  98. copy(frame[i:i+2], payload[i+2:i+4])
  99. copy(frame[i+2:i+4], payload[i:i+2])
  100. }
  101. if len(frame) < 38 || string(frame[:4])+string(frame[len(frame)-4:]) != "vdfzfoff" {
  102. t.Fatal("invalid marker or short Crypt5 frame")
  103. }
  104. body := frame[4 : len(frame)-4]
  105. nonce, tag, salt := body[:12], body[12:14], body[14:22]
  106. if !regexp.MustCompile(`^[a-zA-Z0-9]{12}$`).Match(nonce) ||
  107. !regexp.MustCompile(`^[a-zA-Z]{2}$`).Match(tag) ||
  108. !regexp.MustCompile(`^[a-zA-Z0-9]{8}$`).Match(salt) {
  109. t.Fatal("incorrect salted field shape")
  110. }
  111. separatorIndex := 22
  112. for separatorIndex < len(body) && body[separatorIndex] >= '0' && body[separatorIndex] <= '9' {
  113. separatorIndex++
  114. }
  115. if separatorIndex == 22 || separatorIndex >= len(body) || body[separatorIndex] != 'V' {
  116. t.Fatal("missing length or wrong tested separator")
  117. }
  118. segmentLength, err := strconv.Atoi(string(body[22:separatorIndex]))
  119. if err != nil || segmentLength < 24 || segmentLength > len(body)-separatorIndex-1 {
  120. t.Fatal("invalid ciphertext segment length")
  121. }
  122. cipherB64 := body[separatorIndex+1 : separatorIndex+1+segmentLength]
  123. rsaB64 := body[separatorIndex+1+segmentLength:]
  124. rsaCipher, err := base64.StdEncoding.Strict().DecodeString(string(rsaB64))
  125. if err != nil || len(rsaCipher) != 512 || len(rsaB64) != 684 {
  126. t.Fatalf("expected standard padded Base64 of a 512-byte RSA block: %v", err)
  127. }
  128. //nolint:staticcheck // Only an ephemeral test key decodes Happ's required PKCS#1 v1.5 wrapping.
  129. rsaPlain, err := rsa.DecryptPKCS1v15(nil, key, rsaCipher)
  130. if err != nil || len(rsaPlain) != 44 {
  131. t.Fatalf("RSA wrapped key should contain 44 encoded bytes: %v", err)
  132. }
  133. keyB64 := make([]byte, len(rsaPlain))
  134. for i := range rsaPlain {
  135. keyB64[i] = rsaPlain[i^1]
  136. }
  137. wrappedKey, err := base64.StdEncoding.Strict().DecodeString(string(keyB64))
  138. if err != nil || len(wrappedKey) != 32 {
  139. t.Fatalf("wrapped key should decode to 32 bytes: %v", err)
  140. }
  141. sessionKey := make([]byte, 32)
  142. for i := range sessionKey {
  143. sessionKey[i] = wrappedKey[i] ^ salt[i%8]
  144. }
  145. ciphertext, err := base64.StdEncoding.Strict().DecodeString(string(cipherB64))
  146. if err != nil || !bytes.Equal([]byte(base64.StdEncoding.EncodeToString(ciphertext)), cipherB64) {
  147. t.Fatalf("noncanonical ciphertext Base64: %v", err)
  148. }
  149. aead, err := chacha20poly1305.New(sessionKey)
  150. if err != nil {
  151. t.Fatal(err)
  152. }
  153. swappedSource, err := aead.Open(nil, nonce, ciphertext, nil)
  154. if err != nil || len(swappedSource)%4 != 0 {
  155. t.Fatalf("AEAD authentication or source framing failed: %v", err)
  156. }
  157. sourceB64 := make([]byte, len(swappedSource))
  158. for i := range swappedSource {
  159. sourceB64[i] = swappedSource[i^1]
  160. }
  161. source, err := base64.StdEncoding.Strict().DecodeString(string(sourceB64))
  162. if err != nil {
  163. t.Fatal(err)
  164. }
  165. return happTestDecoded{string(source), sessionKey, append([]byte{}, nonce...)}
  166. }
  167. func TestHappGenerateRejectsDisabledGateBeforeEncryption(t *testing.T) {
  168. for _, value := range []string{"", "false", "not-a-bool"} {
  169. t.Run("setting="+value, func(t *testing.T) {
  170. initHappTestDB(t)
  171. client := seedHappClient(t, "current-sub-id")
  172. configureHappSubscription(t, true, "https://sub.example/sub/")
  173. if value != "" {
  174. if err := (&SettingService{}).saveSetting("happLinkEnable", value); err != nil {
  175. t.Fatal(err)
  176. }
  177. }
  178. svc := NewHappService(&ClientService{}, &SettingService{})
  179. svc.encrypt = func(string) (string, error) {
  180. t.Fatal("disabled feature attempted encryption")
  181. return "", nil
  182. }
  183. result, err := svc.Generate(context.Background(), client.Id, "panel.example")
  184. if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
  185. t.Fatalf("disabled generation = %#v, %v", result, err)
  186. }
  187. })
  188. }
  189. }
  190. func TestHappGenerateUsesCurrentSourceAndFreshCiphertext(t *testing.T) {
  191. initHappTestDB(t)
  192. client := seedHappClient(t, "before")
  193. configureHappSubscription(t, true, "https://sub.example/sub/")
  194. configureHappLinkGate(t, true)
  195. svc, key := newLocalHappTestService(t)
  196. var previous string
  197. for range 2 {
  198. result, err := svc.Generate(context.Background(), client.Id, "panel.example")
  199. if err != nil {
  200. t.Fatal(err)
  201. }
  202. if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "https://sub.example/sub/before" {
  203. t.Fatalf("source = %q", got)
  204. }
  205. if result.EncryptedLink == previous {
  206. t.Fatal("generation reused cached ciphertext")
  207. }
  208. previous = result.EncryptedLink
  209. }
  210. if err := database.GetDB().Model(client).Update("sub_id", "after").Error; err != nil {
  211. t.Fatal(err)
  212. }
  213. configureHappSubscription(t, true, "https://next.example/中文?literal=%2F&token=")
  214. result, err := svc.Generate(context.Background(), client.Id, "panel.example")
  215. if err != nil {
  216. t.Fatal(err)
  217. }
  218. if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "https://next.example/中文?literal=%2F&token=after" {
  219. t.Fatalf("updated source = %q", got)
  220. }
  221. configureHappSubscription(t, true, "")
  222. result, err = svc.Generate(context.Background(), client.Id, "panel.example")
  223. if err != nil {
  224. t.Fatal(err)
  225. }
  226. if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "http://panel.example/sub/after" {
  227. t.Fatalf("default source = %q", got)
  228. }
  229. }
  230. func TestHappGenerateDiscardsChangedSourceOrGate(t *testing.T) {
  231. for _, tc := range []struct {
  232. name string
  233. reason string
  234. change func(*testing.T, *model.ClientRecord)
  235. }{
  236. {"subscription ID", "source_changed", func(t *testing.T, c *model.ClientRecord) {
  237. if err := database.GetDB().Model(c).Update("sub_id", "after").Error; err != nil {
  238. t.Fatal(err)
  239. }
  240. }},
  241. {"subscription URL", "source_changed", func(t *testing.T, _ *model.ClientRecord) {
  242. configureHappSubscription(t, true, "https://next.example/sub/")
  243. }},
  244. {"subscription disabled", "source_changed", func(t *testing.T, _ *model.ClientRecord) {
  245. configureHappSubscription(t, false, "https://sub.example/sub/")
  246. }},
  247. {"gate disabled", "integration_disabled", func(t *testing.T, _ *model.ClientRecord) {
  248. configureHappLinkGate(t, false)
  249. }},
  250. } {
  251. t.Run(tc.name, func(t *testing.T) {
  252. initHappTestDB(t)
  253. client := seedHappClient(t, "before")
  254. configureHappSubscription(t, true, "https://sub.example/sub/")
  255. configureHappLinkGate(t, true)
  256. svc, _ := newLocalHappTestService(t)
  257. encrypt := svc.encrypt
  258. svc.encrypt = func(source string) (string, error) {
  259. link, err := encrypt(source)
  260. tc.change(t, client)
  261. return link, err
  262. }
  263. result, err := svc.Generate(context.Background(), client.Id, "panel.example")
  264. if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
  265. t.Fatalf("stale result = %#v, %v", result, err)
  266. }
  267. logs := logger.GetLogs(1, "WARNING")
  268. if len(logs) != 1 || !strings.Contains(logs[0], "reason="+tc.reason) {
  269. t.Fatalf("wrong stale-result diagnostic: %v", logs)
  270. }
  271. })
  272. }
  273. }
  274. func TestHappGenerateSkipsUnavailableSources(t *testing.T) {
  275. for _, tc := range []struct {
  276. name string
  277. enabled bool
  278. subID string
  279. missing bool
  280. }{
  281. {"disabled subscription", false, "current", false},
  282. {"missing client", true, "current", true},
  283. {"empty subscription ID", true, "", false},
  284. } {
  285. t.Run(tc.name, func(t *testing.T) {
  286. initHappTestDB(t)
  287. client := seedHappClient(t, tc.subID)
  288. configureHappSubscription(t, tc.enabled, "https://sub.example/sub/")
  289. configureHappLinkGate(t, true)
  290. svc := NewHappService(&ClientService{}, &SettingService{})
  291. svc.encrypt = func(string) (string, error) { t.Fatal("unavailable source was encrypted"); return "", nil }
  292. id := client.Id
  293. if tc.missing {
  294. id++
  295. }
  296. result, err := svc.Generate(context.Background(), id, "panel.example")
  297. if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
  298. t.Fatalf("unavailable result = %#v, %v", result, err)
  299. }
  300. })
  301. }
  302. }
  303. func TestHappGenerateDiscardsCancelledRequests(t *testing.T) {
  304. for _, before := range []bool{true, false} {
  305. t.Run(strconv.FormatBool(before), func(t *testing.T) {
  306. initHappTestDB(t)
  307. client := seedHappClient(t, "current")
  308. configureHappSubscription(t, true, "https://sub.example/sub/")
  309. configureHappLinkGate(t, true)
  310. ctx, cancel := context.WithCancel(context.Background())
  311. defer cancel()
  312. svc, _ := newLocalHappTestService(t)
  313. encrypt := svc.encrypt
  314. svc.encrypt = func(source string) (string, error) {
  315. if before {
  316. t.Fatal("cancelled request attempted encryption")
  317. }
  318. link, err := encrypt(source)
  319. cancel()
  320. return link, err
  321. }
  322. if before {
  323. cancel()
  324. }
  325. result, err := svc.Generate(ctx, client.Id, "panel.example")
  326. if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
  327. t.Fatalf("cancelled result = %#v, %v", result, err)
  328. }
  329. logs := logger.GetLogs(1, "WARNING")
  330. if len(logs) != 1 || !strings.Contains(logs[0], "reason=request_cancelled") {
  331. t.Fatalf("wrong cancellation diagnostic: %v", logs)
  332. }
  333. })
  334. }
  335. }
  336. func TestHappGeneratePropagatesLengthErrorWithoutSecrets(t *testing.T) {
  337. initHappTestDB(t)
  338. client := seedHappClient(t, strings.Repeat("s", 8173))
  339. configureHappSubscription(t, true, "https://example.com/")
  340. configureHappLinkGate(t, true)
  341. result, err := NewHappService(&ClientService{}, &SettingService{}).Generate(context.Background(), client.Id, "panel.example")
  342. if !errors.Is(err, ErrHappSourceTooLong) || result != (HappLinkResult{}) {
  343. t.Fatalf("length result = %#v, %v", result, err)
  344. }
  345. logs := logger.GetLogs(1, "WARNING")
  346. if len(logs) != 1 || !strings.Contains(logs[0], "reason=source_too_long") {
  347. t.Fatalf("length diagnostic = %v", logs)
  348. }
  349. if strings.Contains(logs[0], client.SubID) || strings.Contains(logs[0], "example.com") {
  350. t.Fatal("length diagnostic leaked source")
  351. }
  352. }
  353. func TestHappGenerateLogsSanitizedEncryptionFailure(t *testing.T) {
  354. initHappTestDB(t)
  355. client := seedHappClient(t, "secret-sub-id")
  356. configureHappSubscription(t, true, "https://sub.example/secret-source/")
  357. configureHappLinkGate(t, true)
  358. svc := NewHappService(&ClientService{}, &SettingService{})
  359. svc.encrypt = func(source string) (string, error) {
  360. return "", errors.New("encryption failed " + source + " token=secret cookie=session authorization=Bearer-secret happ://crypt5/leak")
  361. }
  362. result, err := svc.Generate(context.Background(), client.Id, "panel.example")
  363. if !errors.Is(err, ErrHappLinkUnavailable) || err.Error() != "happ link unavailable" || result != (HappLinkResult{}) {
  364. t.Fatalf("failure = %#v, %v", result, err)
  365. }
  366. logs := logger.GetLogs(1, "WARNING")
  367. if len(logs) != 1 {
  368. t.Fatalf("logs = %v", logs)
  369. }
  370. for _, want := range []string{"component=happ_link", "client_id=" + strconv.Itoa(client.Id), "reason=encryption", "elapsed_ms=", "correlation_id=", "encryption failed"} {
  371. if !strings.Contains(logs[0], want) {
  372. t.Fatalf("diagnostic missing %q: %s", want, logs[0])
  373. }
  374. }
  375. for _, secret := range []string{"secret-sub-id", "secret-source", "token=secret", "cookie=session", "Bearer-secret", "happ://"} {
  376. if strings.Contains(logs[0], secret) {
  377. t.Fatalf("diagnostic leaked %q", secret)
  378. }
  379. }
  380. }
  381. func TestSanitizeHappDetailRedactsSensitiveTokens(t *testing.T) {
  382. detail := sanitizeHappDetail("provider said https://provider.example/path?token=secret password=hunter2\nsource=https://sub.example/sub/current-sub-id", "https://sub.example/sub/current-sub-id", "current-sub-id")
  383. for _, secret := range []string{"provider.example", "token=secret", "hunter2", "current-sub-id", "\n"} {
  384. if strings.Contains(detail, secret) {
  385. t.Fatalf("sanitized detail leaked %q: %q", secret, detail)
  386. }
  387. }
  388. }