From 7d0fd3a7bbbb6fa277799f85020bfd3514f993e9 Mon Sep 17 00:00:00 2001 From: Moeein Date: Fri, 22 May 2026 13:10:26 +0330 Subject: [PATCH] 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. --- .../CoreConfig/CoreConfigTestFactory.cs | 30 ++++ .../Singbox/SingboxSshOutboundTests.cs | 106 +++++++++++++ v2rayN/ServiceLib/Enums/EConfigType.cs | 1 + v2rayN/ServiceLib/Global.cs | 4 +- v2rayN/ServiceLib/Handler/ConfigHandler.cs | 45 ++++++ .../Models/CoreConfigs/SingboxConfig.cs | 9 ++ .../Models/Entities/ProtocolExtraItem.cs | 8 + v2rayN/ServiceLib/Resx/ResUI.Designer.cs | 56 ++++++- v2rayN/ServiceLib/Resx/ResUI.resx | 27 ++++ .../Singbox/SingboxOutboundService.cs | 37 ++++- .../ViewModels/AddServerViewModel.cs | 52 ++++++- .../ViewModels/MainWindowViewModel.cs | 5 + .../Views/AddServerWindow.axaml | 114 ++++++++++++++ .../Views/AddServerWindow.axaml.cs | 20 +++ v2rayN/v2rayN.Desktop/Views/MainWindow.axaml | 1 + .../v2rayN.Desktop/Views/MainWindow.axaml.cs | 1 + v2rayN/v2rayN/Views/AddServerWindow.xaml | 144 ++++++++++++++++++ v2rayN/v2rayN/Views/AddServerWindow.xaml.cs | 20 +++ v2rayN/v2rayN/Views/MainWindow.xaml | 4 + v2rayN/v2rayN/Views/MainWindow.xaml.cs | 1 + 20 files changed, 681 insertions(+), 4 deletions(-) create mode 100644 v2rayN/ServiceLib.Tests/CoreConfig/Singbox/SingboxSshOutboundTests.cs diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs b/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs index 812a5a32..5500977e 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs @@ -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 childIndexIds) { diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/SingboxSshOutboundTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/SingboxSshOutboundTests.cs new file mode 100644 index 00000000..6e415cd6 --- /dev/null +++ b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/SingboxSshOutboundTests.cs @@ -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(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(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(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(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)); + } +} diff --git a/v2rayN/ServiceLib/Enums/EConfigType.cs b/v2rayN/ServiceLib/Enums/EConfigType.cs index ae4e30ca..bf75ba2a 100644 --- a/v2rayN/ServiceLib/Enums/EConfigType.cs +++ b/v2rayN/ServiceLib/Enums/EConfigType.cs @@ -14,6 +14,7 @@ public enum EConfigType HTTP = 10, Anytls = 11, Naive = 12, + SSH = 13, PolicyGroup = 101, ProxyChain = 102, } diff --git a/v2rayN/ServiceLib/Global.cs b/v2rayN/ServiceLib/Global.cs index 4b880e7e..20b8e1f4 100644 --- a/v2rayN/ServiceLib/Global.cs +++ b/v2rayN/ServiceLib/Global.cs @@ -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 VmessSecurities = @@ -361,6 +362,7 @@ public class Global EConfigType.TUIC, EConfigType.Anytls, EConfigType.Naive, + EConfigType.SSH, EConfigType.WireGuard, EConfigType.SOCKS, EConfigType.HTTP, diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index e5da7007..528b0240 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -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; } + /// + /// Add or edit an SSH server + /// Validates and processes SSH-specific settings (sing-box core only) + /// + /// Current configuration + /// SSH profile to add + /// Whether to save to file + /// 0 if successful, -1 if failed + public static async Task 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; + } + /// /// 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, }; diff --git a/v2rayN/ServiceLib/Models/CoreConfigs/SingboxConfig.cs b/v2rayN/ServiceLib/Models/CoreConfigs/SingboxConfig.cs index 1514cae4..261039d6 100644 --- a/v2rayN/ServiceLib/Models/CoreConfigs/SingboxConfig.cs +++ b/v2rayN/ServiceLib/Models/CoreConfigs/SingboxConfig.cs @@ -150,6 +150,15 @@ public class Outbound4Sbox : BaseServer4Sbox public List? outbounds { get; set; } public bool? interrupt_exist_connections { get; set; } public int? tolerance { get; set; } + + // ssh + public string? user { get; set; } + public List? private_key { get; set; } + public string? private_key_path { get; set; } + public string? private_key_passphrase { get; set; } + public List? host_key { get; set; } + public List? host_key_algorithms { get; set; } + public string? client_version { get; set; } } public class Endpoints4Sbox : BaseServer4Sbox diff --git a/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs b/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs index e9916e33..49e66c2d 100644 --- a/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs +++ b/v2rayN/ServiceLib/Models/Entities/ProtocolExtraItem.cs @@ -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; } diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index c3a00a51..95e2e5c5 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -752,7 +752,61 @@ namespace ServiceLib.Resx { return ResourceManager.GetString("menuAddNaiveServer", resourceCulture); } } - + + 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); + } + } + /// /// 查找类似 Add Policy Group 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index 6cf545db..28e6be41 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1677,6 +1677,33 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Add [NaïveProxy] + + Add [SSH] + + + Private key (PEM) + + + Private key path + + + Passphrase + + + Host key(s) (comma-separated) + + + Host key algorithms (comma-separated) + + + Client version + + + Provide either a password or a private key (inline or path, not both). + + + Please fill in the username. + Insecure Concurrency diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs index 8d36b353..0df5b090 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs @@ -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? 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; } diff --git a/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs b/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs index 169e8a44..6394f741 100644 --- a/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs @@ -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); diff --git a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs index 4b3f41b9..baa9f443 100644 --- a/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/MainWindowViewModel.cs @@ -19,6 +19,7 @@ public class MainWindowViewModel : MyReactiveObject public ReactiveCommand AddWireguardServerCmd { get; } public ReactiveCommand AddAnytlsServerCmd { get; } public ReactiveCommand AddNaiveServerCmd { get; } + public ReactiveCommand AddSshServerCmd { get; } public ReactiveCommand AddCustomServerCmd { get; } public ReactiveCommand AddPolicyGroupServerCmd { get; } public ReactiveCommand 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); diff --git a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml index dbf031b1..e41fa6df 100644 --- a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml @@ -695,6 +695,120 @@ Margin="{StaticResource Margin4}" HorizontalAlignment="Left" /> + + + + + + + + + + + + + + + + + + + + + + + + + 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 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); diff --git a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml index 5473f96f..781ce35e 100644 --- a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml @@ -51,6 +51,7 @@ + diff --git a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs index ba568f1f..0b4fc1ab 100644 --- a/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/MainWindow.axaml.cs @@ -74,6 +74,7 @@ public partial class MainWindow : WindowBase 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); diff --git a/v2rayN/v2rayN/Views/AddServerWindow.xaml b/v2rayN/v2rayN/Views/AddServerWindow.xaml index 16512751..07654864 100644 --- a/v2rayN/v2rayN/Views/AddServerWindow.xaml +++ b/v2rayN/v2rayN/Views/AddServerWindow.xaml @@ -912,6 +912,150 @@ Margin="{StaticResource Margin4}" HorizontalAlignment="Left" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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); diff --git a/v2rayN/v2rayN/Views/MainWindow.xaml b/v2rayN/v2rayN/Views/MainWindow.xaml index 19376a7d..01b6f865 100644 --- a/v2rayN/v2rayN/Views/MainWindow.xaml +++ b/v2rayN/v2rayN/Views/MainWindow.xaml @@ -128,6 +128,10 @@ x:Name="menuAddNaiveServer" Height="{StaticResource MenuItemHeight}" Header="{x:Static resx:ResUI.menuAddNaiveServer}" /> + diff --git a/v2rayN/v2rayN/Views/MainWindow.xaml.cs b/v2rayN/v2rayN/Views/MainWindow.xaml.cs index c8c57a85..2b1b0023 100644 --- a/v2rayN/v2rayN/Views/MainWindow.xaml.cs +++ b/v2rayN/v2rayN/Views/MainWindow.xaml.cs @@ -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);