Files
3x-ui/internal/sub/happ.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

142 lines
3.9 KiB
Go

package sub
import (
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
var happUserAgentRegex = regexp.MustCompile(`(?i)\bhapp\b`)
// HappConfig holds all Happ client customization parameters.
type HappConfig struct {
AutoDetect bool
ProviderId string
NewUrl string
FallbackUrl string
SubInfoColor string
SubInfoText string
SubInfoButtonText string
SubInfoButtonLink string
SubExpire bool
SubExpireButtonLink string
NotificationExpire bool
NoLimit bool
AlwaysHwid bool
TunMode string
TunType string
ExcludeRoutes string
ExcludeApns bool
ColorProfile string
PingType string
AutoConnect bool
AutoConnectType string
PerAppMode string
PerAppList string
}
// IsHappClient checks if the client user-agent identifies as Happ.
func IsHappClient(userAgent string) bool {
return happUserAgentRegex.MatchString(userAgent)
}
// 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)
}
if cfg.NewUrl != "" {
c.Writer.Header().Set("New-Url", cfg.NewUrl)
}
if cfg.FallbackUrl != "" {
c.Writer.Header().Set("Fallback-Url", cfg.FallbackUrl)
}
if text := strings.TrimSpace(cfg.SubInfoText); text != "" {
color := strings.TrimSpace(cfg.SubInfoColor)
switch strings.ToLower(color) {
case "primary", "info":
color = "blue"
case "success":
color = "green"
case "warning", "danger":
color = "red"
case "":
color = "blue"
}
c.Writer.Header().Set("Sub-Info-Color", color)
c.Writer.Header().Set("Sub-Info-Text", text)
if btnText := strings.TrimSpace(cfg.SubInfoButtonText); btnText != "" {
c.Writer.Header().Set("Sub-Info-Button-Text", btnText)
}
if btnLink := strings.TrimSpace(cfg.SubInfoButtonLink); btnLink != "" {
c.Writer.Header().Set("Sub-Info-Button-Link", btnLink)
}
}
if cfg.SubExpire {
c.Writer.Header().Set("Sub-Expire", "1")
if link := strings.TrimSpace(cfg.SubExpireButtonLink); link != "" {
c.Writer.Header().Set("Sub-Expire-Button-Link", link)
}
}
if cfg.NotificationExpire {
c.Writer.Header().Set("Notification-Subs-Expire", "1")
}
if cfg.NoLimit {
c.Writer.Header().Set("No-Limit-Enabled", "1")
}
if cfg.AlwaysHwid {
c.Writer.Header().Set("Subscription-Always-Hwid-Enable", "1")
}
if cfg.TunMode != "" {
c.Writer.Header().Set("Tun-Mode", cfg.TunMode)
}
if cfg.TunType != "" {
c.Writer.Header().Set("Tun-Type", cfg.TunType)
}
if routes := strings.TrimSpace(cfg.ExcludeRoutes); routes != "" {
c.Writer.Header().Set("Exclude-Routes", routes)
}
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", "")
c.Writer.Header().Set("Color-Profile", profile)
}
if ping := strings.TrimSpace(cfg.PingType); ping != "" {
if strings.EqualFold(ping, "http") {
ping = "proxy"
}
c.Writer.Header().Set("Ping-Type", ping)
}
if cfg.AutoConnect {
c.Writer.Header().Set("Subscription-Autoconnect", "1")
autoType := strings.TrimSpace(cfg.AutoConnectType)
switch strings.ToLower(autoType) {
case "fastest":
autoType = "lowestdelay"
case "last":
autoType = "lastused"
}
if autoType != "" {
c.Writer.Header().Set("Subscription-Autoconnect-Type", autoType)
}
}
if mode := strings.TrimSpace(cfg.PerAppMode); mode != "" && mode != "off" {
switch strings.ToLower(mode) {
case "include":
mode = "on"
case "exclude":
mode = "bypass"
}
c.Writer.Header().Set("Per-App-Proxy-Mode", mode)
if list := strings.TrimSpace(cfg.PerAppList); list != "" {
c.Writer.Header().Set("Per-App-Proxy-List", list)
}
}
}