mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-03 10:59:34 +00:00
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.
517 lines
15 KiB
Go
517 lines
15 KiB
Go
package sub
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-json"
|
|
yaml "github.com/goccy/go-yaml"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
|
)
|
|
|
|
type SubClashService struct {
|
|
inboundService service.InboundService
|
|
SubService *SubService
|
|
}
|
|
|
|
type ClashConfig struct {
|
|
Proxies []map[string]any `yaml:"proxies"`
|
|
ProxyGroups []map[string]any `yaml:"proxy-groups"`
|
|
Rules []string `yaml:"rules"`
|
|
}
|
|
|
|
func NewSubClashService(subService *SubService) *SubClashService {
|
|
return &SubClashService{SubService: subService}
|
|
}
|
|
|
|
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
|
// Set per-request state so resolveInboundAddress sees the node map.
|
|
s.SubService.PrepareForRequest(host)
|
|
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
|
if err != nil || len(inbounds) == 0 {
|
|
return "", "", err
|
|
}
|
|
|
|
var proxies []map[string]any
|
|
|
|
seenEmails := make(map[string]struct{})
|
|
for _, inbound := range inbounds {
|
|
clients, err := s.inboundService.GetClients(inbound)
|
|
if err != nil {
|
|
logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
|
|
}
|
|
if clients == nil {
|
|
continue
|
|
}
|
|
s.SubService.projectThroughFallbackMaster(inbound)
|
|
for _, client := range clients {
|
|
if client.SubID == subId {
|
|
seenEmails[client.Email] = struct{}{}
|
|
proxies = append(proxies, s.getProxies(inbound, client, host)...)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(proxies) == 0 {
|
|
return "", "", nil
|
|
}
|
|
|
|
emails := make([]string, 0, len(seenEmails))
|
|
for e := range seenEmails {
|
|
emails = append(emails, e)
|
|
}
|
|
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
|
|
|
|
proxyNames := make([]string, 0, len(proxies)+1)
|
|
for _, proxy := range proxies {
|
|
if name, ok := proxy["name"].(string); ok && name != "" {
|
|
proxyNames = append(proxyNames, name)
|
|
}
|
|
}
|
|
proxyNames = append(proxyNames, "DIRECT")
|
|
|
|
config := ClashConfig{
|
|
Proxies: proxies,
|
|
ProxyGroups: []map[string]any{{
|
|
"name": "PROXY",
|
|
"type": "select",
|
|
"proxies": proxyNames,
|
|
}},
|
|
Rules: []string{"MATCH,PROXY"},
|
|
}
|
|
|
|
finalYAML, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
|
return string(finalYAML), header, nil
|
|
}
|
|
|
|
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
|
|
stream := s.streamData(inbound.StreamSettings)
|
|
// For node-managed inbounds the Clash proxy "server" must be the
|
|
// node's address, not the request host. resolveInboundAddress handles
|
|
// the node→subscriber-host fallback chain.
|
|
defaultDest := s.SubService.resolveInboundAddress(inbound)
|
|
if defaultDest == "" {
|
|
defaultDest = host
|
|
}
|
|
externalProxies, ok := stream["externalProxy"].([]any)
|
|
hasExternalProxy := ok && len(externalProxies) > 0
|
|
if !hasExternalProxy {
|
|
externalProxies = []any{map[string]any{
|
|
"forceTls": "same",
|
|
"dest": defaultDest,
|
|
"port": float64(inbound.Port),
|
|
"remark": "",
|
|
}}
|
|
}
|
|
delete(stream, "externalProxy")
|
|
|
|
proxies := make([]map[string]any, 0, len(externalProxies))
|
|
for _, ep := range externalProxies {
|
|
extPrxy := ep.(map[string]any)
|
|
workingInbound := *inbound
|
|
workingInbound.Listen = extPrxy["dest"].(string)
|
|
workingInbound.Port = int(extPrxy["port"].(float64))
|
|
workingStream := cloneStreamForExternalProxy(stream)
|
|
|
|
switch extPrxy["forceTls"].(string) {
|
|
case "tls":
|
|
if workingStream["security"] != "tls" {
|
|
workingStream["security"] = "tls"
|
|
workingStream["tlsSettings"] = map[string]any{}
|
|
}
|
|
case "none":
|
|
if workingStream["security"] != "none" {
|
|
workingStream["security"] = "none"
|
|
delete(workingStream, "tlsSettings")
|
|
delete(workingStream, "realitySettings")
|
|
}
|
|
}
|
|
security, _ := workingStream["security"].(string)
|
|
if hasExternalProxy {
|
|
applyExternalProxyTLSToStream(extPrxy, workingStream, security)
|
|
}
|
|
|
|
proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
|
|
if len(proxy) > 0 {
|
|
proxies = append(proxies, proxy)
|
|
}
|
|
}
|
|
return proxies
|
|
}
|
|
|
|
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
|
|
// Hysteria has its own transport + TLS model, applyTransport /
|
|
// applySecurity don't fit.
|
|
if inbound.Protocol == model.Hysteria {
|
|
return s.buildHysteriaProxy(inbound, client, extraRemark)
|
|
}
|
|
|
|
proxy := map[string]any{
|
|
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
|
|
"server": inbound.Listen,
|
|
"port": inbound.Port,
|
|
"udp": true,
|
|
}
|
|
|
|
network, _ := stream["network"].(string)
|
|
if !s.applyTransport(proxy, network, stream) {
|
|
return nil
|
|
}
|
|
|
|
switch inbound.Protocol {
|
|
case model.VMESS:
|
|
proxy["type"] = "vmess"
|
|
proxy["uuid"] = client.ID
|
|
proxy["alterId"] = 0
|
|
cipher := client.Security
|
|
if cipher == "" {
|
|
cipher = "auto"
|
|
}
|
|
proxy["cipher"] = cipher
|
|
case model.VLESS:
|
|
proxy["type"] = "vless"
|
|
proxy["uuid"] = client.ID
|
|
if client.Flow != "" && network == "tcp" {
|
|
proxy["flow"] = client.Flow
|
|
}
|
|
var inboundSettings map[string]any
|
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
|
if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
|
|
proxy["packet-encoding"] = encryption
|
|
}
|
|
case model.Trojan:
|
|
proxy["type"] = "trojan"
|
|
proxy["password"] = client.Password
|
|
case model.Shadowsocks:
|
|
proxy["type"] = "ss"
|
|
proxy["password"] = client.Password
|
|
var inboundSettings map[string]any
|
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
|
method, _ := inboundSettings["method"].(string)
|
|
if method == "" {
|
|
return nil
|
|
}
|
|
proxy["cipher"] = method
|
|
if strings.HasPrefix(method, "2022") {
|
|
if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" {
|
|
proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password)
|
|
}
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
security, _ := stream["security"].(string)
|
|
if !s.applySecurity(proxy, security, stream) {
|
|
return nil
|
|
}
|
|
|
|
return proxy
|
|
}
|
|
|
|
// buildHysteriaProxy produces a mihomo-compatible Clash entry for a
|
|
// Hysteria (v1) or Hysteria2 inbound. It reads `inbound.StreamSettings`
|
|
// directly instead of going through streamData/tlsData, because those
|
|
// helpers prune fields (like `allowInsecure` / the salamander obfs
|
|
// block) that the hysteria proxy wants preserved.
|
|
func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
|
|
var inboundSettings map[string]any
|
|
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
|
|
|
proxyType := "hysteria2"
|
|
authKey := "password"
|
|
if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 {
|
|
proxyType = "hysteria"
|
|
authKey = "auth-str"
|
|
}
|
|
|
|
proxy := map[string]any{
|
|
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
|
|
"type": proxyType,
|
|
"server": inbound.Listen,
|
|
"port": inbound.Port,
|
|
"udp": true,
|
|
authKey: client.Auth,
|
|
}
|
|
|
|
var rawStream map[string]any
|
|
_ = json.Unmarshal([]byte(inbound.StreamSettings), &rawStream)
|
|
|
|
// TLS details — hysteria always uses TLS.
|
|
if tlsSettings, ok := rawStream["tlsSettings"].(map[string]any); ok {
|
|
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
|
|
proxy["sni"] = serverName
|
|
}
|
|
if alpnList, ok := tlsSettings["alpn"].([]any); ok && len(alpnList) > 0 {
|
|
out := make([]string, 0, len(alpnList))
|
|
for _, a := range alpnList {
|
|
if s, ok := a.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
proxy["alpn"] = out
|
|
}
|
|
}
|
|
if inner, ok := tlsSettings["settings"].(map[string]any); ok {
|
|
if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
|
|
proxy["skip-cert-verify"] = true
|
|
}
|
|
if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
|
|
proxy["client-fingerprint"] = fp
|
|
}
|
|
}
|
|
}
|
|
|
|
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
|
|
// block the subscription link generator uses.
|
|
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
|
|
if udpMasks, ok := finalmask["udp"].([]any); ok {
|
|
for _, m := range udpMasks {
|
|
mask, _ := m.(map[string]any)
|
|
if mask == nil || mask["type"] != "salamander" {
|
|
continue
|
|
}
|
|
settings, _ := mask["settings"].(map[string]any)
|
|
if pw, ok := settings["password"].(string); ok && pw != "" {
|
|
proxy["obfs"] = "salamander"
|
|
proxy["obfs-password"] = pw
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return proxy
|
|
}
|
|
|
|
func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
|
|
switch network {
|
|
case "", "tcp":
|
|
proxy["network"] = "tcp"
|
|
tcp, _ := stream["tcpSettings"].(map[string]any)
|
|
if tcp != nil {
|
|
header, _ := tcp["header"].(map[string]any)
|
|
if header != nil {
|
|
typeStr, _ := header["type"].(string)
|
|
if typeStr != "" && typeStr != "none" {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
case "ws":
|
|
proxy["network"] = "ws"
|
|
ws, _ := stream["wsSettings"].(map[string]any)
|
|
wsOpts := map[string]any{}
|
|
if ws != nil {
|
|
if path, ok := ws["path"].(string); ok && path != "" {
|
|
wsOpts["path"] = path
|
|
}
|
|
host := ""
|
|
if v, ok := ws["host"].(string); ok && v != "" {
|
|
host = v
|
|
} else if headers, ok := ws["headers"].(map[string]any); ok {
|
|
host = searchHost(headers)
|
|
}
|
|
if host != "" {
|
|
wsOpts["headers"] = map[string]any{"Host": host}
|
|
}
|
|
}
|
|
if len(wsOpts) > 0 {
|
|
proxy["ws-opts"] = wsOpts
|
|
}
|
|
return true
|
|
case "grpc":
|
|
proxy["network"] = "grpc"
|
|
grpc, _ := stream["grpcSettings"].(map[string]any)
|
|
grpcOpts := map[string]any{}
|
|
if grpc != nil {
|
|
if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" {
|
|
grpcOpts["grpc-service-name"] = serviceName
|
|
}
|
|
}
|
|
if len(grpcOpts) > 0 {
|
|
proxy["grpc-opts"] = grpcOpts
|
|
}
|
|
return true
|
|
case "httpupgrade":
|
|
proxy["network"] = "httpupgrade"
|
|
hu, _ := stream["httpupgradeSettings"].(map[string]any)
|
|
opts := map[string]any{}
|
|
if hu != nil {
|
|
if path, ok := hu["path"].(string); ok && path != "" {
|
|
opts["path"] = path
|
|
}
|
|
host := ""
|
|
if v, ok := hu["host"].(string); ok && v != "" {
|
|
host = v
|
|
} else if headers, ok := hu["headers"].(map[string]any); ok {
|
|
host = searchHost(headers)
|
|
}
|
|
if host != "" {
|
|
opts["headers"] = map[string]any{"Host": host}
|
|
}
|
|
}
|
|
if len(opts) > 0 {
|
|
proxy["http-upgrade-opts"] = opts
|
|
}
|
|
return true
|
|
case "xhttp":
|
|
proxy["network"] = "xhttp"
|
|
xhttp, _ := stream["xhttpSettings"].(map[string]any)
|
|
opts := map[string]any{}
|
|
if xhttp != nil {
|
|
if path, ok := xhttp["path"].(string); ok && path != "" {
|
|
opts["path"] = path
|
|
}
|
|
host := ""
|
|
if v, ok := xhttp["host"].(string); ok && v != "" {
|
|
host = v
|
|
} else if headers, ok := xhttp["headers"].(map[string]any); ok {
|
|
host = searchHost(headers)
|
|
}
|
|
if host != "" {
|
|
opts["host"] = host
|
|
}
|
|
if mode, ok := xhttp["mode"].(string); ok && mode != "" {
|
|
opts["mode"] = mode
|
|
}
|
|
}
|
|
if len(opts) > 0 {
|
|
proxy["xhttp-opts"] = opts
|
|
}
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool {
|
|
switch security {
|
|
case "", "none":
|
|
proxy["tls"] = false
|
|
return true
|
|
case "tls":
|
|
proxy["tls"] = true
|
|
tlsSettings, _ := stream["tlsSettings"].(map[string]any)
|
|
if tlsSettings != nil {
|
|
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
|
|
proxy["servername"] = serverName
|
|
switch proxy["type"] {
|
|
case "trojan":
|
|
proxy["sni"] = serverName
|
|
}
|
|
}
|
|
if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" {
|
|
proxy["client-fingerprint"] = fingerprint
|
|
}
|
|
if alpn, ok := externalProxyALPNList(tlsSettings["alpn"]); ok {
|
|
out := make([]string, 0, len(alpn))
|
|
for _, item := range alpn {
|
|
if s, ok := item.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
proxy["alpn"] = out
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
case "reality":
|
|
proxy["tls"] = true
|
|
realitySettings, _ := stream["realitySettings"].(map[string]any)
|
|
if realitySettings == nil {
|
|
return false
|
|
}
|
|
if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" {
|
|
proxy["servername"] = serverName
|
|
}
|
|
realityOpts := map[string]any{}
|
|
if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" {
|
|
realityOpts["public-key"] = publicKey
|
|
}
|
|
if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" {
|
|
realityOpts["short-id"] = shortID
|
|
}
|
|
if len(realityOpts) > 0 {
|
|
proxy["reality-opts"] = realityOpts
|
|
}
|
|
if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" {
|
|
proxy["client-fingerprint"] = fingerprint
|
|
}
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *SubClashService) streamData(stream string) map[string]any {
|
|
var streamSettings map[string]any
|
|
json.Unmarshal([]byte(stream), &streamSettings)
|
|
security, _ := streamSettings["security"].(string)
|
|
switch security {
|
|
case "tls":
|
|
if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
|
|
streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
|
|
}
|
|
case "reality":
|
|
if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
|
|
streamSettings["realitySettings"] = s.realityData(realitySettings)
|
|
}
|
|
}
|
|
delete(streamSettings, "sockopt")
|
|
return streamSettings
|
|
}
|
|
|
|
func (s *SubClashService) tlsData(tData map[string]any) map[string]any {
|
|
tlsData := make(map[string]any, 1)
|
|
tlsClientSettings, _ := tData["settings"].(map[string]any)
|
|
tlsData["serverName"] = tData["serverName"]
|
|
tlsData["alpn"] = tData["alpn"]
|
|
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
|
|
tlsData["fingerprint"] = fingerprint
|
|
}
|
|
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
|
|
tlsData["pin-sha256"] = pins
|
|
}
|
|
return tlsData
|
|
}
|
|
|
|
func (s *SubClashService) realityData(rData map[string]any) map[string]any {
|
|
rDataOut := make(map[string]any, 1)
|
|
realityClientSettings, _ := rData["settings"].(map[string]any)
|
|
if publicKey, ok := realityClientSettings["publicKey"].(string); ok {
|
|
rDataOut["publicKey"] = publicKey
|
|
}
|
|
if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok {
|
|
rDataOut["fingerprint"] = fingerprint
|
|
}
|
|
if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 {
|
|
rDataOut["serverName"] = fmt.Sprint(serverNames[0])
|
|
}
|
|
if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 {
|
|
rDataOut["shortId"] = fmt.Sprint(shortIDs[0])
|
|
}
|
|
return rDataOut
|
|
}
|
|
|
|
func cloneMap(src map[string]any) map[string]any {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
dst := make(map[string]any, len(src))
|
|
maps.Copy(dst, src)
|
|
return dst
|
|
}
|