happ.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "regexp"
  6. "strings"
  7. "time"
  8. "unicode"
  9. "github.com/google/uuid"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  11. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  12. )
  13. var (
  14. // Failures deliberately carry no subscription details.
  15. ErrHappLinkUnavailable = errors.New("happ link unavailable")
  16. ErrHappSourceTooLong = errors.New("happ subscription source exceeds 8192 bytes")
  17. )
  18. type HappLinkResult struct {
  19. EncryptedLink string `json:"encryptedLink" example:"happ://crypt5/example"`
  20. }
  21. type HappLinkGenerator interface {
  22. Generate(context.Context, int, string) (HappLinkResult, error)
  23. }
  24. // HappService generates one local encrypted link per action and does not retain results.
  25. type HappService struct {
  26. clientService *ClientService
  27. settingService *SettingService
  28. encrypt func(string) (string, error)
  29. }
  30. func NewHappService(clientService *ClientService, settingService *SettingService) *HappService {
  31. return &HappService{
  32. clientService: clientService,
  33. settingService: settingService,
  34. encrypt: encryptHappLink,
  35. }
  36. }
  37. func (s *HappService) Generate(ctx context.Context, clientID int, host string) (HappLinkResult, error) {
  38. started := time.Now()
  39. correlationID := uuid.NewString()
  40. // Check the operator gate before constructing a subscription URL or encrypting it.
  41. if reason := s.gateFailureReason(); reason != "" {
  42. return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "generation unavailable", "", "")
  43. }
  44. if ctx.Err() != nil {
  45. return HappLinkResult{}, s.fail(clientID, "request_cancelled", started, correlationID, "request cancelled", "", "")
  46. }
  47. source, client, reason := s.currentSource(clientID, host)
  48. if reason != "" {
  49. return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "source unavailable", "", "")
  50. }
  51. if s.encrypt == nil {
  52. return HappLinkResult{}, s.fail(clientID, "service_unavailable", started, correlationID, "encryption unavailable", "", "")
  53. }
  54. link, err := s.encrypt(source)
  55. if err != nil {
  56. if errors.Is(err, ErrHappSourceTooLong) {
  57. _ = s.fail(clientID, "source_too_long", started, correlationID, "source exceeds application byte limit", "", "")
  58. return HappLinkResult{}, ErrHappSourceTooLong
  59. }
  60. return HappLinkResult{}, s.fail(clientID, "encryption", started, correlationID, err.Error(), source, client.SubID)
  61. }
  62. if ctx.Err() != nil {
  63. return HappLinkResult{}, s.fail(clientID, "request_cancelled", started, correlationID, "request cancelled", "", "")
  64. }
  65. currentSource, _, currentReason := s.currentSource(clientID, host)
  66. if currentReason != "" || currentSource != source {
  67. return HappLinkResult{}, s.fail(clientID, "source_changed", started, correlationID, "source changed before response", "", "")
  68. }
  69. // Local work can still overlap a settings change; discard results after the gate is disabled.
  70. if reason := s.gateFailureReason(); reason != "" {
  71. return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "generation unavailable", "", "")
  72. }
  73. return HappLinkResult{EncryptedLink: link}, nil
  74. }
  75. func (s *HappService) gateFailureReason() string {
  76. if s.settingService == nil {
  77. return "service_unavailable"
  78. }
  79. enabled, err := s.settingService.GetHappLinkEnable()
  80. if err != nil {
  81. return "settings_unavailable"
  82. }
  83. if !enabled {
  84. return "integration_disabled"
  85. }
  86. return ""
  87. }
  88. func (s *HappService) currentSource(clientID int, host string) (string, *model.ClientRecord, string) {
  89. if s.clientService == nil || s.settingService == nil {
  90. return "", nil, "service_unavailable"
  91. }
  92. client, err := s.clientService.GetByID(clientID)
  93. if err != nil {
  94. return "", nil, "client_unavailable"
  95. }
  96. settings, err := s.settingService.GetDefaultSettings(host)
  97. if err != nil {
  98. return "", client, "settings_unavailable"
  99. }
  100. values, ok := settings.(map[string]any)
  101. if !ok {
  102. return "", client, "settings_unavailable"
  103. }
  104. subEnable, enabled := values["subEnable"].(bool)
  105. subURI, hasURI := values["subURI"].(string)
  106. if !enabled || !subEnable || !hasURI || subURI == "" || client.SubID == "" {
  107. return "", client, "source_unavailable"
  108. }
  109. return subURI + client.SubID, client, ""
  110. }
  111. var happSensitiveDetailToken = regexp.MustCompile(`(?i)(?:[a-z][a-z0-9+.-]*://\S+|(?:token|secret|password|passwd|credential|authorization|bearer|api[_-]?key|cookie|session)\s*(?:=|:)\s*\S+)`)
  112. func (s *HappService) fail(clientID int, reason string, started time.Time, correlationID, detail, source, subID string) error {
  113. logger.Warningf("component=happ_link operation=generate outcome=failure client_id=%d reason=%s elapsed_ms=%d correlation_id=%s detail=%s",
  114. clientID, reason, time.Since(started).Milliseconds(), correlationID, sanitizeHappDetail(detail, source, subID))
  115. return ErrHappLinkUnavailable
  116. }
  117. func sanitizeHappDetail(detail, source, subID string) string {
  118. if source != "" {
  119. detail = strings.ReplaceAll(detail, source, "[redacted]")
  120. }
  121. if subID != "" {
  122. detail = strings.ReplaceAll(detail, subID, "[redacted]")
  123. }
  124. detail = strings.Map(func(r rune) rune {
  125. if unicode.IsControl(r) {
  126. return -1
  127. }
  128. return r
  129. }, detail)
  130. detail = happSensitiveDetailToken.ReplaceAllString(detail, "[redacted]")
  131. runes := []rune(detail)
  132. if len(runes) > 160 {
  133. detail = string(runes[:160])
  134. }
  135. return detail
  136. }