mirror of
https://github.com/2dust/v2rayN.git
synced 2026-05-30 01:34:08 +00:00
feat: add SSH outbound protocol (sing-box, chainable)
Add SSH as a first-class protocol alongside VMess/VLESS/Anytls/Naive: - New EConfigType.SSH (sing-box only; not in XraySupportConfigType). - ProtocolExtraItem gains SshPrivateKey / SshPrivateKeyPath / SshPrivateKeyPassphrase / SshHostKey / SshHostKeyAlgorithms / SshClientVersion. - Outbound4Sbox gains user / private_key / private_key_path / private_key_passphrase / host_key / host_key_algorithms / client_version, mapping 1:1 to sing-box's ssh outbound schema. - SingboxOutboundService emits the ssh outbound and bypasses TLS for SSH nodes; multiplex is not called for SSH (sing-box rejects it). - ConfigHandler.AddSSHServer pins CoreType=sing_box, requires a username, and requires exactly one of password / inline key / key path (inline and path are mutually exclusive). - AddServerViewModel exposes SSH fields and mirrors the same validation rules at save time. - MainWindowViewModel: AddSshServerCmd routed to AddServerAsync(SSH). - WPF + Avalonia: new gridSsh on AddServerWindow with username, password, inline PEM, key path, passphrase, host key(s), host-key algorithms, and client version. Hides transport/TLS/finalmask. - MainWindow menu item 'Add [SSH]' bound on both UIs. - Resources: menuAddSshServer + Tb*Ssh* + FillSshAuth + PleaseFillUsername (resx + Designer). - Tests: SingboxSshOutboundTests covers password-only output, inline PEM line splitting, host_key CSV trimming, and SSH-in-ProxyChain detour wiring. Chaining works through the existing ProxyChain machinery (detour field), since SSH is now a regular sing-box outbound.
This commit is contained in:
parent
f4a2086dfb
commit
7d0fd3a7bb
20 changed files with 681 additions and 4 deletions
|
|
@ -130,6 +130,36 @@ internal static class CoreConfigTestFactory
|
|||
};
|
||||
}
|
||||
|
||||
public static ProfileItem CreateSshNode(ECoreType coreType = ECoreType.sing_box,
|
||||
string indexId = "node-ssh-1", string remarks = "demo-ssh",
|
||||
string? privateKeyPem = null, string? privateKeyPath = null,
|
||||
string? hostKey = null, string? hostKeyAlgorithms = null,
|
||||
string password = "secret", string username = "root")
|
||||
{
|
||||
var node = new ProfileItem
|
||||
{
|
||||
IndexId = indexId,
|
||||
ConfigType = EConfigType.SSH,
|
||||
CoreType = coreType,
|
||||
Remarks = remarks,
|
||||
Address = "ssh.example.com",
|
||||
Port = 22,
|
||||
Username = username,
|
||||
Password = password,
|
||||
Network = string.Empty,
|
||||
StreamSecurity = string.Empty,
|
||||
Subid = string.Empty,
|
||||
};
|
||||
node.SetProtocolExtra(node.GetProtocolExtra() with
|
||||
{
|
||||
SshPrivateKey = privateKeyPem,
|
||||
SshPrivateKeyPath = privateKeyPath,
|
||||
SshHostKey = hostKey,
|
||||
SshHostKeyAlgorithms = hostKeyAlgorithms,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
|
||||
IEnumerable<string> childIndexIds)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
using AwesomeAssertions;
|
||||
using ServiceLib.Common;
|
||||
using ServiceLib.Enums;
|
||||
using ServiceLib.Models;
|
||||
using ServiceLib.Services.CoreConfig;
|
||||
using Xunit;
|
||||
|
||||
namespace ServiceLib.Tests.CoreConfig.Singbox;
|
||||
|
||||
public class SingboxSshOutboundTests
|
||||
{
|
||||
[Fact]
|
||||
public void Ssh_PasswordOnly_EmitsExpectedFields()
|
||||
{
|
||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||
|
||||
var node = CoreConfigTestFactory.CreateSshNode(password: "p@ss");
|
||||
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
|
||||
|
||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
|
||||
|
||||
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
||||
|
||||
proxy.type.Should().Be("ssh");
|
||||
proxy.server.Should().Be(node.Address);
|
||||
proxy.server_port.Should().Be(node.Port);
|
||||
proxy.user.Should().Be(node.Username);
|
||||
proxy.password.Should().Be("p@ss");
|
||||
proxy.private_key.Should().BeNull();
|
||||
proxy.private_key_path.Should().BeNull();
|
||||
proxy.host_key.Should().BeNull();
|
||||
proxy.multiplex.Should().BeNull();
|
||||
proxy.tls.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssh_InlinePrivateKey_IsSplitIntoLines()
|
||||
{
|
||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||
|
||||
var pem = "-----BEGIN OPENSSH PRIVATE KEY-----\r\nAAAA\nBBBB\n\n-----END OPENSSH PRIVATE KEY-----";
|
||||
var node = CoreConfigTestFactory.CreateSshNode(password: string.Empty, privateKeyPem: pem);
|
||||
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
|
||||
|
||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
|
||||
|
||||
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
||||
|
||||
proxy.private_key.Should().NotBeNull();
|
||||
proxy.private_key!.Should().ContainInOrder("-----BEGIN OPENSSH PRIVATE KEY-----", "AAAA", "BBBB",
|
||||
"-----END OPENSSH PRIVATE KEY-----");
|
||||
proxy.private_key.Should().NotContain(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssh_HostKeyCsv_BecomesTrimmedList()
|
||||
{
|
||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||
|
||||
var node = CoreConfigTestFactory.CreateSshNode(
|
||||
hostKey: " ssh-ed25519 AAAA , ssh-rsa BBBB ",
|
||||
hostKeyAlgorithms: "ssh-ed25519, rsa-sha2-256");
|
||||
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
|
||||
|
||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
|
||||
|
||||
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
||||
|
||||
proxy.host_key.Should().Equal("ssh-ed25519 AAAA", "ssh-rsa BBBB");
|
||||
proxy.host_key_algorithms.Should().Equal("ssh-ed25519", "rsa-sha2-256");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ssh_InProxyChain_ReceivesDetourTag()
|
||||
{
|
||||
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||
|
||||
var sshNode = CoreConfigTestFactory.CreateSshNode(indexId: "ssh1");
|
||||
var socksNode = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "s1", "exit");
|
||||
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
|
||||
[sshNode.IndexId, socksNode.IndexId]);
|
||||
|
||||
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.sing_box);
|
||||
context.AllProxiesMap[sshNode.IndexId] = sshNode;
|
||||
context.AllProxiesMap[socksNode.IndexId] = socksNode;
|
||||
context.AllProxiesMap[chain.IndexId] = chain;
|
||||
|
||||
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
|
||||
|
||||
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||
cfg.outbounds.Should().Contain(o => o.type == "ssh");
|
||||
cfg.outbounds.Where(o => o.type == "ssh")
|
||||
.Should().OnlyContain(o => !string.IsNullOrEmpty(o.detour));
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ public enum EConfigType
|
|||
HTTP = 10,
|
||||
Anytls = 11,
|
||||
Naive = 12,
|
||||
SSH = 13,
|
||||
PolicyGroup = 101,
|
||||
ProxyChain = 102,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,7 +237,8 @@ public class Global
|
|||
{ EConfigType.TUIC, "tuic" },
|
||||
{ EConfigType.WireGuard, "wireguard" },
|
||||
{ EConfigType.Anytls, "anytls" },
|
||||
{ EConfigType.Naive, "naive" }
|
||||
{ EConfigType.Naive, "naive" },
|
||||
{ EConfigType.SSH, "ssh" }
|
||||
};
|
||||
|
||||
public static readonly List<string> VmessSecurities =
|
||||
|
|
@ -361,6 +362,7 @@ public class Global
|
|||
EConfigType.TUIC,
|
||||
EConfigType.Anytls,
|
||||
EConfigType.Naive,
|
||||
EConfigType.SSH,
|
||||
EConfigType.WireGuard,
|
||||
EConfigType.SOCKS,
|
||||
EConfigType.HTTP,
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ public static class ConfigHandler
|
|||
EConfigType.WireGuard => await AddWireguardServer(config, item),
|
||||
EConfigType.Anytls => await AddAnytlsServer(config, item),
|
||||
EConfigType.Naive => await AddNaiveServer(config, item),
|
||||
EConfigType.SSH => await AddSSHServer(config, item),
|
||||
_ => -1,
|
||||
};
|
||||
return ret;
|
||||
|
|
@ -887,6 +888,48 @@ public static class ConfigHandler
|
|||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add or edit an SSH server
|
||||
/// Validates and processes SSH-specific settings (sing-box core only)
|
||||
/// </summary>
|
||||
/// <param name="config">Current configuration</param>
|
||||
/// <param name="profileItem">SSH profile to add</param>
|
||||
/// <param name="toFile">Whether to save to file</param>
|
||||
/// <returns>0 if successful, -1 if failed</returns>
|
||||
public static async Task<int> AddSSHServer(Config config, ProfileItem profileItem, bool toFile = true)
|
||||
{
|
||||
profileItem.ConfigType = EConfigType.SSH;
|
||||
profileItem.CoreType = ECoreType.sing_box;
|
||||
|
||||
profileItem.Address = profileItem.Address.TrimEx();
|
||||
profileItem.Username = profileItem.Username.TrimEx();
|
||||
profileItem.Password = profileItem.Password.TrimEx();
|
||||
profileItem.Network = string.Empty;
|
||||
profileItem.StreamSecurity = string.Empty;
|
||||
profileItem.Alpn = string.Empty;
|
||||
profileItem.Fingerprint = string.Empty;
|
||||
|
||||
if (profileItem.Username.IsNullOrEmpty())
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var extra = profileItem.GetProtocolExtra();
|
||||
var hasInlineKey = extra.SshPrivateKey.IsNotEmpty();
|
||||
var hasKeyPath = extra.SshPrivateKeyPath.IsNotEmpty();
|
||||
if (hasInlineKey && hasKeyPath)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (profileItem.Password.IsNullOrEmpty() && !hasInlineKey && !hasKeyPath)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
await AddServerCommon(config, profileItem, toFile);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort the server list by the specified column
|
||||
/// Updates the sort order in the profile extension data
|
||||
|
|
@ -1583,6 +1626,7 @@ public static class ConfigHandler
|
|||
EConfigType.WireGuard => await AddWireguardServer(config, profileItem, false),
|
||||
EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false),
|
||||
EConfigType.Naive => await AddNaiveServer(config, profileItem, false),
|
||||
EConfigType.SSH => await AddSSHServer(config, profileItem, false),
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
|
|
@ -1780,6 +1824,7 @@ public static class ConfigHandler
|
|||
EConfigType.WireGuard => await AddWireguardServer(config, profileItem, false),
|
||||
EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false),
|
||||
EConfigType.Naive => await AddNaiveServer(config, profileItem, false),
|
||||
EConfigType.SSH => await AddSSHServer(config, profileItem, false),
|
||||
EConfigType.PolicyGroup or EConfigType.ProxyChain => await AddServerCommon(config, profileItem, false),
|
||||
_ => -1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ public class Outbound4Sbox : BaseServer4Sbox
|
|||
public List<string>? outbounds { get; set; }
|
||||
public bool? interrupt_exist_connections { get; set; }
|
||||
public int? tolerance { get; set; }
|
||||
|
||||
// ssh
|
||||
public string? user { get; set; }
|
||||
public List<string>? private_key { get; set; }
|
||||
public string? private_key_path { get; set; }
|
||||
public string? private_key_passphrase { get; set; }
|
||||
public List<string>? host_key { get; set; }
|
||||
public List<string>? host_key_algorithms { get; set; }
|
||||
public string? client_version { get; set; }
|
||||
}
|
||||
|
||||
public class Endpoints4Sbox : BaseServer4Sbox
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ public record ProtocolExtraItem
|
|||
public int? InsecureConcurrency { get; init; }
|
||||
public bool? NaiveQuic { get; init; }
|
||||
|
||||
// ssh
|
||||
public string? SshPrivateKey { get; init; }
|
||||
public string? SshPrivateKeyPath { get; init; }
|
||||
public string? SshPrivateKeyPassphrase { get; init; }
|
||||
public string? SshHostKey { get; init; }
|
||||
public string? SshHostKeyAlgorithms { get; init; }
|
||||
public string? SshClientVersion { get; init; }
|
||||
|
||||
// group profile
|
||||
public string? GroupType { get; init; }
|
||||
public string? ChildItems { get; init; }
|
||||
|
|
|
|||
54
v2rayN/ServiceLib/Resx/ResUI.Designer.cs
generated
54
v2rayN/ServiceLib/Resx/ResUI.Designer.cs
generated
|
|
@ -753,6 +753,60 @@ namespace ServiceLib.Resx {
|
|||
}
|
||||
}
|
||||
|
||||
public static string menuAddSshServer {
|
||||
get {
|
||||
return ResourceManager.GetString("menuAddSshServer", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string TbSshPrivateKey {
|
||||
get {
|
||||
return ResourceManager.GetString("TbSshPrivateKey", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string TbSshPrivateKeyPath {
|
||||
get {
|
||||
return ResourceManager.GetString("TbSshPrivateKeyPath", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string TbSshPassphrase {
|
||||
get {
|
||||
return ResourceManager.GetString("TbSshPassphrase", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string TbSshHostKey {
|
||||
get {
|
||||
return ResourceManager.GetString("TbSshHostKey", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string TbSshHostKeyAlgorithms {
|
||||
get {
|
||||
return ResourceManager.GetString("TbSshHostKeyAlgorithms", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string TbSshClientVersion {
|
||||
get {
|
||||
return ResourceManager.GetString("TbSshClientVersion", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string FillSshAuth {
|
||||
get {
|
||||
return ResourceManager.GetString("FillSshAuth", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
public static string PleaseFillUsername {
|
||||
get {
|
||||
return ResourceManager.GetString("PleaseFillUsername", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找类似 Add Policy Group 的本地化字符串。
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -1677,6 +1677,33 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
|
|||
<data name="menuAddNaiveServer" xml:space="preserve">
|
||||
<value>Add [NaïveProxy]</value>
|
||||
</data>
|
||||
<data name="menuAddSshServer" xml:space="preserve">
|
||||
<value>Add [SSH]</value>
|
||||
</data>
|
||||
<data name="TbSshPrivateKey" xml:space="preserve">
|
||||
<value>Private key (PEM)</value>
|
||||
</data>
|
||||
<data name="TbSshPrivateKeyPath" xml:space="preserve">
|
||||
<value>Private key path</value>
|
||||
</data>
|
||||
<data name="TbSshPassphrase" xml:space="preserve">
|
||||
<value>Passphrase</value>
|
||||
</data>
|
||||
<data name="TbSshHostKey" xml:space="preserve">
|
||||
<value>Host key(s) (comma-separated)</value>
|
||||
</data>
|
||||
<data name="TbSshHostKeyAlgorithms" xml:space="preserve">
|
||||
<value>Host key algorithms (comma-separated)</value>
|
||||
</data>
|
||||
<data name="TbSshClientVersion" xml:space="preserve">
|
||||
<value>Client version</value>
|
||||
</data>
|
||||
<data name="FillSshAuth" xml:space="preserve">
|
||||
<value>Provide either a password or a private key (inline or path, not both).</value>
|
||||
</data>
|
||||
<data name="PleaseFillUsername" xml:space="preserve">
|
||||
<value>Please fill in the username.</value>
|
||||
</data>
|
||||
<data name="TbInsecureConcurrency" xml:space="preserve">
|
||||
<value>Insecure Concurrency</value>
|
||||
</data>
|
||||
|
|
|
|||
|
|
@ -293,6 +293,27 @@ public partial class CoreConfigSingboxService
|
|||
outbound.udp_over_tcp = protocolExtra.Uot == true ? true : null;
|
||||
break;
|
||||
}
|
||||
case EConfigType.SSH:
|
||||
{
|
||||
outbound.user = _node.Username.NullIfEmpty();
|
||||
outbound.password = _node.Password.NullIfEmpty();
|
||||
|
||||
var inlineKey = protocolExtra.SshPrivateKey;
|
||||
if (inlineKey.IsNotEmpty())
|
||||
{
|
||||
outbound.private_key = inlineKey
|
||||
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)
|
||||
.Select(l => l.TrimEnd())
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
outbound.private_key_path = protocolExtra.SshPrivateKeyPath.NullIfEmpty();
|
||||
outbound.private_key_passphrase = protocolExtra.SshPrivateKeyPassphrase.NullIfEmpty();
|
||||
outbound.host_key = SplitCsv(protocolExtra.SshHostKey);
|
||||
outbound.host_key_algorithms = SplitCsv(protocolExtra.SshHostKeyAlgorithms);
|
||||
outbound.client_version = protocolExtra.SshClientVersion.NullIfEmpty();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
FillOutboundTls(outbound);
|
||||
|
|
@ -338,6 +359,20 @@ public partial class CoreConfigSingboxService
|
|||
}
|
||||
}
|
||||
|
||||
private static List<string>? SplitCsv(string? value)
|
||||
{
|
||||
if (value.IsNullOrEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var list = value!
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
return list.Count > 0 ? list : null;
|
||||
}
|
||||
|
||||
private void FillOutboundMux(Outbound4Sbox outbound)
|
||||
{
|
||||
try
|
||||
|
|
@ -369,7 +404,7 @@ public partial class CoreConfigSingboxService
|
|||
{
|
||||
return;
|
||||
}
|
||||
if (_node.ConfigType is EConfigType.Shadowsocks or EConfigType.SOCKS or EConfigType.WireGuard)
|
||||
if (_node.ConfigType is EConfigType.Shadowsocks or EConfigType.SOCKS or EConfigType.WireGuard or EConfigType.SSH)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,24 @@ public class AddServerViewModel : MyReactiveObject
|
|||
[Reactive]
|
||||
public bool NaiveQuic { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SshPrivateKey { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SshPrivateKeyPath { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SshPrivateKeyPassphrase { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SshHostKey { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SshHostKeyAlgorithms { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SshClientVersion { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string RawHeaderType { get; set; }
|
||||
|
||||
|
|
@ -297,6 +315,12 @@ public class AddServerViewModel : MyReactiveObject
|
|||
CongestionControl = protocolExtra.CongestionControl ?? string.Empty;
|
||||
InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null;
|
||||
NaiveQuic = protocolExtra.NaiveQuic ?? false;
|
||||
SshPrivateKey = protocolExtra.SshPrivateKey ?? string.Empty;
|
||||
SshPrivateKeyPath = protocolExtra.SshPrivateKeyPath ?? string.Empty;
|
||||
SshPrivateKeyPassphrase = protocolExtra.SshPrivateKeyPassphrase ?? string.Empty;
|
||||
SshHostKey = protocolExtra.SshHostKey ?? string.Empty;
|
||||
SshHostKeyAlgorithms = protocolExtra.SshHostKeyAlgorithms ?? string.Empty;
|
||||
SshClientVersion = protocolExtra.SshClientVersion ?? string.Empty;
|
||||
|
||||
RawHeaderType = transport.RawHeaderType ?? Global.None;
|
||||
Host = transport.Host ?? string.Empty;
|
||||
|
|
@ -344,7 +368,27 @@ public class AddServerViewModel : MyReactiveObject
|
|||
return;
|
||||
}
|
||||
}
|
||||
if (SelectedSource.ConfigType is not EConfigType.SOCKS and not EConfigType.HTTP)
|
||||
if (SelectedSource.ConfigType == EConfigType.SSH)
|
||||
{
|
||||
if (SelectedSource.Username.IsNullOrEmpty())
|
||||
{
|
||||
NoticeManager.Instance.Enqueue(ResUI.PleaseFillUsername);
|
||||
return;
|
||||
}
|
||||
var hasInlineKey = SshPrivateKey.IsNotEmpty();
|
||||
var hasKeyPath = SshPrivateKeyPath.IsNotEmpty();
|
||||
if (hasInlineKey && hasKeyPath)
|
||||
{
|
||||
NoticeManager.Instance.Enqueue(ResUI.FillSshAuth);
|
||||
return;
|
||||
}
|
||||
if (SelectedSource.Password.IsNullOrEmpty() && !hasInlineKey && !hasKeyPath)
|
||||
{
|
||||
NoticeManager.Instance.Enqueue(ResUI.FillSshAuth);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (SelectedSource.ConfigType is not EConfigType.SOCKS and not EConfigType.HTTP)
|
||||
{
|
||||
if (SelectedSource.Password.IsNullOrEmpty())
|
||||
{
|
||||
|
|
@ -396,6 +440,12 @@ public class AddServerViewModel : MyReactiveObject
|
|||
CongestionControl = CongestionControl.NullIfEmpty(),
|
||||
InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null,
|
||||
NaiveQuic = NaiveQuic ? true : null,
|
||||
SshPrivateKey = SshPrivateKey.NullIfEmpty(),
|
||||
SshPrivateKeyPath = SshPrivateKeyPath.NullIfEmpty(),
|
||||
SshPrivateKeyPassphrase = SshPrivateKeyPassphrase.NullIfEmpty(),
|
||||
SshHostKey = SshHostKey.NullIfEmpty(),
|
||||
SshHostKeyAlgorithms = SshHostKeyAlgorithms.NullIfEmpty(),
|
||||
SshClientVersion = SshClientVersion.NullIfEmpty(),
|
||||
});
|
||||
SelectedSource.SetTransportExtra(transport);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ public class MainWindowViewModel : MyReactiveObject
|
|||
public ReactiveCommand<Unit, Unit> AddWireguardServerCmd { get; }
|
||||
public ReactiveCommand<Unit, Unit> AddAnytlsServerCmd { get; }
|
||||
public ReactiveCommand<Unit, Unit> AddNaiveServerCmd { get; }
|
||||
public ReactiveCommand<Unit, Unit> AddSshServerCmd { get; }
|
||||
public ReactiveCommand<Unit, Unit> AddCustomServerCmd { get; }
|
||||
public ReactiveCommand<Unit, Unit> AddPolicyGroupServerCmd { get; }
|
||||
public ReactiveCommand<Unit, Unit> AddProxyChainServerCmd { get; }
|
||||
|
|
@ -124,6 +125,10 @@ public class MainWindowViewModel : MyReactiveObject
|
|||
{
|
||||
await AddServerAsync(EConfigType.Naive);
|
||||
});
|
||||
AddSshServerCmd = ReactiveCommand.CreateFromTask(async () =>
|
||||
{
|
||||
await AddServerAsync(EConfigType.SSH);
|
||||
});
|
||||
AddCustomServerCmd = ReactiveCommand.CreateFromTask(async () =>
|
||||
{
|
||||
await AddServerAsync(EConfigType.Custom);
|
||||
|
|
|
|||
|
|
@ -695,6 +695,120 @@
|
|||
Margin="{StaticResource Margin4}"
|
||||
HorizontalAlignment="Left" />
|
||||
</Grid>
|
||||
<Grid
|
||||
x:Name="gridSsh"
|
||||
Grid.Row="2"
|
||||
ColumnDefinitions="300,Auto"
|
||||
IsVisible="False"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Static resx:ResUI.TbUsername}" />
|
||||
<TextBox
|
||||
x:Name="txtUsernameSsh"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Static resx:ResUI.TbId3}" />
|
||||
<TextBox
|
||||
x:Name="txtPasswordSsh"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Top"
|
||||
Text="{x:Static resx:ResUI.TbSshPrivateKey}" />
|
||||
<TextBox
|
||||
x:Name="txtPrivateKeySsh"
|
||||
Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Height="120"
|
||||
Margin="{StaticResource Margin4}"
|
||||
AcceptsReturn="True"
|
||||
FontFamily="Consolas,Menlo,monospace"
|
||||
TextWrapping="NoWrap" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="3"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Static resx:ResUI.TbSshPrivateKeyPath}" />
|
||||
<TextBox
|
||||
x:Name="txtPrivateKeyPathSsh"
|
||||
Grid.Row="3"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="4"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Static resx:ResUI.TbSshPassphrase}" />
|
||||
<TextBox
|
||||
x:Name="txtPassphraseSsh"
|
||||
Grid.Row="4"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="5"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Static resx:ResUI.TbSshHostKey}" />
|
||||
<TextBox
|
||||
x:Name="txtHostKeySsh"
|
||||
Grid.Row="5"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="6"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Static resx:ResUI.TbSshHostKeyAlgorithms}" />
|
||||
<TextBox
|
||||
x:Name="txtHostKeyAlgoSsh"
|
||||
Grid.Row="6"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="7"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Static resx:ResUI.TbSshClientVersion}" />
|
||||
<TextBox
|
||||
x:Name="txtClientVersionSsh"
|
||||
Grid.Row="7"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}" />
|
||||
</Grid>
|
||||
|
||||
<Separator
|
||||
x:Name="sepa2"
|
||||
|
|
|
|||
|
|
@ -120,6 +120,15 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
|
|||
|
||||
cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls;
|
||||
break;
|
||||
|
||||
case EConfigType.SSH:
|
||||
gridSsh.IsVisible = true;
|
||||
sepa2.IsVisible = false;
|
||||
gridTransport.IsVisible = false;
|
||||
gridTls.IsVisible = false;
|
||||
cmbCoreType.IsEnabled = false;
|
||||
gridFinalmask.IsVisible = false;
|
||||
break;
|
||||
}
|
||||
cmbStreamSecurity.ItemsSource = lstStreamSecurity;
|
||||
|
||||
|
|
@ -204,6 +213,17 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
|
|||
this.Bind(ViewModel, vm => vm.InsecureConcurrency, v => v.txtInsecureConcurrency12.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(disposables);
|
||||
break;
|
||||
|
||||
case EConfigType.SSH:
|
||||
this.Bind(ViewModel, vm => vm.SelectedSource.Username, v => v.txtUsernameSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SelectedSource.Password, v => v.txtPasswordSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshPrivateKey, v => v.txtPrivateKeySsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshPrivateKeyPath, v => v.txtPrivateKeyPathSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshPrivateKeyPassphrase, v => v.txtPassphraseSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshHostKey, v => v.txtHostKeySsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshHostKeyAlgorithms, v => v.txtHostKeyAlgoSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshClientVersion, v => v.txtClientVersionSsh.Text).DisposeWith(disposables);
|
||||
break;
|
||||
}
|
||||
this.Bind(ViewModel, vm => vm.SelectedSource.Network, v => v.cmbNetwork.SelectedValue).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.RawHeaderType, v => v.cmbHeaderTypeRaw.SelectedValue).DisposeWith(disposables);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
<MenuItem x:Name="menuAddTuicServer" Header="{x:Static resx:ResUI.menuAddTuicServer}" />
|
||||
<MenuItem x:Name="menuAddAnytlsServer" Header="{x:Static resx:ResUI.menuAddAnytlsServer}" />
|
||||
<MenuItem x:Name="menuAddNaiveServer" Header="{x:Static resx:ResUI.menuAddNaiveServer}" />
|
||||
<MenuItem x:Name="menuAddSshServer" Header="{x:Static resx:ResUI.menuAddSshServer}" />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem Header="{x:Static resx:ResUI.menuSubscription}">
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
|
|||
this.BindCommand(ViewModel, vm => vm.AddWireguardServerCmd, v => v.menuAddWireguardServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddSshServerCmd, v => v.menuAddSshServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables);
|
||||
|
|
|
|||
|
|
@ -912,6 +912,150 @@
|
|||
Margin="{StaticResource Margin4}"
|
||||
HorizontalAlignment="Left" />
|
||||
</Grid>
|
||||
<Grid
|
||||
x:Name="gridSsh"
|
||||
Grid.Row="2"
|
||||
Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="300" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToolbarTextBlock}"
|
||||
Text="{x:Static resx:ResUI.TbUsername}" />
|
||||
<TextBox
|
||||
x:Name="txtUsernameSsh"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToolbarTextBlock}"
|
||||
Text="{x:Static resx:ResUI.TbId3}" />
|
||||
<TextBox
|
||||
x:Name="txtPasswordSsh"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Top"
|
||||
Style="{StaticResource ToolbarTextBlock}"
|
||||
Text="{x:Static resx:ResUI.TbSshPrivateKey}" />
|
||||
<TextBox
|
||||
x:Name="txtPrivateKeySsh"
|
||||
Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Height="120"
|
||||
Margin="{StaticResource Margin4}"
|
||||
AcceptsReturn="True"
|
||||
FontFamily="Consolas"
|
||||
TextWrapping="NoWrap"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="3"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToolbarTextBlock}"
|
||||
Text="{x:Static resx:ResUI.TbSshPrivateKeyPath}" />
|
||||
<TextBox
|
||||
x:Name="txtPrivateKeyPathSsh"
|
||||
Grid.Row="3"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="4"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToolbarTextBlock}"
|
||||
Text="{x:Static resx:ResUI.TbSshPassphrase}" />
|
||||
<TextBox
|
||||
x:Name="txtPassphraseSsh"
|
||||
Grid.Row="4"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="5"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToolbarTextBlock}"
|
||||
Text="{x:Static resx:ResUI.TbSshHostKey}" />
|
||||
<TextBox
|
||||
x:Name="txtHostKeySsh"
|
||||
Grid.Row="5"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="6"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToolbarTextBlock}"
|
||||
Text="{x:Static resx:ResUI.TbSshHostKeyAlgorithms}" />
|
||||
<TextBox
|
||||
x:Name="txtHostKeyAlgoSsh"
|
||||
Grid.Row="6"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="7"
|
||||
Grid.Column="0"
|
||||
Margin="{StaticResource Margin4}"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToolbarTextBlock}"
|
||||
Text="{x:Static resx:ResUI.TbSshClientVersion}" />
|
||||
<TextBox
|
||||
x:Name="txtClientVersionSsh"
|
||||
Grid.Row="7"
|
||||
Grid.Column="1"
|
||||
Width="400"
|
||||
Margin="{StaticResource Margin4}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
</Grid>
|
||||
|
||||
<Separator
|
||||
x:Name="sepa2"
|
||||
|
|
|
|||
|
|
@ -119,6 +119,15 @@ public partial class AddServerWindow
|
|||
|
||||
cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls;
|
||||
break;
|
||||
|
||||
case EConfigType.SSH:
|
||||
gridSsh.Visibility = Visibility.Visible;
|
||||
sepa2.Visibility = Visibility.Collapsed;
|
||||
gridTransport.Visibility = Visibility.Collapsed;
|
||||
gridTls.Visibility = Visibility.Collapsed;
|
||||
cmbCoreType.IsEnabled = false;
|
||||
gridFinalmask.Visibility = Visibility.Collapsed;
|
||||
break;
|
||||
}
|
||||
cmbStreamSecurity.ItemsSource = lstStreamSecurity;
|
||||
|
||||
|
|
@ -202,6 +211,17 @@ public partial class AddServerWindow
|
|||
this.Bind(ViewModel, vm => vm.InsecureConcurrency, v => v.txtInsecureConcurrency12.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(disposables);
|
||||
break;
|
||||
|
||||
case EConfigType.SSH:
|
||||
this.Bind(ViewModel, vm => vm.SelectedSource.Username, v => v.txtUsernameSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SelectedSource.Password, v => v.txtPasswordSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshPrivateKey, v => v.txtPrivateKeySsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshPrivateKeyPath, v => v.txtPrivateKeyPathSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshPrivateKeyPassphrase, v => v.txtPassphraseSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshHostKey, v => v.txtHostKeySsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshHostKeyAlgorithms, v => v.txtHostKeyAlgoSsh.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.SshClientVersion, v => v.txtClientVersionSsh.Text).DisposeWith(disposables);
|
||||
break;
|
||||
}
|
||||
this.Bind(ViewModel, vm => vm.SelectedSource.Network, v => v.cmbNetwork.Text).DisposeWith(disposables);
|
||||
this.Bind(ViewModel, vm => vm.RawHeaderType, v => v.cmbHeaderTypeRaw.Text).DisposeWith(disposables);
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@
|
|||
x:Name="menuAddNaiveServer"
|
||||
Height="{StaticResource MenuItemHeight}"
|
||||
Header="{x:Static resx:ResUI.menuAddNaiveServer}" />
|
||||
<MenuItem
|
||||
x:Name="menuAddSshServer"
|
||||
Height="{StaticResource MenuItemHeight}"
|
||||
Header="{x:Static resx:ResUI.menuAddSshServer}" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<Separator />
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ public partial class MainWindow
|
|||
this.BindCommand(ViewModel, vm => vm.AddWireguardServerCmd, v => v.menuAddWireguardServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddSshServerCmd, v => v.menuAddSshServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
|
||||
this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables);
|
||||
|
|
|
|||
Loading…
Reference in a new issue