From 23aba3764e636568f69665941433190b04010fec Mon Sep 17 00:00:00 2001 From: database64128 Date: Sat, 6 Mar 2021 14:29:05 +0800 Subject: [PATCH] =?UTF-8?q?=E2=8F=B3=20CLI:=20config=20converter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert between ss:// links, SIP008 JSON, V2Ray JSON. --- Shadowsocks.CLI/ConfigConverter.cs | 199 ++++++++++++++++++ Shadowsocks.CLI/Program.cs | 56 ++++- Shadowsocks.Interop/V2Ray/Config.cs | 14 +- Shadowsocks.Interop/V2Ray/OutboundObject.cs | 2 +- .../Shadowsocks/InboundConfigurationObject.cs | 2 +- .../V2Ray/Protocols/VMess/UserObject.cs | 2 +- Shadowsocks/Models/Group.cs | 7 +- Shadowsocks/Models/Server.cs | 30 ++- 8 files changed, 286 insertions(+), 26 deletions(-) create mode 100644 Shadowsocks.CLI/ConfigConverter.cs diff --git a/Shadowsocks.CLI/ConfigConverter.cs b/Shadowsocks.CLI/ConfigConverter.cs new file mode 100644 index 00000000..be3eece4 --- /dev/null +++ b/Shadowsocks.CLI/ConfigConverter.cs @@ -0,0 +1,199 @@ +using Shadowsocks.Interop.Utils; +using Shadowsocks.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Shadowsocks.CLI +{ + public class ConfigConverter + { + /// + /// Gets or sets whether to prefix group name to server names. + /// + public bool PrefixGroupName { get; set; } + + /// + /// Gets or sets the list of servers that are not in any groups. + /// + public List Servers { get; set; } = new(); + + public ConfigConverter(bool prefixGroupName = false) => PrefixGroupName = prefixGroupName; + + /// + /// Collects servers from ss:// links or SIP008 delivery links. + /// + /// URLs to collect servers from. + /// A token that may be used to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. + public async Task FromUrls(IEnumerable uris, CancellationToken cancellationToken = default) + { + var sip008Links = new List(); + + foreach (var uri in uris) + { + switch (uri.Scheme) + { + case "ss": + { + if (Server.TryParse(uri, out var server)) + Servers.Add(server); + break; + } + + case "https": + sip008Links.Add(uri); + break; + } + } + + if (sip008Links.Count > 0) + { + var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromSeconds(30.0); + var tasks = sip008Links.Select(async x => await httpClient.GetFromJsonAsync(x, JsonHelper.snakeCaseJsonDeserializerOptions, cancellationToken)) + .ToList(); + while (tasks.Count > 0) + { + var finishedTask = await Task.WhenAny(tasks); + var group = await finishedTask; + if (group != null) + Servers.AddRange(group.Servers); + tasks.Remove(finishedTask); + } + } + } + + /// + /// Collects servers from SIP008 JSON files. + /// + /// JSON file paths. + /// A token that may be used to cancel the read operation. + /// A task that represents the asynchronous read operation. + public async Task FromSip008Json(IEnumerable paths, CancellationToken cancellationToken = default) + { + foreach (var path in paths) + { + using var jsonFile = new FileStream(path, FileMode.Open); + var group = await JsonSerializer.DeserializeAsync(jsonFile, JsonHelper.snakeCaseJsonDeserializerOptions, cancellationToken); + if (group != null) + { + if (PrefixGroupName && !string.IsNullOrEmpty(group.Name)) + group.Servers.ForEach(x => x.Name = $"{group.Name} - {x.Name}"); + Servers.AddRange(group.Servers); + } + } + } + + /// + /// Collects servers from outbounds in V2Ray JSON files. + /// + /// JSON file paths. + /// A token that may be used to cancel the read operation. + /// A task that represents the asynchronous read operation. + public async Task FromV2rayJson(IEnumerable paths, CancellationToken cancellationToken = default) + { + foreach (var path in paths) + { + using var jsonFile = new FileStream(path, FileMode.Open); + var v2rayConfig = await JsonSerializer.DeserializeAsync(jsonFile, JsonHelper.camelCaseJsonDeserializerOptions, cancellationToken); + if (v2rayConfig?.Outbounds != null) + { + foreach (var outbound in v2rayConfig.Outbounds) + { + if (outbound.Protocol == "shadowsocks" + && outbound.Settings is Interop.V2Ray.Protocols.Shadowsocks.OutboundConfigurationObject ssConfig) + { + foreach (var ssServer in ssConfig.Servers) + { + var server = new Server(); + server.Name = outbound.Tag; + server.Host = ssServer.Address; + server.Port = ssServer.Port; + server.Method = ssServer.Method; + server.Password = ssServer.Password; + Servers.Add(server); + } + } + } + } + } + } + + /// + /// Converts saved servers to ss:// URLs. + /// + /// A list of ss:// URLs. + public List ToUrls() + { + var urls = new List(); + + foreach (var server in Servers) + urls.Add(server.ToUrl()); + + return urls; + } + + /// + /// Converts saved servers to SIP008 JSON. + /// + /// JSON file path. + /// A token that may be used to cancel the write operation. + /// A task that represents the asynchronous write operation. + public Task ToSip008Json(string path, CancellationToken cancellationToken = default) + { + var group = new Group(); + + group.Servers.AddRange(Servers); + + var fullPath = Path.GetFullPath(path); + var directoryPath = Path.GetDirectoryName(fullPath) ?? throw new ArgumentException("Invalid path", nameof(path)); + Directory.CreateDirectory(directoryPath); + using var jsonFile = new FileStream(fullPath, FileMode.Create); + return JsonSerializer.SerializeAsync(jsonFile, group, JsonHelper.snakeCaseJsonSerializerOptions, cancellationToken); + } + + /// + /// Converts saved servers to V2Ray outbounds. + /// + /// JSON file path. + /// Whether to prefix group name to server names. + /// A token that may be used to cancel the write operation. + /// A task that represents the asynchronous write operation. + public Task ToV2rayJson(string path, CancellationToken cancellationToken = default) + { + var v2rayConfig = new Interop.V2Ray.Config(); + v2rayConfig.Outbounds = new(); + + foreach (var server in Servers) + { + var ssOutbound = Interop.V2Ray.OutboundObject.GetShadowsocks(server); + v2rayConfig.Outbounds.Add(ssOutbound); + } + + // enforce outbound tag uniqueness + var serversWithDuplicateTags = v2rayConfig.Outbounds.GroupBy(x => x.Tag) + .Where(x => x.Count() > 1); + foreach (var serversWithSameTag in serversWithDuplicateTags) + { + var duplicates = serversWithSameTag.ToList(); + for (var i = 0; i < duplicates.Count; i++) + { + duplicates[i].Tag = $"{duplicates[i].Tag} {i}"; + } + } + + var fullPath = Path.GetFullPath(path); + var directoryPath = Path.GetDirectoryName(fullPath) ?? throw new ArgumentException("Invalid path", nameof(path)); + Directory.CreateDirectory(directoryPath); + using var jsonFile = new FileStream(fullPath, FileMode.Create); + return JsonSerializer.SerializeAsync(jsonFile, v2rayConfig, JsonHelper.camelCaseJsonSerializerOptions, cancellationToken); + } + } +} diff --git a/Shadowsocks.CLI/Program.cs b/Shadowsocks.CLI/Program.cs index eba20fd2..f36383d9 100644 --- a/Shadowsocks.CLI/Program.cs +++ b/Shadowsocks.CLI/Program.cs @@ -1,9 +1,11 @@ using Shadowsocks.Protocol; using System; +using System.Collections.Generic; using System.CommandLine; using System.CommandLine.Invocation; using System.Net; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Shadowsocks.CLI @@ -46,7 +48,59 @@ namespace Shadowsocks.CLI Console.WriteLine("Not implemented."); }); - var utilitiesCommand = new Command("utilities", "Shadowsocks-related utilities."); + var convertConfigCommand = new Command("convert-config", "Convert between different config formats. Supported formats: SIP002 links, SIP008 delivery JSON, and V2Ray JSON (outbound only)."); + convertConfigCommand.AddOption(new Option("--from-urls", "URL conversion sources. Multiple URLs are supported. Supported protocols are ss:// and https://.")); + convertConfigCommand.AddOption(new Option("--from-sip008-json", "SIP008 JSON conversion sources. Multiple JSON files are supported.")); + convertConfigCommand.AddOption(new Option("--from-v2ray-json", "V2Ray JSON conversion sources. Multiple JSON files are supported.")); + convertConfigCommand.AddOption(new Option("--prefix-group-name", "Whether to prefix group name to server names after conversion.")); + convertConfigCommand.AddOption(new Option("--to-urls", "Convert to ss:// links and print.")); + convertConfigCommand.AddOption(new Option("--to-sip008-json", "Convert to SIP008 JSON and save to the specified path.")); + convertConfigCommand.AddOption(new Option("--to-v2ray-json", "Convert to V2Ray JSON and save to the specified path.")); + convertConfigCommand.Handler = CommandHandler.Create( + async (string[]? fromUrls, string[]? fromSip008Json, string[]? fromV2rayJson, bool prefixGroupName, bool toUrls, string? toSip008Json, string? toV2rayJson, CancellationToken cancellationToken) => + { + var configConverter = new ConfigConverter(prefixGroupName); + + try + { + if (fromUrls != null) + { + var uris = new List(); + foreach (var url in fromUrls) + { + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + uris.Add(uri); + else + Console.WriteLine($"Invalid URL: {url}"); + } + await configConverter.FromUrls(uris, cancellationToken); + } + if (fromSip008Json != null) + await configConverter.FromSip008Json(fromSip008Json, cancellationToken); + if (fromV2rayJson != null) + await configConverter.FromV2rayJson(fromV2rayJson, cancellationToken); + + if (toUrls) + { + var uris = configConverter.ToUrls(); + foreach (var uri in uris) + Console.WriteLine(uri.AbsoluteUri); + } + if (!string.IsNullOrEmpty(toSip008Json)) + await configConverter.ToSip008Json(toSip008Json, cancellationToken); + if (!string.IsNullOrEmpty(toV2rayJson)) + await configConverter.ToV2rayJson(toV2rayJson, cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + }); + + var utilitiesCommand = new Command("utilities", "Shadowsocks-related utilities.") + { + convertConfigCommand, + }; utilitiesCommand.AddAlias("u"); utilitiesCommand.AddAlias("util"); utilitiesCommand.AddAlias("utils"); diff --git a/Shadowsocks.Interop/V2Ray/Config.cs b/Shadowsocks.Interop/V2Ray/Config.cs index 0e4ba9be..a6682104 100644 --- a/Shadowsocks.Interop/V2Ray/Config.cs +++ b/Shadowsocks.Interop/V2Ray/Config.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Shadowsocks.Interop.V2Ray @@ -7,20 +8,13 @@ namespace Shadowsocks.Interop.V2Ray public LogObject? Log { get; set; } public ApiObject? Api { get; set; } public DnsObject? Dns { get; set; } - public RoutingObject Routing { get; set; } + public RoutingObject? Routing { get; set; } public PolicyObject? Policy { get; set; } - public InboundObject Inbounds { get; set; } - public OutboundObject Outbounds { get; set; } + public List? Inbounds { get; set; } + public List? Outbounds { get; set; } public TransportObject? Transport { get; set; } public StatsObject? Stats { get; set; } public ReverseObject? Reverse { get; set; } - - public Config() - { - Routing = new(); - Inbounds = new(); - Outbounds = new(); - } /// /// Gets the default configuration. diff --git a/Shadowsocks.Interop/V2Ray/OutboundObject.cs b/Shadowsocks.Interop/V2Ray/OutboundObject.cs index 6bc15290..8366601e 100644 --- a/Shadowsocks.Interop/V2Ray/OutboundObject.cs +++ b/Shadowsocks.Interop/V2Ray/OutboundObject.cs @@ -46,7 +46,7 @@ namespace Shadowsocks.Interop.V2Ray { Tag = server.Name, Protocol = "shadowsocks", - Settings = new Protocols.Shadowsocks.OutboundConfigurationObject(), + Settings = new Protocols.Shadowsocks.OutboundConfigurationObject(server.Host, server.Port, server.Method, server.Password), }; /// diff --git a/Shadowsocks.Interop/V2Ray/Protocols/Shadowsocks/InboundConfigurationObject.cs b/Shadowsocks.Interop/V2Ray/Protocols/Shadowsocks/InboundConfigurationObject.cs index 842dc707..8a234dcf 100644 --- a/Shadowsocks.Interop/V2Ray/Protocols/Shadowsocks/InboundConfigurationObject.cs +++ b/Shadowsocks.Interop/V2Ray/Protocols/Shadowsocks/InboundConfigurationObject.cs @@ -18,7 +18,7 @@ namespace Shadowsocks.Interop.V2Ray.Protocols.Shadowsocks public InboundConfigurationObject() { Method = "chacha20-ietf-poly1305"; - Password = new Guid().ToString(); + Password = Guid.NewGuid().ToString(); Network = "tcp,udp"; } } diff --git a/Shadowsocks.Interop/V2Ray/Protocols/VMess/UserObject.cs b/Shadowsocks.Interop/V2Ray/Protocols/VMess/UserObject.cs index 1162cbbd..99f76b29 100644 --- a/Shadowsocks.Interop/V2Ray/Protocols/VMess/UserObject.cs +++ b/Shadowsocks.Interop/V2Ray/Protocols/VMess/UserObject.cs @@ -18,7 +18,7 @@ namespace Shadowsocks.Interop.V2Ray.Protocols.VMess public static UserObject Default => new() { - Id = new Guid().ToString(), + Id = Guid.NewGuid().ToString(), }; } } diff --git a/Shadowsocks/Models/Group.cs b/Shadowsocks/Models/Group.cs index 50b09265..a224caed 100644 --- a/Shadowsocks/Models/Group.cs +++ b/Shadowsocks/Models/Group.cs @@ -36,13 +36,10 @@ namespace Shadowsocks.Models [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ulong BytesRemaining { get; set; } - public Group() : this(string.Empty) - { } - - public Group(string name) + public Group(string name = "") { Name = name; - Id = new Guid(); + Id = Guid.NewGuid(); Version = 1; BytesUsed = 0UL; BytesRemaining = 0UL; diff --git a/Shadowsocks/Models/Server.cs b/Shadowsocks/Models/Server.cs index 34f64e31..4fd8a613 100644 --- a/Shadowsocks/Models/Server.cs +++ b/Shadowsocks/Models/Server.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace Shadowsocks.Models @@ -49,7 +50,7 @@ namespace Shadowsocks.Models Password = ""; Method = "chacha20-ietf-poly1305"; Name = ""; - Uuid = ""; + Uuid = Guid.NewGuid().ToString(); } public bool Equals(IServer? other) => other is Server anotherServer && Uuid == anotherServer.Uuid; @@ -81,15 +82,31 @@ namespace Shadowsocks.Models /// The ss:// URL to parse. /// /// A Server object represented by the URL. - /// A new empty Server object if the URL is invalid. + /// A new empty Server object if the URL is invalid. + /// /// True for success. False for failure. - public static bool TryParse(string url, out Server server) + public static bool TryParse(string url, [NotNullWhen(true)] out Server? server) { + server = null; + return Uri.TryCreate(url, UriKind.Absolute, out var uri) && TryParse(uri, out server); + } + + /// + /// Tries to parse an ss:// URL into a Server object. + /// + /// The ss:// URL to parse. + /// + /// A Server object represented by the URL. + /// A new empty Server object if the URL is invalid. + /// + /// True for success. False for failure. + public static bool TryParse(Uri uri, [NotNullWhen(true)] out Server? server) + { + server = null; try { - var uri = new Uri(url); if (uri.Scheme != "ss") - throw new ArgumentException("Wrong URL scheme"); + return false; var userinfo_base64url = uri.UserInfo; var userinfo = Utilities.Base64Url.DecodeToString(userinfo_base64url); var userinfoSplitArray = userinfo.Split(':', 2); @@ -101,7 +118,7 @@ namespace Shadowsocks.Models server = new Server() { Name = name, - Uuid = new Guid().ToString(), + Uuid = Guid.NewGuid().ToString(), Host = host, Port = uri.Port, Password = password, @@ -134,7 +151,6 @@ namespace Shadowsocks.Models } catch { - server = new(); return false; } }