Files
3x-ui/internal/sub/sub.go
T
DIMFLIX 71e38367c1 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>
2026-09-27 01:06:17 +02:00

481 lines
14 KiB
Go

// Package sub provides subscription server functionality for the 3x-ui panel,
// including HTTP/HTTPS servers for serving subscription links and JSON configurations.
package sub
import (
"context"
"crypto/tls"
"io"
"io/fs"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/network"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/gin-gonic/gin"
)
// Server represents the subscription server that serves subscription links and JSON configurations.
type Server struct {
httpServer *http.Server
listener net.Listener
sub *SUBController
settingService service.SettingService
ctx context.Context
cancel context.CancelFunc
}
// NewServer creates a new subscription server instance with a cancellable context.
func NewServer() *Server {
ctx, cancel := context.WithCancel(context.Background())
return &Server{
ctx: ctx,
cancel: cancel,
}
}
// initRouter configures the subscription server's Gin engine, middleware,
// templates and static assets and returns the ready-to-use engine.
func (s *Server) initRouter() (*gin.Engine, error) {
// Always run in release mode for the subscription server
gin.DefaultWriter = io.Discard
gin.DefaultErrorWriter = io.Discard
gin.SetMode(gin.ReleaseMode)
engine := gin.Default()
subDomain, err := s.settingService.GetSubDomain()
if err != nil {
return nil, err
}
if subDomain != "" {
engine.Use(middleware.DomainValidatorMiddleware(subDomain))
}
LinksPath, err := s.settingService.GetSubPath()
if err != nil {
return nil, err
}
JsonPath, err := s.settingService.GetSubJsonPath()
if err != nil {
return nil, err
}
ClashPath, err := s.settingService.GetSubClashPath()
if err != nil {
return nil, err
}
subJsonEnable, err := s.settingService.GetSubJsonEnable()
if err != nil {
return nil, err
}
subClashEnable, err := s.settingService.GetSubClashEnable()
if err != nil {
return nil, err
}
subClashAutoDetect, err := s.settingService.GetSubClashAutoDetect()
if err != nil {
subClashAutoDetect = false
}
subJsonAutoDetect, err := s.settingService.GetSubJsonAutoDetect()
if err != nil {
subJsonAutoDetect = false
}
subJsonAlwaysArray, err := s.settingService.GetSubJsonAlwaysArray()
if err != nil {
subJsonAlwaysArray = false
}
subJsonUserAgentRegex, err := s.settingService.GetSubJsonUserAgentRegex()
if err != nil {
subJsonUserAgentRegex = service.DefaultSubJsonUserAgentRegex
}
subClashUserAgentRegex, err := s.settingService.GetSubClashUserAgentRegex()
if err != nil {
subClashUserAgentRegex = service.DefaultSubClashUserAgentRegex
}
// Set base_path based on LinksPath for template rendering
// Ensure LinksPath ends with "/" for proper asset URL generation
basePath := LinksPath
if basePath != "/" && !strings.HasSuffix(basePath, "/") {
basePath += "/"
}
// logger.Debug("sub: Setting base_path to:", basePath)
engine.Use(func(c *gin.Context) {
c.Set("base_path", basePath)
})
Encrypt, err := s.settingService.GetSubEncrypt()
if err != nil {
return nil, err
}
RemarkTemplate, err := s.settingService.GetRemarkTemplate()
if err != nil {
RemarkTemplate = ""
}
SubUpdates, err := s.settingService.GetSubUpdates()
if err != nil {
SubUpdates = "10"
}
SubJsonMux, err := s.settingService.GetSubJsonMux()
if err != nil {
SubJsonMux = ""
}
SubJsonRules, err := s.settingService.GetSubJsonRules()
if err != nil {
SubJsonRules = ""
}
SubJsonRoutingRules, err := s.settingService.GetSubJsonRoutingRules()
if err != nil {
SubJsonRoutingRules = ""
}
SubJsonDns, err := s.settingService.GetSubJsonDns()
if err != nil {
SubJsonDns = ""
}
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil {
SubJsonFinalMask = ""
}
SubJsonObservatory, err := s.settingService.GetSubJsonObservatory()
if err != nil {
SubJsonObservatory = ""
}
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
if err != nil {
SubClashEnableRouting = false
}
SubClashRules, err := s.settingService.GetSubClashRules()
if err != nil {
SubClashRules = ""
}
SubTitle, err := s.settingService.GetSubTitle()
if err != nil {
SubTitle = ""
}
SubSupportUrl, err := s.settingService.GetSubSupportUrl()
if err != nil {
SubSupportUrl = ""
}
SubProfileUrl, err := s.settingService.GetSubProfileUrl()
if err != nil {
SubProfileUrl = ""
}
SubProfileMode, err := s.settingService.GetSubProfileMode()
if err != nil {
SubProfileMode = service.SubProfileModeNone
}
SubAnnounce, err := s.settingService.GetSubAnnounce()
if err != nil {
SubAnnounce = ""
}
SubEnableRouting, err := s.settingService.GetSubEnableRouting()
if err != nil {
return nil, err
}
SubRoutingRules, err := s.settingService.GetSubRoutingRules()
if err != nil {
SubRoutingRules = ""
}
SubHideSettings, err := s.settingService.GetSubHideSettings()
if err != nil {
SubHideSettings = false
}
SubIncyEnableRouting, err := s.settingService.GetSubIncyEnableRouting()
if err != nil {
SubIncyEnableRouting = false
}
SubIncyRoutingRules, err := s.settingService.GetSubIncyRoutingRules()
if err != nil {
SubIncyRoutingRules = ""
}
happCfg := HappConfig{}
happCfg.AutoDetect, _ = s.settingService.GetSubHappAutoDetect()
happCfg.ProviderId, _ = s.settingService.GetSubHappProviderId()
happCfg.NewUrl, _ = s.settingService.GetSubHappNewUrl()
happCfg.FallbackUrl, _ = s.settingService.GetSubHappFallbackUrl()
happCfg.SubInfoColor, _ = s.settingService.GetSubHappSubInfoColor()
happCfg.SubInfoText, _ = s.settingService.GetSubHappSubInfoText()
happCfg.SubInfoButtonText, _ = s.settingService.GetSubHappSubInfoButtonText()
happCfg.SubInfoButtonLink, _ = s.settingService.GetSubHappSubInfoButtonLink()
happCfg.SubExpire, _ = s.settingService.GetSubHappSubExpire()
happCfg.SubExpireButtonLink, _ = s.settingService.GetSubHappSubExpireButtonLink()
happCfg.NotificationExpire, _ = s.settingService.GetSubHappNotificationExpire()
happCfg.NoLimit, _ = s.settingService.GetSubHappNoLimit()
happCfg.AlwaysHwid, _ = s.settingService.GetSubHappAlwaysHwid()
happCfg.TunMode, _ = s.settingService.GetSubHappTunMode()
happCfg.TunType, _ = s.settingService.GetSubHappTunType()
happCfg.ExcludeRoutes, _ = s.settingService.GetSubHappExcludeRoutes()
happCfg.ExcludeApns, _ = s.settingService.GetSubHappExcludeApns()
happCfg.ColorProfile, _ = s.settingService.GetSubHappColorProfile()
happCfg.PingType, _ = s.settingService.GetSubHappPingType()
happCfg.AutoConnect, _ = s.settingService.GetSubHappAutoConnect()
happCfg.AutoConnectType, _ = s.settingService.GetSubHappAutoConnectType()
happCfg.PerAppMode, _ = s.settingService.GetSubHappPerAppMode()
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())
// Mount the Vite-built dist/assets/ so the subscription page's JS/CSS
// bundles load from `/assets/...`. Also mount the same FS under the
// subscription path prefix (LinksPath + "assets") so reverse proxies
// running the panel under a URI prefix can resolve those URLs too.
// Note: LinksPath always starts and ends with "/" (validated in settings).
var linksPathForAssets string
if LinksPath == "/" {
linksPathForAssets = "/assets"
} else {
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
}
var assetsFS http.FileSystem
if _, err := os.Stat("internal/web/dist/assets"); err == nil {
assetsFS = http.FS(os.DirFS("internal/web/dist/assets"))
} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
assetsFS = http.FS(subFS)
} else {
logger.Error("sub: failed to mount embedded dist assets:", err)
}
if assetsFS != nil {
engine.StaticFS("/assets", assetsFS)
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, assetsFS)
}
// Browser may resolve subpage assets relative to the request URL —
// /sub/<basePath>/<subId>/assets/... — so route those to the same FS.
if LinksPath != "/" {
engine.Use(func(c *gin.Context) {
path := c.Request.URL.Path
pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
_, after, ok := strings.Cut(path, "/assets/")
if ok {
assetPath := after // +8 to skip "/assets/"
if assetPath != "" {
c.FileFromFS(assetPath, assetsFS)
c.Abort()
return
}
}
}
c.Next()
})
}
}
g := engine.Group("/")
s.sub = NewSUBController(g,
WithSUBPath(LinksPath),
WithSUBJsonPath(JsonPath),
WithSUBClashPath(ClashPath),
WithSUBClashAutoDetect(subClashAutoDetect),
WithSUBClashUserAgentRegex(subClashUserAgentRegex),
WithSUBJsonAutoDetect(subJsonAutoDetect),
WithSUBJsonUserAgentRegex(subJsonUserAgentRegex),
WithSUBJsonAlwaysArray(subJsonAlwaysArray),
WithSUBJsonEnabled(subJsonEnable),
WithSUBClashEnabled(subClashEnable),
WithSUBEncryption(Encrypt),
WithSUBRemarkTemplate(RemarkTemplate),
WithSUBUpdateInterval(SubUpdates),
WithSUBJsonMux(SubJsonMux),
WithSUBJsonRules(SubJsonRules),
WithSUBJsonRoutingRules(SubJsonRoutingRules),
WithSUBJsonDns(SubJsonDns),
WithSUBJsonFinalMask(SubJsonFinalMask),
WithSUBJsonObservatory(SubJsonObservatory),
WithSUBClashEnableRouting(SubClashEnableRouting),
WithSUBClashRules(SubClashRules),
WithSUBTitle(SubTitle),
WithSUBSupportURL(SubSupportUrl),
WithSUBProfileURL(SubProfileUrl),
WithSUBProfileMode(SubProfileMode),
WithSUBAnnounce(SubAnnounce),
WithSUBEnableRouting(SubEnableRouting),
WithSUBRoutingRules(SubRoutingRules),
WithSUBHideSettings(SubHideSettings),
WithSUBHappConfig(happCfg),
WithSUBIncyConfig(incyCfg),
WithSUBIncyEnableRouting(SubIncyEnableRouting),
WithSUBIncyRoutingRules(SubIncyRoutingRules),
)
return engine, nil
}
// Start initializes and starts the subscription server with configured settings.
func (s *Server) Start() (err error) {
// This is an anonymous function, no function name
defer func() {
if err != nil {
_ = s.Stop()
}
}()
subEnable, err := s.settingService.GetSubEnable()
if err != nil {
return err
}
if !subEnable {
return nil
}
engine, err := s.initRouter()
if err != nil {
return err
}
certFile, err := s.settingService.GetSubCertFile()
if err != nil {
return err
}
keyFile, err := s.settingService.GetSubKeyFile()
if err != nil {
return err
}
listen, err := s.settingService.GetSubListen()
if err != nil {
return err
}
port, err := s.settingService.GetSubPort()
if err != nil {
return err
}
listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", listenAddr)
if err != nil {
return err
}
if certFile != "" || keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {
c := &tls.Config{
Certificates: []tls.Certificate{cert},
}
listener = network.NewAutoHttpsListener(listener)
listener = tls.NewListener(listener, c)
logger.Info("Sub server running HTTPS on", listener.Addr())
} else {
logger.Error("Error loading certificates:", err)
logger.Info("Sub server running HTTP on", listener.Addr())
}
} else {
logger.Info("Sub server running HTTP on", listener.Addr())
}
s.listener = listener
s.httpServer = &http.Server{
Handler: engine,
// The subscription server is the most exposed (public) listener; without
// these a few slow-header connections exhaust it (Slowloris). Mirrors the
// panel server timeouts in internal/web/web.go.
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
go network.ServeHTTP(s.httpServer, listener, "Subscription server")
return nil
}
// Stop gracefully shuts down the subscription server and closes the listener.
func (s *Server) Stop() error {
s.cancel()
var err1 error
var err2 error
if s.httpServer != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
err1 = s.httpServer.Shutdown(shutdownCtx)
}
if s.listener != nil {
err2 = s.listener.Close()
}
return common.Combine(err1, err2)
}
// GetCtx returns the server's context for cancellation and deadline management.
func (s *Server) GetCtx() context.Context {
return s.ctx
}