happ_test.go 14 KB

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