mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-28 12:46:41 +08:00
6ab718f813
Happ ships its local SOCKS5 (127.0.0.1:10808) and HTTP inbounds with authorization disabled by default. Any app on the same device can then connect to that proxy, bypassing Android's per-app VPN routing, and learn the VPN server address - the leak publicly described in March-April 2026 for Happ, v2rayNG and other VLESS clients. Happ fixed its Xray API exposure, but the unauthenticated local proxy remained. Happ exposes a standard subscription header for this (no Provider ID required): socks-auth-mode / http-auth-mode = auto|manual|from-json| disable. A new subscription setting, subHappLocalProxyAuth (default "auto"), sends both headers to Happ clients. Like every other Happ header it is emitted only when Happ auto-detect is enabled and the User-Agent is Happ, so panels that never opted into the Happ integration see no change. An empty value sends nothing and keeps the client's own setting. Verified on Happ Android 4.4.1 (Xray 26.7.28): a subscription carrying socks-auth-mode manual + a test user/password switched the client's Inbounds screen to Manual with those credentials on "refresh subscription", and "auto" switched it to Auto with generated credentials. Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
450 lines
12 KiB
Go
450 lines
12 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()
|
|
|
|
// 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),
|
|
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
|
|
}
|