mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
- Fix REALITY settings extraction: publicKey/shortIds/fingerprint are nested under realitySettings.settings, not directly under realitySettings - Add network field to all proxy entries (default "tcp") - Move non-REALITY fingerprint into else branch to avoid duplication
432 lines
14 KiB
Go
432 lines
14 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
|
)
|
|
|
|
// SubClashService handles Clash YAML subscription generation.
|
|
type SubClashService struct {
|
|
template string
|
|
servers []ClashServer
|
|
inboundService service.InboundService
|
|
SubService *SubService
|
|
}
|
|
|
|
// NewSubClashService creates a new Clash subscription service with the given template and servers.
|
|
func NewSubClashService(template string, servers []ClashServer, subService *SubService) *SubClashService {
|
|
return &SubClashService{
|
|
template: template,
|
|
servers: servers,
|
|
SubService: subService,
|
|
}
|
|
}
|
|
|
|
// splitTemplate splits a full mihomo template at "proxies:" and "proxy-groups:" markers.
|
|
// Returns header (everything before proxies) and footer (everything from proxy-groups onwards).
|
|
func splitTemplate(template string) (header, footer string, err error) {
|
|
proxiesIdx := strings.Index(template, "\nproxies:")
|
|
if proxiesIdx == -1 {
|
|
if strings.HasPrefix(template, "proxies:") {
|
|
proxiesIdx = 0
|
|
} else {
|
|
return "", "", fmt.Errorf("template: 'proxies:' section not found")
|
|
}
|
|
} else {
|
|
proxiesIdx++ // skip the leading newline
|
|
}
|
|
|
|
proxyGroupsIdx := strings.Index(template, "\nproxy-groups:")
|
|
if proxyGroupsIdx == -1 {
|
|
if strings.HasPrefix(template[proxiesIdx:], "proxy-groups:") {
|
|
proxyGroupsIdx = proxiesIdx
|
|
} else {
|
|
return "", "", fmt.Errorf("template: 'proxy-groups:' section not found")
|
|
}
|
|
} else {
|
|
proxyGroupsIdx++ // skip the leading newline
|
|
}
|
|
|
|
header = template[:proxiesIdx]
|
|
footer = template[proxyGroupsIdx:]
|
|
return header, footer, nil
|
|
}
|
|
|
|
// GetClash generates a Clash YAML configuration for the given subscription ID.
|
|
func (s *SubClashService) GetClash(subId string) (string, string, error) {
|
|
if s.template == "" {
|
|
return "", "", fmt.Errorf("clash template is empty")
|
|
}
|
|
|
|
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
|
if err != nil || len(inbounds) == 0 {
|
|
return "", "", err
|
|
}
|
|
|
|
var header string
|
|
var traffic xray.ClientTraffic
|
|
var clientTraffics []xray.ClientTraffic
|
|
var proxies []string
|
|
|
|
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
|
|
}
|
|
if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
|
|
listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
|
|
if err == nil {
|
|
inbound.Listen = listen
|
|
inbound.Port = port
|
|
inbound.StreamSettings = streamSettings
|
|
}
|
|
}
|
|
|
|
for _, client := range clients {
|
|
if client.Enable && client.SubID == subId {
|
|
clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
|
|
newProxies := s.getProxy(inbound, client)
|
|
proxies = append(proxies, newProxies...)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(proxies) == 0 {
|
|
return "", "", nil
|
|
}
|
|
|
|
// Aggregate traffic stats
|
|
for index, clientTraffic := range clientTraffics {
|
|
if index == 0 {
|
|
traffic.Up = clientTraffic.Up
|
|
traffic.Down = clientTraffic.Down
|
|
traffic.Total = clientTraffic.Total
|
|
if clientTraffic.ExpiryTime > 0 {
|
|
traffic.ExpiryTime = clientTraffic.ExpiryTime
|
|
}
|
|
} else {
|
|
traffic.Up += clientTraffic.Up
|
|
traffic.Down += clientTraffic.Down
|
|
if traffic.Total == 0 || clientTraffic.Total == 0 {
|
|
traffic.Total = 0
|
|
} else {
|
|
traffic.Total += clientTraffic.Total
|
|
}
|
|
if clientTraffic.ExpiryTime != traffic.ExpiryTime {
|
|
traffic.ExpiryTime = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build proxies YAML block
|
|
proxiesYaml := ""
|
|
for _, p := range proxies {
|
|
proxiesYaml += " - " + p + "\n"
|
|
}
|
|
|
|
// Try split-template approach first (for full mihomo templates)
|
|
var result string
|
|
if header, footer, err := splitTemplate(s.template); err == nil {
|
|
result = header + "proxies:\n" + proxiesYaml + footer
|
|
} else {
|
|
// Fall back to old "proxies: []" replacement for backward compatibility
|
|
result = strings.Replace(s.template, "proxies: []", "proxies:\n"+proxiesYaml, 1)
|
|
}
|
|
|
|
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
|
return result, header, nil
|
|
}
|
|
|
|
// getProxy generates Clash proxy entries for a client.
|
|
// If servers are configured, generates one entry per server using the server's name and address.
|
|
// Otherwise, uses the inbound's own address (backward compatible).
|
|
func (s *SubClashService) getProxy(inbound *model.Inbound, client model.Client) []string {
|
|
var proxies []string
|
|
var stream map[string]any
|
|
if err := json.Unmarshal([]byte(inbound.StreamSettings), &stream); err != nil {
|
|
logger.Warning("SubClashService - failed to parse StreamSettings for inbound", inbound.Tag, ":", err)
|
|
}
|
|
|
|
// Resolve default address from inbound
|
|
var defaultAddress string
|
|
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
|
defaultAddress = s.SubService.address
|
|
} else {
|
|
defaultAddress = inbound.Listen
|
|
}
|
|
|
|
// Parse stream settings
|
|
network, _ := stream["network"].(string)
|
|
security, _ := stream["security"].(string)
|
|
|
|
// Handle external proxies
|
|
externalProxies, ok := stream["externalProxy"].([]any)
|
|
if !ok || len(externalProxies) == 0 {
|
|
externalProxies = []any{
|
|
map[string]any{
|
|
"forceTls": "same",
|
|
},
|
|
}
|
|
}
|
|
|
|
// If servers are configured, generate one proxy per server
|
|
if len(s.servers) > 0 {
|
|
for _, server := range s.servers {
|
|
for _, ep := range externalProxies {
|
|
externalProxy, _ := ep.(map[string]any)
|
|
destPort := inbound.Port
|
|
if port, ok := externalProxy["port"].(float64); ok && port > 0 {
|
|
destPort = int(port)
|
|
}
|
|
|
|
forceTls, _ := externalProxy["forceTls"].(string)
|
|
tlsEnabled := false
|
|
switch forceTls {
|
|
case "tls":
|
|
tlsEnabled = true
|
|
case "none":
|
|
tlsEnabled = false
|
|
default: // "same"
|
|
tlsEnabled = security == "tls" || security == "reality"
|
|
}
|
|
|
|
proxy := s.buildProxyEntry(inbound, client, server.Server, destPort, network, security, tlsEnabled, server.Name, stream)
|
|
proxies = append(proxies, proxy)
|
|
}
|
|
}
|
|
return proxies
|
|
}
|
|
|
|
// No servers configured — use inbound's own address (backward compatible)
|
|
remark := s.SubService.genRemark(inbound, client.Email, "")
|
|
|
|
for _, ep := range externalProxies {
|
|
externalProxy, _ := ep.(map[string]any)
|
|
destAddress := defaultAddress
|
|
destPort := inbound.Port
|
|
|
|
if dest, ok := externalProxy["dest"].(string); ok && dest != "" {
|
|
destAddress = dest
|
|
}
|
|
if port, ok := externalProxy["port"].(float64); ok && port > 0 {
|
|
destPort = int(port)
|
|
}
|
|
|
|
forceTls, _ := externalProxy["forceTls"].(string)
|
|
tlsEnabled := false
|
|
switch forceTls {
|
|
case "tls":
|
|
tlsEnabled = true
|
|
case "none":
|
|
tlsEnabled = false
|
|
default: // "same"
|
|
tlsEnabled = security == "tls" || security == "reality"
|
|
}
|
|
|
|
remarkExtra := remark
|
|
if customRemark, ok := externalProxy["remark"].(string); ok && customRemark != "" {
|
|
remarkExtra = customRemark
|
|
}
|
|
|
|
proxy := s.buildProxyEntry(inbound, client, destAddress, destPort, network, security, tlsEnabled, remarkExtra, stream)
|
|
proxies = append(proxies, proxy)
|
|
}
|
|
|
|
return proxies
|
|
}
|
|
|
|
// buildProxyEntry builds a single Clash proxy entry as an inline YAML map string.
|
|
func (s *SubClashService) buildProxyEntry(inbound *model.Inbound, client model.Client, address string, port int, network, security string, tlsEnabled bool, remark string, stream map[string]any) string {
|
|
var parts []string
|
|
|
|
parts = append(parts, fmt.Sprintf("name: %q", remark))
|
|
|
|
// Protocol-specific fields
|
|
switch inbound.Protocol {
|
|
case model.VMESS:
|
|
parts = append(parts, "type: vmess")
|
|
parts = append(parts, fmt.Sprintf("server: %q", address))
|
|
parts = append(parts, fmt.Sprintf("port: %d", port))
|
|
parts = append(parts, fmt.Sprintf("uuid: %q", client.ID))
|
|
parts = append(parts, "alterId: 0")
|
|
parts = append(parts, "cipher: auto")
|
|
|
|
case model.VLESS:
|
|
parts = append(parts, "type: vless")
|
|
parts = append(parts, fmt.Sprintf("server: %q", address))
|
|
parts = append(parts, fmt.Sprintf("port: %d", port))
|
|
parts = append(parts, fmt.Sprintf("uuid: %q", client.ID))
|
|
if client.Flow != "" {
|
|
parts = append(parts, fmt.Sprintf("flow: %q", client.Flow))
|
|
}
|
|
|
|
case model.Trojan:
|
|
parts = append(parts, "type: trojan")
|
|
parts = append(parts, fmt.Sprintf("server: %q", address))
|
|
parts = append(parts, fmt.Sprintf("port: %d", port))
|
|
parts = append(parts, fmt.Sprintf("password: %q", client.Password))
|
|
|
|
case model.Shadowsocks:
|
|
parts = append(parts, "type: ss")
|
|
parts = append(parts, fmt.Sprintf("server: %q", address))
|
|
parts = append(parts, fmt.Sprintf("port: %d", port))
|
|
cipher, password := s.parseShadowsocksSettings(client)
|
|
parts = append(parts, fmt.Sprintf("cipher: %q", cipher))
|
|
parts = append(parts, fmt.Sprintf("password: %q", password))
|
|
parts = append(parts, "udp: true")
|
|
return strings.Join(parts, "\n ")
|
|
}
|
|
|
|
// TLS settings
|
|
if tlsEnabled {
|
|
parts = append(parts, "tls: true")
|
|
if security == "reality" {
|
|
realitySetting, _ := stream["realitySettings"].(map[string]any)
|
|
// publicKey and fingerprint are nested under realitySettings.settings
|
|
realityInner, _ := realitySetting["settings"].(map[string]any)
|
|
if publicKey, ok := realityInner["publicKey"].(string); ok && publicKey != "" {
|
|
realityOpts := fmt.Sprintf("reality-opts:\n public-key: %q", publicKey)
|
|
if shortIds, ok := realitySetting["shortIds"].([]any); ok && len(shortIds) > 0 {
|
|
realityOpts += fmt.Sprintf("\n short-id: %q", fmt.Sprintf("%v", shortIds[0]))
|
|
}
|
|
parts = append(parts, realityOpts)
|
|
}
|
|
// Reality server names
|
|
serverNames, _ := realitySetting["serverNames"].([]any)
|
|
if len(serverNames) > 0 {
|
|
sni := fmt.Sprintf("%v", serverNames[0])
|
|
parts = append(parts, fmt.Sprintf("sni: %q", sni))
|
|
}
|
|
// Fingerprint from reality settings inner
|
|
if fp, ok := realityInner["fingerprint"].(string); ok && fp != "" {
|
|
parts = append(parts, fmt.Sprintf("client-fingerprint: %q", fp))
|
|
}
|
|
} else {
|
|
// TLS settings
|
|
tlsSetting, _ := stream["tlsSettings"].(map[string]any)
|
|
if serverName, ok := tlsSetting["serverName"].(string); ok && serverName != "" {
|
|
parts = append(parts, fmt.Sprintf("sni: %q", serverName))
|
|
}
|
|
if alpn, ok := tlsSetting["alpn"].([]any); ok && len(alpn) > 0 {
|
|
alpnStrs := make([]string, len(alpn))
|
|
for i, a := range alpn {
|
|
alpnStrs[i] = fmt.Sprintf("%v", a)
|
|
}
|
|
parts = append(parts, fmt.Sprintf("alpn: [%s]", strings.Join(alpnStrs, ", ")))
|
|
}
|
|
// Fingerprint for non-REALITY TLS
|
|
if fp, ok := stream["fingerprint"].(string); ok && fp != "" {
|
|
parts = append(parts, fmt.Sprintf("client-fingerprint: %q", fp))
|
|
}
|
|
}
|
|
} else {
|
|
parts = append(parts, "tls: false")
|
|
}
|
|
|
|
parts = append(parts, "udp: true")
|
|
|
|
// Network type
|
|
if network == "" {
|
|
network = "tcp"
|
|
}
|
|
parts = append(parts, fmt.Sprintf("network: %s", network))
|
|
|
|
// Network-specific settings
|
|
switch network {
|
|
case "ws":
|
|
ws, _ := stream["wsSettings"].(map[string]any)
|
|
if path, ok := ws["path"].(string); ok && path != "" {
|
|
wsOpts := fmt.Sprintf("ws-opts:\n path: %q", path)
|
|
if host, ok := ws["host"].(string); ok && host != "" {
|
|
wsOpts += fmt.Sprintf("\n headers:\n Host: %q", host)
|
|
} else {
|
|
headers, _ := ws["headers"].(map[string]any)
|
|
if h, ok := headers["Host"].(string); ok && h != "" {
|
|
wsOpts += fmt.Sprintf("\n headers:\n Host: %q", h)
|
|
}
|
|
}
|
|
parts = append(parts, wsOpts)
|
|
}
|
|
|
|
case "grpc":
|
|
grpc, _ := stream["grpcSettings"].(map[string]any)
|
|
if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" {
|
|
parts = append(parts, fmt.Sprintf("grpc-opts:\n grpc-service-name: %q", serviceName))
|
|
}
|
|
|
|
case "h2":
|
|
h2, _ := stream["h2Settings"].(map[string]any)
|
|
if path, ok := h2["path"].(string); ok && path != "" {
|
|
h2Opts := fmt.Sprintf("h2-opts:\n path: %q", path)
|
|
if host, ok := h2["host"].([]any); ok && len(host) > 0 {
|
|
hostStrs := make([]string, len(host))
|
|
for i, h := range host {
|
|
hostStrs[i] = fmt.Sprintf("%q", fmt.Sprintf("%v", h))
|
|
}
|
|
h2Opts += fmt.Sprintf("\n host: [%s]", strings.Join(hostStrs, ", "))
|
|
}
|
|
parts = append(parts, h2Opts)
|
|
}
|
|
|
|
case "tcp":
|
|
tcp, _ := stream["tcpSettings"].(map[string]any)
|
|
header, _ := tcp["header"].(map[string]any)
|
|
if typeStr, ok := header["type"].(string); ok && typeStr == "http" {
|
|
request, _ := header["request"].(map[string]any)
|
|
httpOpts := "http-opts:"
|
|
if path, ok := request["path"].([]any); ok && len(path) > 0 {
|
|
httpOpts += fmt.Sprintf("\n path:\n - %q", fmt.Sprintf("%v", path[0]))
|
|
}
|
|
if headers, ok := request["headers"].(map[string]any); ok && len(headers) > 0 {
|
|
httpOpts += "\n headers:"
|
|
for k, v := range headers {
|
|
if vals, ok := v.([]any); ok && len(vals) > 0 {
|
|
httpOpts += fmt.Sprintf("\n %s:\n - %q", k, fmt.Sprintf("%v", vals[0]))
|
|
}
|
|
}
|
|
}
|
|
parts = append(parts, httpOpts)
|
|
}
|
|
|
|
case "httpupgrade":
|
|
hu, _ := stream["httpupgradeSettings"].(map[string]any)
|
|
if path, ok := hu["path"].(string); ok && path != "" {
|
|
huOpts := fmt.Sprintf("httpupgrade-opts:\n path: %q", path)
|
|
if host, ok := hu["host"].(string); ok && host != "" {
|
|
huOpts += fmt.Sprintf("\n host: %q", host)
|
|
} else {
|
|
headers, _ := hu["headers"].(map[string]any)
|
|
if h, ok := headers["Host"].(string); ok && h != "" {
|
|
huOpts += fmt.Sprintf("\n host: %q", h)
|
|
}
|
|
}
|
|
parts = append(parts, huOpts)
|
|
}
|
|
}
|
|
|
|
return strings.Join(parts, "\n ")
|
|
}
|
|
|
|
// parseShadowsocksSettings extracts cipher and password from shadowsocks client settings.
|
|
func (s *SubClashService) parseShadowsocksSettings(client model.Client) (string, string) {
|
|
// Default cipher
|
|
cipher := "aes-128-gcm"
|
|
password := client.Password
|
|
|
|
// Try to parse the method from the client's settings
|
|
// Shadowsocks protocol stores method in client.ID for some configurations
|
|
if client.Security != "" {
|
|
cipher = client.Security
|
|
}
|
|
|
|
return cipher, password
|
|
}
|