feat(sub): add Clash/Mihomo YAML subscription service

Add SubClashService to convert subscription links to Clash/Mihomo
YAML format for direct client compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
haimu0427 2026-03-12 15:14:51 +08:00
parent 570657a641
commit 9478e1a3e4

385
sub/subClashService.go Normal file
View file

@ -0,0 +1,385 @@
package sub
import (
"fmt"
"strings"
"github.com/goccy/go-json"
yaml "github.com/goccy/go-yaml"
"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"
)
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) {
inbounds, err := s.SubService.getInboundsBySubId(subId)
if err != nil || len(inbounds) == 0 {
return "", "", err
}
var traffic xray.ClientTraffic
var clientTraffics []xray.ClientTraffic
var proxies []map[string]any
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))
proxies = append(proxies, s.getProxies(inbound, client, host)...)
}
}
}
if len(proxies) == 0 {
return "", "", nil
}
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
}
}
}
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)
externalProxies, ok := stream["externalProxy"].([]any)
if !ok || len(externalProxies) == 0 {
externalProxies = []any{map[string]any{
"forceTls": "same",
"dest": host,
"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 := cloneMap(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")
}
}
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 {
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
}
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
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
}
}
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
}
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))
for k, v := range src {
dst[k] = v
}
return dst
}