From f61e6d8c630085ccaec8d03e948c4ff2a442d2df Mon Sep 17 00:00:00 2001 From: DHR60 Date: Thu, 20 Nov 2025 19:35:57 +0800 Subject: [PATCH] perf: Shadowsocks (#8352) * perf: Shadowsocks * stricter plugin name fix for SIP002 URI * Fix --- .../ServiceLib/Handler/Fmt/ShadowsocksFmt.cs | 145 +++++++++++++++-- .../Singbox/SingboxOutboundService.cs | 151 ++++++++++++------ 2 files changed, 232 insertions(+), 64 deletions(-) diff --git a/v2rayN/ServiceLib/Handler/Fmt/ShadowsocksFmt.cs b/v2rayN/ServiceLib/Handler/Fmt/ShadowsocksFmt.cs index adaeb954..04f52232 100644 --- a/v2rayN/ServiceLib/Handler/Fmt/ShadowsocksFmt.cs +++ b/v2rayN/ServiceLib/Handler/Fmt/ShadowsocksFmt.cs @@ -41,7 +41,68 @@ public class ShadowsocksFmt : BaseFmt //url = Utile.Base64Encode(url); //new Sip002 var pw = Utils.Base64Encode($"{item.Security}:{item.Id}", true); - return ToUri(EConfigType.Shadowsocks, item.Address, item.Port, pw, null, remark); + + // plugin + var plugin = string.Empty; + var pluginArgs = string.Empty; + + if (item.Network == nameof(ETransport.tcp) && item.HeaderType == Global.TcpHeaderHttp) + { + plugin = "obfs-local"; + pluginArgs = $"obfs=http;obfs-host={item.RequestHost};"; + } + else + { + if (item.Network == nameof(ETransport.ws)) + { + pluginArgs += "mode=websocket;"; + pluginArgs += $"host={item.RequestHost};"; + pluginArgs += $"path={item.Path};"; + } + else if (item.Network == nameof(ETransport.quic)) + { + pluginArgs += "mode=quic;"; + } + if (item.StreamSecurity == Global.StreamSecurity) + { + pluginArgs += "tls;"; + var certs = CertPemManager.ParsePemChain(item.Cert); + if (certs.Count > 0) + { + var cert = certs.First(); + const string beginMarker = "-----BEGIN CERTIFICATE-----\n"; + const string endMarker = "\n-----END CERTIFICATE-----"; + + var base64Start = beginMarker.Length; + var endIndex = cert.IndexOf(endMarker, base64Start, StringComparison.Ordinal); + var base64Content = cert.Substring(base64Start, endIndex - base64Start); + + // https://github.com/shadowsocks/v2ray-plugin/blob/e9af1cdd2549d528deb20a4ab8d61c5fbe51f306/args.go#L172 + // Equal signs and commas [and backslashes] must be escaped with a backslash. + base64Content = base64Content.Replace("\\", "\\\\").Replace("=", "\\=").Replace(",", "\\,"); + + pluginArgs += $"certRaw={base64Content};"; + } + } + if (pluginArgs.Length > 0) + { + plugin = "v2ray-plugin"; + } + } + + var dicQuery = new Dictionary(); + if (plugin.IsNotEmpty()) + { + var pluginStr = plugin + ";" + pluginArgs; + // pluginStr remove last ';' and url encode + if (pluginStr.EndsWith(';')) + { + pluginStr = pluginStr[..^1]; + } + dicQuery["plugin"] = Utils.UrlEncode(pluginStr); + } + + return ToUri(EConfigType.Shadowsocks, item.Address, item.Port, pw, dicQuery, remark); } private static readonly Regex UrlFinder = new(@"ss://(?[A-Za-z0-9+-/=_]+)(?:#(?\S+))?", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -124,19 +185,81 @@ public class ShadowsocksFmt : BaseFmt var queryParameters = Utils.ParseQueryString(parsedUrl.Query); if (queryParameters["plugin"] != null) { - //obfs-host exists - var obfsHost = queryParameters["plugin"]?.Split(';').FirstOrDefault(t => t.Contains("obfs-host")); - if (queryParameters["plugin"].Contains("obfs=http") && obfsHost.IsNotEmpty()) - { - obfsHost = obfsHost?.Replace("obfs-host=", ""); - item.Network = Global.DefaultNetwork; - item.HeaderType = Global.TcpHeaderHttp; - item.RequestHost = obfsHost ?? ""; - } - else + var pluginStr = queryParameters["plugin"]; + var pluginParts = pluginStr.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + + if (pluginParts.Length == 0) { return null; } + + var pluginName = pluginParts[0]; + + // A typo in https://github.com/shadowsocks/shadowsocks-org/blob/6b1c064db4129de99c516294960e731934841c94/docs/doc/sip002.md?plain=1#L15 + // "simple-obfs" should be "obfs-local" + if (pluginName == "simple-obfs") + { + pluginName = "obfs-local"; + } + + // Parse obfs-local plugin + if (pluginName == "obfs-local") + { + var obfsMode = pluginParts.FirstOrDefault(t => t.StartsWith("obfs=")); + var obfsHost = pluginParts.FirstOrDefault(t => t.StartsWith("obfs-host=")); + + if ((!obfsMode.IsNullOrEmpty()) && obfsMode.Contains("obfs=http") && obfsHost.IsNotEmpty()) + { + obfsHost = obfsHost.Replace("obfs-host=", ""); + item.Network = Global.DefaultNetwork; + item.HeaderType = Global.TcpHeaderHttp; + item.RequestHost = obfsHost; + } + } + // Parse v2ray-plugin + else if (pluginName == "v2ray-plugin") + { + var mode = pluginParts.FirstOrDefault(t => t.StartsWith("mode="), "websocket"); + var host = pluginParts.FirstOrDefault(t => t.StartsWith("host=")); + var path = pluginParts.FirstOrDefault(t => t.StartsWith("path=")); + var hasTls = pluginParts.Any(t => t == "tls"); + var certRaw = pluginParts.FirstOrDefault(t => t.StartsWith("certRaw=")); + + var modeValue = mode.Replace("mode=", ""); + if (modeValue == "websocket") + { + item.Network = nameof(ETransport.ws); + if (!host.IsNullOrEmpty()) + { + item.RequestHost = host.Replace("host=", ""); + } + if (!path.IsNullOrEmpty()) + { + item.Path = path.Replace("path=", ""); + } + } + else if (modeValue == "quic") + { + item.Network = nameof(ETransport.quic); + } + + if (hasTls) + { + item.StreamSecurity = Global.StreamSecurity; + + if (!certRaw.IsNullOrEmpty()) + { + var certBase64 = certRaw.Replace("certRaw=", ""); + + certBase64 = certBase64.Replace("\\=", "=").Replace("\\,", ",").Replace("\\\\", "\\"); + + const string beginMarker = "-----BEGIN CERTIFICATE-----\n"; + const string endMarker = "\n-----END CERTIFICATE-----"; + var certPem = beginMarker + certBase64 + endMarker; + item.Cert = certPem; + } + } + } } return item; diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs index 9671dc77..1bda5981 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs @@ -26,6 +26,7 @@ public partial class CoreConfigSingboxService } await GenOutboundMux(node, outbound); + await GenOutboundTransport(node, outbound); break; } case EConfigType.Shadowsocks: @@ -33,6 +34,52 @@ public partial class CoreConfigSingboxService outbound.method = AppManager.Instance.GetShadowsocksSecurities(node).Contains(node.Security) ? node.Security : Global.None; outbound.password = node.Id; + if (node.Network == nameof(ETransport.tcp) && node.HeaderType == Global.TcpHeaderHttp) + { + outbound.plugin = "obfs-local"; + outbound.plugin_opts = $"obfs=http;obfs-host={node.RequestHost};"; + } + else + { + var pluginArgs = string.Empty; + if (node.Network == nameof(ETransport.ws)) + { + pluginArgs += "mode=websocket;"; + pluginArgs += $"host={node.RequestHost};"; + pluginArgs += $"path={node.Path};"; + } + else if (node.Network == nameof(ETransport.quic)) + { + pluginArgs += "mode=quic;"; + } + if (node.StreamSecurity == Global.StreamSecurity) + { + pluginArgs += "tls;"; + var certs = CertPemManager.ParsePemChain(node.Cert); + if (certs.Count > 0) + { + var cert = certs.First(); + const string beginMarker = "-----BEGIN CERTIFICATE-----\n"; + const string endMarker = "\n-----END CERTIFICATE-----"; + + var base64Start = beginMarker.Length; + var endIndex = cert.IndexOf(endMarker, base64Start, StringComparison.Ordinal); + var base64Content = cert.Substring(base64Start, endIndex - base64Start); + + // https://github.com/shadowsocks/v2ray-plugin/blob/e9af1cdd2549d528deb20a4ab8d61c5fbe51f306/args.go#L172 + // Equal signs and commas [and backslashes] must be escaped with a backslash. + base64Content = base64Content.Replace("\\", "\\\\").Replace("=", "\\=").Replace(",", "\\,"); + + pluginArgs += $"certRaw={base64Content};"; + } + } + if (pluginArgs.Length > 0) + { + outbound.plugin = "v2ray-plugin"; + outbound.plugin_opts = pluginArgs; + } + } + await GenOutboundMux(node, outbound); break; } @@ -71,6 +118,8 @@ public partial class CoreConfigSingboxService { outbound.flow = node.Flow; } + + await GenOutboundTransport(node, outbound); break; } case EConfigType.Trojan: @@ -78,6 +127,7 @@ public partial class CoreConfigSingboxService outbound.password = node.Id; await GenOutboundMux(node, outbound); + await GenOutboundTransport(node, outbound); break; } case EConfigType.Hysteria2: @@ -127,8 +177,6 @@ public partial class CoreConfigSingboxService } await GenOutboundTls(node, outbound); - - await GenOutboundTransport(node, outbound); } catch (Exception ex) { @@ -232,54 +280,59 @@ public partial class CoreConfigSingboxService { try { - if (node.StreamSecurity is Global.StreamSecurityReality or Global.StreamSecurity) + if (node.StreamSecurity is not (Global.StreamSecurityReality or Global.StreamSecurity)) { - var server_name = string.Empty; - if (node.Sni.IsNotEmpty()) - { - server_name = node.Sni; - } - else if (node.RequestHost.IsNotEmpty()) - { - server_name = Utils.String2List(node.RequestHost)?.First(); - } - var tls = new Tls4Sbox() + return await Task.FromResult(0); + } + if (node.ConfigType is EConfigType.Shadowsocks or EConfigType.SOCKS or EConfigType.WireGuard) + { + return await Task.FromResult(0); + } + var server_name = string.Empty; + if (node.Sni.IsNotEmpty()) + { + server_name = node.Sni; + } + else if (node.RequestHost.IsNotEmpty()) + { + server_name = Utils.String2List(node.RequestHost)?.First(); + } + var tls = new Tls4Sbox() + { + enabled = true, + record_fragment = _config.CoreBasicItem.EnableFragment ? true : null, + server_name = server_name, + insecure = Utils.ToBool(node.AllowInsecure.IsNullOrEmpty() ? _config.CoreBasicItem.DefAllowInsecure.ToString().ToLower() : node.AllowInsecure), + alpn = node.GetAlpn(), + }; + if (node.Fingerprint.IsNotEmpty()) + { + tls.utls = new Utls4Sbox() { enabled = true, - record_fragment = _config.CoreBasicItem.EnableFragment ? true : null, - server_name = server_name, - insecure = Utils.ToBool(node.AllowInsecure.IsNullOrEmpty() ? _config.CoreBasicItem.DefAllowInsecure.ToString().ToLower() : node.AllowInsecure), - alpn = node.GetAlpn(), + fingerprint = node.Fingerprint.IsNullOrEmpty() ? _config.CoreBasicItem.DefFingerprint : node.Fingerprint }; - if (node.Fingerprint.IsNotEmpty()) + } + if (node.StreamSecurity == Global.StreamSecurity) + { + var certs = CertPemManager.ParsePemChain(node.Cert); + if (certs.Count > 0) { - tls.utls = new Utls4Sbox() - { - enabled = true, - fingerprint = node.Fingerprint.IsNullOrEmpty() ? _config.CoreBasicItem.DefFingerprint : node.Fingerprint - }; - } - if (node.StreamSecurity == Global.StreamSecurity) - { - var certs = CertPemManager.ParsePemChain(node.Cert); - if (certs.Count > 0) - { - tls.certificate = certs; - tls.insecure = false; - } - } - else if (node.StreamSecurity == Global.StreamSecurityReality) - { - tls.reality = new Reality4Sbox() - { - enabled = true, - public_key = node.PublicKey, - short_id = node.ShortId - }; + tls.certificate = certs; tls.insecure = false; } - outbound.tls = tls; } + else if (node.StreamSecurity == Global.StreamSecurityReality) + { + tls.reality = new Reality4Sbox() + { + enabled = true, + public_key = node.PublicKey, + short_id = node.ShortId + }; + tls.insecure = false; + } + outbound.tls = tls; } catch (Exception ex) { @@ -305,17 +358,9 @@ public partial class CoreConfigSingboxService case nameof(ETransport.tcp): //http if (node.HeaderType == Global.TcpHeaderHttp) { - if (node.ConfigType == EConfigType.Shadowsocks) - { - outbound.plugin = "obfs-local"; - outbound.plugin_opts = $"obfs=http;obfs-host={node.RequestHost};"; - } - else - { - transport.type = nameof(ETransport.http); - transport.host = node.RequestHost.IsNullOrEmpty() ? null : Utils.String2List(node.RequestHost); - transport.path = node.Path.IsNullOrEmpty() ? null : node.Path; - } + transport.type = nameof(ETransport.http); + transport.host = node.RequestHost.IsNullOrEmpty() ? null : Utils.String2List(node.RequestHost); + transport.path = node.Path.IsNullOrEmpty() ? null : node.Path; } break;