一区二区三区在线-一区二区三区亚洲视频-一区二区三区亚洲-一区二区三区午夜-一区二区三区四区在线视频-一区二区三区四区在线免费观看

服務器之家:專注于服務器技術及軟件下載分享
分類導航

PHP教程|ASP.NET教程|Java教程|ASP教程|編程技術|正則表達式|C/C++|IOS|C#|Swift|Android|VB|R語言|JavaScript|易語言|vb.net|

服務器之家 - 編程語言 - ASP.NET教程 - Web SSH 的原理與在 ASP.NET Core SignalR 中的實現

Web SSH 的原理與在 ASP.NET Core SignalR 中的實現

2023-12-20 00:09未知服務器之家 ASP.NET教程

前言 有個項目,需要在前端有個管理終端可以 SSH 到主控機的終端,如果不考慮用戶使用 vim 等需要在控制臺內現實界面的軟件的話,其實使用 Process 類型去啟動相應程序就夠了。而這次的需求則需要考慮用戶會做相關設置。 原理

前言

有個項目,需要在前端有個管理終端可以 SSH 到主控機的終端,如果不考慮用戶使用 vim 等需要在控制臺內現實界面的軟件的話,其實使用 Process 類型去啟動相應程序就夠了。而這次的需求則需要考慮用戶會做相關設置。

原理

這里用到的原理是偽終端。偽終端(pseudo terminal)是現代操作系統的一個功能,他會模擬一對輸入輸出設備來模擬終端環境去執行相應的進程。偽終端通常會給相應的進程提供例如環境變量或文件等來告知他在終端中運行,這樣像 vim 這樣的程序可以在最后一行輸出命令菜單或者像 npm / pip 這樣的程序可以打印炫酷的進度條。通常在我們直接創建子進程的時候,在 Linux 上系統自帶了 openpty 方法可以打開偽終端,而在 Windows 上則等到 Windows Terminal 推出后才出現了真正的系統級偽終端。下面付一張來自微軟博客的偽終端原理圖,Linux 上的原理與之類似

Web SSH 的原理與在 ASP.NET Core SignalR 中的實現

基本設計

建立連接與監聽終端輸出

Web SSH 的原理與在 ASP.NET Core SignalR 中的實現

監聽前端輸入

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

延伸 · 閱讀

精彩推薦
主站蜘蛛池模板: 倩女还魂在线观看完整版免费 | 国产精品久久久久久久牛牛 | 农村妇女野外牲交一级毛片 | 77成人影视 | 大又大又黄又爽免费毛片 | 99久久综合久中文字幕 | 香蕉精品国产高清自在自线 | 青青青视频免费线看 视频 青青青青青国产免费手机看视频 | 四虎免费影院ww4164h | 嫩草影院国产 | 国产精品久久久久久久久久久久 | 亚洲天堂成人在线观看 | 九九热在线观看视频 | 黄漫免费观看 | 白丝尤物的下面被疯狂蹂躏 | 碰91精品国产91久久婷婷 | 98精品全国免费观看视频 | 无码专区aaaaaa免费视频 | 婷婷伊人综合亚洲综合网 | 日韩成人av在线 | 久久国产乱子伦免费精品 | 欧美xbxbxbxb大片 | videodesexo中国妞| 日本一级不卡一二三区免费 | 天天做天天爱天天综合网 | 国产一区二区播放 | 欧美人xxxxxbbbb | 国产成人高清精品免费5388密 | 性xxxx直播放免费 | 5x社区在线观看直接进入 | 色偷偷亚洲男人 | 国产在线综合网 | 欧亚专线欧洲m码可遇不可求 | 青青成人 | 日日骑夜夜骑 | 精品一区二区高清在线观看 | 草草影院国产 | 69av导航| 国产v在线在线观看羞羞答答 | 青草青草久热精品视频在线网站 | 亚洲AV永久无码精品澳门 |