.Net Core SSH via Javascript

Small project I needed to add the ability to console into devices from a web interface. Looking around SSH.Net satisfied having the web server handle proxy the connection. I came across xTerm.js as a solution for a web based console. I then needed to figure out now to piece them together. I decided I would use a web sockets because why not.

You can see the completed work on GitHub: https://github.com/jdarwood007/SshNetWebTerminal

Firstly I started a new .Net Core MVC application. In my production application, I wouldn’t be using this, but I needed a simple starting point for testing.

I then added a NuGet package, I added SSH.NET reference.

I also needed to add some client side libraries (libman.json), I added some libraries, SignalR, xTerm.js and the addon fit.


    {
      "provider": "unpkg",
      "library": "@microsoft/signalr@latest",
      "destination": "wwwroot/lib/microsoft/signalr/",
      "files": [
        "dist/browser/signalr.js",
        "dist/browser/signalr.js.map",
        "dist/browser/signalr.min.js",
        "dist/browser/signalr.min.js.map"
      ]
    },
    {
      "provider": "unpkg",
      "library": "@xterm/xterm@5.5.0",
      "destination": "wwwroot/lib/xterm/xterm/",
      "files": [
        "LICENSE",
        "css/xterm.css",
        "lib/xterm.js",
        "lib/xterm.js.map"
      ]
    },
    {
      "provider": "unpkg",
      "library": "@xterm/addon-fit@0.10.0",
      "destination": "wwwroot/lib/xterm/addon-fit/",
      "files": [
        "LICENSE",
        "lib/addon-fit.js",
        "lib/addon-fit.js.map"
      ]
    }

I knew I would need a container to hold the SSH sessions to keep track of things.

I created a new Service, called SshSessionManager, which would be a static class. I then added 2 dictionaries. One would hold the client and the other the stream. SSH.Net doesn’t let me from the steram, return to a reference to the client or the client to its streams. So this was the only solution.

    private static readonly Dictionary<string, SshClient> SshClients = [];
    private static readonly Dictionary<string, ShellStream> SshStreams = [];

My code needed a way to register these in, so I added a simple method.

    public static void Register(string connectionId, SshClient client, ShellStream stream)
    {
        SshClients.Add(connectionId, client);
        SshStreams.Add(connectionId, stream);
    }

I would need a way to remove these later, so a simple cleanup method was added

    public static async Task Remove(string connectionId)
    {
        ShellStream? s = GetShellStream(connectionId);
        if (SshClients.TryGetValue(connectionId, out ShellStream? s))
        {
            await s.DisposeAsync();
        }
        SshStreams.Remove(connectionId);

        if (SshClients.TryGetValue(connectionId, out SshClient? c))
        {
            c?.Disconnect();
            c?.Dispose();
        }
        SshClients.Remove(connectionId);
    }

This would take the connection id. Using SignalR inside of ASP.Net core, there is a Context.ConnectionId in which I would use to locate this.

I decided to use Hubs, so I created a new SshHub and derived it from Hub.

My Program.cs would need to know how to handle all of this, so I added a call to load the signal service

builder.Services.AddSignalR();

And then register the Hub

app.MapHub<SshHub>("/ssh");

I then added a Connect method to my hub

    public async Task Connect(string Host, string User, string Pass)
    {
        try
        {
            if (string.IsNullOrEmpty(Host) || string.IsNullOrEmpty(User) || string.IsNullOrEmpty(Pass))
            {
                throw new Exception("Invalid Login");
            }

            // Attempt to login.
            SshClient client = new(Host, User, Pass);
            CancellationToken cancellation = new();
            await client.ConnectAsync(cancellation);

            // Open a Stream to act as our terminal.
            ShellStream shellStream = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 1024);

            SshSessionManager.Register(Context.ConnectionId, client, shellStream);
        }
        catch (Exception e)
        {
            await Clients.Caller.SendAsync("Error", e.Message);
            throw new HubException(e.Message);
        }
    }

I wanted to get a test going, so I replaced the Home/Index.cshtml with some basic calls

<div class="row p-1">
    <div class="input-group">
        <label class="input-group-text">Host</label>
        <input type="text" id="host" class="form-control" />
        <input type="button" id="connectBtn" value="Connect" class="btn btn-outline-primary" />
        <input type="button" id="disconnectBtn" value="Disconnect" class="btn btn-outline-primary" disabled />
    </div>
</div>
<div class="row p-1">
    <div class="input-group">
        <label class="input-group-text">Username</label>
        <input type="text" id="user" class="form-control" value="pi" />
        <label class="input-group-text">Password</label>
        <input type="password" id="pass" class="form-control" value="password" />
    </div>
</div>
<div id="terminal-container"></div>
@section scripts {
    @* Add Libraries *@
    <link rel="stylesheet" href="~/lib/xterm/xterm/css/xterm.css" asp-append-version="true" />
    <script src="~/lib/xterm/xterm/lib/xterm.js" asp-append-version="true"></script>
    <script src="~/lib/xterm/addon-fit/lib/addon-fit.js" asp-append-version="true"></script>
    <script src="~/lib/microsoft/signalr/dist/browser/signalr.js" asp-append-version="true"></script>
    <script src="~/js/terminal.js" asp-append-version="true"></script>
}

And terminal.js

const socket = new signalR.HubConnectionBuilder().withUrl("/ssh").build();
const terminalContainer = document.getElementById('terminal-container');
const xterm = new Terminal({
    rows: 24,
    cols: 80,
    'cursorBlink': false,
    rendererType: "canvas"
});

// Fit the content to the canvas.
const fitAddon = new FitAddon.FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(terminalContainer);
fitAddon.fit();

// Connect to the Web Socket.
socket.start().catch(function (err) {
    return console.error(err.toString());
});

// Backend -> Browser - Receivng generic data.
socket.on("ReceiveMessage", function (data) {
    xterm.write(data);
});

// Connect button.
document.getElementById("connectBtn").addEventListener("click", function (event) {
    event.preventDefault();

    const host = document.getElementById("host").value;
    const user = document.getElementById("user").value;
    const pass = document.getElementById("pass").value;

    // Connect.
    xterm.write('\r\n*** Conneccting to SSH Server***\r\n');

    // Send the message we want to connect to a host.
    socket.invoke("Connect", host, user, pass).then(function () {
        xterm.write('\r\n*** Connected to SSH Server ***\r\n');
        isConnected = true;
        xterm.focus();
    }).catch(function (err) {
        return console.error(err);
    });
});

With this, I had enough to add some break points and check things. Loading up the page and trying to connect, its was succesful. No output yet. Lets take care of that.

Back in my SshHub, I added some event handlers.

         string socketid = Context.ConnectionId;

        shellStream.DataReceived += (object? s, ShellDataEventArgs e) =>
        {
            ShellStream_DataReceived(s, e, socketid);
        };
   }

    private async void ShellStream_DataReceived(object? s, ShellDataEventArgs e, string ConnectionId)
    {
        try
        {
            SshClient? client = GetSshClient(ConnectionId);
            ShellStream? shellStream = GetShellStream(ConnectionId);

            string msg = shellStream?.Read() ?? throw new Exception("Missing Stream");

            if (HubContext != null)
            {
                ISingleClientProxy socket = HubContext.Clients.Client(ConnectionId);

                await socket.SendAsync("ReceiveMessage", msg);
            }
            return;
        }
        catch (Exception ex)
        {
            throw;
        }
    }

Rebuilding and trying again, I get output from my connection. Success. Now to write some stuff to the console. Back into javascript, I needed to be able to write back.

// Browser -> Backend
xterm.onData((data) => {
    socket.invoke("SendMessage", data).catch(function (err) {
        return console.error(err.toString());
    });
});

And in the hub

    public async Task SendMessage(string data)
    {
        try
        {
            ShellStream shellStream = SshSessionManager.GetShellStream(Context.ConnectionId) ?? throw new Exception("INvalid Shell");

            byte[] encoded = Encoding.UTF8.GetBytes(data);
            await shellStream.WriteAsync(encoded);
            await shellStream.FlushAsync();

        }
        catch (Exception e)
        {
            await Clients.Caller.SendAsync("Error", e.Message);
            throw new HubException(e.Message);
        }
    }

The Session Manager works well here to retrieve our shell stream and be able to write to it. Rebuilding and trying again, things are working. I was very surprised at how easy it was at this point to get something working.

There was more to things. Because clients didn’t disconnect. Well that isn’t to hard. Just some javascript

document.getElementById("disconnectBtn").addEventListener("click", function (event) {
    event.preventDefault();

    if (isConnected) {
        socket.invoke("Disconnect").catch(function (err) {
            return console.error(err.toString());
        });
    }
});

// Backend -> Browser - Ssh disconnected.
socket.on("Disconnect", function (data) {
    isConnected = false;
    toggleConnectBtns(isConnected);
    xterm.write('\r\n*** Lost connection to SSH Server ***\r\n');
});

And another method in my hub

    public async Task Disconnect()
    {
        try
        {
            await SshSessionManager.Remove(Context.ConnectionId);
            await Clients.Caller.SendAsync("Disconnect");
        }
        catch {}
    }

This actually works great, because I can have clients disconnected from the server side. Such as a timeout or cleanup. Speaking of cleanups, I wasn’t doing much about old data laying around. All hubs are disposed of upon finishing a request, so I decided to add some GC to this.

    protected async override void Dispose(bool disposing)
    {
        base.Dispose(disposing);

        try
        {
            List<string> Ids = await SshSessionManager.Cleanup();

            foreach (string id in Ids)
            {
                await Clients.Client(id).SendAsync("Disconnect");
            }
        }
        catch (Exception)
        {
            throw;
        }
    }

And in the Session Manager

    public static async Task<List<string>> Cleanup()
    {
        List<string> Ids = [];

        foreach ((var id, var c) in SshClients)
        {
            if (c == null || !c.IsConnected)
            {
                await Remove(id);
                Ids.Add(id);
            }
        }

        foreach ((var id, var s) in SshStreams)
        {
            if (s == null || (!s.CanRead && s.CanWrite))
            {
                await Remove(id);
                Ids.Add(id);
            }
        }

        return Ids;
    }

This will look at all of our sessions and find any clients that are invalid, not connected or unable to read/write and dump them. It then returns a list of ids it purged, so we can send a notification to them. I could have writen that better, but it works for my case and I wasn’t worried about a race condition and even then, it would simply result in the client getting an error.

Speaking of errors. We need to let the client know when things break. So some javascript.`

// Backend -> Browser - Receiving a error
socket.on("Error", function (data) {
    xterm.write('\r\n*** Error: ' + data + ' ***\r\n');
});

Then in my hub, I need to register a new event to the stream.

        shellStream.ErrorOccurred += (object? s, ExceptionEventArgs e) =>
        {
            ShellStream_ErrorOccurred(s, e, socketid);
        };

[...]

    private async void ShellStream_ErrorOccurred(object? sender, ExceptionEventArgs e, string ConnectionId)
    {
        try
        {
            string msg = e.Exception.Message;

            if (HubContext != null)
            {
                await HubContext.Clients.Client(ConnectionId).SendAsync("Error", msg);
            }
            return;
        }
        catch (Exception)
        {
            throw;
        }
    }

If the stream produces a error, we now can tell the client.

I was thinking this was all good and ready for integration into the real application until I performed some resource tests against this. I was leaking memory. This admitly took longer than it should have for me to realize and come up with a solution. But because the event handler was being registered, the reference to the SshHub could not be destroyed properly.

After some trial and error, I came up with a new class SshStreamEventWrapper and had it hold onto the ISingleClientProxy and ShellStream then register that into the session manager. I made it dispoable so I could have it unregister the events properly.

public class SshStreamEventWrapper : IDisposable
{
    private readonly ISingleClientProxy Socket;
    private readonly ShellStream ShellStream;

    public SshStreamEventWrapper(ISingleClientProxy socket, ShellStream shellStream)
    {
        Socket = socket;
        ShellStream = shellStream;

        ShellStream.DataReceived += DataReceived;
        ShellStream.ErrorOccurred += ErrorOccurred;
        ShellStream.Closed += Closed;
    }

    private async void DataReceived(object? _, ShellDataEventArgs e) => await Socket.SendAsync("ReceiveMessage", Encoding.UTF8.GetString(e.Data));

    private async void ErrorOccurred(object? _, ExceptionEventArgs e) => await Socket.SendAsync("Error", e.Exception.Message);

    private async void Closed(object? sender, EventArgs e) => await Socket.SendAsync("Disconnect");

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            ShellStream.DataReceived -= DataReceived;
            ShellStream.ErrorOccurred -= ErrorOccurred;
            ShellStream.Closed -= Closed;
        }
    }
}

I used ISingleClientProxy here so I didn’t have to call back into the hub to find the client, etc.. I should try to do more error handling on it, but for my testing, it wasn’t important, just couldn’t have a leaky app.

In my Session manager, I added a new dictionary, updated the registration and removal.

    private static readonly Dictionary<string, SshStreamEventWrapper> Wrappers = [];

    public static void Register(string connectionId, SshClient client, ShellStream stream, SshStreamEventWrapper wrapper)
    {
[...]
        Wrappers.Add(connectionId, wrapper);
    }
[...]
    public static async Task Remove(string connectionId)
    {
        if (Wrappers.TryGetValue(connectionId, out SshStreamEventWrapper? w))
        {
            w?.Dispose();
        }
        Wrappers.Remove(connectionId);

Finally the hub was updated

            // Wrap our event handlers to prevent memory leaks.
            SshStreamEventWrapper w = new(Clients.Caller, shellStream);

            SshSessionManager.Register(Context.ConnectionId, client, shellStream, w);

Now no reference to the hub was being left behind. The code no longer leaks.

I sent my code up to GitHub for anyone to look over https://github.com/jdarwood007/SshNetWebTerminal. It has a few other things I toyed with. I stopped working with this once I got it working enough that I would be baking the real logic into my production application, so I didn’t work on additional validation, error handling, security checks and authentication here. My production app has all of that. Anyone using this should take that into great consideration. Espically since your web server will be performing SSH connections, your firewall will need to allow this.

I did come across one issue while working with the produciton application. I wanted my hub to receive a int on the method and when the data was being sent over, it was being sent as a string.

I first tried to do a simple overload, thinking I can fix the problem in javascript but wanted a backup method.

    public async Task Connect(string myVar)
	{
		if (int.TryParse(myVar, out int id))
		{
			await Connect(id);
		}
		else
		{
			throw new Exception('Invalid parameter');
		}
	}

    public async Task Connect(int myVar)
    {
    	[... Do things ...]
    }

However, hubs don’t let you do that. So that just left javascript. I simply wrapped the data being sent for that parameter with a parseInt call and some sanity checks.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.