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:
Moeein 2026-05-22 13:10:26 +03:30
parent f4a2086dfb
commit 7d0fd3a7bb
20 changed files with 681 additions and 4 deletions

View file

@ -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, public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
IEnumerable<string> childIndexIds) IEnumerable<string> childIndexIds)
{ {

View file

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

View file

@ -14,6 +14,7 @@ public enum EConfigType
HTTP = 10, HTTP = 10,
Anytls = 11, Anytls = 11,
Naive = 12, Naive = 12,
SSH = 13,
PolicyGroup = 101, PolicyGroup = 101,
ProxyChain = 102, ProxyChain = 102,
} }

View file

@ -237,7 +237,8 @@ public class Global
{ EConfigType.TUIC, "tuic" }, { EConfigType.TUIC, "tuic" },
{ EConfigType.WireGuard, "wireguard" }, { EConfigType.WireGuard, "wireguard" },
{ EConfigType.Anytls, "anytls" }, { EConfigType.Anytls, "anytls" },
{ EConfigType.Naive, "naive" } { EConfigType.Naive, "naive" },
{ EConfigType.SSH, "ssh" }
}; };
public static readonly List<string> VmessSecurities = public static readonly List<string> VmessSecurities =
@ -361,6 +362,7 @@ public class Global
EConfigType.TUIC, EConfigType.TUIC,
EConfigType.Anytls, EConfigType.Anytls,
EConfigType.Naive, EConfigType.Naive,
EConfigType.SSH,
EConfigType.WireGuard, EConfigType.WireGuard,
EConfigType.SOCKS, EConfigType.SOCKS,
EConfigType.HTTP, EConfigType.HTTP,

View file

@ -273,6 +273,7 @@ public static class ConfigHandler
EConfigType.WireGuard => await AddWireguardServer(config, item), EConfigType.WireGuard => await AddWireguardServer(config, item),
EConfigType.Anytls => await AddAnytlsServer(config, item), EConfigType.Anytls => await AddAnytlsServer(config, item),
EConfigType.Naive => await AddNaiveServer(config, item), EConfigType.Naive => await AddNaiveServer(config, item),
EConfigType.SSH => await AddSSHServer(config, item),
_ => -1, _ => -1,
}; };
return ret; return ret;
@ -887,6 +888,48 @@ public static class ConfigHandler
return 0; 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> /// <summary>
/// Sort the server list by the specified column /// Sort the server list by the specified column
/// Updates the sort order in the profile extension data /// 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.WireGuard => await AddWireguardServer(config, profileItem, false),
EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false), EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false),
EConfigType.Naive => await AddNaiveServer(config, profileItem, false), EConfigType.Naive => await AddNaiveServer(config, profileItem, false),
EConfigType.SSH => await AddSSHServer(config, profileItem, false),
_ => -1, _ => -1,
}; };
@ -1780,6 +1824,7 @@ public static class ConfigHandler
EConfigType.WireGuard => await AddWireguardServer(config, profileItem, false), EConfigType.WireGuard => await AddWireguardServer(config, profileItem, false),
EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false), EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false),
EConfigType.Naive => await AddNaiveServer(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), EConfigType.PolicyGroup or EConfigType.ProxyChain => await AddServerCommon(config, profileItem, false),
_ => -1, _ => -1,
}; };

View file

@ -150,6 +150,15 @@ public class Outbound4Sbox : BaseServer4Sbox
public List<string>? outbounds { get; set; } public List<string>? outbounds { get; set; }
public bool? interrupt_exist_connections { get; set; } public bool? interrupt_exist_connections { get; set; }
public int? tolerance { 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 public class Endpoints4Sbox : BaseServer4Sbox

View file

@ -36,6 +36,14 @@ public record ProtocolExtraItem
public int? InsecureConcurrency { get; init; } public int? InsecureConcurrency { get; init; }
public bool? NaiveQuic { 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 // group profile
public string? GroupType { get; init; } public string? GroupType { get; init; }
public string? ChildItems { get; init; } public string? ChildItems { get; init; }

View file

@ -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> /// <summary>
/// 查找类似 Add Policy Group 的本地化字符串。 /// 查找类似 Add Policy Group 的本地化字符串。
/// </summary> /// </summary>

View file

@ -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"> <data name="menuAddNaiveServer" xml:space="preserve">
<value>Add [NaïveProxy]</value> <value>Add [NaïveProxy]</value>
</data> </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"> <data name="TbInsecureConcurrency" xml:space="preserve">
<value>Insecure Concurrency</value> <value>Insecure Concurrency</value>
</data> </data>

View file

@ -293,6 +293,27 @@ public partial class CoreConfigSingboxService
outbound.udp_over_tcp = protocolExtra.Uot == true ? true : null; outbound.udp_over_tcp = protocolExtra.Uot == true ? true : null;
break; 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); 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) private void FillOutboundMux(Outbound4Sbox outbound)
{ {
try try
@ -369,7 +404,7 @@ public partial class CoreConfigSingboxService
{ {
return; 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; return;
} }

View file

@ -77,6 +77,24 @@ public class AddServerViewModel : MyReactiveObject
[Reactive] [Reactive]
public bool NaiveQuic { get; set; } 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] [Reactive]
public string RawHeaderType { get; set; } public string RawHeaderType { get; set; }
@ -297,6 +315,12 @@ public class AddServerViewModel : MyReactiveObject
CongestionControl = protocolExtra.CongestionControl ?? string.Empty; CongestionControl = protocolExtra.CongestionControl ?? string.Empty;
InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null; InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null;
NaiveQuic = protocolExtra.NaiveQuic ?? false; 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; RawHeaderType = transport.RawHeaderType ?? Global.None;
Host = transport.Host ?? string.Empty; Host = transport.Host ?? string.Empty;
@ -344,7 +368,27 @@ public class AddServerViewModel : MyReactiveObject
return; 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()) if (SelectedSource.Password.IsNullOrEmpty())
{ {
@ -396,6 +440,12 @@ public class AddServerViewModel : MyReactiveObject
CongestionControl = CongestionControl.NullIfEmpty(), CongestionControl = CongestionControl.NullIfEmpty(),
InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null, InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null,
NaiveQuic = NaiveQuic ? true : 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); SelectedSource.SetTransportExtra(transport);

View file

@ -19,6 +19,7 @@ public class MainWindowViewModel : MyReactiveObject
public ReactiveCommand<Unit, Unit> AddWireguardServerCmd { get; } public ReactiveCommand<Unit, Unit> AddWireguardServerCmd { get; }
public ReactiveCommand<Unit, Unit> AddAnytlsServerCmd { get; } public ReactiveCommand<Unit, Unit> AddAnytlsServerCmd { get; }
public ReactiveCommand<Unit, Unit> AddNaiveServerCmd { get; } public ReactiveCommand<Unit, Unit> AddNaiveServerCmd { get; }
public ReactiveCommand<Unit, Unit> AddSshServerCmd { get; }
public ReactiveCommand<Unit, Unit> AddCustomServerCmd { get; } public ReactiveCommand<Unit, Unit> AddCustomServerCmd { get; }
public ReactiveCommand<Unit, Unit> AddPolicyGroupServerCmd { get; } public ReactiveCommand<Unit, Unit> AddPolicyGroupServerCmd { get; }
public ReactiveCommand<Unit, Unit> AddProxyChainServerCmd { get; } public ReactiveCommand<Unit, Unit> AddProxyChainServerCmd { get; }
@ -124,6 +125,10 @@ public class MainWindowViewModel : MyReactiveObject
{ {
await AddServerAsync(EConfigType.Naive); await AddServerAsync(EConfigType.Naive);
}); });
AddSshServerCmd = ReactiveCommand.CreateFromTask(async () =>
{
await AddServerAsync(EConfigType.SSH);
});
AddCustomServerCmd = ReactiveCommand.CreateFromTask(async () => AddCustomServerCmd = ReactiveCommand.CreateFromTask(async () =>
{ {
await AddServerAsync(EConfigType.Custom); await AddServerAsync(EConfigType.Custom);

View file

@ -695,6 +695,120 @@
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" /> HorizontalAlignment="Left" />
</Grid> </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 <Separator
x:Name="sepa2" x:Name="sepa2"

View file

@ -120,6 +120,15 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls; cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls;
break; 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; 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.InsecureConcurrency, v => v.txtInsecureConcurrency12.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(disposables);
break; 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.SelectedSource.Network, v => v.cmbNetwork.SelectedValue).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.RawHeaderType, v => v.cmbHeaderTypeRaw.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.RawHeaderType, v => v.cmbHeaderTypeRaw.SelectedValue).DisposeWith(disposables);

View file

@ -51,6 +51,7 @@
<MenuItem x:Name="menuAddTuicServer" Header="{x:Static resx:ResUI.menuAddTuicServer}" /> <MenuItem x:Name="menuAddTuicServer" Header="{x:Static resx:ResUI.menuAddTuicServer}" />
<MenuItem x:Name="menuAddAnytlsServer" Header="{x:Static resx:ResUI.menuAddAnytlsServer}" /> <MenuItem x:Name="menuAddAnytlsServer" Header="{x:Static resx:ResUI.menuAddAnytlsServer}" />
<MenuItem x:Name="menuAddNaiveServer" Header="{x:Static resx:ResUI.menuAddNaiveServer}" /> <MenuItem x:Name="menuAddNaiveServer" Header="{x:Static resx:ResUI.menuAddNaiveServer}" />
<MenuItem x:Name="menuAddSshServer" Header="{x:Static resx:ResUI.menuAddSshServer}" />
</MenuItem> </MenuItem>
<MenuItem Header="{x:Static resx:ResUI.menuSubscription}"> <MenuItem Header="{x:Static resx:ResUI.menuSubscription}">

View file

@ -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.AddWireguardServerCmd, v => v.menuAddWireguardServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).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.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.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables);

View file

@ -912,6 +912,150 @@
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" /> HorizontalAlignment="Left" />
</Grid> </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 <Separator
x:Name="sepa2" x:Name="sepa2"

View file

@ -119,6 +119,15 @@ public partial class AddServerWindow
cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls; cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls;
break; 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; 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.InsecureConcurrency, v => v.txtInsecureConcurrency12.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(disposables);
break; 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.SelectedSource.Network, v => v.cmbNetwork.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.RawHeaderType, v => v.cmbHeaderTypeRaw.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.RawHeaderType, v => v.cmbHeaderTypeRaw.Text).DisposeWith(disposables);

View file

@ -128,6 +128,10 @@
x:Name="menuAddNaiveServer" x:Name="menuAddNaiveServer"
Height="{StaticResource MenuItemHeight}" Height="{StaticResource MenuItemHeight}"
Header="{x:Static resx:ResUI.menuAddNaiveServer}" /> Header="{x:Static resx:ResUI.menuAddNaiveServer}" />
<MenuItem
x:Name="menuAddSshServer"
Height="{StaticResource MenuItemHeight}"
Header="{x:Static resx:ResUI.menuAddSshServer}" />
</MenuItem> </MenuItem>
</Menu> </Menu>
<Separator /> <Separator />

View file

@ -73,6 +73,7 @@ public partial class MainWindow
this.BindCommand(ViewModel, vm => vm.AddWireguardServerCmd, v => v.menuAddWireguardServer).DisposeWith(disposables); 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.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).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.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables);