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 Shadowsocks.Protocol; | ||||
using System; | using System; | ||||
using System.Collections.Generic; | |||||
using System.CommandLine; | using System.CommandLine; | ||||
using System.CommandLine.Invocation; | using System.CommandLine.Invocation; | ||||
using System.Net; | using System.Net; | ||||
using System.Text; | using System.Text; | ||||
using System.Threading; | |||||
using System.Threading.Tasks; | using System.Threading.Tasks; | ||||
namespace Shadowsocks.CLI | namespace Shadowsocks.CLI | ||||
@@ -46,7 +48,59 @@ namespace Shadowsocks.CLI | |||||
Console.WriteLine("Not implemented."); | 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("u"); | ||||
utilitiesCommand.AddAlias("util"); | utilitiesCommand.AddAlias("util"); | ||||
utilitiesCommand.AddAlias("utils"); | utilitiesCommand.AddAlias("utils"); | ||||
@@ -1,3 +1,4 @@ | |||||
using System.Collections.Generic; | |||||
using System.Text.Json.Serialization; | using System.Text.Json.Serialization; | ||||
namespace Shadowsocks.Interop.V2Ray | namespace Shadowsocks.Interop.V2Ray | ||||
@@ -7,20 +8,13 @@ namespace Shadowsocks.Interop.V2Ray | |||||
public LogObject? Log { get; set; } | public LogObject? Log { get; set; } | ||||
public ApiObject? Api { get; set; } | public ApiObject? Api { get; set; } | ||||
public DnsObject? Dns { get; set; } | public DnsObject? Dns { get; set; } | ||||
public RoutingObject Routing { get; set; } | |||||
public RoutingObject? Routing { get; set; } | |||||
public PolicyObject? Policy { 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 TransportObject? Transport { get; set; } | ||||
public StatsObject? Stats { get; set; } | public StatsObject? Stats { get; set; } | ||||
public ReverseObject? Reverse { get; set; } | public ReverseObject? Reverse { get; set; } | ||||
public Config() | |||||
{ | |||||
Routing = new(); | |||||
Inbounds = new(); | |||||
Outbounds = new(); | |||||
} | |||||
/// <summary> | /// <summary> | ||||
/// Gets the default configuration. | /// Gets the default configuration. | ||||
@@ -46,7 +46,7 @@ namespace Shadowsocks.Interop.V2Ray | |||||
{ | { | ||||
Tag = server.Name, | Tag = server.Name, | ||||
Protocol = "shadowsocks", | Protocol = "shadowsocks", | ||||
Settings = new Protocols.Shadowsocks.OutboundConfigurationObject(), | |||||
Settings = new Protocols.Shadowsocks.OutboundConfigurationObject(server.Host, server.Port, server.Method, server.Password), | |||||
}; | }; | ||||
/// <summary> | /// <summary> | ||||
@@ -18,7 +18,7 @@ namespace Shadowsocks.Interop.V2Ray.Protocols.Shadowsocks | |||||
public InboundConfigurationObject() | public InboundConfigurationObject() | ||||
{ | { | ||||
Method = "chacha20-ietf-poly1305"; | Method = "chacha20-ietf-poly1305"; | ||||
Password = new Guid().ToString(); | |||||
Password = Guid.NewGuid().ToString(); | |||||
Network = "tcp,udp"; | Network = "tcp,udp"; | ||||
} | } | ||||
} | } | ||||
@@ -18,7 +18,7 @@ namespace Shadowsocks.Interop.V2Ray.Protocols.VMess | |||||
public static UserObject Default => new() | public static UserObject Default => new() | ||||
{ | { | ||||
Id = new Guid().ToString(), | |||||
Id = Guid.NewGuid().ToString(), | |||||
}; | }; | ||||
} | } | ||||
} | } |
@@ -36,13 +36,10 @@ namespace Shadowsocks.Models | |||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] | ||||
public ulong BytesRemaining { get; set; } | public ulong BytesRemaining { get; set; } | ||||
public Group() : this(string.Empty) | |||||
{ } | |||||
public Group(string name) | |||||
public Group(string name = "") | |||||
{ | { | ||||
Name = name; | Name = name; | ||||
Id = new Guid(); | |||||
Id = Guid.NewGuid(); | |||||
Version = 1; | Version = 1; | ||||
BytesUsed = 0UL; | BytesUsed = 0UL; | ||||
BytesRemaining = 0UL; | BytesRemaining = 0UL; | ||||
@@ -1,5 +1,6 @@ | |||||
using System; | using System; | ||||
using System.Collections.Generic; | using System.Collections.Generic; | ||||
using System.Diagnostics.CodeAnalysis; | |||||
using System.Text.Json.Serialization; | using System.Text.Json.Serialization; | ||||
namespace Shadowsocks.Models | namespace Shadowsocks.Models | ||||
@@ -49,7 +50,7 @@ namespace Shadowsocks.Models | |||||
Password = ""; | Password = ""; | ||||
Method = "chacha20-ietf-poly1305"; | Method = "chacha20-ietf-poly1305"; | ||||
Name = ""; | Name = ""; | ||||
Uuid = ""; | |||||
Uuid = Guid.NewGuid().ToString(); | |||||
} | } | ||||
public bool Equals(IServer? other) => other is Server anotherServer && Uuid == anotherServer.Uuid; | 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="url">The ss:// URL to parse.</param> | ||||
/// <param name="server"> | /// <param name="server"> | ||||
/// A Server object represented by the URL. | /// 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> | /// <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 | try | ||||
{ | { | ||||
var uri = new Uri(url); | |||||
if (uri.Scheme != "ss") | if (uri.Scheme != "ss") | ||||
throw new ArgumentException("Wrong URL scheme"); | |||||
return false; | |||||
var userinfo_base64url = uri.UserInfo; | var userinfo_base64url = uri.UserInfo; | ||||
var userinfo = Utilities.Base64Url.DecodeToString(userinfo_base64url); | var userinfo = Utilities.Base64Url.DecodeToString(userinfo_base64url); | ||||
var userinfoSplitArray = userinfo.Split(':', 2); | var userinfoSplitArray = userinfo.Split(':', 2); | ||||
@@ -101,7 +118,7 @@ namespace Shadowsocks.Models | |||||
server = new Server() | server = new Server() | ||||
{ | { | ||||
Name = name, | Name = name, | ||||
Uuid = new Guid().ToString(), | |||||
Uuid = Guid.NewGuid().ToString(), | |||||
Host = host, | Host = host, | ||||
Port = uri.Port, | Port = uri.Port, | ||||
Password = password, | Password = password, | ||||
@@ -134,7 +151,6 @@ namespace Shadowsocks.Models | |||||
} | } | ||||
catch | catch | ||||
{ | { | ||||
server = new(); | |||||
return false; | return false; | ||||
} | } | ||||
} | } | ||||