Convert between ss:// links, SIP008 JSON, V2Ray JSON.pull/3127/head
| @@ -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 | |||
| { | |||
| /// <summary> | |||
| /// Gets or sets whether to prefix group name to server names. | |||
| /// </summary> | |||
| public bool PrefixGroupName { get; set; } | |||
| /// <summary> | |||
| /// Gets or sets the list of servers that are not in any groups. | |||
| /// </summary> | |||
| public List<Server> Servers { get; set; } = new(); | |||
| public ConfigConverter(bool prefixGroupName = false) => PrefixGroupName = prefixGroupName; | |||
| /// <summary> | |||
| /// Collects servers from ss:// links or SIP008 delivery links. | |||
| /// </summary> | |||
| /// <param name="uris">URLs to collect servers from.</param> | |||
| /// <param name="cancellationToken">A token that may be used to cancel the asynchronous operation.</param> | |||
| /// <returns>A task that represents the asynchronous operation.</returns> | |||
| public async Task FromUrls(IEnumerable<Uri> uris, CancellationToken cancellationToken = default) | |||
| { | |||
| var sip008Links = new List<Uri>(); | |||
| 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<Group>(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); | |||
| } | |||
| } | |||
| } | |||
| /// <summary> | |||
| /// Collects servers from SIP008 JSON files. | |||
| /// </summary> | |||
| /// <param name="paths">JSON file paths.</param> | |||
| /// <param name="cancellationToken">A token that may be used to cancel the read operation.</param> | |||
| /// <returns>A task that represents the asynchronous read operation.</returns> | |||
| public async Task FromSip008Json(IEnumerable<string> paths, CancellationToken cancellationToken = default) | |||
| { | |||
| foreach (var path in paths) | |||
| { | |||
| using var jsonFile = new FileStream(path, FileMode.Open); | |||
| var group = await JsonSerializer.DeserializeAsync<Group>(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); | |||
| } | |||
| } | |||
| } | |||
| /// <summary> | |||
| /// Collects servers from outbounds in V2Ray JSON files. | |||
| /// </summary> | |||
| /// <param name="paths">JSON file paths.</param> | |||
| /// <param name="cancellationToken">A token that may be used to cancel the read operation.</param> | |||
| /// <returns>A task that represents the asynchronous read operation.</returns> | |||
| public async Task FromV2rayJson(IEnumerable<string> paths, CancellationToken cancellationToken = default) | |||
| { | |||
| foreach (var path in paths) | |||
| { | |||
| using var jsonFile = new FileStream(path, FileMode.Open); | |||
| var v2rayConfig = await JsonSerializer.DeserializeAsync<Interop.V2Ray.Config>(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); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| /// <summary> | |||
| /// Converts saved servers to ss:// URLs. | |||
| /// </summary> | |||
| /// <returns>A list of ss:// URLs.</returns> | |||
| public List<Uri> ToUrls() | |||
| { | |||
| var urls = new List<Uri>(); | |||
| foreach (var server in Servers) | |||
| urls.Add(server.ToUrl()); | |||
| return urls; | |||
| } | |||
| /// <summary> | |||
| /// Converts saved servers to SIP008 JSON. | |||
| /// </summary> | |||
| /// <param name="path">JSON file path.</param> | |||
| /// <param name="cancellationToken">A token that may be used to cancel the write operation.</param> | |||
| /// <returns>A task that represents the asynchronous write operation.</returns> | |||
| 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); | |||
| } | |||
| /// <summary> | |||
| /// Converts saved servers to V2Ray outbounds. | |||
| /// </summary> | |||
| /// <param name="path">JSON file path.</param> | |||
| /// <param name="prefixGroupName">Whether to prefix group name to server names.</param> | |||
| /// <param name="cancellationToken">A token that may be used to cancel the write operation.</param> | |||
| /// <returns>A task that represents the asynchronous write operation.</returns> | |||
| 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); | |||
| } | |||
| } | |||
| } | |||
| @@ -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<string[]?>("--from-urls", "URL conversion sources. Multiple URLs are supported. Supported protocols are ss:// and https://.")); | |||
| convertConfigCommand.AddOption(new Option<string[]?>("--from-sip008-json", "SIP008 JSON conversion sources. Multiple JSON files are supported.")); | |||
| convertConfigCommand.AddOption(new Option<string[]?>("--from-v2ray-json", "V2Ray JSON conversion sources. Multiple JSON files are supported.")); | |||
| convertConfigCommand.AddOption(new Option<bool>("--prefix-group-name", "Whether to prefix group name to server names after conversion.")); | |||
| convertConfigCommand.AddOption(new Option<bool>("--to-urls", "Convert to ss:// links and print.")); | |||
| convertConfigCommand.AddOption(new Option<string?>("--to-sip008-json", "Convert to SIP008 JSON and save to the specified path.")); | |||
| convertConfigCommand.AddOption(new Option<string?>("--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<Uri>(); | |||
| 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"); | |||
| @@ -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<InboundObject>? Inbounds { get; set; } | |||
| public List<OutboundObject>? 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(); | |||
| } | |||
| /// <summary> | |||
| /// Gets the default configuration. | |||
| @@ -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), | |||
| }; | |||
| /// <summary> | |||
| @@ -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"; | |||
| } | |||
| } | |||
| @@ -18,7 +18,7 @@ namespace Shadowsocks.Interop.V2Ray.Protocols.VMess | |||
| public static UserObject Default => new() | |||
| { | |||
| Id = new Guid().ToString(), | |||
| Id = Guid.NewGuid().ToString(), | |||
| }; | |||
| } | |||
| } | |||
| @@ -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; | |||
| @@ -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 | |||
| /// <param name="url">The ss:// URL to parse.</param> | |||
| /// <param name="server"> | |||
| /// A Server object represented by the URL. | |||
| /// A new empty Server object if the URL is invalid.</param> | |||
| /// A new empty Server object if the URL is invalid. | |||
| /// </param> | |||
| /// <returns>True for success. False for failure.</returns> | |||
| 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); | |||
| } | |||
| /// <summary> | |||
| /// Tries to parse an ss:// URL into a Server object. | |||
| /// </summary> | |||
| /// <param name="uri">The ss:// URL to parse.</param> | |||
| /// <param name="server"> | |||
| /// A Server object represented by the URL. | |||
| /// A new empty Server object if the URL is invalid. | |||
| /// </param> | |||
| /// <returns>True for success. False for failure.</returns> | |||
| 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; | |||
| } | |||
| } | |||