Files
3x-ui/internal/sub/happ_test.go
T
Pejman Yousefi 1456658028 feat(sub): add Happ client integration, routing presets, and app management (#6434)
* feat(sub): add Happ client integration, routing presets, and app management

Implement comprehensive Happ proxy client integration according to official developer specifications.

- Fix header emission on disabled routing and hidden settings to send explicit '0' headers rather than omitting, allowing Happ clients to reset cached settings.
- Add support for 'happ://routing/off' deeplink in routing validation.
- Preserve '?serverDescription=' query parameters in link fragments without escaping to support Happ server subtitles across VMess, VLESS, Trojan and SS.
- Add Happ application management headers: ProviderID, New-Url, Fallback-Url, Sub-Info banners, Sub-Expire notifications, No-Limit mode, hardware ID enforcement, TUN modes/types, route exclusions, APNS exclusions, and per-app proxy settings.
- Add curated routing presets (Iran Bypass, China Direct, AdBlock, Global) and interactive visual rule generator in frontend settings.
- Synchronize all 13 translation locales with native Persian, Russian, and Chinese translations.

* fix(sub): keep Happ header overrides behind the auto-detect opt-in

The Routing-Enable/Hide-Settings off values were emitted on the
User-Agent alone, so every panel that upgraded would push
"Routing-Enable: 0" — documented by happ.su as disabling routing
globally — to every Happ client without the operator enabling anything.
They now ride subHappAutoDetect like every other Happ header.

Two further mismatches against the vendor spec:

- serverDescription was written as a key of the VMess base64 JSON
  object. happ.su documents it as a "#Title?serverDescription=<base64>"
  link parameter or a JSON "meta" entry, so the caption never reached
  Happ while every other VMess consumer received an unknown key.
  Dropped rather than moved: emitting the documented form is unsafe
  here because our own parser base64-decodes the whole VMess body
  (internal/util/link/outbound.go).

- The TUN Mode dropdown stored the literal "default", forwarded as
  "Tun-Mode: default", where happ.su documents system|gvisor only. It
  now stores the unset value so no header is sent. TUN Type "default"
  is a documented value and is unchanged.

Each fix carries a test that fails without it.
2026-09-10 15:46:12 +02:00

228 lines
8.5 KiB
Go

package sub
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestNormalizeHappRouting_Off(t *testing.T) {
got, err := normalizeHappRouting([]byte("happ://routing/off"))
if err != nil {
t.Fatalf("normalizeHappRouting(off) error: %v", err)
}
if got != "happ://routing/off" {
t.Fatalf("normalizeHappRouting(off) = %q, want happ://routing/off", got)
}
}
func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := HappConfig{
AutoDetect: true,
ProviderId: "pid-test-123",
NewUrl: "https://new.example.com/sub",
FallbackUrl: "https://backup.example.com/sub",
SubInfoColor: "primary",
SubInfoText: "Welcome to VIP Network",
SubInfoButtonText: "Telegram",
SubInfoButtonLink: "https://t.me/example",
SubExpire: true,
SubExpireButtonLink: "https://renew.example.com",
NotificationExpire: true,
NoLimit: true,
AlwaysHwid: true,
TunMode: "gvisor",
TunType: "singbox",
ExcludeRoutes: "192.168.1.0/24, 10.0.0.0/8",
ExcludeApns: true,
ColorProfile: "{\"serverRowBackgroundColor\":\n\"#21003D67\"}",
PingType: "http",
AutoConnect: true,
AutoConnectType: "fastest",
PerAppMode: "include",
PerAppList: "com.google.chrome,com.meta.instagram",
}
controller := &SUBController{happConfig: cfg}
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone; iOS 17.5)")
controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "", false)
h := recorder.Header()
if h.Get("Routing-Enable") != "0" {
t.Fatalf("Routing-Enable = %q, want 0 for Happ with disabled routing", h.Get("Routing-Enable"))
}
if h.Get("Hide-Settings") != "0" {
t.Fatalf("Hide-Settings = %q, want 0 for Happ with disabled hideSettings", h.Get("Hide-Settings"))
}
if h.Get("ProviderID") != "pid-test-123" {
t.Fatalf("ProviderID = %q, want pid-test-123", h.Get("ProviderID"))
}
if h.Get("New-Url") != "https://new.example.com/sub" {
t.Fatalf("New-Url = %q", h.Get("New-Url"))
}
if h.Get("Fallback-Url") != "https://backup.example.com/sub" {
t.Fatalf("Fallback-Url = %q", h.Get("Fallback-Url"))
}
if h.Get("Sub-Info-Color") != "blue" || h.Get("Sub-Info-Text") != "Welcome to VIP Network" {
t.Fatalf("Sub-Info = %s / %s, want blue / Welcome to VIP Network", h.Get("Sub-Info-Color"), h.Get("Sub-Info-Text"))
}
if h.Get("Sub-Info-Button-Text") != "Telegram" || h.Get("Sub-Info-Button-Link") != "https://t.me/example" {
t.Fatalf("Sub-Info button = %s / %s", h.Get("Sub-Info-Button-Text"), h.Get("Sub-Info-Button-Link"))
}
if h.Get("Sub-Expire") != "1" || h.Get("Sub-Expire-Button-Link") != "https://renew.example.com" {
t.Fatalf("Sub-Expire = %s / %s", h.Get("Sub-Expire"), h.Get("Sub-Expire-Button-Link"))
}
if h.Get("Notification-Subs-Expire") != "1" {
t.Fatalf("Notification-Subs-Expire = %q", h.Get("Notification-Subs-Expire"))
}
if h.Get("No-Limit-Enabled") != "1" {
t.Fatalf("No-Limit-Enabled = %q", h.Get("No-Limit-Enabled"))
}
if h.Get("Subscription-Always-Hwid-Enable") != "1" {
t.Fatalf("Subscription-Always-Hwid-Enable = %q", h.Get("Subscription-Always-Hwid-Enable"))
}
if h.Get("Tun-Mode") != "gvisor" || h.Get("Tun-Type") != "singbox" {
t.Fatalf("Tun mode/type = %s / %s", h.Get("Tun-Mode"), h.Get("Tun-Type"))
}
if h.Get("Exclude-Routes") != "192.168.1.0/24, 10.0.0.0/8" || h.Get("Exclude-Apns-Enable") != "true" {
t.Fatalf("Exclude routes/apns = %s / %s", h.Get("Exclude-Routes"), h.Get("Exclude-Apns-Enable"))
}
if wantProfile := "{\"serverRowBackgroundColor\":\"#21003D67\"}"; h.Get("Color-Profile") != wantProfile {
t.Fatalf("Color-Profile = %q, want %q", h.Get("Color-Profile"), wantProfile)
}
if h.Get("Ping-Type") != "proxy" {
t.Fatalf("Ping-Type = %q, want proxy for http alias", h.Get("Ping-Type"))
}
if h.Get("Subscription-Autoconnect") != "1" || h.Get("Subscription-Autoconnect-Type") != "lowestdelay" {
t.Fatalf("Autoconnect = %s / %s, want 1 / lowestdelay for fastest alias", h.Get("Subscription-Autoconnect"), h.Get("Subscription-Autoconnect-Type"))
}
if h.Get("Per-App-Proxy-Mode") != "on" || h.Get("Per-App-Proxy-List") != "com.google.chrome,com.meta.instagram" {
t.Fatalf("Per-App = %s / %s, want on / com.google.chrome,com.meta.instagram", h.Get("Per-App-Proxy-Mode"), h.Get("Per-App-Proxy-List"))
}
}
func TestApplyHappHeaders_Gating(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := HappConfig{
AutoDetect: true,
ProviderId: "pid-secret",
SubInfoText: "Banner",
TunMode: "system",
}
t.Run("non-Happ User-Agent receives no headers", func(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
ctx.Request.Header.Set("User-Agent", "v2rayNG/1.8.5")
controller := &SUBController{happConfig: cfg}
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
if got := recorder.Header().Get("ProviderID"); got != "" {
t.Fatalf("ProviderID emitted to non-Happ client: %q", got)
}
if got := recorder.Header().Get("Sub-Info-Text"); got != "" {
t.Fatalf("Sub-Info-Text emitted to non-Happ client: %q", got)
}
if got := recorder.Header().Get("Tun-Mode"); got != "" {
t.Fatalf("Tun-Mode emitted to non-Happ client: %q", got)
}
})
t.Run("AutoDetect disabled suppresses Happ headers", func(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (Android)")
disabledCfg := cfg
disabledCfg.AutoDetect = false
controller := &SUBController{happConfig: disabledCfg}
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
if got := recorder.Header().Get("ProviderID"); got != "" {
t.Fatalf("ProviderID emitted when AutoDetect is false: %q", got)
}
if got := recorder.Header().Get("Sub-Info-Text"); got != "" {
t.Fatalf("Sub-Info-Text emitted when AutoDetect is false: %q", got)
}
// happ.su documents routing-enable 0/false as "disables routing
// globally", so it must stay behind the same opt-in as the rest.
if got := recorder.Header().Get("Routing-Enable"); got != "" {
t.Fatalf("Routing-Enable emitted when AutoDetect is false: %q", got)
}
if got := recorder.Header().Get("Hide-Settings"); got != "" {
t.Fatalf("Hide-Settings emitted when AutoDetect is false: %q", got)
}
})
}
func TestApplyHappHeaders_Aliases(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
cfg HappConfig
wantHeader string
wantValue string
}{
{
name: "color warning maps to red",
cfg: HappConfig{AutoDetect: true, SubInfoText: "Alert", SubInfoColor: "warning"},
wantHeader: "Sub-Info-Color",
wantValue: "red",
},
{
name: "color danger maps to red",
cfg: HappConfig{AutoDetect: true, SubInfoText: "Alert", SubInfoColor: "danger"},
wantHeader: "Sub-Info-Color",
wantValue: "red",
},
{
name: "color success maps to green",
cfg: HappConfig{AutoDetect: true, SubInfoText: "Ok", SubInfoColor: "success"},
wantHeader: "Sub-Info-Color",
wantValue: "green",
},
{
name: "autoconnect last maps to lastused",
cfg: HappConfig{AutoDetect: true, AutoConnect: true, AutoConnectType: "last"},
wantHeader: "Subscription-Autoconnect-Type",
wantValue: "lastused",
},
{
name: "per-app exclude maps to bypass",
cfg: HappConfig{AutoDetect: true, PerAppMode: "exclude", PerAppList: "app.id"},
wantHeader: "Per-App-Proxy-Mode",
wantValue: "bypass",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iOS)")
controller := &SUBController{happConfig: tc.cfg}
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
if got := recorder.Header().Get(tc.wantHeader); got != tc.wantValue {
t.Fatalf("%s = %q, want %q", tc.wantHeader, got, tc.wantValue)
}
})
}
}