diff --git a/internal/protocol/clash.go b/internal/protocol/clash.go index 8793741..80776bb 100644 --- a/internal/protocol/clash.go +++ b/internal/protocol/clash.go @@ -104,6 +104,9 @@ func buildClashProxy(templateName string, conf service.NodeServerConfig, passwor switch toClashInt(conf.Tls) { case 1: proxy["tls"] = true + if fp := tlsFingerprint(conf.UTLS); fp != "" { + proxy["client-fingerprint"] = fp + } if tlsSettings, ok := conf.TlsSettings.(map[string]any); ok { proxy["skip-cert-verify"] = toClashBool(tlsSettings["allow_insecure"]) if serverName := toClashString(tlsSettings["server_name"]); serverName != "" { @@ -112,6 +115,9 @@ func buildClashProxy(templateName string, conf service.NodeServerConfig, passwor } case 2: proxy["tls"] = true + if fp := tlsFingerprint(conf.UTLS); fp != "" { + proxy["client-fingerprint"] = fp + } if tlsSettings, ok := conf.TlsSettings.(map[string]any); ok { proxy["skip-cert-verify"] = toClashBool(tlsSettings["allow_insecure"]) if serverName := toClashString(tlsSettings["server_name"]); serverName != "" { @@ -123,11 +129,83 @@ func buildClashProxy(templateName string, conf service.NodeServerConfig, passwor } } } - network := toClashString(conf.Network) - if network == "" { - network = "tcp" + proxy["network"] = "tcp" + if settings, ok := conf.NetworkSettings.(map[string]any); ok { + switch toClashString(conf.Network) { + case "", "tcp": + if header, ok := settings["header"].(map[string]any); ok && toClashString(header["type"]) == "http" { + proxy["network"] = "http" + httpOpts := map[string]any{} + if request, ok := header["request"].(map[string]any); ok { + if headers, ok := request["headers"].(map[string]any); ok && len(headers) > 0 { + httpOpts["headers"] = headers + } + if paths := clashPathList(request["path"]); len(paths) > 0 { + httpOpts["path"] = paths + } + } + if len(httpOpts) > 0 { + proxy["http-opts"] = httpOpts + } + } + case "ws": + proxy["network"] = "ws" + wsOpts := map[string]any{} + if path := toClashString(settings["path"]); path != "" { + wsOpts["path"] = path + } + if headers, ok := settings["headers"].(map[string]any); ok { + if host := toClashString(headers["Host"]); host != "" { + wsOpts["headers"] = map[string]any{"Host": host} + } + } + if len(wsOpts) > 0 { + proxy["ws-opts"] = wsOpts + } + case "grpc": + proxy["network"] = "grpc" + if serviceName := toClashString(settings["serviceName"]); serviceName != "" { + proxy["grpc-opts"] = map[string]any{"grpc-service-name": serviceName} + } + case "h2": + proxy["network"] = "h2" + h2Opts := map[string]any{} + if path := toClashString(settings["path"]); path != "" { + h2Opts["path"] = path + } + if hosts := clashHostList(settings["host"]); len(hosts) > 0 { + h2Opts["host"] = hosts + } + if len(h2Opts) > 0 { + proxy["h2-opts"] = h2Opts + } + case "httpupgrade": + proxy["network"] = "ws" + wsOpts := map[string]any{ + "v2ray-http-upgrade": true, + } + if path := toClashString(settings["path"]); path != "" { + wsOpts["path"] = path + } + host := toClashString(settings["host"]) + if host == "" { + host = conf.RawHost + } + if host != "" { + wsOpts["headers"] = map[string]any{"Host": host} + } + proxy["ws-opts"] = wsOpts + default: + if network := toClashString(conf.Network); network != "" { + proxy["network"] = network + } + } + } else if network := toClashString(conf.Network); network != "" { + proxy["network"] = network + } + if smux := buildClashSmux(conf.Multiplex); smux != nil { + proxy["smux"] = smux } - proxy["network"] = network return proxy case "trojan": return map[string]any{ @@ -208,6 +286,80 @@ func appendUniqueAny(base []any, values ...any) []any { return base } +func clashPathList(value any) []string { + switch typed := value.(type) { + case []string: + return append([]string{}, typed...) + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + text := toClashString(item) + if text != "" { + result = append(result, text) + } + } + return result + case string: + if typed == "" { + return nil + } + return []string{typed} + default: + return nil + } +} + +func clashHostList(value any) []string { + switch typed := value.(type) { + case []string: + return append([]string{}, typed...) + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + text := toClashString(item) + if text != "" { + result = append(result, text) + } + } + return result + case string: + if typed == "" { + return nil + } + return []string{typed} + default: + return nil + } +} + +func buildClashSmux(value any) map[string]any { + settings, ok := value.(map[string]any) + if !ok || !toClashBool(settings["enabled"]) { + return nil + } + + smux := map[string]any{ + "enabled": true, + } + if protocol := toClashString(settings["protocol"]); protocol != "" { + smux["protocol"] = protocol + } + if maxConnections := toClashInt(settings["max_connections"]); maxConnections > 0 { + smux["max-connections"] = maxConnections + } + if toClashBool(settings["padding"]) { + smux["padding"] = true + } + if brutal, ok := settings["brutal"].(map[string]any); ok && toClashBool(brutal["enabled"]) { + smux["brutal-opts"] = map[string]any{ + "enabled": true, + "up": toClashInt(brutal["up_mbps"]), + "down": toClashInt(brutal["down_mbps"]), + } + } + return smux +} + func toClashString(value any) string { switch typed := value.(type) { case string: diff --git a/internal/protocol/link.go b/internal/protocol/link.go index b37a334..4600598 100644 --- a/internal/protocol/link.go +++ b/internal/protocol/link.go @@ -107,6 +107,7 @@ func buildVmess(c service.NodeServerConfig, password string) string { func buildVless(c service.NodeServerConfig, password string) string { params := url.Values{} + params.Set("mode", "multi") params.Set("encryption", "none") if c.Flow != nil { params.Set("flow", toString(c.Flow)) @@ -116,18 +117,26 @@ func buildVless(c service.NodeServerConfig, password string) string { switch toInt(c.Tls) { case 1: security = "tls" + if fp := tlsFingerprint(c.UTLS); fp != "" { + params.Set("fp", fp) + } if tlsSettings, ok := c.TlsSettings.(map[string]any); ok { params.Set("sni", toString(tlsSettings["server_name"])) - if toString(tlsSettings["allow_insecure"]) == "1" { + if truthy(tlsSettings["allow_insecure"]) { params.Set("allowInsecure", "1") } } case 2: security = "reality" + if fp := tlsFingerprint(c.UTLS); fp != "" { + params.Set("fp", fp) + } if tlsSettings, ok := c.TlsSettings.(map[string]any); ok { params.Set("pbk", toString(tlsSettings["public_key"])) params.Set("sid", toString(tlsSettings["short_id"])) params.Set("sni", toString(tlsSettings["server_name"])) + params.Set("servername", toString(tlsSettings["server_name"])) + params.Set("spx", "/") } } params.Set("security", security) @@ -142,6 +151,19 @@ func buildVless(c service.NodeServerConfig, password string) string { } case "grpc": params.Set("serviceName", toString(settings["serviceName"])) + case "h2": + params.Set("type", "http") + params.Set("path", toString(settings["path"])) + if host := joinConfigValue(settings["host"]); host != "" { + params.Set("host", host) + } + case "httpupgrade": + params.Set("path", toString(settings["path"])) + host := toString(settings["host"]) + if host == "" { + host = c.RawHost + } + params.Set("host", host) } } @@ -244,6 +266,34 @@ func wrapIPv6(host string) string { return host } +func tlsFingerprint(value any) string { + settings, ok := value.(map[string]any) + if !ok || !truthy(settings["enabled"]) { + return "" + } + return toString(settings["fingerprint"]) +} + +func joinConfigValue(value any) string { + switch typed := value.(type) { + case string: + return typed + case []string: + return strings.Join(typed, ",") + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + text := toString(item) + if text != "" { + result = append(result, text) + } + } + return strings.Join(result, ",") + default: + return "" + } +} + func toString(v any) string { if s, ok := v.(string); ok { return s @@ -265,3 +315,22 @@ func toInt(v any) int { } return 0 } + +func truthy(v any) bool { + switch typed := v.(type) { + case bool: + return typed + case int: + return typed != 0 + case int64: + return typed != 0 + case float64: + return typed != 0 + case string: + switch strings.ToLower(strings.TrimSpace(typed)) { + case "1", "true", "yes", "on": + return true + } + } + return false +} diff --git a/internal/protocol/singbox.go b/internal/protocol/singbox.go index ff53708..1a6e30f 100644 --- a/internal/protocol/singbox.go +++ b/internal/protocol/singbox.go @@ -89,6 +89,9 @@ func buildSingBoxOutbound(conf service.NodeServerConfig, password string) map[st if transport := buildSingBoxTransport(conf); transport != nil { outbound["transport"] = transport } + if multiplex := buildSingBoxMultiplex(conf.Multiplex); multiplex != nil { + outbound["multiplex"] = multiplex + } case "trojan": outbound["type"] = "trojan" outbound["password"] = password @@ -134,6 +137,9 @@ func generateSingBoxFallback(servers []model.Server, user model.User) (string, e if transport := buildSingBoxTransport(conf); transport != nil { outbound["transport"] = transport } + if multiplex := buildSingBoxMultiplex(conf.Multiplex); multiplex != nil { + outbound["multiplex"] = multiplex + } case "trojan": outbound["type"] = "trojan" outbound["password"] = password @@ -161,6 +167,7 @@ func generateSingBoxFallback(servers []model.Server, user model.User) (string, e } func buildSingBoxTLS(conf service.NodeServerConfig) map[string]any { + utlsConfig := buildSingBoxUTLS(conf.UTLS) switch singBoxInt(conf.Tls) { case 1: tls := map[string]any{ @@ -172,6 +179,9 @@ func buildSingBoxTLS(conf service.NodeServerConfig) map[string]any { tls["server_name"] = serverName } } + if utlsConfig != nil { + tls["utls"] = utlsConfig + } return tls case 2: tls := map[string]any{ @@ -188,6 +198,9 @@ func buildSingBoxTLS(conf service.NodeServerConfig) map[string]any { "short_id": singBoxString(tlsSettings["short_id"]), } } + if utlsConfig != nil { + tls["utls"] = utlsConfig + } return tls default: return nil @@ -223,6 +236,19 @@ func buildSingBoxTransport(conf service.NodeServerConfig) map[string]any { } } return transport + case "h2": + transport := map[string]any{ + "type": "http", + } + if settings != nil { + if path := singBoxString(settings["path"]); path != "" { + transport["path"] = path + } + if host := singBoxHostValue(settings["host"]); len(host) > 0 { + transport["host"] = host + } + } + return transport case "httpupgrade": transport := map[string]any{ "type": "httpupgrade", @@ -239,6 +265,81 @@ func buildSingBoxTransport(conf service.NodeServerConfig) map[string]any { } } return transport + case "quic": + return map[string]any{ + "type": "quic", + } + default: + return nil + } +} + +func buildSingBoxUTLS(value any) map[string]any { + settings, ok := value.(map[string]any) + if !ok || !singBoxBool(settings["enabled"]) { + return nil + } + + utls := map[string]any{ + "enabled": true, + } + if fingerprint := singBoxString(settings["fingerprint"]); fingerprint != "" { + utls["fingerprint"] = fingerprint + } + return utls +} + +func buildSingBoxMultiplex(value any) map[string]any { + settings, ok := value.(map[string]any) + if !ok || !singBoxBool(settings["enabled"]) { + return nil + } + + multiplex := map[string]any{ + "enabled": true, + } + if protocol := singBoxString(settings["protocol"]); protocol != "" { + multiplex["protocol"] = protocol + } + if maxConnections := singBoxInt(settings["max_connections"]); maxConnections > 0 { + multiplex["max_connections"] = maxConnections + } + if minStreams := singBoxInt(settings["min_streams"]); minStreams > 0 { + multiplex["min_streams"] = minStreams + } + if maxStreams := singBoxInt(settings["max_streams"]); maxStreams > 0 { + multiplex["max_streams"] = maxStreams + } + if singBoxBool(settings["padding"]) { + multiplex["padding"] = true + } + if brutal, ok := settings["brutal"].(map[string]any); ok && singBoxBool(brutal["enabled"]) { + multiplex["brutal"] = map[string]any{ + "enabled": true, + "up_mbps": singBoxInt(brutal["up_mbps"]), + "down_mbps": singBoxInt(brutal["down_mbps"]), + } + } + return multiplex +} + +func singBoxHostValue(value any) []string { + switch typed := value.(type) { + case string: + if typed == "" { + return nil + } + return []string{typed} + case []string: + return append([]string{}, typed...) + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + if text := singBoxString(item); text != "" { + result = append(result, text) + } + } + return result default: return nil } @@ -251,7 +352,7 @@ func singBoxString(value any) string { case nil: return "" default: - return "" + return toString(value) } } diff --git a/internal/service/node.go b/internal/service/node.go index 078e3ed..a3a0f97 100644 --- a/internal/service/node.go +++ b/internal/service/node.go @@ -84,6 +84,7 @@ type NodeServerConfig struct { TlsSettings any `json:"tls_settings,omitempty"` Flow any `json:"flow,omitempty"` Multiplex any `json:"multiplex,omitempty"` + UTLS any `json:"utls,omitempty"` UpMbps any `json:"up_mbps,omitempty"` DownMbps any `json:"down_mbps,omitempty"` Version any `json:"version,omitempty"` @@ -265,10 +266,12 @@ func BuildNodeConfig(node *model.Server) NodeServerConfig { case "vmess": response.Tls = getMapInt(settings, "tls") response.Multiplex = getMapAny(settings, "multiplex") + response.UTLS = getMapAny(settings, "utls") case "trojan": response.Host = node.Host response.ServerName = getMapString(settings, "server_name") response.Multiplex = getMapAny(settings, "multiplex") + response.UTLS = getMapAny(settings, "utls") response.Tls = getMapInt(settings, "tls") if getMapInt(settings, "tls") == 2 { response.TlsSettings = getMapAny(settings, "reality_settings") @@ -279,6 +282,7 @@ func BuildNodeConfig(node *model.Server) NodeServerConfig { response.Tls = getMapInt(settings, "tls") response.Flow = getMapString(settings, "flow") response.Multiplex = getMapAny(settings, "multiplex") + response.UTLS = getMapAny(settings, "utls") response.Decryption = nil if encryption, ok := settings["encryption"].(map[string]any); ok { if enabled, ok := encryption["enabled"].(bool); ok && enabled {