前言

有个项目,需要在前端有个管理终端可以 SSH 到主控机的终端,如果不考虑用户使用 vim 等需要在控制台内现实界面的软件的话,其实使用 Process 类型去启动相应程序就够了。而这次的需求则需要考虑用户会做相关设置。

原理

这里用到的原理是伪终端。伪终端(pseudo terminal)是现代操作系统的一个功能,他会模拟一对输入输出设备来模拟终端环境去执行相应的进程。伪终端通常会给相应的进程提供例如环境变量或文件等来告知他在终端中运行,这样像 vim 这样的程序可以在最后一行输出命令菜单或者像 npm / pip 这样的程序可以打印炫酷的进度条。通常在我们直接创建子进程的时候,在 Linux 上系统自带了 openpty 方法可以打开伪终端,而在 Windows 上则等到 Windows Terminal 推出后才出现了真正的系统级伪终端。下面付一张来自微软博客的伪终端原理图,Linux 上的原理与之类似

基本设计

建立连接与监听终端输出

监听前端输入

graph TD;
A[终端窗口收到键盘事件] --> B[SignalR 发送请求];
B --> C[后台转发到对应终端]

超时与关闭

graph TD;
A[当 SignalR 发送断开连接或终端超时] --> B[关闭终端进程];

依赖库

portable_pty

这里用到这个 Rust 库来建立终端,这个库是一个独立的进程,每次建立连接都会运行。这里当初考虑过直接在 ASP.NET Core 应用里调用 vs-pty(微软开发的,用在 vs 里的库,可以直接在 vs 安装位置复制一份),但是 vs-pty 因为种种原因在 .NET 7 + Ubuntu 22.04 的环境下运行不起来故放弃了。

xterm.js

这个是前端展示终端界面用的库,据说 vs code 也在用这个库,虽然文档不多,但是用起来真的很简单。

SignalR

这个不多说了,咱 .NET 系列 Web 实时通信选他就没错。

代码

废话不多讲了,咱还是直接看代码吧,这里代码还是比较长的,我节选了一些必要的代码。具体 SignalR 之类的配置,还请读者自行参考微软官方文档。

  1. main.rs 这个 Rust 代码用于建立伪终端并和 .NET 服务通信,这里使用了最简单的 UDP 方式通信。
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<dyn std::error::Error>> {
let args = std::env::args().collect::<Vec<_>>();
// 启动一个终端
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,
})?;
// 执行传进来的命令
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)?;
// 绑定输入输出
let mut reader = pty_pair.master.try_clone_reader()?;
let mut writer = pty_pair.master.take_writer()?;
// 绑定网络
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?;
// 连接到主服务后发送地址
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?;
// 读取终端数据并发送
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();
}
});
// 接收数据并写入终端
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();
}
});
// 接收调整窗口大小的数据
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();
}
});
// 等待进程结束
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(());
}
/// 窗口大小
#[derive(serde::Deserialize)]
struct WinSize {
/// 行数
rows: u16,
/// 列数
cols: u16,
}
/// 客户端地址
#[derive(serde::Serialize)]
struct ClientAddr {
/// 主要地址
main: String,
/// 调整窗口大小地址
resize: String,
}
  1. SshPtyConnection.cs 这个代码用于维持一个后台运行的 Rust 进程,并管理他的双向通信。
    public class SshPtyConnection : IDisposable
{
/// <summary>
/// 客户端地址
/// </summary>
private class ClientEndPoint
{
public required string Main { get; set; }
public required string Resize { get; set; }
}
/// <summary>
/// 窗口大小
/// </summary>
private class WinSize
{
public int Cols { get; set; }
public int Rows { get; set; }
}
/// <summary>
/// SignalR 上下文
/// </summary>
private readonly IHubContext<SshHub> _hubContext;
/// <summary>
/// 日志记录器
/// </summary>
private readonly ILogger<SshPtyConnection> _logger;
/// <summary>
/// UDP 客户端
/// </summary>
private readonly UdpClient udpClient;
/// <summary>
/// 最后活动时间
/// </summary>
private DateTime lastActivity = DateTime.UtcNow;
/// <summary>
/// 是否已释放
/// </summary>
private bool disposedValue;
/// <summary>
/// 是否已释放
/// </summary>
public bool IsDisposed => disposedValue;
/// <summary>
/// 最后活动时间
/// </summary>
public DateTime LastActivity => lastActivity;
/// <summary>
/// 取消令牌
/// </summary>
public CancellationTokenSource CancellationTokenSource { get; } = new CancellationTokenSource();
/// <summary>
/// 窗口大小
/// </summary>
public event EventHandler<EventArgs> Closed = delegate { };
/// <summary>
/// 构造函数
/// </summary>
/// <param name="hubContext"></param>
/// <param name="logger"></param>
/// <exception cref="ArgumentNullException"></exception>
public SshPtyConnection(IHubContext<SshHub> hubContext, ILogger<SshPtyConnection> 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"));
}
/// <summary>
/// 开始监听
/// </summary>
/// <param name="connectionId">连接 ID</param>
/// <param name="username">用户名</param>
/// <param name="height">行数</param>
/// <param name="width">列数</param>
public async void StartAsync(string connectionId, string username, int height, int width)
{
var token = CancellationTokenSource.Token;
_logger.LogInformation("process starting");
// 启动进程
using var process = Process.Start(new ProcessStartInfo
{
FileName = OperatingSystem.IsOSPlatform("windows") ? "PtyWrapper.exe" : "pty-wrapper",
// 这里用了 su -l username,因为程序直接部署在主控机的 root 下,所以不需要 ssh 只需要切换用户即可,如果程序部署在其他机器上,需要使用 ssh
ArgumentList = { udpClient.Client.LocalEndPoint!.ToString() ?? "127.0.0.1:0", height.ToString(), width.ToString(), "su", "-l", username }
});
// 接收客户端地址
var result = await udpClient.ReceiveAsync();
var clientEndPoint = await JsonSerializer.DeserializeAsync<ClientEndPoint>(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();
// 接收客户端数据,并发送到 SignalR,直到客户端断开连接或者超时 10 分钟
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;
}
}
// 如果客户端断开连接或者超时 10 分钟,关闭进程
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();
}
/// <summary>
/// 接收 SignalR 数据,并发送到客户端
/// </summary>
/// <param name="data">数据</param>
/// <returns></returns>
/// <exception cref="AppException"></exception>
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);
}
}
/// <summary>
/// 回收资源
/// </summary>
/// <param name="disposing"></param>
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);
}
}
  1. SshService 这段代码用于管理 SshPtyConnection 和 SignalR 客户端连接之间的关系
    public class SshService : IDisposable
{
private bool disposedValue;
private readonly IHubContext<SshHub> _hubContext;
private readonly ILoggerFactory _loggerFactory;
private Dictionary<string, SshPtyConnection> _connections; public SshService(IHubContext<SshHub> hubContext, ILoggerFactory loggerFactory)
{
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
_connections = new Dictionary<string, SshPtyConnection>();
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
} /// <summary>
/// 创建终端连接
/// </summary>
/// <param name="connectionId">连接 ID</param>
/// <param name="username">用户名</param>
/// <param name="height">行数</param>
/// <param name="width">列数</param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
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<SshPtyConnection>());
connection.Closed += (sender, args) =>
{
_hubContext.Clients.Client(connectionId).SendAsync("WriteErrorAsync", "SessionClosed");
_connections.Remove(connectionId);
};
_connections.Add(connectionId, connection);
// 运行一个后台线程
connection.StartAsync(connectionId, username, height, width);
return Task.CompletedTask;
}
/// <summary>
/// 写入数据
/// </summary>
/// <param name="connectionId">连接 ID</param>
/// <param name="data">数据</param>
/// <exception cref="AppException"></exception>
public async Task ReadDataAsync(string connectionId, string data)
{
if (_connections.TryGetValue(connectionId, out var connection))
{
await connection.WriteDataAsync(data);
}
else
throw new AppException("SessionClosed");
}
/// <summary>
/// 关闭连接
/// </summary>
/// <param name="connectionId">连接 ID</param>
/// <exception cref="AppException"></exception>
public Task CloseConnectionAsync(string connectionId)
{
if (_connections.TryGetValue(connectionId, out var connection))
{
connection.Dispose();
}
else
throw new AppException("SessionClosed");
return Task.CompletedTask;
}
/// <summary>
/// 回收资源
/// </summary>
/// <param name="disposing"></param>
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);
}
}
  1. WebSsh.vue 这段代码是使用 vue 展示终端窗口的代码
<script setup lang="ts">
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<HTMLElement | null>(null);
// 创建 xterm 终端
const term = new Terminal();
// 定义 SignalR 客户端
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/ssh', {
accessTokenFactory: () => localStorage.getItem('token'),
} as any)
.build();
let isClosed = false;
// 监听键盘事件并发送到后端
term.onData((data) => {
if (isClosed) {
return;
}
connection.invoke('ReadDataAsync', data).then((result) => {
if (result.code == 400) {
isClosed = true;
term.write('SessionClosed');
}
});
});
// 监听后端数据回传
connection.on('WriteDataAsync', (data) => {
term.write(data);
});
// 监听后端终端关闭
connection.on('WriteErrorAsync', () => {
isClosed = true;
term.write('SessionClosed');
});
// 加载插件
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();
// 启动 SignalR 客户端
await connection.start();
// 创建终端
connection.invoke('CreateNewTerminalAsync', term.rows, term.cols);
});
</script> <template>
<div ref="termRef" class="xTerm"></div>
</template> <style scoped>
</style>
  1. SshHub.cs 这个文件是 SignalR 的 Hub 文件,用来做监听的。
    [Authorize]
public class SshHub : Hub<ISshHubClient>
{
private readonly SshService _sshService;
private readonly ILogger<SshHub> _logger; public SshHub(SshService sshService, ILogger<SshHub> logger)
{
_sshService = sshService ?? throw new ArgumentNullException(nameof(sshService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 创建一个新的终端
/// </summary>
/// <param name="height"></param>
/// <param name="width"></param>
/// <returns></returns>
public async Task<BaseResponse> 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" };
}
}
/// <summary>
/// 读取输入数据
/// </summary>
/// <param name="data"></param>
/// <returns></returns>
public async Task<BaseResponse> 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 };
}
}
}
/// <summary>
/// 客户端接口
/// </summary>
public interface ISshHubClient
{
/// <summary>
/// 写入输出数据
/// </summary>
/// <param name="data"></param>
/// <returns></returns>
Task WriteDataAsync(string data);
/// <summary>
/// 写入错误数据
/// </summary>
/// <param name="data"></param>
/// <returns></returns>
Task WriteErrorAsync(string data);
}

参考文献

  1. Windows Command-Line: Introducing the Windows Pseudo Console (ConPTY)
  2. portable_pty - Rust
  3. xterm.js
  4. 教程:使用 TypeScript 和 Webpack 开始使用 ASP.NET Core SignalR

Web SSH 的原理与在 ASP.NET Core SignalR 中的实现的更多相关文章

  1. ASP.NET Core SignalR中的流式传输

    什么是流式传输? 流式传输是这一种以稳定持续流的形式传输数据的技术. 流式传输的使用场景 有些场景中,服务器返回的数据量较大,等待时间较长,客户端不得不等待服务器返回所有数据后,再进行相应的操作.这时 ...

  2. 百度Web富文本编辑器ueditor在ASP.NET MVC3项目中的使用说明

    ====================================================================== [百度Web富文本编辑器ueditor在ASP.NET M ...

  3. ASP.NET Core MVC中构建Web API

    在ASP.NET CORE MVC中,Web API是其中一个功能子集,可以直接使用MVC的特性及路由等功能. 在成功构建 ASP.NET CORE MVC项目之后,选中解决方案,先填加一个API的文 ...

  4. ASP.NET Core MVC 中的 [Controller] 和 [NonController]

    前言 我们知道,在 MVC 应用程序中,有一部分约定的内容.其中关于 Controller 的约定是这样的. 每个 Controller 类的名字以 Controller 结尾,并且放置在 Contr ...

  5. ASP.NET Core中的缓存[1]:如何在一个ASP.NET Core应用中使用缓存

    .NET Core针对缓存提供了很好的支持 ,我们不仅可以选择将数据缓存在应用进程自身的内存中,还可以采用分布式的形式将缓存数据存储在一个“中心数据库”中.对于分布式缓存,.NET Core提供了针对 ...

  6. 006.Adding a controller to a ASP.NET Core MVC app with Visual Studio -- 【在asp.net core mvc 中添加一个控制器】

    Adding a controller to a ASP.NET Core MVC app with Visual Studio 在asp.net core mvc 中添加一个控制器 2017-2-2 ...

  7. 008.Adding a model to an ASP.NET Core MVC app --【在 asp.net core mvc 中添加一个model (模型)】

    Adding a model to an ASP.NET Core MVC app在 asp.net core mvc 中添加一个model (模型)2017-3-30 8 分钟阅读时长 本文内容1. ...

  8. ASP.NET Core Razor中处理Ajax请求

    如何ASP.NET Core Razor中处理Ajax请求 在ASP.NET Core Razor(以下简称Razor)刚出来的时候,看了一下官方的文档,一直没怎么用过.今天闲来无事,准备用Rozor ...

  9. 007.Adding a view to an ASP.NET Core MVC app -- 【在asp.net core mvc中添加视图】

    Adding a view to an ASP.NET Core MVC app 在asp.net core mvc中添加视图 2017-3-4 7 分钟阅读时长 本文内容 1.Changing vi ...

  10. ASP.NET Core WebAPI中的分析工具MiniProfiler

    介绍 作为一个开发人员,你知道如何分析自己开发的Api性能么? 在Visual Studio和Azure中, 我们可以使用Application Insight来监控项目.除此之外我们还可以使用一个免 ...

随机推荐

  1. 【Shell】字符串

    单引号和双引号 shell 字符串可以用单引号 '',也可以用双引号 "",也可以不用引号. 单引号的特点 单引号里不识别变量 单引号里不能出现单独的单引号(使用转义符也不行),但 ...

  2. String、StringBuffer、StringBuilder 的区别?

    一. 介绍 String.StringBuffer.StringBuilder: 前言: String.StringBuffer.StringBuilder 均在java.lang包下: String ...

  3. CRM系统化整合从N-1做减法实践

    1 背景 京销易系统已经接入大网.KA以及云仓三个条线商机,每个条线商机规则差异比较大,当前现状是独立实现三套系统分别做支撑. 2 目标 2022年下半年CRM目标是完成9个新条线业务接入,完成销售过 ...

  4. Ubutnu 20.04 安装和使用单机版hadoop 3.2 [转载]

    按照此文档操作,可以一次部署成功:Ubutnu 20.04 安装和使用单机版hadoop 3.2 部署之后,提交测试任务报资源问题.原因是yarn还需要配置,如下: $ cat yarn-site.x ...

  5. shell: xcall

    #!/bin/bash ips=( 1.1.1.2 1.1.1.1 ) port= user= passwd= for i in ${ips[@]} do echo "== $i ==&qu ...

  6. KVM VM 添加 usb 设备

    制作xml文件 参考链接:https://libvirt.org/formatdomain.html#usb-pci-scsi-devices <hostdev mode='subsystem' ...

  7. sqli笔记

    MySQL数据库自带的数据库information_schema 里面有三个比较重要的表  SCHEMATA .TABLES . COLUMNS  保存数据库所有的数据库名 表名  字段名 SCHEM ...

  8. 如何实现Excel中的多级数据联动

    摘要:本文由葡萄城技术团队于博客园原创并首发.转载请注明出处:葡萄城官网,葡萄城为开发者提供专业的开发工具.解决方案和服务,赋能开发者. 前言 在类Excel表格应用中,常用的需求场景是根据单元格之间 ...

  9. SpringBoot3.x原生镜像-Native Image实践

    前提 之前曾经写过一篇<SpringBoot3.x 原生镜像-Native Image 尝鲜>,当时SpringBoot处于3.0.0-M5版本,功能尚未稳定.这次会基于SpringBoo ...

  10. [ABC143E] Travel by Car

    2023-02-20 题目 题目传送门 翻译 翻译 难度&重要性(1~10):4.5 题目来源 AtCoder 题目算法 最短路 解题思路 我们枚举每一对点 \((u_i,v_i)\) 间的距 ...