feat(happ): generate Crypt5 subscription links locally (#6494)

* feat(clients): add stateless Happ link generator

Generate Happ provider links from the current effective subscription source without caching results. Reject unsafe provider responses and redact failure diagnostics.

* fix(clients): reject duplicate Happ provider fields

Parse Happ provider objects token by token so duplicate supported keys cannot be silently overwritten by encoding/json.

* feat(clients): expose on-demand Happ link API

Expose a no-store client endpoint backed by the Happ link generator and keep its generated OpenAPI contract synchronized.

* fix(openapi): exclude service interfaces from generated types

Keep dependency-injection interfaces out of the frontend API surface while preserving allowed response schemas.

* feat(clients): add stateless Happ QR presentation

Generate Happ links only for the active modal scope and retire late responses so Standard remains immediately available. Add focused component coverage and localized retry guidance across every locale.

* fix(clients): cover overlapping Happ generations

Prove the cancellation cleanup is required by resolving a retired request while its replacement remains pending. Also wait for Regenerate to leave loading state before exercising the existing action.

* fix(clients): harden Happ link handling

Validate generated responses before rendering and hide actions during unresolved requests. Strengthen route, redirect, timeout, and lint regression coverage with mutation-sensitive tests.

* fix(clients): gate Happ link generation behind operator opt-in

- add a fail-closed happLinkEnable setting
- enforce the gate before and after provider requests
- add locked Happ QR state with privacy disclosure and settings link
- cover backend, frontend, settings, and i18n regressions

* fix(frontend): guard oversized Happ QR codes

Keep valid long crypt5 links copyable while suppressing QR rendering and image actions above the encoder's UTF-8 byte limit. Add localized guidance and boundary coverage.

* fix(clients): log the sanitized transport error for Happ link failures

Every fail() call in HappService.Generate passed a string literal as the
detail, so the sanitizer written for provider errors only ever saw
constants, and an operator following the QR modal's "check Logs" hint
found nothing beyond reason=transport. Transport and body-read errors now
flow through sanitizeHappDetail, which also redacts cookie/session pairs.

Drop TestHappLinkEnableDefaultsOffWithoutPersistingRow: it pinned a getter
and its constant default, which the Generate gate test already drives.

* fix(frontend): size the Happ QR cap to level L and keep the QR modal mounted on close

HAPP_QR_MAX_BYTES was the level-M capacity (2331) while QrPanel encodes at
errorLevel "L", whose version-40 byte-mode capacity is 2953, so valid links
between 2332 and 2953 bytes lost their QR. The cap now matches the encoder
and a test renders the real QrPanel at the boundary.

Keying the modal content on `open` remounted it on every close, which cut
the Modal's exit transition and made the openSubId sync unreachable, so
`loading` never turned on for the subLinks fetch and a client without a
subscription link flashed noLinks on reopen. `open` leaves the key and the
sync block now also resets the Happ state.

* chore(clients): request Happ crypt5 links from api-v3

crypto.happ.su serves api-v2.php and api-v3.php side by side. Probed with
the same payloads, both take {"url"} over a JSON POST, answer
{"encrypted_link":"happ://crypt5/..."} of identical length with the same
crypt5 key marker, and fail the same way: 400 "No url provided.",
500 "Invalid URL format.", 405 on GET. Happ's own generator page is
branded "URL Encryption v3", so the panel follows it. The parser and the
link validator are unchanged.

* feat: add local generation of encrypted Happ links

- Implemented functionality to generate encrypted Happ links locally without network dependency.
- Added validation for URL length and format to ensure compliance with processing limits.
- Introduced new error handling for invalid URLs and control characters.
- Updated translations for various languages to reflect changes in Happ link generation.
- Created unit tests to validate the encryption process and ensure session keys and nonces are unique.

* fix(frontend): match the tuic memo deps to the non-optional subSettings

The Happ branch reads subSettings non-optionally in ClientQrModalContent
(happLinkEnable and the WireGuard/AmneziaWG publicHost memos), so React
Compiler infers subSettings.publicHost. The TUIC memo merged in from main
still listed subSettings?.publicHost, which fails oxlint's
preserve-manual-memoization rule and makes the compiler skip optimizing
the component. make verify stopped at lint-fe on the branch head.

* chore(happ): trim the pinned-key provenance comment to two lines

CLAUDE.md caps a comment block at two lines. The bare URL line repeated
the repository and file the next line already names, so it is folded
into that line (review LOW on happ_crypto.go).

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
NgaiYeanCoi
2026-09-13 18:44:55 +08:00
committed by GitHub
parent c3b08b6d9f
commit 6a5b4fab6a
45 changed files with 2652 additions and 26 deletions
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"context"
"errors"
"regexp"
"strings"
"time"
"unicode"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
var (
// Failures deliberately carry no subscription details.
ErrHappLinkUnavailable = errors.New("happ link unavailable")
ErrHappSourceTooLong = errors.New("happ subscription source exceeds 8192 bytes")
)
type HappLinkResult struct {
EncryptedLink string `json:"encryptedLink" example:"happ://crypt5/example"`
}
type HappLinkGenerator interface {
Generate(context.Context, int, string) (HappLinkResult, error)
}
// HappService generates one local encrypted link per action and does not retain results.
type HappService struct {
clientService *ClientService
settingService *SettingService
encrypt func(string) (string, error)
}
func NewHappService(clientService *ClientService, settingService *SettingService) *HappService {
return &HappService{
clientService: clientService,
settingService: settingService,
encrypt: encryptHappLink,
}
}
func (s *HappService) Generate(ctx context.Context, clientID int, host string) (HappLinkResult, error) {
started := time.Now()
correlationID := uuid.NewString()
// Check the operator gate before constructing a subscription URL or encrypting it.
if reason := s.gateFailureReason(); reason != "" {
return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "generation unavailable", "", "")
}
if ctx.Err() != nil {
return HappLinkResult{}, s.fail(clientID, "request_cancelled", started, correlationID, "request cancelled", "", "")
}
source, client, reason := s.currentSource(clientID, host)
if reason != "" {
return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "source unavailable", "", "")
}
if s.encrypt == nil {
return HappLinkResult{}, s.fail(clientID, "service_unavailable", started, correlationID, "encryption unavailable", "", "")
}
link, err := s.encrypt(source)
if err != nil {
if errors.Is(err, ErrHappSourceTooLong) {
_ = s.fail(clientID, "source_too_long", started, correlationID, "source exceeds application byte limit", "", "")
return HappLinkResult{}, ErrHappSourceTooLong
}
return HappLinkResult{}, s.fail(clientID, "encryption", started, correlationID, err.Error(), source, client.SubID)
}
if ctx.Err() != nil {
return HappLinkResult{}, s.fail(clientID, "request_cancelled", started, correlationID, "request cancelled", "", "")
}
currentSource, _, currentReason := s.currentSource(clientID, host)
if currentReason != "" || currentSource != source {
return HappLinkResult{}, s.fail(clientID, "source_changed", started, correlationID, "source changed before response", "", "")
}
// Local work can still overlap a settings change; discard results after the gate is disabled.
if reason := s.gateFailureReason(); reason != "" {
return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "generation unavailable", "", "")
}
return HappLinkResult{EncryptedLink: link}, nil
}
func (s *HappService) gateFailureReason() string {
if s.settingService == nil {
return "service_unavailable"
}
enabled, err := s.settingService.GetHappLinkEnable()
if err != nil {
return "settings_unavailable"
}
if !enabled {
return "integration_disabled"
}
return ""
}
func (s *HappService) currentSource(clientID int, host string) (string, *model.ClientRecord, string) {
if s.clientService == nil || s.settingService == nil {
return "", nil, "service_unavailable"
}
client, err := s.clientService.GetByID(clientID)
if err != nil {
return "", nil, "client_unavailable"
}
settings, err := s.settingService.GetDefaultSettings(host)
if err != nil {
return "", client, "settings_unavailable"
}
values, ok := settings.(map[string]any)
if !ok {
return "", client, "settings_unavailable"
}
subEnable, enabled := values["subEnable"].(bool)
subURI, hasURI := values["subURI"].(string)
if !enabled || !subEnable || !hasURI || subURI == "" || client.SubID == "" {
return "", client, "source_unavailable"
}
return subURI + client.SubID, client, ""
}
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+)`)
func (s *HappService) fail(clientID int, reason string, started time.Time, correlationID, detail, source, subID string) error {
logger.Warningf("component=happ_link operation=generate outcome=failure client_id=%d reason=%s elapsed_ms=%d correlation_id=%s detail=%s",
clientID, reason, time.Since(started).Milliseconds(), correlationID, sanitizeHappDetail(detail, source, subID))
return ErrHappLinkUnavailable
}
func sanitizeHappDetail(detail, source, subID string) string {
if source != "" {
detail = strings.ReplaceAll(detail, source, "[redacted]")
}
if subID != "" {
detail = strings.ReplaceAll(detail, subID, "[redacted]")
}
detail = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, detail)
detail = happSensitiveDetailToken.ReplaceAllString(detail, "[redacted]")
runes := []rune(detail)
if len(runes) > 160 {
detail = string(runes[:160])
}
return detail
}
+165
View File
@@ -0,0 +1,165 @@
package service
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net/url"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/crypto/chacha20poly1305"
)
// vdfzfoff public key from Omegaplexx/hpwnr 3745cb96e2551e003cb217ab7705b4d67f8ac006, src/keys.rs.
// Salted Crypt5 with separator V passed Android 4.3.0 and Windows 4.1.2 import/update probes.
const happPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9+umWSxp8coKnMONnI4u
NvtPErJZt8VNgNb2XS+RrCMc9AFWZQH01ILr3Py/mviuqFgNLMEcPs3k6+ZPh6Sa
OCXHmjQicGPJAw6Co6GQwO/b4vspHgOM4HSvX5r6SY1EKIHUSLIyRV28DfwJKdFv
x2EKqypewlrAo4AV76uI/9U+1t40yHcVCj/OtFxsq+mMM6qySieTsA1q6C5raBrJ
u3l/RWMxFYvDInYDs1IaTFGDFwSdFDqhNU19gPGloT/GApy+U32R6AGSxJymS2nh
e6pm/M9bvsH0o0Oc1kyXsBpVN04n/a9gVVUoqODzrUyXDx7/jAzNJD43PWtblcz0
ZNBKN50wvpSD5UuAQydwMT7xWJIpPaZqTUj/sg8hIm57XGlUxRCge17nB0Ff7sKO
JAgaXVdbfqDdzx+PhSaZY9xfcAh/sHfE6hKaCQ9kIn5cjbx9bcYqZWnpuSOzSFg+
CgMSqvG6rV6d+96dNMHuE0tRIUJ83xrLcm9hZJmJ6WDm6hteZbnb1k3eQF9c+XCF
wSEvsWiXyduQmkVNJaCRXwy8tSaZp9JftALhRHMvd7Eq6ctAkvn7w0upynsAtLeL
N8xZ5q1gcRgboydr588D3m8KF7mVuX/XRp2AG7hzyYdkQov9bfEfXIaBVlwHMKhy
uPTxeM4Les6fvaHMSWJ+8EUCAwEAAQ==
-----END PUBLIC KEY-----`
const (
happCrypt5Marker = "vdfzfoff"
happPublicKeyFingerprint = "22319c7b13647897bf5fd4f827ba92bf3946d738007a0054ccd931c31f221768"
// Bound application work before encoding; this is not a promised Happ client limit.
happMaxSourceBytes = 8192
)
func encryptHappLink(source string) (string, error) {
key, err := checkedHappPublicKey([]byte(happPublicKeyPEM))
if err != nil {
return "", err
}
return encryptHappSource(source, key)
}
func encryptHappSource(source string, key *rsa.PublicKey) (string, error) {
if !validHappRSAKey(key) {
return "", ErrHappLinkUnavailable
}
if len(source) > happMaxSourceBytes {
return "", ErrHappSourceTooLong
}
if len(source) == 0 || !utf8.ValidString(source) || strings.IndexFunc(source, unicode.IsControl) >= 0 {
return "", ErrHappLinkUnavailable
}
parsed, err := url.Parse(source)
if err != nil || !parsed.IsAbs() || parsed.Opaque != "" || parsed.Hostname() == "" ||
parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", ErrHappLinkUnavailable
}
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const alnum = letters + "0123456789"
sessionKey := make([]byte, 32)
if _, err := rand.Read(sessionKey); err != nil {
return "", fmt.Errorf("happ key randomness: %w", err)
}
nonce, err := randomHappCharacters(12, alnum)
if err != nil {
return "", err
}
tag, err := randomHappCharacters(2, letters)
if err != nil {
return "", err
}
salt, err := randomHappCharacters(8, alnum)
if err != nil {
return "", err
}
wrappedKey := make([]byte, 32)
for i := range wrappedKey {
wrappedKey[i] = sessionKey[i] ^ salt[i%8]
}
rsaPlain := swapHappPairs([]byte(base64.StdEncoding.EncodeToString(wrappedKey)))
//nolint:staticcheck // Happ Crypt5 requires PKCS#1 v1.5 key wrapping; OAEP changes the wire format.
rsaCipher, err := rsa.EncryptPKCS1v15(rand.Reader, key, rsaPlain)
if err != nil {
return "", fmt.Errorf("happ RSA wrapping: %w", err)
}
aead, err := chacha20poly1305.New(sessionKey)
if err != nil {
return "", fmt.Errorf("happ AEAD initialization: %w", err)
}
// Parsing validates the URL but must not normalize its UTF-8, escapes, or query bytes.
plain := swapHappPairs([]byte(base64.StdEncoding.EncodeToString([]byte(source))))
cipherB64 := base64.StdEncoding.EncodeToString(aead.Seal(nil, nonce, plain, nil))
body := string(nonce) + string(tag) + string(salt) + strconv.Itoa(len(cipherB64)) +
"V" + cipherB64 + base64.StdEncoding.EncodeToString(rsaCipher)
frame := []byte(happCrypt5Marker[:4] + body + happCrypt5Marker[4:])
for i := 0; i+3 < len(frame); i += 4 {
frame[i], frame[i+2] = frame[i+2], frame[i]
frame[i+1], frame[i+3] = frame[i+3], frame[i+1]
}
return "happ://crypt5/" + string(frame), nil
}
func checkedHappPublicKey(pemData []byte) (*rsa.PublicKey, error) {
trimmed := bytes.TrimSpace(pemData)
block, rest := pem.Decode(trimmed)
if !bytes.HasPrefix(trimmed, []byte("-----BEGIN PUBLIC KEY-----")) || block == nil ||
block.Type != "PUBLIC KEY" || len(block.Headers) != 0 || len(bytes.TrimSpace(rest)) != 0 {
return nil, ErrHappLinkUnavailable
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, ErrHappLinkUnavailable
}
key, ok := parsed.(*rsa.PublicKey)
if !ok || !validHappRSAKey(key) {
return nil, ErrHappLinkUnavailable
}
spki, err := x509.MarshalPKIXPublicKey(key)
if err != nil {
return nil, ErrHappLinkUnavailable
}
fingerprint := sha256.Sum256(spki)
// The marker selects the client's private key, so accepting any RSA public key would be incorrect.
if hex.EncodeToString(fingerprint[:]) != happPublicKeyFingerprint {
return nil, ErrHappLinkUnavailable
}
return key, nil
}
func validHappRSAKey(key *rsa.PublicKey) bool {
return key != nil && key.N != nil && key.N.Sign() > 0 && key.N.BitLen() == 4096 && key.N.Bit(0) == 1 && key.E == 65537
}
func randomHappCharacters(length int, alphabet string) ([]byte, error) {
result := make([]byte, length)
limit := big.NewInt(int64(len(alphabet)))
for i := range result {
index, err := rand.Int(rand.Reader, limit)
if err != nil {
return nil, fmt.Errorf("happ character randomness: %w", err)
}
result[i] = alphabet[index.Int64()]
}
return result, nil
}
func swapHappPairs(data []byte) []byte {
for i := 0; i+1 < len(data); i += 2 {
data[i], data[i+1] = data[i+1], data[i]
}
return data
}
+129
View File
@@ -0,0 +1,129 @@
package service
import (
"context"
"crypto/rsa"
"crypto/sha256"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync/atomic"
"testing"
)
func TestHappGenerateLocallyWithoutNetwork(t *testing.T) {
initHappTestDB(t)
client := seedHappClient(t, "local-only")
configureHappSubscription(t, true, "https://sub.example/sub/")
configureHappLinkGate(t, true)
var calls atomic.Int32
previous := http.DefaultTransport
// Fail before opening a socket, including clients cloned from the default transport.
http.DefaultTransport = &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) {
calls.Add(1)
return nil, errors.New("network is unavailable in the local-generation test")
}}
t.Cleanup(func() { http.DefaultTransport = previous })
svc := NewHappService(&ClientService{}, &SettingService{})
result, err := svc.Generate(context.Background(), client.Id, "panel.example")
if err != nil || !strings.HasPrefix(result.EncryptedLink, "happ://crypt5/") {
t.Fatalf("local generation = %#v, %v; network attempts = %d", result, err, calls.Load())
}
if calls.Load() != 0 {
t.Fatalf("local generation attempted %d network connections", calls.Load())
}
}
func TestHappEncryptPreservesUTF8AndEnforcesResourceLimit(t *testing.T) {
key, err := syntheticHappKey()
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, source string
wantError error
}{
{"unicode URL", "https://example.com/中文?emoji=🔒&literal=%2F&x=a+b", nil},
{"501 ASCII bytes", "https://example.com/" + strings.Repeat("a", 481), nil},
{"502 ASCII bytes", "https://example.com/" + strings.Repeat("a", 482), nil},
{"501 UTF8 bytes", "https://example.com/" + strings.Repeat("界", 160) + "a", nil},
{"502 UTF8 bytes", "https://example.com/" + strings.Repeat("界", 160) + "ab", nil},
{"8192 ASCII bytes", "https://example.com/" + strings.Repeat("a", 8172), nil},
{"8193 ASCII bytes", "https://example.com/" + strings.Repeat("a", 8173), ErrHappSourceTooLong},
{"8192 UTF8 bytes", "https://example.com/" + strings.Repeat("界", 2724), nil},
{"8193 UTF8 bytes", "https://example.com/" + strings.Repeat("界", 2724) + "a", ErrHappSourceTooLong},
{"raw query and fragment", "https://example.com/s%2fb?a=one+two&b=%2B#标题", nil},
{"empty URL", "", ErrHappLinkUnavailable},
{"invalid URL", "not-a-url", ErrHappLinkUnavailable},
{"unsupported scheme", "file:///tmp/sub", ErrHappLinkUnavailable},
{"empty host", "https:///sub", ErrHappLinkUnavailable},
{"control character", "https://example.com/a\nb", ErrHappLinkUnavailable},
{"Unicode control", "https://example.com/a\u0085b", ErrHappLinkUnavailable},
{"invalid UTF8", "https://example.com/" + string([]byte{0xff}), ErrHappLinkUnavailable},
{"userinfo", "https://user:pass@example.com/sub", ErrHappLinkUnavailable},
{"opaque URL", "https:sub", ErrHappLinkUnavailable},
} {
t.Run(tc.name, func(t *testing.T) {
link, err := encryptHappSource(tc.source, &key.PublicKey)
if !errors.Is(err, tc.wantError) {
t.Fatalf("error = %v, want %v", err, tc.wantError)
}
if tc.wantError != nil {
if link != "" {
t.Fatal("failed encryption returned a link")
}
return
}
if got := decryptHappTestLink(t, link, key); got != tc.source {
t.Fatalf("source was changed or truncated: %q", got)
}
})
}
for _, invalidKey := range []*rsa.PublicKey{nil, {N: key.N, E: 0}} {
link, err := encryptHappSource("https://example.com/sub", invalidKey)
if !errors.Is(err, ErrHappLinkUnavailable) || link != "" {
t.Fatalf("invalid key result = %q, %v", link, err)
}
}
}
func TestHappEncryptUsesClientValidatedPublicKey(t *testing.T) {
block, _ := pem.Decode([]byte(happPublicKeyPEM))
if block == nil {
t.Fatal("missing public key")
}
// Pin the marker's public key from the accepted Android/Windows Crypt5 probe.
if got := fmt.Sprintf("%x", sha256.Sum256(block.Bytes)); got != "22319c7b13647897bf5fd4f827ba92bf3946d738007a0054ccd931c31f221768" {
t.Fatalf("unvalidated public key: %s", got)
}
first, err := encryptHappLink("https://example.com/sub")
if err != nil {
t.Fatal(err)
}
second, err := encryptHappLink("https://example.com/sub")
if err != nil || len(first) != 795 || !strings.HasPrefix(first, "happ://crypt5/") || first == second {
t.Fatalf("expected fresh crypt5 ciphertext: length=%d, err=%v", len(first), err)
}
}
func TestHappEncryptUsesFreshSessionKeysAndNonces(t *testing.T) {
key, err := syntheticHappKey()
if err != nil {
t.Fatal(err)
}
keys, nonces := map[string]bool{}, map[string]bool{}
for range 8 {
link, err := encryptHappSource("https://example.com/sub", &key.PublicKey)
if err != nil {
t.Fatal(err)
}
decoded := decodeHappTestLink(t, link, key)
if keys[string(decoded.key)] || nonces[string(decoded.nonce)] {
t.Fatal("generation reused a session key or nonce")
}
keys[string(decoded.key)], nonces[string(decoded.nonce)] = true, true
}
}
+408
View File
@@ -0,0 +1,408 @@
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)
}
}
}
+6
View File
@@ -89,6 +89,7 @@ var defaultValueMap = map[string]string{
"tgLang": "en-US",
"twoFactorEnable": "false",
"twoFactorToken": "",
"happLinkEnable": "false",
"subEnable": "true",
"subJsonEnable": "false",
"subJsonAutoDetect": "false",
@@ -783,6 +784,10 @@ func (s *SettingService) GetSubEnable() (bool, error) {
return s.getBool("subEnable")
}
func (s *SettingService) GetHappLinkEnable() (bool, error) {
return s.getBool("happLinkEnable")
}
func (s *SettingService) GetSubJsonEnable() (bool, error) {
return s.getBool("subJsonEnable")
}
@@ -1602,6 +1607,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
"happLinkEnable": func() (any, error) { return s.GetHappLinkEnable() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
+43
View File
@@ -0,0 +1,43 @@
package service
import "testing"
func TestHappLinkEnableReadsExplicitValues(t *testing.T) {
initHappTestDB(t)
s := &SettingService{}
for _, want := range []bool{false, true} {
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
settings.HappLinkEnable = want
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err)
}
gotDirect, err := s.GetHappLinkEnable()
if err != nil || gotDirect != want {
t.Fatalf("GetHappLinkEnable = %t, %v; want %t, nil", gotDirect, err, want)
}
if got := happLinkEnableFromDefaults(t, s); got != want {
t.Fatalf("stored happLinkEnable = %t, want %t", got, want)
}
}
}
func happLinkEnableFromDefaults(t *testing.T, s *SettingService) bool {
t.Helper()
defaults, err := s.GetDefaultSettings("panel.example")
if err != nil {
t.Fatal(err)
}
values, ok := defaults.(map[string]any)
if !ok {
t.Fatalf("GetDefaultSettings type = %T, want map[string]any", defaults)
}
enabled, ok := values["happLinkEnable"].(bool)
if !ok {
t.Fatalf("happLinkEnable = %#v, want bool", values["happLinkEnable"])
}
return enabled
}