Browse Source

CLI: config converter

Convert between ss:// links, SIP008 JSON, V2Ray JSON.
pull/3127/head
database64128 3 years ago
parent
commit
23aba3764e
No known key found for this signature in database GPG Key ID: 1CA27546BEDB8B01
8 changed files with 286 additions and 26 deletions
  1. +199
    -0
      Shadowsocks.CLI/ConfigConverter.cs
  2. +55
    -1
      Shadowsocks.CLI/Program.cs
  3. +4
    -10
      Shadowsocks.Interop/V2Ray/Config.cs
  4. +1
    -1
      Shadowsocks.Interop/V2Ray/OutboundObject.cs
  5. +1
    -1
      Shadowsocks.Interop/V2Ray/Protocols/Shadowsocks/InboundConfigurationObject.cs
  6. +1
    -1
      Shadowsocks.Interop/V2Ray/Protocols/VMess/UserObject.cs
  7. +2
    -5
      Shadowsocks/Models/Group.cs
  8. +23
    -7
      Shadowsocks/Models/Server.cs

+ 199
- 0
Shadowsocks.CLI/ConfigConverter.cs View File

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

+ 55
- 1
Shadowsocks.CLI/Program.cs View File

@@ -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");


+ 4
- 10
Shadowsocks.Interop/V2Ray/Config.cs View File

@@ -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.


+ 1
- 1
Shadowsocks.Interop/V2Ray/OutboundObject.cs View File

@@ -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>


+ 1
- 1
Shadowsocks.Interop/V2Ray/Protocols/Shadowsocks/InboundConfigurationObject.cs View File

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


+ 1
- 1
Shadowsocks.Interop/V2Ray/Protocols/VMess/UserObject.cs View File

@@ -18,7 +18,7 @@ namespace Shadowsocks.Interop.V2Ray.Protocols.VMess

public static UserObject Default => new()
{
Id = new Guid().ToString(),
Id = Guid.NewGuid().ToString(),
};
}
}

+ 2
- 5
Shadowsocks/Models/Group.cs View File

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


+ 23
- 7
Shadowsocks/Models/Server.cs View File

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


Loading…
Cancel
Save