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;
}
}