Files
3x-ui/internal/sub/happ_test.go
T
Kirill Rudenko 6ab718f813 fix(sub): make Happ require auth on its local SOCKS/HTTP proxy (#6628)
Happ ships its local SOCKS5 (127.0.0.1:10808) and HTTP inbounds with
authorization disabled by default. Any app on the same device can then
connect to that proxy, bypassing Android's per-app VPN routing, and learn
the VPN server address - the leak publicly described in March-April 2026
for Happ, v2rayNG and other VLESS clients. Happ fixed its Xray API
exposure, but the unauthenticated local proxy remained.

Happ exposes a standard subscription header for this (no Provider ID
required): socks-auth-mode / http-auth-mode = auto|manual|from-json|
disable. A new subscription setting, subHappLocalProxyAuth (default
"auto"), sends both headers to Happ clients. Like every other Happ header
it is emitted only when Happ auto-detect is enabled and the User-Agent is
Happ, so panels that never opted into the Happ integration see no change.
An empty value sends nothing and keeps the client's own setting.

Verified on Happ Android 4.4.1 (Xray 26.7.28): a subscription carrying
socks-auth-mode manual + a test user/password switched the client's
Inbounds screen to Manual with those credentials on "refresh subscription",
and "auto" switched it to Auto with generated credentials.

Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:03:20 +02:00

385 lines
14 KiB
Go

package sub
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"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, "happ://routing/onadd/existing-rules", 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("Routing") != "happ://routing/onadd/existing-rules" {
t.Fatalf("Routing = %q, want happ://routing/onadd/existing-rules for Happ with configured routing", h.Get("Routing"))
}
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 TestApplyCommonHeaders_HappRoutingOffDeeplink(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := HappConfig{AutoDetect: true}
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)")
// When rules is happ://routing/off, Routing-Enable is 0 and Routing is happ://routing/off
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "happ://routing/off", false)
h := recorder.Header()
if h.Get("Routing-Enable") != "0" {
t.Fatalf("Routing-Enable = %q, want 0 when rules is happ://routing/off", h.Get("Routing-Enable"))
}
if h.Get("Routing") != "happ://routing/off" {
t.Fatalf("Routing = %q, want happ://routing/off", h.Get("Routing"))
}
}
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)
}
if got := recorder.Header().Get("Routing"); got != "" {
t.Fatalf("Routing 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)
}
})
}
}
func TestAppendHappServerDescription(t *testing.T) {
desc := "VIP Server"
encoded := base64.StdEncoding.EncodeToString([]byte(desc))
got := appendHappServerDescription("My Node", desc)
want := "My Node?serverDescription=" + encoded
if got != want {
t.Fatalf("appendHappServerDescription = %q, want %q", got, want)
}
if gotEmpty := appendHappServerDescription("My Node", ""); gotEmpty != "My Node" {
t.Fatalf("appendHappServerDescription with empty desc = %q, want My Node", gotEmpty)
}
}
func TestAppendQueryAndFragment_PreservesServerDescription(t *testing.T) {
desc := "Fast Server"
encoded := base64.StdEncoding.EncodeToString([]byte(desc))
t.Run("preserves serverDescription with encoded title", func(t *testing.T) {
fragment := "Server 01?serverDescription=" + encoded
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
want := "vless://user@host:443#Server%2001?serverDescription=" + encoded
if link != want {
t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
}
})
t.Run("properly escapes remark containing literal question mark without serverDescription", func(t *testing.T) {
fragment := "Fast? Server"
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
want := "vless://user@host:443#Fast%3F%20Server"
if link != want {
t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
}
})
t.Run("properly escapes remark containing literal question mark with serverDescription", func(t *testing.T) {
fragment := "Fast? Server?serverDescription=" + encoded
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
want := "vless://user@host:443#Fast%3F%20Server?serverDescription=" + encoded
if link != want {
t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
}
})
t.Run("escapes fragment when serverDescription tail contains invalid base64", func(t *testing.T) {
fragment := "Node 1?serverDescription=not-base64!!!"
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
want := "vless://user@host:443#Node%201%3FserverDescription%3Dnot-base64%21%21%21"
if link != want {
t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
}
})
t.Run("escapes fragment when serverDescription tail contains newline injection", func(t *testing.T) {
fragment := "Node 1?serverDescription=" + encoded + "\nevil://inject"
link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
if strings.Contains(link, "\n") {
t.Fatalf("appendQueryAndFragment emitted raw newline: %q", link)
}
})
}
func TestIsHappClient(t *testing.T) {
matching := []string{
"Happ/1.2.0 (iPhone; iOS 17.5)",
"happ/2.0",
"happ",
"HAPP/1.0",
"Mozilla/5.0 Happ/1.0",
}
for _, ua := range matching {
if !IsHappClient(ua) {
t.Errorf("IsHappClient(%q) = false, want true", ua)
}
}
nonMatching := []string{
"Happy/2.0",
"happier-client",
"Happening/1.0",
"unhappy",
"v2rayNG/1.8.5",
"",
}
for _, ua := range nonMatching {
if IsHappClient(ua) {
t.Errorf("IsHappClient(%q) = true, want false", ua)
}
}
}
func TestApplyHappHeaders_LocalProxyAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
autoDetect bool
userAgent string
mode string
want string
}{
{"Happ gets auto for both inbounds", true, "Happ/4.4.1 (Android)", "auto", "auto"},
{"explicit disable is forwarded", true, "Happ/4.4.1 (Android)", "disable", "disable"},
{"empty mode sends nothing", true, "Happ/4.4.1 (Android)", "", ""},
{"non-Happ client gets nothing", true, "v2rayNG/1.8.5", "auto", ""},
{"AutoDetect off sends nothing", false, "Happ/4.4.1 (Android)", "auto", ""},
}
for _, tc := range cases {
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", tc.userAgent)
controller := &SUBController{happConfig: HappConfig{AutoDetect: tc.autoDetect, LocalProxyAuth: tc.mode}}
controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
for _, h := range []string{"Socks-Auth-Mode", "Http-Auth-Mode"} {
if got := recorder.Header().Get(h); got != tc.want {
t.Fatalf("%s = %q, want %q", h, got, tc.want)
}
}
})
}
}