feat(sub): add Incy app-management parameters (#6650)

* feat(sub): add Incy app-management parameters

The panel already pushes a set of Happ headers, but INCY documents its own
lowercase header names and its own value domains, so a Happ-shaped payload gets
ignored by the client (per-app mode is bypass|proxy, not on|bypass, and
per-app-proxy-enable has no Happ counterpart at all). Add a sibling Incy path
that emits exactly the documented headers.

Covered, per https://docs.incy.cc/en/app-management/:
- profile-description, sort-order, support-email, announce-url, premium-url
- banner text/button/URL and the two hex colours
- hide-url, hide-check, no-limit-enabled
- per-app split tunnelling (enable/mode/list)
- TCP fragmentation (enable/length/interval/packets)
- UDP noise packets (enable/type/packet/delay)
- DoH pre-resolution (enable/domain/IP)

Each string setting is tri-state: an empty value omits the header, so an
untouched panel never overrides the subscriber's own choice in the app. Values
are validated against the documented domains and dropped when they do not
match, and non-ASCII text is base64-wrapped the way the docs require for
Cyrillic. INCY identifies itself as INCY/<version>/<platform>, which gates the
headers behind the same auto-detect switch the Happ path uses.

Headers the panel already emits for every client (Profile-Title, Support-Url,
Profile-Web-Page-Url, Announce, Profile-Update-Interval, Subscription-Userinfo)
and Incy's routing line are left as they are.

The Premium API (theme, defaultPingProtocol, fallbackHosts, ...) is a separate
encrypted endpoint and stays out of scope here.

* fix(sub): keep Incy per-app list entries separate on the wire

The Incy settings textarea takes one package per line, as Incy documents for
per-app-proxy-list, but the header path ran the value through
sanitizeHeaderValue, which deletes CR/LF. "com.google.chrome\norg.telegram.messenger"
reached the client as the single bogus package
"com.google.chromeorg.telegram.messenger", so per-app split tunnelling silently
matched no app. Join comma- or line-separated entries as CSV instead.

Also drop three tests that could not fail: TestIncyExcludesHappOnlyHeaders
(ApplyIncyHeaders has no path that emits Happ headers, and the non-Happ UA
gate is already pinned by TestApplyHappHeaders_Gating) and two UI tests that
only asserted updateSetting received the key the JSX passes it.

---------

Co-authored-by: DIMFLIX <dimflix@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
DIMFLIX
2026-09-27 02:06:17 +03:00
committed by GitHub
parent bd9ccde1f4
commit 71e38367c1
30 changed files with 2994 additions and 37 deletions
+8
View File
@@ -62,6 +62,7 @@ type SUBController struct {
subJsonRoutingRules string
subHideSettings bool
happConfig HappConfig
incyConfig IncyConfig
subIncyEnableRouting bool
subIncyRoutingRules string
@@ -124,6 +125,7 @@ type subControllerConfig struct {
subRoutingRules string
subHideSettings bool
happConfig HappConfig
incyConfig IncyConfig
subIncyEnableRouting bool
subIncyRoutingRules string
@@ -259,6 +261,10 @@ func WithSUBHappConfig(value HappConfig) SUBControllerOption {
return func(config *subControllerConfig) { config.happConfig = value }
}
func WithSUBIncyConfig(value IncyConfig) SUBControllerOption {
return func(config *subControllerConfig) { config.incyConfig = value }
}
func defaultSUBControllerConfig() subControllerConfig {
return subControllerConfig{
subPath: "/sub/",
@@ -293,6 +299,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
subJsonRoutingRules: config.subJsonRoutingRules,
subHideSettings: config.subHideSettings,
happConfig: config.happConfig,
incyConfig: config.incyConfig,
subIncyEnableRouting: config.subIncyEnableRouting,
subIncyRoutingRules: config.subIncyRoutingRules,
@@ -974,4 +981,5 @@ func (a *SUBController) ApplyCommonHeaders(
}
ApplyHappHeaders(c, a.happConfig, happManaged)
ApplyIncyHeaders(c, a.incyConfig, a.incyConfig.AutoDetect && c.Request != nil && IsIncyClient(c.GetHeader("User-Agent")))
}
+180
View File
@@ -0,0 +1,180 @@
package sub
import (
"encoding/base64"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
// INCY clients identify themselves as INCY/<version>/<platform>.
var incyUserAgentRegex = regexp.MustCompile(`(?i)\bincy\b`)
var (
incyRangeRegex = regexp.MustCompile(`^\d+(-\d+)?$`)
incyHexRegex = regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
// Incy documents fragmentation-packets as tlshello | 1-3 | 1 | all.
incyPacketsRangeRegex = regexp.MustCompile(`^\d+-\d+$`)
)
// IncyConfig holds the Incy app-management headers the panel can push.
// A "" field omits its header, so an untouched panel never overrides the app.
type IncyConfig struct {
AutoDetect bool
ProfileDescription string
SortOrder string
SupportEmail string
AnnounceUrl string
PremiumUrl string
BannerText string
BannerButtonText string
BannerButtonUrl string
BannerBgColor string
BannerButtonColor string
HideUrl string
HideCheck string
NoLimitEnabled string
PerAppProxyEnable string
PerAppProxyMode string
PerAppProxyList string
FragmentationEnable string
FragmentationLength string
FragmentationInterval string
FragmentationPackets string
NoisesEnable string
NoisesType string
NoisesPacket string
NoisesDelay string
ServerAddressResolveEnable string
ServerAddressResolveDnsDomain string
ServerAddressResolveDnsIp string
}
// IsIncyClient checks if the client user-agent identifies as INCY.
func IsIncyClient(userAgent string) bool {
return incyUserAgentRegex.MatchString(userAgent)
}
// incyOnOff maps a switch setting to the documented `1`/`0` literal. An
// unrecognised or unset value stays empty so the header is omitted.
func incyOnOff(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return "1"
case "0", "false", "no", "off":
return "0"
}
return ""
}
// incyEnum passes through only a documented literal from a fixed value set.
func incyEnum(v string, allowed ...string) string {
value := strings.ToLower(strings.TrimSpace(v))
for _, a := range allowed {
if value == a {
return value
}
}
return ""
}
// incyMatch returns the trimmed value only when it matches a documented shape.
func incyMatch(v string, re *regexp.Regexp) string {
value := strings.TrimSpace(v)
if re.MatchString(value) {
return value
}
return ""
}
func incyASCII(v string) bool {
for i := 0; i < len(v); i++ {
if v[i] > 0x7e || v[i] < 0x20 {
return false
}
}
return true
}
// incyPackageList joins a comma- or line-separated app list as CSV, since a
// header cannot carry the newlines the settings textarea accepts.
func incyPackageList(v string) string {
entries := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' })
kept := entries[:0]
for _, entry := range entries {
if entry = strings.TrimSpace(entry); entry != "" {
kept = append(kept, entry)
}
}
return strings.Join(kept, ",")
}
// incyHeaderText base64-wraps non-ASCII text because the docs require
// `base64:<...>` for anything outside the ASCII range (Cyrillic, CJK, emoji).
func incyHeaderText(v string) string {
text := sanitizeHeaderValue(v)
if text == "" || incyASCII(text) {
return text
}
return "base64:" + base64.StdEncoding.EncodeToString([]byte(text))
}
// ApplyIncyHeaders sets the Incy app-management headers from
// https://docs.incy.cc/en/app-management/ (matched case-insensitively).
func ApplyIncyHeaders(c *gin.Context, cfg IncyConfig, isIncy bool) {
if c == nil || c.Writer == nil || !cfg.AutoDetect || !isIncy {
return
}
h := c.Writer.Header()
set := func(name, value string) {
if value != "" {
h.Set(name, value)
}
}
set("Profile-Description", incyHeaderText(cfg.ProfileDescription))
set("Sort-Order", incyEnum(cfg.SortOrder, "none", "ping", "name"))
set("Support-Email", sanitizeHeaderValue(cfg.SupportEmail))
set("Announce-Url", sanitizeHeaderValue(cfg.AnnounceUrl))
set("Premium-Url", sanitizeHeaderValue(cfg.PremiumUrl))
set("Banner-Text", incyHeaderText(cfg.BannerText))
set("Banner-Button-Text", incyHeaderText(cfg.BannerButtonText))
set("Banner-Button-Url", sanitizeHeaderValue(cfg.BannerButtonUrl))
set("Banner-Bg-Color", incyMatch(cfg.BannerBgColor, incyHexRegex))
set("Banner-Button-Color", incyMatch(cfg.BannerButtonColor, incyHexRegex))
set("Hide-Url", incyOnOff(cfg.HideUrl))
set("Hide-Check", incyOnOff(cfg.HideCheck))
set("No-Limit-Enabled", incyOnOff(cfg.NoLimitEnabled))
set("Per-App-Proxy-Enable", incyOnOff(cfg.PerAppProxyEnable))
set("Per-App-Proxy-Mode", incyEnum(cfg.PerAppProxyMode, "bypass", "proxy"))
set("Per-App-Proxy-List", incyPackageList(cfg.PerAppProxyList))
set("Fragmentation-Enable", incyOnOff(cfg.FragmentationEnable))
set("Fragmentation-Length", incyMatch(cfg.FragmentationLength, incyRangeRegex))
set("Fragmentation-Interval", incyMatch(cfg.FragmentationInterval, incyRangeRegex))
if packets := strings.ToLower(strings.TrimSpace(cfg.FragmentationPackets)); packets != "" {
if packets == "tlshello" || packets == "all" || packets == "1" || incyPacketsRangeRegex.MatchString(packets) {
set("Fragmentation-Packets", packets)
}
}
set("Noises-Enable", incyOnOff(cfg.NoisesEnable))
set("Noises-Type", incyEnum(cfg.NoisesType, "rand", "str", "hex"))
set("Noises-Packet", sanitizeHeaderValue(cfg.NoisesPacket))
set("Noises-Delay", incyMatch(cfg.NoisesDelay, incyRangeRegex))
set("Server-Address-Resolve-Enable", incyOnOff(cfg.ServerAddressResolveEnable))
set("Server-Address-Resolve-Dns-Domain", sanitizeHeaderValue(cfg.ServerAddressResolveDnsDomain))
set("Server-Address-Resolve-Dns-Ip", sanitizeHeaderValue(cfg.ServerAddressResolveDnsIp))
}
+208
View File
@@ -0,0 +1,208 @@
package sub
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func fullIncyConfig() IncyConfig {
return IncyConfig{
AutoDetect: true,
ProfileDescription: "Fast and stable network",
SortOrder: "ping",
SupportEmail: "support@example.com",
AnnounceUrl: "https://t.me/incy_news",
PremiumUrl: "https://example.com/buy",
BannerText: "Summer sale",
BannerButtonText: "Buy now",
BannerButtonUrl: "https://example.com/sale",
BannerBgColor: "#E53E3E",
BannerButtonColor: "#38A169",
HideUrl: "1",
HideCheck: "true",
NoLimitEnabled: "0",
PerAppProxyEnable: "1",
PerAppProxyMode: "bypass",
PerAppProxyList: "com.google.chrome,org.telegram.messenger",
FragmentationEnable: "1",
FragmentationLength: "10-30",
FragmentationInterval: "20-40",
FragmentationPackets: "tlshello",
NoisesEnable: "1",
NoisesType: "rand",
NoisesPacket: "10-20",
NoisesDelay: "10-50",
ServerAddressResolveEnable: "1",
ServerAddressResolveDnsDomain: "https://common.dot.dns.yandex.net/dns-query",
ServerAddressResolveDnsIp: "77.88.8.8",
}
}
func applyIncyToHeaders(t *testing.T, cfg IncyConfig, userAgent string) http.Header {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
if userAgent != "" {
ctx.Request.Header.Set("User-Agent", userAgent)
}
ApplyIncyHeaders(ctx, cfg, cfg.AutoDetect && IsIncyClient(ctx.GetHeader("User-Agent")))
return recorder.Header()
}
func TestIsIncyClient(t *testing.T) {
for _, tc := range []struct {
userAgent string
want bool
}{
{"INCY/1.0.0/ios", true},
{"incy/2.4/android", true},
{"Mozilla/5.0 (Linux; Android 14) INCY/1.2.3/android", true},
{"Happ/1.2.0 (iPhone; iOS 17.5)", false},
{"v2rayNG/1.8.5", false},
{"", false},
} {
if got := IsIncyClient(tc.userAgent); got != tc.want {
t.Errorf("IsIncyClient(%q) = %v, want %v", tc.userAgent, got, tc.want)
}
}
}
func TestApplyIncyHeaders_AllDocumentedHeaders(t *testing.T) {
cfg := fullIncyConfig()
h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/android")
want := map[string]string{
"Profile-Description": "Fast and stable network",
"Sort-Order": "ping",
"Support-Email": "support@example.com",
"Announce-Url": "https://t.me/incy_news",
"Premium-Url": "https://example.com/buy",
"Banner-Text": "Summer sale",
"Banner-Button-Text": "Buy now",
"Banner-Button-Url": "https://example.com/sale",
"Banner-Bg-Color": "#E53E3E",
"Banner-Button-Color": "#38A169",
"Hide-Url": "1",
"Hide-Check": "1",
"No-Limit-Enabled": "0",
"Per-App-Proxy-Enable": "1",
"Per-App-Proxy-Mode": "bypass",
"Per-App-Proxy-List": "com.google.chrome,org.telegram.messenger",
"Fragmentation-Enable": "1",
"Fragmentation-Length": "10-30",
"Fragmentation-Interval": "20-40",
"Fragmentation-Packets": "tlshello",
"Noises-Enable": "1",
"Noises-Type": "rand",
"Noises-Packet": "10-20",
"Noises-Delay": "10-50",
"Server-Address-Resolve-Enable": "1",
"Server-Address-Resolve-Dns-Domain": "https://common.dot.dns.yandex.net/dns-query",
"Server-Address-Resolve-Dns-Ip": "77.88.8.8",
}
for name, value := range want {
if got := h.Get(name); got != value {
t.Errorf("header %s = %q, want %q", name, got, value)
}
}
}
func TestApplyIncyHeaders_SkipsUnsetValues(t *testing.T) {
cfg := IncyConfig{AutoDetect: true}
cfg.HideUrl = ""
cfg.SortOrder = ""
h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/ios")
for _, name := range []string{"Hide-Url", "Sort-Order", "Fragmentation-Enable", "Banner-Text"} {
if got := h.Get(name); got != "" {
t.Errorf("unset header %s = %q, want omitted", name, got)
}
}
}
func TestApplyIncyHeaders_NotAppliedWithoutIncyClient(t *testing.T) {
cfg := fullIncyConfig()
for _, userAgent := range []string{"Happ/1.2.0 (iPhone)", "v2rayNG/1.8.5", ""} {
h := applyIncyToHeaders(t, cfg, userAgent)
if got := h.Get("Hide-Url"); got != "" {
t.Errorf("user-agent %q: Hide-Url = %q, want omitted", userAgent, got)
}
}
}
func TestApplyIncyHeaders_NotAppliedWhenAutoDetectOff(t *testing.T) {
cfg := fullIncyConfig()
cfg.AutoDetect = false
h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/android")
if got := h.Get("Hide-Url"); got != "" {
t.Errorf("AutoDetect off: Hide-Url = %q, want omitted", got)
}
}
func TestIncyHeaderText_Base64ForNonASCII(t *testing.T) {
ascii := incyHeaderText("Plain banner")
if ascii != "Plain banner" {
t.Errorf("ASCII text = %q, want unchanged", ascii)
}
cyrillic := incyHeaderText("Здравствуйте")
if !strings.HasPrefix(cyrillic, "base64:") {
t.Fatalf("Cyrillic text = %q, want base64: prefix", cyrillic)
}
// The docs require base64 for anything outside the ASCII range, so the
// raw value must not survive on the wire.
if strings.Contains(cyrillic, "Здравствуйте") {
t.Errorf("Cyrillic text = %q, want the raw value base64-encoded", cyrillic)
}
}
func TestApplyIncyHeaders_RejectsUndocumentedValues(t *testing.T) {
cfg := IncyConfig{
AutoDetect: true,
SortOrder: "alphabetical", // not none|ping|name
PerAppProxyMode: "include", // Happ spelling, not bypass|proxy
NoisesType: "uuid", // not rand|str|hex
FragmentationLength: "10..30", // not min-max
FragmentationPackets: "3", // documented only as tlshello|1-3|1|all
BannerBgColor: "red", // not #RRGGBB
HideUrl: "maybe", // not 1|0
}
h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/android")
for _, name := range []string{
"Sort-Order", "Per-App-Proxy-Mode", "Noises-Type",
"Fragmentation-Length", "Fragmentation-Packets", "Banner-Bg-Color", "Hide-Url",
} {
if got := h.Get(name); got != "" {
t.Errorf("undocumented value accepted for %s: %q", name, got)
}
}
}
func TestApplyIncyHeaders_PerAppListKeepsSeparators(t *testing.T) {
// The settings textarea takes one package per line, and a header cannot
// carry a newline, so each line must stay a separate list entry.
for _, tc := range []struct {
name, list, want string
}{
{"newline", "com.google.chrome\norg.telegram.messenger", "com.google.chrome,org.telegram.messenger"},
{"crlf and blank lines", "com.google.chrome\r\n\r\norg.telegram.messenger\r\n", "com.google.chrome,org.telegram.messenger"},
{"url", " https://example.com/apps.txt ", "https://example.com/apps.txt"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := IncyConfig{AutoDetect: true, PerAppProxyList: tc.list}
h := applyIncyToHeaders(t, cfg, "INCY/1.0.0/android")
if got := h.Get("Per-App-Proxy-List"); got != tc.want {
t.Errorf("Per-App-Proxy-List = %q, want %q", got, tc.want)
}
})
}
}
+31
View File
@@ -255,6 +255,36 @@ func (s *Server) initRouter() (*gin.Engine, error) {
happCfg.PerAppList, _ = s.settingService.GetSubHappPerAppList()
happCfg.LocalProxyAuth, _ = s.settingService.GetSubHappLocalProxyAuth()
incyCfg := IncyConfig{}
incyCfg.AutoDetect, _ = s.settingService.GetSubIncyAppAutoDetect()
incyCfg.ProfileDescription, _ = s.settingService.GetSubIncyProfileDescription()
incyCfg.SortOrder, _ = s.settingService.GetSubIncySortOrder()
incyCfg.SupportEmail, _ = s.settingService.GetSubIncySupportEmail()
incyCfg.AnnounceUrl, _ = s.settingService.GetSubIncyAnnounceUrl()
incyCfg.PremiumUrl, _ = s.settingService.GetSubIncyPremiumUrl()
incyCfg.BannerText, _ = s.settingService.GetSubIncyBannerText()
incyCfg.BannerButtonText, _ = s.settingService.GetSubIncyBannerButtonText()
incyCfg.BannerButtonUrl, _ = s.settingService.GetSubIncyBannerButtonUrl()
incyCfg.BannerBgColor, _ = s.settingService.GetSubIncyBannerBgColor()
incyCfg.BannerButtonColor, _ = s.settingService.GetSubIncyBannerButtonColor()
incyCfg.HideUrl, _ = s.settingService.GetSubIncyHideUrl()
incyCfg.HideCheck, _ = s.settingService.GetSubIncyHideCheck()
incyCfg.NoLimitEnabled, _ = s.settingService.GetSubIncyNoLimitEnabled()
incyCfg.PerAppProxyEnable, _ = s.settingService.GetSubIncyPerAppEnable()
incyCfg.PerAppProxyMode, _ = s.settingService.GetSubIncyPerAppMode()
incyCfg.PerAppProxyList, _ = s.settingService.GetSubIncyPerAppList()
incyCfg.FragmentationEnable, _ = s.settingService.GetSubIncyFragmentationEnable()
incyCfg.FragmentationLength, _ = s.settingService.GetSubIncyFragmentLength()
incyCfg.FragmentationInterval, _ = s.settingService.GetSubIncyFragmentInterval()
incyCfg.FragmentationPackets, _ = s.settingService.GetSubIncyFragmentPackets()
incyCfg.NoisesEnable, _ = s.settingService.GetSubIncyNoisesEnable()
incyCfg.NoisesType, _ = s.settingService.GetSubIncyNoisesType()
incyCfg.NoisesPacket, _ = s.settingService.GetSubIncyNoisesPacket()
incyCfg.NoisesDelay, _ = s.settingService.GetSubIncyNoisesDelay()
incyCfg.ServerAddressResolveEnable, _ = s.settingService.GetSubIncyResolveEnable()
incyCfg.ServerAddressResolveDnsDomain, _ = s.settingService.GetSubIncyResolveDnsDomain()
incyCfg.ServerAddressResolveDnsIp, _ = s.settingService.GetSubIncyResolveDnsIp()
// set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware())
@@ -340,6 +370,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
WithSUBRoutingRules(SubRoutingRules),
WithSUBHideSettings(SubHideSettings),
WithSUBHappConfig(happCfg),
WithSUBIncyConfig(incyCfg),
WithSUBIncyEnableRouting(SubIncyEnableRouting),
WithSUBIncyRoutingRules(SubIncyRoutingRules),
)