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()
}