| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- package service
- import (
- "bytes"
- "context"
- "crypto/rand"
- "crypto/rsa"
- "encoding/base64"
- "errors"
- "os"
- "path/filepath"
- "regexp"
- "strconv"
- "strings"
- "sync"
- "testing"
- "golang.org/x/crypto/chacha20poly1305"
- "github.com/mhsanaei/3x-ui/v3/internal/database"
- "github.com/mhsanaei/3x-ui/v3/internal/database/model"
- "github.com/mhsanaei/3x-ui/v3/internal/logger"
- )
- func initHappTestDB(t *testing.T) {
- t.Helper()
- dbDir := t.TempDir()
- t.Setenv("XUI_DB_FOLDER", dbDir)
- t.Setenv("XUI_BIN_FOLDER", dbDir)
- if err := os.WriteFile(filepath.Join(dbDir, "config.json"), []byte(`{"log":{}}`), 0o600); err != nil {
- t.Fatalf("write Xray config: %v", err)
- }
- if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
- t.Fatalf("InitDB: %v", err)
- }
- t.Cleanup(func() { _ = database.CloseDB() })
- }
- func seedHappClient(t *testing.T, subID string) *model.ClientRecord {
- t.Helper()
- client := &model.ClientRecord{Email: "happ@test", SubID: subID, Enable: true}
- if err := database.GetDB().Create(client).Error; err != nil {
- t.Fatalf("seed client: %v", err)
- }
- return client
- }
- func configureHappSubscription(t *testing.T, enabled bool, subURI string) {
- t.Helper()
- settings := &SettingService{}
- for key, value := range map[string]string{
- "subEnable": "false",
- "subURI": subURI,
- "subPath": "/sub/",
- "subPort": "80",
- "subDomain": "",
- } {
- if key == "subEnable" && enabled {
- value = "true"
- }
- if err := settings.saveSetting(key, value); err != nil {
- t.Fatalf("save %s: %v", key, err)
- }
- }
- }
- func configureHappLinkGate(t *testing.T, enabled bool) {
- t.Helper()
- if err := (&SettingService{}).saveSetting("happLinkEnable", strconv.FormatBool(enabled)); err != nil {
- t.Fatalf("save happLinkEnable: %v", err)
- }
- }
- var syntheticHappKey = sync.OnceValues(func() (*rsa.PrivateKey, error) {
- return rsa.GenerateKey(rand.Reader, 4096)
- })
- func newLocalHappTestService(t *testing.T) (*HappService, *rsa.PrivateKey) {
- t.Helper()
- key, err := syntheticHappKey()
- if err != nil {
- t.Fatal(err)
- }
- svc := NewHappService(&ClientService{}, &SettingService{})
- svc.encrypt = func(source string) (string, error) { return encryptHappSource(source, &key.PublicKey) }
- return svc, key
- }
- func decryptHappTestLink(t *testing.T, link string, key *rsa.PrivateKey) string {
- t.Helper()
- return decodeHappTestLink(t, link, key).source
- }
- type happTestDecoded struct {
- source string
- key []byte
- nonce []byte
- }
- func decodeHappTestLink(t *testing.T, link string, key *rsa.PrivateKey) happTestDecoded {
- t.Helper()
- const prefix = "happ://crypt5/"
- if !strings.HasPrefix(link, prefix) {
- t.Fatal("unexpected Happ protocol")
- }
- payload := []byte(link[len(prefix):])
- // Independent inverse indexing catches encoder swap errors without sharing its helpers.
- frame := append([]byte{}, payload...)
- for i := 0; i+4 <= len(payload); i += 4 {
- copy(frame[i:i+2], payload[i+2:i+4])
- copy(frame[i+2:i+4], payload[i:i+2])
- }
- if len(frame) < 38 || string(frame[:4])+string(frame[len(frame)-4:]) != "vdfzfoff" {
- t.Fatal("invalid marker or short Crypt5 frame")
- }
- body := frame[4 : len(frame)-4]
- nonce, tag, salt := body[:12], body[12:14], body[14:22]
- if !regexp.MustCompile(`^[a-zA-Z0-9]{12}$`).Match(nonce) ||
- !regexp.MustCompile(`^[a-zA-Z]{2}$`).Match(tag) ||
- !regexp.MustCompile(`^[a-zA-Z0-9]{8}$`).Match(salt) {
- t.Fatal("incorrect salted field shape")
- }
- separatorIndex := 22
- for separatorIndex < len(body) && body[separatorIndex] >= '0' && body[separatorIndex] <= '9' {
- separatorIndex++
- }
- if separatorIndex == 22 || separatorIndex >= len(body) || body[separatorIndex] != 'V' {
- t.Fatal("missing length or wrong tested separator")
- }
- segmentLength, err := strconv.Atoi(string(body[22:separatorIndex]))
- if err != nil || segmentLength < 24 || segmentLength > len(body)-separatorIndex-1 {
- t.Fatal("invalid ciphertext segment length")
- }
- cipherB64 := body[separatorIndex+1 : separatorIndex+1+segmentLength]
- rsaB64 := body[separatorIndex+1+segmentLength:]
- rsaCipher, err := base64.StdEncoding.Strict().DecodeString(string(rsaB64))
- if err != nil || len(rsaCipher) != 512 || len(rsaB64) != 684 {
- t.Fatalf("expected standard padded Base64 of a 512-byte RSA block: %v", err)
- }
- //nolint:staticcheck // Only an ephemeral test key decodes Happ's required PKCS#1 v1.5 wrapping.
- rsaPlain, err := rsa.DecryptPKCS1v15(nil, key, rsaCipher)
- if err != nil || len(rsaPlain) != 44 {
- t.Fatalf("RSA wrapped key should contain 44 encoded bytes: %v", err)
- }
- keyB64 := make([]byte, len(rsaPlain))
- for i := range rsaPlain {
- keyB64[i] = rsaPlain[i^1]
- }
- wrappedKey, err := base64.StdEncoding.Strict().DecodeString(string(keyB64))
- if err != nil || len(wrappedKey) != 32 {
- t.Fatalf("wrapped key should decode to 32 bytes: %v", err)
- }
- sessionKey := make([]byte, 32)
- for i := range sessionKey {
- sessionKey[i] = wrappedKey[i] ^ salt[i%8]
- }
- ciphertext, err := base64.StdEncoding.Strict().DecodeString(string(cipherB64))
- if err != nil || !bytes.Equal([]byte(base64.StdEncoding.EncodeToString(ciphertext)), cipherB64) {
- t.Fatalf("noncanonical ciphertext Base64: %v", err)
- }
- aead, err := chacha20poly1305.New(sessionKey)
- if err != nil {
- t.Fatal(err)
- }
- swappedSource, err := aead.Open(nil, nonce, ciphertext, nil)
- if err != nil || len(swappedSource)%4 != 0 {
- t.Fatalf("AEAD authentication or source framing failed: %v", err)
- }
- sourceB64 := make([]byte, len(swappedSource))
- for i := range swappedSource {
- sourceB64[i] = swappedSource[i^1]
- }
- source, err := base64.StdEncoding.Strict().DecodeString(string(sourceB64))
- if err != nil {
- t.Fatal(err)
- }
- return happTestDecoded{string(source), sessionKey, append([]byte{}, nonce...)}
- }
- func TestHappGenerateRejectsDisabledGateBeforeEncryption(t *testing.T) {
- for _, value := range []string{"", "false", "not-a-bool"} {
- t.Run("setting="+value, func(t *testing.T) {
- initHappTestDB(t)
- client := seedHappClient(t, "current-sub-id")
- configureHappSubscription(t, true, "https://sub.example/sub/")
- if value != "" {
- if err := (&SettingService{}).saveSetting("happLinkEnable", value); err != nil {
- t.Fatal(err)
- }
- }
- svc := NewHappService(&ClientService{}, &SettingService{})
- svc.encrypt = func(string) (string, error) {
- t.Fatal("disabled feature attempted encryption")
- return "", nil
- }
- result, err := svc.Generate(context.Background(), client.Id, "panel.example")
- if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
- t.Fatalf("disabled generation = %#v, %v", result, err)
- }
- })
- }
- }
- func TestHappGenerateUsesCurrentSourceAndFreshCiphertext(t *testing.T) {
- initHappTestDB(t)
- client := seedHappClient(t, "before")
- configureHappSubscription(t, true, "https://sub.example/sub/")
- configureHappLinkGate(t, true)
- svc, key := newLocalHappTestService(t)
- var previous string
- for range 2 {
- result, err := svc.Generate(context.Background(), client.Id, "panel.example")
- if err != nil {
- t.Fatal(err)
- }
- if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "https://sub.example/sub/before" {
- t.Fatalf("source = %q", got)
- }
- if result.EncryptedLink == previous {
- t.Fatal("generation reused cached ciphertext")
- }
- previous = result.EncryptedLink
- }
- if err := database.GetDB().Model(client).Update("sub_id", "after").Error; err != nil {
- t.Fatal(err)
- }
- configureHappSubscription(t, true, "https://next.example/中文?literal=%2F&token=")
- result, err := svc.Generate(context.Background(), client.Id, "panel.example")
- if err != nil {
- t.Fatal(err)
- }
- if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "https://next.example/中文?literal=%2F&token=after" {
- t.Fatalf("updated source = %q", got)
- }
- configureHappSubscription(t, true, "")
- result, err = svc.Generate(context.Background(), client.Id, "panel.example")
- if err != nil {
- t.Fatal(err)
- }
- if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "http://panel.example/sub/after" {
- t.Fatalf("default source = %q", got)
- }
- }
- func TestHappGenerateDiscardsChangedSourceOrGate(t *testing.T) {
- for _, tc := range []struct {
- name string
- reason string
- change func(*testing.T, *model.ClientRecord)
- }{
- {"subscription ID", "source_changed", func(t *testing.T, c *model.ClientRecord) {
- if err := database.GetDB().Model(c).Update("sub_id", "after").Error; err != nil {
- t.Fatal(err)
- }
- }},
- {"subscription URL", "source_changed", func(t *testing.T, _ *model.ClientRecord) {
- configureHappSubscription(t, true, "https://next.example/sub/")
- }},
- {"subscription disabled", "source_changed", func(t *testing.T, _ *model.ClientRecord) {
- configureHappSubscription(t, false, "https://sub.example/sub/")
- }},
- {"gate disabled", "integration_disabled", func(t *testing.T, _ *model.ClientRecord) {
- configureHappLinkGate(t, false)
- }},
- } {
- t.Run(tc.name, func(t *testing.T) {
- initHappTestDB(t)
- client := seedHappClient(t, "before")
- configureHappSubscription(t, true, "https://sub.example/sub/")
- configureHappLinkGate(t, true)
- svc, _ := newLocalHappTestService(t)
- encrypt := svc.encrypt
- svc.encrypt = func(source string) (string, error) {
- link, err := encrypt(source)
- tc.change(t, client)
- return link, err
- }
- result, err := svc.Generate(context.Background(), client.Id, "panel.example")
- if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
- t.Fatalf("stale result = %#v, %v", result, err)
- }
- logs := logger.GetLogs(1, "WARNING")
- if len(logs) != 1 || !strings.Contains(logs[0], "reason="+tc.reason) {
- t.Fatalf("wrong stale-result diagnostic: %v", logs)
- }
- })
- }
- }
- func TestHappGenerateSkipsUnavailableSources(t *testing.T) {
- for _, tc := range []struct {
- name string
- enabled bool
- subID string
- missing bool
- }{
- {"disabled subscription", false, "current", false},
- {"missing client", true, "current", true},
- {"empty subscription ID", true, "", false},
- } {
- t.Run(tc.name, func(t *testing.T) {
- initHappTestDB(t)
- client := seedHappClient(t, tc.subID)
- configureHappSubscription(t, tc.enabled, "https://sub.example/sub/")
- configureHappLinkGate(t, true)
- svc := NewHappService(&ClientService{}, &SettingService{})
- svc.encrypt = func(string) (string, error) { t.Fatal("unavailable source was encrypted"); return "", nil }
- id := client.Id
- if tc.missing {
- id++
- }
- result, err := svc.Generate(context.Background(), id, "panel.example")
- if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
- t.Fatalf("unavailable result = %#v, %v", result, err)
- }
- })
- }
- }
- func TestHappGenerateDiscardsCancelledRequests(t *testing.T) {
- for _, before := range []bool{true, false} {
- t.Run(strconv.FormatBool(before), func(t *testing.T) {
- initHappTestDB(t)
- client := seedHappClient(t, "current")
- configureHappSubscription(t, true, "https://sub.example/sub/")
- configureHappLinkGate(t, true)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- svc, _ := newLocalHappTestService(t)
- encrypt := svc.encrypt
- svc.encrypt = func(source string) (string, error) {
- if before {
- t.Fatal("cancelled request attempted encryption")
- }
- link, err := encrypt(source)
- cancel()
- return link, err
- }
- if before {
- cancel()
- }
- result, err := svc.Generate(ctx, client.Id, "panel.example")
- if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
- t.Fatalf("cancelled result = %#v, %v", result, err)
- }
- logs := logger.GetLogs(1, "WARNING")
- if len(logs) != 1 || !strings.Contains(logs[0], "reason=request_cancelled") {
- t.Fatalf("wrong cancellation diagnostic: %v", logs)
- }
- })
- }
- }
- func TestHappGeneratePropagatesLengthErrorWithoutSecrets(t *testing.T) {
- initHappTestDB(t)
- client := seedHappClient(t, strings.Repeat("s", 8173))
- configureHappSubscription(t, true, "https://example.com/")
- configureHappLinkGate(t, true)
- result, err := NewHappService(&ClientService{}, &SettingService{}).Generate(context.Background(), client.Id, "panel.example")
- if !errors.Is(err, ErrHappSourceTooLong) || result != (HappLinkResult{}) {
- t.Fatalf("length result = %#v, %v", result, err)
- }
- logs := logger.GetLogs(1, "WARNING")
- if len(logs) != 1 || !strings.Contains(logs[0], "reason=source_too_long") {
- t.Fatalf("length diagnostic = %v", logs)
- }
- if strings.Contains(logs[0], client.SubID) || strings.Contains(logs[0], "example.com") {
- t.Fatal("length diagnostic leaked source")
- }
- }
- func TestHappGenerateLogsSanitizedEncryptionFailure(t *testing.T) {
- initHappTestDB(t)
- client := seedHappClient(t, "secret-sub-id")
- configureHappSubscription(t, true, "https://sub.example/secret-source/")
- configureHappLinkGate(t, true)
- svc := NewHappService(&ClientService{}, &SettingService{})
- svc.encrypt = func(source string) (string, error) {
- return "", errors.New("encryption failed " + source + " token=secret cookie=session authorization=Bearer-secret happ://crypt5/leak")
- }
- result, err := svc.Generate(context.Background(), client.Id, "panel.example")
- if !errors.Is(err, ErrHappLinkUnavailable) || err.Error() != "happ link unavailable" || result != (HappLinkResult{}) {
- t.Fatalf("failure = %#v, %v", result, err)
- }
- logs := logger.GetLogs(1, "WARNING")
- if len(logs) != 1 {
- t.Fatalf("logs = %v", logs)
- }
- for _, want := range []string{"component=happ_link", "client_id=" + strconv.Itoa(client.Id), "reason=encryption", "elapsed_ms=", "correlation_id=", "encryption failed"} {
- if !strings.Contains(logs[0], want) {
- t.Fatalf("diagnostic missing %q: %s", want, logs[0])
- }
- }
- for _, secret := range []string{"secret-sub-id", "secret-source", "token=secret", "cookie=session", "Bearer-secret", "happ://"} {
- if strings.Contains(logs[0], secret) {
- t.Fatalf("diagnostic leaked %q", secret)
- }
- }
- }
- func TestSanitizeHappDetailRedactsSensitiveTokens(t *testing.T) {
- 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")
- for _, secret := range []string{"provider.example", "token=secret", "hunter2", "current-sub-id", "\n"} {
- if strings.Contains(detail, secret) {
- t.Fatalf("sanitized detail leaked %q: %q", secret, detail)
- }
- }
- }
|