Skip to content

Commit

Permalink
Add high level remote forward API. (#353)
Browse files Browse the repository at this point in the history
  • Loading branch information
tmds authored Feb 18, 2025
1 parent 9b93931 commit eecdb66
Show file tree
Hide file tree
Showing 20 changed files with 748 additions and 317 deletions.
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Tmds.Ssh

`Tmds.Ssh` is a modern, managed .NET SSH client implementation for .NET 6+.
`Tmds.Ssh` is a modern, managed .NET SSH client library for .NET 6+.

## Getting Started

Expand Down Expand Up @@ -67,11 +67,17 @@ class SshClient : IDisposable

Task<SshDataStream> OpenTcpConnectionAsync(string host, int port, CancellationToken cancellationToken = default);
Task<SshDataStream> OpenUnixConnectionAsync(string path, CancellationToken cancellationToken = default);

Task<RemoteListener> ListenTcpAsync(string address, int port, CancellationToken cancellationToken = default);

// bindEP can be an IPEndPoint or a UnixDomainSocketEndPoint.
// remoteEP can be a RemoteHostEndPoint, a RemoteUnixEndPoint or a RemoteIPEndPoint.
Task<DirectForward> StartForwardAsync(EndPoint bindEP, RemoteEndPoint remoteEP, CancellationToken cancellationToken = default);
Task<SocksForward> StartForwardSocksAsync(EndPoint bindEP, CancellationToken cancellationToken = default);

Task<RemoteListener> ListenTcpAsync(string address, int port, CancellationToken cancellationToken = default);
// bindEP can be a RemoteIPListenEndPoint.
// localEP can be a DnsEndPoint or an IPEndPoint.
Task<RemoteForward> StartRemoteForwardAsync(RemoteEndPoint bindEP, EndPoint localEP, CancellationToken cancellationToken = default);

Task<SftpClient> OpenSftpClientAsync(CancellationToken cancellationToken);
Task<SftpClient> OpenSftpClientAsync(SftpClientOptions? options = null, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -141,6 +147,12 @@ class SocksForward : IDisposable
CancellationToken Stopped { get; }
void ThrowIfStopped();
}
class RemoteForward : IDisposable
{
RemoteEndPoint RemoteEndPoint { get; }
CancellationToken Stopped { get; }
void ThrowIfStopped();
}
class RemoteListener : IDisposable
{
// For ListenTcpAsync, type is RemoteIPListenEndPoint.
Expand Down
11 changes: 10 additions & 1 deletion src/Tmds.Ssh/ArgumentValidation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@ namespace Tmds.Ssh;
static class ArgumentValidation
{
public static void ValidatePort(int port, bool allowZero, string argumentName = "port")
{
if (!IsValidPort(port, allowZero))
{
throw new ArgumentException($"Invalid port number: '{port}'.", argumentName);
}
}

private static bool IsValidPort(int port, bool allowZero)
{
if (port < 0 || port > 0xffff || (!allowZero && port == 0))
{
throw new ArgumentException(argumentName);
return false;
}
return true;
}

public static void ValidateIPListenAddress(string address, string argumentName = "address")
Expand Down
8 changes: 8 additions & 0 deletions src/Tmds.Ssh/Connect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@

using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;

namespace Tmds.Ssh;

static class Connect
{
private static ConnectCallback _defaultConnect = TcpConnectAsync;

public static async Task<Stream> ConnectTcpAsync(string host, int port, CancellationToken cancellationToken)
{
var endPoint = new ConnectEndPoint(host, port);
var context = new ConnectContext(endPoint, NullLoggerFactory.Instance);
return await _defaultConnect(context, cancellationToken).ConfigureAwait(false);
}

public static async ValueTask<Stream> ConnectAsync(ConnectCallback? connect, Proxy? proxy, ConnectContext context, CancellationToken cancellationToken)
{
connect ??= _defaultConnect;
Expand Down
6 changes: 3 additions & 3 deletions src/Tmds.Ssh/DirectForward.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ namespace Tmds.Ssh;

public sealed class DirectForward : IDisposable
{
private readonly ForwardServer<DirectForward> _forwarder;
private readonly LocalForwardServer<DirectForward> _forwarder;

internal DirectForward(ILogger<DirectForward> logger)
{
_forwarder = new(logger);
}

internal void Start(SshSession session, EndPoint bindEP, RemoteEndPoint remoteEndPoint)
=> _forwarder.StartDirectForward(session, bindEP, remoteEndPoint);
internal ValueTask StartAsync(SshSession session, EndPoint bindEP, RemoteEndPoint remoteEndPoint, CancellationToken cancellationToken)
=> _forwarder.StartDirectForwardAsync(session, bindEP, remoteEndPoint, cancellationToken);

public EndPoint LocalEndPoint
=> _forwarder.LocalEndPoint;
Expand Down
50 changes: 0 additions & 50 deletions src/Tmds.Ssh/ForwardServer.Direct.cs

This file was deleted.

29 changes: 9 additions & 20 deletions src/Tmds.Ssh/ForwardServer.Socks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace Tmds.Ssh;

sealed partial class ForwardServer<T> : IDisposable
abstract partial class ForwardServer<T, TTargetStream>
{
private const int SocksNegotiationTimeOut = 10_000; // 10s

Expand All @@ -22,15 +21,7 @@ sealed partial class ForwardServer<T> : IDisposable
private const byte ATYP_IPV6 = 4;
private const byte Socks5_Success = 0;

internal void StartSocksForward(SshSession session, EndPoint bindEP)
{
CheckBindEndPoint(bindEP);

Start(session, bindEP, ForwardProtocol.Socks,
(NetworkStream clientStream, CancellationToken ct) => AcceptSocks5Async(session, clientStream, ct));
}

private async ValueTask<(Task<SshDataStream>, RemoteEndPoint)> AcceptSocks5Async(SshSession session, NetworkStream clientStream, CancellationToken ct)
protected static async ValueTask<(string host, int port)> ReadSocks5HostAndPortAsync(Stream stream, CancellationToken ct)
{
string remoteHost;
int remotePort;
Expand All @@ -48,7 +39,7 @@ internal void StartSocksForward(SshSession session, EndPoint bindEP)
// +----+----------+----------+
// | 1 | 1 | 1 to 255 |
// +----+----------+----------+
await clientStream.ReadExactlyAsync(buffer.AsMemory(0, 3), socksCts.Token).ConfigureAwait(false);
await stream.ReadExactlyAsync(buffer.AsMemory(0, 3), socksCts.Token).ConfigureAwait(false);
byte ver = buffer[0];
if (ver != ProtocolVersion5)
{
Expand All @@ -57,7 +48,7 @@ internal void StartSocksForward(SshSession session, EndPoint bindEP)
byte nmethods = buffer[1];
if (nmethods > 1)
{
await clientStream.ReadExactlyAsync(buffer.AsMemory(3, nmethods - 1), socksCts.Token).ConfigureAwait(false);
await stream.ReadExactlyAsync(buffer.AsMemory(3, nmethods - 1), socksCts.Token).ConfigureAwait(false);
}
ReadOnlySpan<byte> methods = buffer.AsSpan(2, nmethods);
if (!methods.Contains(METHOD_NO_AUTH))
Expand All @@ -73,15 +64,15 @@ internal void StartSocksForward(SshSession session, EndPoint bindEP)
// +----+--------+
buffer[0] = ProtocolVersion5;
buffer[1] = METHOD_NO_AUTH;
await clientStream.WriteAsync(buffer.AsMemory(0, 2), socksCts.Token);
await stream.WriteAsync(buffer.AsMemory(0, 2), socksCts.Token);

// Connect request.
// +----+-----+-------+------+----------+----------+
// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
// +----+-----+-------+------+----------+----------+
// | 1 | 1 | X'00' | 1 | Variable | 2 |
// +----+-----+-------+------+----------+----------+
await clientStream.ReadExactlyAsync(buffer.AsMemory(0, 5), socksCts.Token).ConfigureAwait(false);
await stream.ReadExactlyAsync(buffer.AsMemory(0, 5), socksCts.Token).ConfigureAwait(false);
ver = buffer[0];
if (ver != ProtocolVersion5)
{
Expand All @@ -105,7 +96,7 @@ internal void StartSocksForward(SshSession session, EndPoint bindEP)
ATYP_DOMAIN_NAME => buffer[4],
_ => throw new SocksException($"Unexpected ATYP value: {atyp}.")
};
await clientStream.ReadExactlyAsync(buffer.AsMemory(5, addressRemaining + 2), socksCts.Token).ConfigureAwait(false);
await stream.ReadExactlyAsync(buffer.AsMemory(5, addressRemaining + 2), socksCts.Token).ConfigureAwait(false);
remoteHost = atyp switch
{
ATYP_IPV4 or ATYP_IPV6 => new IPAddress(buffer.AsSpan(4, addressRemaining + 1)).ToString(),
Expand All @@ -125,7 +116,7 @@ internal void StartSocksForward(SshSession session, EndPoint bindEP)
buffer[2] = 0;
buffer[3] = ATYP_IPV4;
buffer.AsSpan(4, 6).Fill(0);
await clientStream.WriteAsync(buffer.AsMemory(0, 10), socksCts.Token);
await stream.WriteAsync(buffer.AsMemory(0, 10), socksCts.Token);
}
catch (OperationCanceledException e) when (!ct.IsCancellationRequested)
{
Expand All @@ -136,9 +127,7 @@ internal void StartSocksForward(SshSession session, EndPoint bindEP)
ArrayPool<byte>.Shared.Return(buffer);
}

RemoteEndPoint remoteEndPoint = new RemoteHostEndPoint(remoteHost, remotePort);

return (session.OpenTcpConnectionChannelAsync(remoteHost, remotePort, ct), remoteEndPoint);
return (remoteHost, remotePort);
}

sealed class SocksException : Exception
Expand Down
Loading

0 comments on commit eecdb66

Please sign in to comment.