diff --git a/frontend/src/pages/settings/HappSettingsContent.tsx b/frontend/src/pages/settings/HappSettingsContent.tsx index 5839995bc..bba39a454 100644 --- a/frontend/src/pages/settings/HappSettingsContent.tsx +++ b/frontend/src/pages/settings/HappSettingsContent.tsx @@ -51,55 +51,19 @@ export default function HappSettingsContent({ }; const handleBuildDeeplink = () => { - interface FieldRule { - type: string; - outboundTag: string; - domain?: string[]; - ip?: string[]; - network?: string; - } - const rules: FieldRule[] = []; + const profile = { + Name: 'Custom Rules', + GlobalProxy: 'true', + DirectSites: parseList(directDomains), + DirectIp: parseList(directIPs), + ProxySites: parseList(proxyDomains), + ProxyIp: parseList(proxyIPs), + BlockSites: parseList(blockDomains), + BlockIp: parseList(blockIPs), + DomainStrategy: 'IPIfNonMatch', + }; - const bDom = parseList(blockDomains); - const bIp = parseList(blockIPs); - if (bDom.length > 0 || bIp.length > 0) { - rules.push({ - type: 'field', - outboundTag: 'block', - ...(bDom.length > 0 ? { domain: bDom } : {}), - ...(bIp.length > 0 ? { ip: bIp } : {}), - }); - } - - const dDom = parseList(directDomains); - const dIp = parseList(directIPs); - if (dDom.length > 0 || dIp.length > 0) { - rules.push({ - type: 'field', - outboundTag: 'direct', - ...(dDom.length > 0 ? { domain: dDom } : {}), - ...(dIp.length > 0 ? { ip: dIp } : {}), - }); - } - - const pDom = parseList(proxyDomains); - const pIp = parseList(proxyIPs); - if (pDom.length > 0 || pIp.length > 0) { - rules.push({ - type: 'field', - outboundTag: 'proxy', - ...(pDom.length > 0 ? { domain: pDom } : {}), - ...(pIp.length > 0 ? { ip: pIp } : {}), - }); - } - - rules.push({ - type: 'field', - outboundTag: 'proxy', - network: 'tcp,udp', - }); - - const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify({ rules })); + const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile)); updateSetting({ subRoutingRules: deeplink }); setIsModalOpen(false); message.success(t('pages.settings.subHappDeeplinkGenerated')); @@ -107,6 +71,17 @@ export default function HappSettingsContent({ return ( <> + + updateSetting({ subHappAutoDetect: v })} + /> + + - updateSetting({ subHappColorProfile: e.target.value })} - /> + + updateSetting({ subHappColorProfile: e.target.value })} + /> + + + + + + ), @@ -472,17 +479,6 @@ export default function HappSettingsContent({ ), children: ( <> - - updateSetting({ subHappAutoDetect: v })} - /> - - { const jsonStr = atob(b64); const parsed = JSON.parse(jsonStr); - expect(parsed).toHaveProperty('rules'); - expect(Array.isArray(parsed.rules)).toBe(true); - - const directRule = parsed.rules.find( - (r: { outboundTag: string }) => r.outboundTag === 'direct', - ); - expect(directRule).toBeDefined(); - expect(directRule.domain).toContain('domain:ir'); - expect(directRule.ip).toContain('geoip:ir'); - - const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block'); - expect(blockRule).toBeDefined(); - expect(blockRule.domain).toContain('geosite:category-ads-all'); + expect(parsed.Name).toBe('Iran Bypass'); + expect(parsed.GlobalProxy).toBe('true'); + expect(parsed.DirectSites).toContain('domain:ir'); + expect(parsed.DirectIp).toContain('geoip:ir'); + expect(parsed.BlockSites).toContain('geosite:category-ads-all'); }); it('generates valid base64 payload for china-direct preset', () => { @@ -45,11 +37,9 @@ describe('Happ presets and helpers', () => { const jsonStr = atob(b64); const parsed = JSON.parse(jsonStr); - const directRule = parsed.rules.find( - (r: { outboundTag: string }) => r.outboundTag === 'direct', - ); - expect(directRule.domain).toContain('domain:cn'); - expect(directRule.ip).toContain('geoip:cn'); + expect(parsed.Name).toBe('China Direct'); + expect(parsed.DirectSites).toContain('geosite:cn'); + expect(parsed.DirectIp).toContain('geoip:cn'); }); it('generates valid base64 payload for adblock preset', () => { @@ -58,8 +48,8 @@ describe('Happ presets and helpers', () => { const jsonStr = atob(b64); const parsed = JSON.parse(jsonStr); - const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block'); - expect(blockRule.domain).toContain('geosite:category-ads-all'); + expect(parsed.Name).toBe('AdBlock'); + expect(parsed.BlockSites).toContain('geosite:category-ads-all'); }); it('generates valid base64 payload for global preset', () => { @@ -68,8 +58,8 @@ describe('Happ presets and helpers', () => { const jsonStr = atob(b64); const parsed = JSON.parse(jsonStr); - expect(parsed.rules[0].outboundTag).toBe('proxy'); - expect(parsed.rules[0].network).toBe('tcp,udp'); + expect(parsed.Name).toBe('Global Proxy'); + expect(parsed.DomainStrategy).toBe('AsIs'); }); it('encodes unicode properly via toBase64Utf8', () => { diff --git a/frontend/src/test/happ-settings-tun-mode.test.tsx b/frontend/src/test/happ-settings-tun-mode.test.tsx index 5872b6ed4..686f7b549 100644 --- a/frontend/src/test/happ-settings-tun-mode.test.tsx +++ b/frontend/src/test/happ-settings-tun-mode.test.tsx @@ -69,4 +69,25 @@ describe('Happ TUN Mode select', () => { const select = selectFor('TUN Mode'); expect(select.querySelector('.ant-select-content')?.textContent).toBe('Default'); }); + + it('renders Auto-Detection master switch at the top and triggers update', () => { + const updateSetting = vi.fn(); + const allSetting = new AllSetting(); + allSetting.subHappAutoDetect = false; + + renderWithProviders( + null} + />, + ); + + const switchBtn = document.querySelector('.ant-switch'); + if (!switchBtn) throw new Error('switch not found'); + fireEvent.click(switchBtn); + + expect(updateSetting).toHaveBeenCalledWith({ subHappAutoDetect: true }); + }); }); diff --git a/internal/sub/endpoint.go b/internal/sub/endpoint.go index 07772414b..f164a5390 100644 --- a/internal/sub/endpoint.go +++ b/internal/sub/endpoint.go @@ -1,6 +1,7 @@ package sub import ( + "encoding/base64" "strings" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -20,10 +21,11 @@ import ( // because three behaviors branch on the raw string (keep-base, obj["tls"] // rewrite, none-strip). type ShareEndpoint struct { - Address string - Port int - Remark string // extra remark slot fed to genRemark, not a rendered remark - ForceTls string + Address string + Port int + Remark string // extra remark slot fed to genRemark, not a rendered remark + ServerDescription string // subtitle caption displayed in Happ client + ForceTls string // ep is the source externalProxy entry. nil for host/default endpoints. ep map[string]any @@ -38,6 +40,7 @@ func externalProxyToEndpoint(ep map[string]any) ShareEndpoint { e.Port = int(p) } e.Remark, _ = ep["remark"].(string) + e.ServerDescription, _ = ep["serverDescription"].(string) e.ForceTls, _ = ep["forceTls"].(string) return e } @@ -106,10 +109,14 @@ func (s *SubService) buildEndpointLinks( applyEndpointHostPath(e, nextParams) applyEndpointFinalMask(e, nextParams) applyEndpointAllowInsecure(e, nextParams, securityToApply) + remark := makeRemark(e) + if e.ServerDescription != "" { + remark = appendHappServerDescription(remark, e.ServerDescription) + } links = append(links, buildLinkWithParamsAndSecurity( makeLink(e), nextParams, - makeRemark(e), + remark, securityToApply, e.ForceTls == "none", )) @@ -117,6 +124,14 @@ func (s *SubService) buildEndpointLinks( return strings.Join(links, "\n") } +func appendHappServerDescription(remark, desc string) string { + if desc == "" { + return remark + } + encoded := base64.StdEncoding.EncodeToString([]byte(desc)) + return remark + "?serverDescription=" + encoded +} + // buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint. func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string { var links strings.Builder @@ -132,6 +147,9 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st if e.ForceTls != "same" { newObj["tls"] = e.ForceTls } + if e.ServerDescription != "" { + newObj["serverDescription"] = e.ServerDescription + } applyEndpointTLSObj(e, newObj, securityToApply) applyEndpointHostPathObj(e, newObj) applyEndpointFinalMaskObj(e, newObj) diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index 1ab4143e0..8983f3df8 100644 --- a/internal/sub/endpoint_test.go +++ b/internal/sub/endpoint_test.go @@ -116,8 +116,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) { } } -// happ.su documents serverDescription as a "#title?serverDescription=" -// link parameter, never a key of the VMess object, so nothing may leak into it. +// happ.su documents serverDescription for VMess as a JSON object key +// {"add":"...","ps":"...","serverDescription":"Happ the best"}. func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) { s := &SubService{} in := &model.Inbound{Remark: "ib"} @@ -137,8 +137,8 @@ func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) { if obj["add"] != "a.example.com" { t.Fatalf("host endpoint not applied: add = %v", obj["add"]) } - if value, ok := obj["serverDescription"]; ok { - t.Fatalf("VMess object carries serverDescription = %v; it is not a VMess object key", value) + if obj["serverDescription"] != "Berlin premium" { + t.Fatalf("VMess object missing serverDescription: got %v, want Berlin premium", obj["serverDescription"]) } } diff --git a/internal/sub/happ.go b/internal/sub/happ.go index d00374921..b1056526a 100644 --- a/internal/sub/happ.go +++ b/internal/sub/happ.go @@ -41,21 +41,25 @@ func IsHappClient(userAgent string) bool { return happUserAgentRegex.MatchString(userAgent) } +func sanitizeHeaderValue(v string) string { + return strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(v), "\r", ""), "\n", "") +} + // ApplyHappHeaders sets standard and advanced Happ subscription headers. func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) { if c == nil || c.Writer == nil || !cfg.AutoDetect || !isHapp { return } if cfg.ProviderId != "" { - c.Writer.Header().Set("ProviderID", cfg.ProviderId) + c.Writer.Header().Set("ProviderID", strings.TrimSpace(cfg.ProviderId)) } if cfg.NewUrl != "" { - c.Writer.Header().Set("New-Url", cfg.NewUrl) + c.Writer.Header().Set("New-Url", strings.TrimSpace(cfg.NewUrl)) } if cfg.FallbackUrl != "" { - c.Writer.Header().Set("Fallback-Url", cfg.FallbackUrl) + c.Writer.Header().Set("Fallback-Url", strings.TrimSpace(cfg.FallbackUrl)) } - if text := strings.TrimSpace(cfg.SubInfoText); text != "" { + if text := sanitizeHeaderValue(cfg.SubInfoText); text != "" { color := strings.TrimSpace(cfg.SubInfoColor) switch strings.ToLower(color) { case "primary", "info": @@ -69,10 +73,10 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) { } c.Writer.Header().Set("Sub-Info-Color", color) c.Writer.Header().Set("Sub-Info-Text", text) - if btnText := strings.TrimSpace(cfg.SubInfoButtonText); btnText != "" { + if btnText := sanitizeHeaderValue(cfg.SubInfoButtonText); btnText != "" { c.Writer.Header().Set("Sub-Info-Button-Text", btnText) } - if btnLink := strings.TrimSpace(cfg.SubInfoButtonLink); btnLink != "" { + if btnLink := sanitizeHeaderValue(cfg.SubInfoButtonLink); btnLink != "" { c.Writer.Header().Set("Sub-Info-Button-Link", btnLink) } } @@ -103,8 +107,7 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) { if cfg.ExcludeApns { c.Writer.Header().Set("Exclude-Apns-Enable", "true") } - if profile := strings.TrimSpace(cfg.ColorProfile); profile != "" { - profile = strings.ReplaceAll(strings.ReplaceAll(profile, "\r", ""), "\n", "") + if profile := sanitizeHeaderValue(cfg.ColorProfile); profile != "" { c.Writer.Header().Set("Color-Profile", profile) } if ping := strings.TrimSpace(cfg.PingType); ping != "" { diff --git a/internal/sub/happ_test.go b/internal/sub/happ_test.go index 17f9528cf..1d1985bf6 100644 --- a/internal/sub/happ_test.go +++ b/internal/sub/happ_test.go @@ -1,8 +1,10 @@ package sub import ( + "encoding/base64" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -53,12 +55,15 @@ func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) { 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) + 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")) } @@ -109,6 +114,28 @@ func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) { } } +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) @@ -165,6 +192,9 @@ func TestApplyHappHeaders_Gating(t *testing.T) { 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) + } }) } @@ -225,3 +255,96 @@ func TestApplyHappHeaders_Aliases(t *testing.T) { }) } } + +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) + } + } +} diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index d5a4fc5e1..c960a6736 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -107,6 +107,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int) if h.VlessRoute != "" { ep["vlessRoute"] = h.VlessRoute } + if h.ServerDescription != "" { + ep["serverDescription"] = h.ServerDescription + } return ep } diff --git a/internal/sub/service.go b/internal/sub/service.go index 2c502199e..b0327183f 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2271,8 +2271,18 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec if fragment != "" { sb.WriteByte('#') - // Match the frontend's encodeURIComponent(remark): spaces become %20. - sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20")) + if before, after, ok := strings.Cut(fragment, "?serverDescription="); ok { + if _, err := base64.StdEncoding.DecodeString(after); err == nil && len(after) > 0 && !strings.ContainsAny(after, " \r\n\t#&") { + sb.WriteString(strings.ReplaceAll(url.QueryEscape(before), "+", "%20")) + sb.WriteString("?serverDescription=") + sb.WriteString(after) + } else { + sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20")) + } + } else { + // Match the frontend's encodeURIComponent(remark): spaces become %20. + sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20")) + } } return sb.String() }