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