fix(sub): keep listen/bind IP out of subscription page URLs

The subscription page leaked an inbound's server-side Listen IP into the
client-facing URLs when a bind address was set:

- Per-config links: resolveInboundAddress returned the bind Listen IP
  (loopback/private/public alike) instead of the host the subscriber
  reached the panel on. It now returns the node address for node-managed
  inbounds, otherwise the subscriber host; the bind Listen is ignored
  (External Proxy remains the way to advertise a specific endpoint).

- Subscription Copy URL (SUB/JSON/CLASH): BuildURLs composed the base
  differently from the panel's Client Information page and never
  normalized the request host, so a loopback/bind request leaked the raw
  IP. The composition is extracted into the shared
  SettingService.BuildSubURIBase, used by both the panel and the sub page
  so they render identically, and fed the already-normalized subscriber
  host.
This commit is contained in:
MHSanaei
2026-06-01 05:47:18 +02:00
parent eb78b8666f
commit fb311afa6f
8 changed files with 200 additions and 73 deletions
+14 -48
View File
@@ -663,24 +663,17 @@ func (s *SubService) loadNodes() {
s.nodesByID = m
}
// resolveInboundAddress picks the host an external client should
// connect to. Order:
// 1. If the inbound is node-managed and the node has an address, use
// the node's address — central panel's hostname doesn't speak xray
// for that inbound.
// 2. If the inbound binds to a non-wildcard listen address, use it.
// 3. Otherwise fall back to the request's host (whatever the client
// subscribed against).
// resolveInboundAddress returns the node's address for node-managed inbounds,
// otherwise the subscriber's host (s.address). The inbound's bind Listen is
// deliberately ignored: it's a server-side address, not a client-reachable
// host, so operators advertise a specific endpoint via External Proxy instead.
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
if inbound.NodeID != nil && s.nodesByID != nil {
if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" {
return n.Address
}
}
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
return s.address
}
return inbound.Listen
return s.address
}
func findClientIndex(clients []model.Client, email string) int {
@@ -1866,7 +1859,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
// BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
// It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
func (s *SubService) BuildURLs(subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
if subId == "" {
return "", "", ""
}
@@ -1875,50 +1868,23 @@ func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subCl
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
configuredSubClashURI, _ := s.settingService.GetSubClashURI()
var baseScheme, baseHostWithPort string
if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
}
// Same base as the panel's Client Information page; s.address is the
// subscriber's host already normalized away from any loopback/bind IP.
base := s.settingService.BuildSubURIBase(s.address)
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId)
subURL = s.buildSingleURL(configuredSubURI, base, subPath, subId)
subJsonURL = s.buildSingleURL(configuredSubJsonURI, base, subJsonPath, subId)
subClashURL = s.buildSingleURL(configuredSubClashURI, base, subClashPath, subId)
return subURL, subJsonURL, subClashURL
}
// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
func (s *SubService) getBaseSchemeAndHost(requestScheme, requestHostWithPort string) (string, string) {
subDomain, err := s.settingService.GetSubDomain()
if err != nil || subDomain == "" {
return requestScheme, requestHostWithPort
}
// Get port and TLS settings
subPort, _ := s.settingService.GetSubPort()
subKeyFile, _ := s.settingService.GetSubKeyFile()
subCertFile, _ := s.settingService.GetSubCertFile()
// Determine scheme from TLS configuration
scheme := "http"
if subKeyFile != "" && subCertFile != "" {
scheme = "https"
}
// Build host:port, always include port for clarity
hostWithPort := fmt.Sprintf("%s:%d", subDomain, subPort)
return scheme, hostWithPort
}
// buildSingleURL constructs a single URL using configured URI or base components
func (s *SubService) buildSingleURL(configuredURI, baseScheme, baseHostWithPort, basePath, subId string) string {
func (s *SubService) buildSingleURL(configuredURI, base, basePath, subId string) string {
if configuredURI != "" {
return s.joinPathWithID(configuredURI, subId)
}
baseURL := fmt.Sprintf("%s://%s", baseScheme, baseHostWithPort)
return s.joinPathWithID(baseURL+basePath, subId)
return s.joinPathWithID(base+basePath, subId)
}
// joinPathWithID safely joins a base path with a subscription ID