The principles of Web SSH and its implementation in ASP.NET Core SignalR
Foreword
There is a project that requires a management terminal on the front end that can SSH to the terminal of the main control machine. If you do not consider that users use vim and other software that need to display the interface in the console, actually use Process
Type to start the corresponding program is enough. This time, the requirements need to consider the user’s ability to make relevant settings.
Principle
The principle used here is pseudo terminal. Pseudo terminal is a function of modern operating systems. It simulates a pair of input and output devices to simulate a terminal environment to execute corresponding processes. A pseudo-terminal usually provides the corresponding process with environment variables or files to tell it to run in the terminal, so that a program like vim can output a command menu on the last line or a program like npm/pip can print cool progress strip. Usually when we directly create a child process, the system on Linux comes with the openpty
method to open a pseudo terminal, while on Windows, a real system-level pseudo terminal appears only after Windows Terminal is launched. . Below is a pseudo-terminal schematic from Microsoft’s blog. The principle on Linux is similar.
Basic Design
Establishing a connection and monitoring terminal output
Listen to front-end input
graph TD;
A[Terminal window receives keyboard event] –> B[SignalR sends request];
B –> C [forward to the corresponding terminal in the background]
Timeout and Close
graph TD;
A[When SignalR sends a disconnect or the terminal times out] –> B[Close the terminal process];
Dependent library
portable_pty
This Rust library is used here to create a terminal. This library is an independent process and will be run every time a connection is established. I originally considered calling vs-pty directly in the ASP.NET Core application (a library developed by Microsoft and used in vs. You can directly copy it in the vs installation location), but vs-pty cannot be used in .NET 7 for various reasons. + It cannot run in Ubuntu 22.04 environment, so it was abandoned.
xterm.js
This is a library used by the front-end to display the terminal interface. It is said that vs code also uses this library. Although there are not many documents, it is really simple to use.
SignalR
Not much to say about this, it is right to choose him for our .NET series Web real-time communication.
code
No more nonsense, let’s just look at the code. The code here is quite long. I have excerpted some necessary code. For specific configurations such as SignalR, readers are advised to refer to Microsoft official documents.
main.rs
This Rust code is used to establish a pseudo terminal and communicate with the .NET service. The simplest UDP communication method is used here.
use portable_pty::{self, native_pty_system, CommandBuilder, PtySize};
use std::{io::prelude::*, sync::Arc};
use tokio::net::UdpSocket;
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() -> Result<(), Box> {
let args = std::env::args().collect::<Vec>();
// Start a terminal
let pty_pair = native_pty_system().openpty(PtySize {
rows: args.get(2).ok_or("NoNumber")?.parse()?,
cols: args.get(3).ok_or("NoNumber")?.parse()?,
pixel_width: 0,
pixel_height: 0,
})?;
//Execute the incoming command
let mut cmd = CommandBuilder::new(args.get(4).unwrap_or(&"bash".to_string()));
if args.len() > 5 {
cmd.args(&args[5..]);
}
let mut proc = pty_pair.slave.spawn_command(cmd)?;
// Bind input and output
let mut reader = pty_pair.master.try_clone_reader()?;
let mut writer = pty_pair.master.take_writer()?;
//Bind network
let main_socket = Arc::new(UdpSocket::bind("localhost:0").await?);
let recv_socket = main_socket.clone();
let send_socket = main_socket.clone();
let resize_socket = UdpSocket::bind("localhost:0").await?;
//Send the address after connecting to the main service
main_socket
.connect(args.get(1).ok_or("NoSuchAddr")?)
.await?;
main_socket
.send(&serde_json::to_vec(&ClientAddr {
main: main_socket.local_addr()?.to_string(),
resize: resize_socket.local_addr()?.to_string(),
})?)
.await?;
//Read terminal data and send
let read = tokio::spawn(async move {
loop {
let mut buf = [0; 1024];
let n = reader.read(&mut buf).unwrap();
if n == 0 {
continue;
}
println!("{:?}", &buf[..n]);
send_socket.send(&buf[..n]).await.unwrap();
}
});
//Receive data and write to terminal
let write = tokio::spawn(async move {
loop {
let mut buf = [0; 1024];
let n = recv_socket.recv(&mut buf).await.unwrap();
if n == 0 {
continue;
}
println!("{:?}", &buf[..n]);
writer.write_all(&buf[..n]).unwrap();
}
});
// Receive data to resize the window
let resize = tokio::spawn(async move {
let mut buf = [0; 1024];
loop {
let n = resize_socket.recv(&mut buf).await.unwrap();
if n == 0 {
continue;
}
let size: WinSize = serde_json::from_slice(buf[..n].as_ref()).unwrap();
pty_pair
.master
.resize(PtySize {
rows: size.rows,
cols: size.cols,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
}
});
// Wait for the process to end
let result = proc.wait()?;
write.abort();
read.abort();
resize.abort();
if 0 == result.exit_code() {
std::process::exit(result.exit_code() as i32);
}
return Ok(());
}
/// Window size
#[derive(serde::Deserialize)]
struct WinSize {
/// Rows
rows: u16,
///Number of columns
cols: u16,
}
/// Client address
#[derive(serde::Serialize)]
struct ClientAddr {
/// Primary address
main: String,
/// Adjust window size address
resize: String,
}
SshPtyConnection.cs
This code is used to maintain a Rust process running in the background and manage its two-way communication.
public class SshPtyConnection : IDisposable
{
///
/// Client address
///
private class ClientEndPoint
{
public required string Main { get; set; }
public required string Resize { get; set; }
}
///
/// Window size
///
private class WinSize
{
public int Cols { get; set; }
public int Rows { get; set; }
}
///
/// SignalR context
///
private readonly IHubContext _hubContext;
///
/// Logger
///
private readonly ILogger _logger;
///
/// UDP client
///
private readonly UdpClient udpClient;
///
///Last activity time
///
private DateTime lastActivity = DateTime.UtcNow;
///
/// Whether it has been released
///
private bool disposedValue;
///
/// Whether it has been released
///
public bool IsDisposed => disposedValue;
///
///Last activity time
///
public DateTime LastActivity => lastActivity;
///
/// Cancel token
///
public CancellationTokenSource CancellationTokenSource { get; } = new CancellationTokenSource();
///
/// Window size
///
public event EventHandler Closed = delegate { };
///
/// Constructor
///
///
///
///
public SshPtyConnection(IHubContext hubContext, ILogger logger)
{
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
lastActivity = DateTime.Now;
udpClient = new(IPEndPoint.Parse("127.0.0.1:0"));
}
///
/// Start listening
///
/// Connection ID
/// Username
/// Number of rows
/// Number of columns
public async void StartAsync(string connectionId, string username, int height, int width)
{
var token = CancellationTokenSource.Token;
_logger.LogInformation("process starting");
// start process
using var process = Process.Start(new ProcessStartInfo
{
FileName = OperatingSystem.IsOSPlatform("windows") ? "PtyWrapper.exe" : "pty-wrapper",
// su -l username is used here, because the program is deployed directly under the root of the main control machine, so ssh is not required and only the user needs to be switched. If the program is deployed on other machines, ssh is required.
ArgumentList = { udpClient.Client.LocalEndPoint!.ToString() ?? "127.0.0.1:0", height.ToString(), width.ToString(), "su", "-l", username }
});
//Receive client address
var result = await udpClient.ReceiveAsync();
var clientEndPoint = await JsonSerializer.DeserializeAsync(new MemoryStream(result.Buffer), new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (clientEndPoint == null)
{
CancellationTokenSource.Cancel();
return;
}
process!.Exited += (_, _) => CancellationTokenSource.Cancel();
var remoteEndPoint = IPEndPoint.Parse(clientEndPoint.Main);
udpClient.Connect(remoteEndPoint);
var stringBuilder = new StringBuilder();
// Receive client data and send to SignalR until the client disconnects or times out for 10 minutes
while (!token.IsCancellationRequested && lastActivity.AddMinutes(10) > DateTime.Now && !(process?.HasExited ?? false))
{
try
{
lastActivity = DateTime.Now;
var buffer = await udpClient.ReceiveAsync(token);
await _hubContext.Clients.Client(connectionId).SendAsync("WriteDataAsync", Encoding.UTF8.GetString(buffer.Buffer));
stringBuilder.Clear();
}
catch (Exception e)
{
_logger.LogError(e, "ConnectionId: {ConnectionId} Unable to read data and send message.", connectionId);
break;
}
}
// If the client disconnects or times out for 10 minutes, close the process
if (process?.HasExited ?? false) process?.Kill();
if (lastActivity.AddMinutes(10) < DateTime.Now)
{
_logger.LogInformation("ConnectionId: {ConnectionId} Pty session has been closed because of inactivity.", connectionId);
try
{
await _hubContext.Clients.Client(connectionId).SendAsync("WriteErrorAsync", "InactiveTimeTooLong");
}
catch (Exception e)
{
_logger.LogError(e, "ConnectionId: {ConnectionId} Unable to send message.", connectionId);
}
}
if (token.IsCancellationRequested)
{
_logger.LogInformation("ConnectionId: {ConnectionId} Pty session has been closed because of session closed.", connectionId);
try
{
await _hubContext.Clients.Client(connectionId).SendAsync("WriteErrorAsync", "SessionClosed");
}
catch (Exception e)
{
_logger.LogError(e, "ConnectionId: {ConnectionId} Unable to send message.", connectionId);
}
}
Dispose();
}
///
/// Receive SignalR data and send to client
///
/// data
///
///
public async Task WriteDataAsync(string data)
{
if (disposedValue)
{
throw new AppException("SessionClosed");
}
try
{
lastActivity = DateTime.Now;
await udpClient.SendAsync(Encoding.UTF8.GetBytes(data));
}
catch (Exception e)
{
CancellationTokenSource.Cancel();
Dispose();
throw new AppException("SessionClosed", e);
}
}
///
/// Recycle resources
///
///
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
CancellationTokenSource.Cancel();
udpClient.Dispose();
}
disposedValue = true;
Closed(this, new EventArgs());
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
SshService
This code is used to manage the relationship betweenSshPtyConnection
and the SignalR client connection
public class SshService : IDisposable
{
private bool disposedValue;
private readonly IHubContext _hubContext;
private readonly ILoggerFactory _loggerFactory;
private Dictionary _connections;
public SshService(IHubContext hubContext, ILoggerFactory loggerFactory)
{
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
_connections = new Dictionary();
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
///
/// Create terminal connection
///
/// Connection ID
/// Username
/// Number of rows
/// Number of columns
///
///
public Task CreateConnectionAsync(string connectionId, string username, int height, int width)
{
if (_connections.ContainsKey(connectionId))
throw new InvalidOperationException();
var connection = new SshPtyConnection(_hubContext, _loggerFactory.CreateLogger());
connection.Closed += (sender, args) =>
{
_hubContext.Clients.Client(connectionId).SendAsync("WriteErrorAsync", "SessionClosed");
_connections.Remove(connectionId);
};
_connections.Add(connectionId, connection);
//Run a background thread
connection.StartAsync(connectionId, username, height, width);
return Task.CompletedTask;
}
///
/// data input
///
/// Connection ID
/// data
///
public async Task ReadDataAsync(string connectionId, string data)
{
if (_connections.TryGetValue(connectionId, out var connection))
{
await connection.WriteDataAsync(data);
}
else
throw new AppException("SessionClosed");
}
///
/// Close the connection
///
/// Connection ID
///
public Task CloseConnectionAsync(string connectionId)
{
if (_connections.TryGetValue(connectionId, out var connection))
{
connection.Dispose();
}
else
throw new AppException("SessionClosed");
return Task.CompletedTask;
}
///
/// Recycle resources
///
///
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
foreach (var item in _connections.Values)
{
item.Dispose();
}
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
WebSsh.vue
This code is the code that uses vue to display the terminal window
import { onMounted, ref } from 'vue';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
import { SearchAddon } from 'xterm-addon-search';
import { WebglAddon } from 'xterm-addon-webgl';
import * as signalR from '@microsoft/signalr';
import 'xterm/css/xterm.css';
const termRef = ref(null);
// Create xterm terminal
const term = new Terminal();
//Define SignalR client
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/ssh', {
accessTokenFactory: () => localStorage.getItem('token'),
} as any)
.build();
let isClosed = false;
//Listen to keyboard events and send them to the backend
term.onData((data) => {
if (isClosed) {
return;
}
connection.invoke('ReadDataAsync', data).then((result) => {
if (result.code == 400) {
isClosed = true;
term.write('SessionClosed');
}
});
});
// Monitor backend data return
connection.on('WriteDataAsync', (data) => {
term.write(data);
});
// Monitor the backend terminal to close
connection.on('WriteErrorAsync', () => {
isClosed = true;
term.write('SessionClosed');
});
//Load plugin
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.loadAddon(new SearchAddon());
term.loadAddon(new WebglAddon());
onMounted(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
term.open(termRef.value!);
fit.fit();
// Start SignalR client
await connection.start();
//Create terminal
connection.invoke('CreateNewTerminalAsync', term.rows, term.cols);
});
SshHub.cs
This file is the Hub file of SignalR and is used for monitoring.
[Authorize]
public class SshHub : Hub
{
private readonly SshService _sshService;
private readonly ILogger _logger;
public SshHub(SshService sshService, ILogger logger)
{
_sshService = sshService ?? throw new ArgumentNullException(nameof(sshService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
/// Create a new terminal
///
///
///
///
public async Task CreateNewTerminalAsync(int height = 24, int width = 80)
{
try
{
var username = Context.User?.FindFirst("preferred_username")?.Value;
if (username == null)
{
return new BaseResponse
{
Code = 401,
Message = "NoUsername"
};
}
if (!Context.User?.IsInRole("user") ?? false)
{
username = "root";
}
_logger.LogInformation($"{username}");
await _sshService.CreateConnectionAsync(Context.ConnectionId, username, height, width);
return new BaseResponse();
}
catch(InvalidOperationException)
{
return new BaseResponse() { Code = 500, Message = "TerminalAlreadyExist" };
}
catch (Exception e)
{
_logger.LogError(e, "ConnectionId: {ConnectionId} No such pty session.", Context.ConnectionId);
return new BaseResponse() { Code = 500, Message = "UnableToCreateTerminal" };
}
}
///
/// Read input data
///
///
///
public async Task ReadDataAsync(string data)
{
try
{
await _sshService.ReadDataAsync(Context.ConnectionId, data);
return new BaseResponse();
}
catch (Exception e)
{
_logger.LogError(e, "ConnectionId: {ConnectionId} No such pty session.", Context.ConnectionId);
return new BaseResponse { Message = "NoSuchSeesion", Code = 400 };
}
}
}
///
/// Client interface
///
public interface ISshHubClient
{
///
///Write output data
///
///
///
Task WriteDataAsync(string data);
///
///Write error data
///
///
///
Task WriteErrorAsync(string data);
}
References
- Windows Command-Line: Introducing the Windows Pseudo Console (ConPTY)
- portable_pty – Rust
- xterm.js
- Tutorial: Get started with ASP.NET Core SignalR using TypeScript and Webpack
rm.write(data);
});
// Monitor the backend terminal to close
connection.on(‘WriteErrorAsync’, () => {
isClosed = true;
term.write(‘SessionClosed’);
});
//Load plugin
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.loadAddon(new SearchAddon());
term.loadAddon(new WebglAddon());
onMounted(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
term.open(termRef.value!);
fit.fit();
// Start SignalR client
await connection.start();
//Create terminal
connection.invoke(‘CreateNewTerminalAsync’, term.rows, term.cols);
});
SshHub.cs
This file is the Hub file of SignalR and is used for monitoring.
[Authorize]
public class SshHub : Hub
{
private readonly SshService _sshService;
private readonly ILogger _logger;
public SshHub(SshService sshService, ILogger logger)
{
_sshService = sshService ?? throw new ArgumentNullException(nameof(sshService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
/// Create a new terminal
///
///
///
///
public async Task CreateNewTerminalAsync(int height = 24, int width = 80)
{
try
{
var username = Context.User?.FindFirst("preferred_username")?.Value;
if (username == null)
{
return new BaseResponse
{
Code = 401,
Message = "NoUsername"
};
}
if (!Context.User?.IsInRole("user") ?? false)
{
username = "root";
}
_logger.LogInformation($"{username}");
await _sshService.CreateConnectionAsync(Context.ConnectionId, username, height, width);
return new BaseResponse();
}
catch(InvalidOperationException)
{
return new BaseResponse() { Code = 500, Message = "TerminalAlreadyExist" };
}
catch (Exception e)
{
_logger.LogError(e, "ConnectionId: {ConnectionId} No such pty session.", Context.ConnectionId);
return new BaseResponse() { Code = 500, Message = "UnableToCreateTerminal" };
}
}
///
/// Read input data
///
///
///
public async Task ReadDataAsync(string data)
{
try
{
await _sshService.ReadDataAsync(Context.ConnectionId, data);
return new BaseResponse();
}
catch (Exception e)
{
_logger.LogError(e, "ConnectionId: {ConnectionId} No such pty session.", Context.ConnectionId);
return new BaseResponse { Message = "NoSuchSeesion", Code = 400 };
}
}
}
///
/// Client interface
///
public interface ISshHubClient
{
///
///Write output data
///
///
///
Task WriteDataAsync(string data);
///
///Write error data
///
///
///
Task WriteErrorAsync(string data);
}
References
- Windows Command-Line: Introducing the Windows Pseudo Console (ConPTY)
- portable_pty – Rust
- xterm.js
- Tutorial: Get started with ASP.NET Core SignalR using TypeScript and Webpack