You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
514 lines
16 KiB
514 lines
16 KiB
|
1 month ago
|
using System;
|
||
|
|
using System.Collections.ObjectModel;
|
||
|
|
using System.Linq;
|
||
|
|
using System.Reactive;
|
||
|
|
using System.Reactive.Disposables;
|
||
|
|
using System.Reactive.Linq;
|
||
|
|
using System.Threading;
|
||
|
|
using System.Threading.Tasks;
|
||
|
|
using AuroraDesk.Core.Entities;
|
||
|
|
using AuroraDesk.Core.Interfaces;
|
||
|
|
using AuroraDesk.Presentation.ViewModels.Base;
|
||
|
|
using Microsoft.Extensions.Logging;
|
||
|
|
using ReactiveUI;
|
||
|
|
using Avalonia.Threading;
|
||
|
|
using System.Reactive.Threading.Tasks;
|
||
|
|
|
||
|
|
namespace AuroraDesk.Presentation.ViewModels.Pages;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// SSH 多客户端实时交互页面 ViewModel
|
||
|
|
/// </summary>
|
||
|
|
public sealed class SshPageViewModel : RoutableViewModel, IDisposable
|
||
|
|
{
|
||
|
|
private readonly ISshSessionService _sshSessionService;
|
||
|
|
private readonly ILogger<SshPageViewModel>? _logger;
|
||
|
|
private readonly ObservableCollection<SshSessionItemViewModel> _sessions = new();
|
||
|
|
|
||
|
|
private SshSessionItemViewModel? _selectedSession;
|
||
|
|
private string _host = "127.0.0.1";
|
||
|
|
private int _port = 22;
|
||
|
|
private string _userName = "root";
|
||
|
|
private string? _password;
|
||
|
|
private string? _privateKeyPath;
|
||
|
|
private string? _privateKeyPassphrase;
|
||
|
|
private string? _displayName;
|
||
|
|
private string? _preferredShell;
|
||
|
|
private string? _sshExecutablePath;
|
||
|
|
private bool _allowAnyHostKey = true;
|
||
|
|
private bool _isBusy;
|
||
|
|
private string? _statusMessage;
|
||
|
|
private SshAuthenticationMode _selectedAuthenticationMode = SshAuthenticationMode.Password;
|
||
|
|
|
||
|
|
public SshPageViewModel(
|
||
|
|
IScreen hostScreen,
|
||
|
|
ISshSessionService sshSessionService,
|
||
|
|
ILogger<SshPageViewModel>? logger = null)
|
||
|
|
: base(hostScreen, "ssh-manager")
|
||
|
|
{
|
||
|
|
_sshSessionService = sshSessionService;
|
||
|
|
_logger = logger;
|
||
|
|
|
||
|
|
AddSessionCommand = ReactiveCommand.CreateFromTask(AddSessionAsync,
|
||
|
|
this.WhenAnyValue(x => x.IsBusy, busy => !busy));
|
||
|
|
|
||
|
|
RemoveSessionCommand = ReactiveCommand.CreateFromTask<SshSessionItemViewModel>(RemoveSessionAsync);
|
||
|
|
|
||
|
|
SelectPasswordAuthCommand = ReactiveCommand.Create(() =>
|
||
|
|
{
|
||
|
|
SelectedAuthenticationMode = SshAuthenticationMode.Password;
|
||
|
|
});
|
||
|
|
SelectPrivateKeyAuthCommand = ReactiveCommand.Create(() =>
|
||
|
|
{
|
||
|
|
SelectedAuthenticationMode = SshAuthenticationMode.PrivateKey;
|
||
|
|
});
|
||
|
|
|
||
|
|
RefreshSnapshotCommand = ReactiveCommand.Create(RefreshSnapshot);
|
||
|
|
|
||
|
|
RefreshSnapshot();
|
||
|
|
}
|
||
|
|
|
||
|
|
public ObservableCollection<SshSessionItemViewModel> Sessions => _sessions;
|
||
|
|
|
||
|
|
public SshSessionItemViewModel? SelectedSession
|
||
|
|
{
|
||
|
|
get => _selectedSession;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _selectedSession, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string Host
|
||
|
|
{
|
||
|
|
get => _host;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _host, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public int Port
|
||
|
|
{
|
||
|
|
get => _port;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _port, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string UserName
|
||
|
|
{
|
||
|
|
get => _userName;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _userName, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? Password
|
||
|
|
{
|
||
|
|
get => _password;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _password, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? PrivateKeyPath
|
||
|
|
{
|
||
|
|
get => _privateKeyPath;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _privateKeyPath, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? PrivateKeyPassphrase
|
||
|
|
{
|
||
|
|
get => _privateKeyPassphrase;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _privateKeyPassphrase, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? DisplayName
|
||
|
|
{
|
||
|
|
get => _displayName;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _displayName, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? PreferredShell
|
||
|
|
{
|
||
|
|
get => _preferredShell;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _preferredShell, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? SshExecutablePath
|
||
|
|
{
|
||
|
|
get => _sshExecutablePath;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _sshExecutablePath, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool AllowAnyHostKey
|
||
|
|
{
|
||
|
|
get => _allowAnyHostKey;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _allowAnyHostKey, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool IsBusy
|
||
|
|
{
|
||
|
|
get => _isBusy;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _isBusy, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string? StatusMessage
|
||
|
|
{
|
||
|
|
get => _statusMessage;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _statusMessage, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public SshAuthenticationMode SelectedAuthenticationMode
|
||
|
|
{
|
||
|
|
get => _selectedAuthenticationMode;
|
||
|
|
private set
|
||
|
|
{
|
||
|
|
if (_selectedAuthenticationMode == value)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.RaiseAndSetIfChanged(ref _selectedAuthenticationMode, value);
|
||
|
|
this.RaisePropertyChanged(nameof(IsPasswordMode));
|
||
|
|
this.RaisePropertyChanged(nameof(IsPrivateKeyMode));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool IsPasswordMode => SelectedAuthenticationMode == SshAuthenticationMode.Password;
|
||
|
|
|
||
|
|
public bool IsPrivateKeyMode => SelectedAuthenticationMode == SshAuthenticationMode.PrivateKey;
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> AddSessionCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<SshSessionItemViewModel, Unit> RemoveSessionCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> SelectPasswordAuthCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> SelectPrivateKeyAuthCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> RefreshSnapshotCommand { get; }
|
||
|
|
|
||
|
|
public void Dispose()
|
||
|
|
{
|
||
|
|
foreach (var session in _sessions.ToArray())
|
||
|
|
{
|
||
|
|
session.Dispose();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task AddSessionAsync()
|
||
|
|
{
|
||
|
|
SshSessionItemViewModel? sessionViewModel = null;
|
||
|
|
try
|
||
|
|
{
|
||
|
|
IsBusy = true;
|
||
|
|
StatusMessage = "正在创建 SSH 会话...";
|
||
|
|
|
||
|
|
if (IsPasswordMode && string.IsNullOrWhiteSpace(Password))
|
||
|
|
{
|
||
|
|
StatusMessage = "请填写密码或切换至私钥登录。";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (IsPrivateKeyMode && string.IsNullOrWhiteSpace(PrivateKeyPath))
|
||
|
|
{
|
||
|
|
StatusMessage = "请提供私钥路径或切换至密码登录。";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var options = new SshSessionOptions(
|
||
|
|
Host,
|
||
|
|
Port,
|
||
|
|
UserName,
|
||
|
|
IsPasswordMode ? Password : null,
|
||
|
|
IsPrivateKeyMode ? PrivateKeyPath : null,
|
||
|
|
IsPrivateKeyMode ? PrivateKeyPassphrase : null,
|
||
|
|
DisplayName,
|
||
|
|
AllowAnyHostKey,
|
||
|
|
PreferredShell,
|
||
|
|
SshExecutablePath);
|
||
|
|
|
||
|
|
var info = await _sshSessionService.StartSessionAsync(options, CancellationToken.None).ConfigureAwait(false);
|
||
|
|
|
||
|
|
sessionViewModel = new SshSessionItemViewModel(info, _sshSessionService, _logger);
|
||
|
|
sessionViewModel.Initialize();
|
||
|
|
|
||
|
|
var firstStatus = await sessionViewModel
|
||
|
|
.WhenAnyValue(x => x.Status)
|
||
|
|
.SkipWhile(status => status == SshSessionStatus.Connecting)
|
||
|
|
.Take(1)
|
||
|
|
.Timeout(TimeSpan.FromSeconds(10))
|
||
|
|
.ToTask(CancellationToken.None)
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
|
||
|
|
if (firstStatus is SshSessionStatus.Error or SshSessionStatus.Disconnected)
|
||
|
|
{
|
||
|
|
var latestError = await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
sessionViewModel.Messages.LastOrDefault(m => m.IsError)?.Content);
|
||
|
|
sessionViewModel.Dispose();
|
||
|
|
|
||
|
|
StatusMessage = string.IsNullOrWhiteSpace(latestError)
|
||
|
|
? "创建会话失败:未能保持连接。"
|
||
|
|
: $"创建会话失败:{latestError}";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const string handshakeToken = "__aurora_ssh_handshake__";
|
||
|
|
var handshakeSent = await _sshSessionService.SendAsync(sessionViewModel.SessionId, $"echo {handshakeToken}", CancellationToken.None).ConfigureAwait(false);
|
||
|
|
if (!handshakeSent)
|
||
|
|
{
|
||
|
|
sessionViewModel.Dispose();
|
||
|
|
StatusMessage = "创建会话失败:无法发送握手命令。";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await _sshSessionService.ObserveMessages(sessionViewModel.SessionId)
|
||
|
|
.Where(m => m.Direction == SshMessageDirection.Incoming && m.Content.Contains(handshakeToken, StringComparison.Ordinal))
|
||
|
|
.Take(1)
|
||
|
|
.Timeout(TimeSpan.FromSeconds(5))
|
||
|
|
.ToTask(CancellationToken.None)
|
||
|
|
.ConfigureAwait(false);
|
||
|
|
}
|
||
|
|
catch (TimeoutException)
|
||
|
|
{
|
||
|
|
sessionViewModel.Dispose();
|
||
|
|
StatusMessage = "创建会话失败:握手命令无响应。";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await Task.Delay(TimeSpan.FromSeconds(0.5)).ConfigureAwait(false);
|
||
|
|
|
||
|
|
var stableStatus = await Dispatcher.UIThread.InvokeAsync(() => sessionViewModel.Status);
|
||
|
|
if (stableStatus is SshSessionStatus.Error or SshSessionStatus.Disconnected)
|
||
|
|
{
|
||
|
|
var latestError = await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
sessionViewModel.Messages.LastOrDefault(m => m.IsError)?.Content);
|
||
|
|
sessionViewModel.Dispose();
|
||
|
|
|
||
|
|
StatusMessage = string.IsNullOrWhiteSpace(latestError)
|
||
|
|
? "创建会话失败:未能保持连接。"
|
||
|
|
: $"创建会话失败:{latestError}";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
_sessions.Add(sessionViewModel);
|
||
|
|
SelectedSession = sessionViewModel;
|
||
|
|
});
|
||
|
|
|
||
|
|
StatusMessage = $"会话 {sessionViewModel.DisplayName} 已创建。";
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
StatusMessage = $"创建会话失败: {ex.Message}";
|
||
|
|
_logger?.LogError(ex, "创建 SSH 会话失败。");
|
||
|
|
sessionViewModel?.Dispose();
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
IsBusy = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task RemoveSessionAsync(SshSessionItemViewModel? session)
|
||
|
|
{
|
||
|
|
if (session is null)
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
_logger?.LogInformation("准备移除 SSH 会话 {SessionId}", session.SessionId);
|
||
|
|
await _sshSessionService.StopSessionAsync(session.SessionId).ConfigureAwait(false);
|
||
|
|
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
_sessions.Remove(session);
|
||
|
|
if (ReferenceEquals(SelectedSession, session))
|
||
|
|
{
|
||
|
|
SelectedSession = _sessions.LastOrDefault();
|
||
|
|
}
|
||
|
|
|
||
|
|
session.Dispose();
|
||
|
|
StatusMessage = $"会话 {session.DisplayName} 已移除。";
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private void RefreshSnapshot()
|
||
|
|
{
|
||
|
|
var snapshot = _sshSessionService.GetSessions();
|
||
|
|
|
||
|
|
foreach (var session in _sessions.ToArray())
|
||
|
|
{
|
||
|
|
if (snapshot.All(x => x.SessionId != session.SessionId))
|
||
|
|
{
|
||
|
|
session.Dispose();
|
||
|
|
_sessions.Remove(session);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var info in snapshot)
|
||
|
|
{
|
||
|
|
if (_sessions.All(x => x.SessionId != info.SessionId))
|
||
|
|
{
|
||
|
|
var vm = new SshSessionItemViewModel(info, _sshSessionService, _logger);
|
||
|
|
vm.Initialize();
|
||
|
|
_sessions.Add(vm);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (SelectedSession == null || !_sessions.Contains(SelectedSession))
|
||
|
|
{
|
||
|
|
SelectedSession = _sessions.FirstOrDefault();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 表示单个 SSH 会话的视图模型
|
||
|
|
/// </summary>
|
||
|
|
public sealed class SshSessionItemViewModel : ReactiveObject, IDisposable
|
||
|
|
{
|
||
|
|
private readonly ISshSessionService _sshSessionService;
|
||
|
|
private readonly ILogger? _logger;
|
||
|
|
private readonly CompositeDisposable _disposables = new();
|
||
|
|
private readonly ObservableCollection<SshMessageEntry> _messages = new();
|
||
|
|
|
||
|
|
private string _displayName;
|
||
|
|
private SshSessionStatus _status;
|
||
|
|
private string _outgoingText = string.Empty;
|
||
|
|
private DateTime _createdAtUtc;
|
||
|
|
|
||
|
|
public SshSessionItemViewModel(SshSessionInfo info, ISshSessionService sshSessionService, ILogger? logger = null)
|
||
|
|
{
|
||
|
|
_sshSessionService = sshSessionService;
|
||
|
|
_logger = logger;
|
||
|
|
SessionId = info.SessionId;
|
||
|
|
_displayName = info.DisplayName;
|
||
|
|
_status = info.Status;
|
||
|
|
_createdAtUtc = info.CreatedAtUtc;
|
||
|
|
|
||
|
|
SendCommand = ReactiveCommand.CreateFromTask(SendAsync,
|
||
|
|
this.WhenAnyValue(x => x.Status, x => x.OutgoingText,
|
||
|
|
(status, text) => status == SshSessionStatus.Connected && !string.IsNullOrWhiteSpace(text)));
|
||
|
|
|
||
|
|
DisconnectCommand = ReactiveCommand.CreateFromTask(DisconnectAsync,
|
||
|
|
this.WhenAnyValue(x => x.Status, status => status is SshSessionStatus.Connected or SshSessionStatus.Connecting));
|
||
|
|
}
|
||
|
|
|
||
|
|
public Guid SessionId { get; }
|
||
|
|
|
||
|
|
public ObservableCollection<SshMessageEntry> Messages => _messages;
|
||
|
|
|
||
|
|
public string DisplayName
|
||
|
|
{
|
||
|
|
get => _displayName;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _displayName, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public SshSessionStatus Status
|
||
|
|
{
|
||
|
|
get => _status;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _status, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public string OutgoingText
|
||
|
|
{
|
||
|
|
get => _outgoingText;
|
||
|
|
set => this.RaiseAndSetIfChanged(ref _outgoingText, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public DateTime CreatedAtUtc
|
||
|
|
{
|
||
|
|
get => _createdAtUtc;
|
||
|
|
private set => this.RaiseAndSetIfChanged(ref _createdAtUtc, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
public bool IsConnected => Status == SshSessionStatus.Connected;
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> SendCommand { get; }
|
||
|
|
|
||
|
|
public ReactiveCommand<Unit, Unit> DisconnectCommand { get; }
|
||
|
|
|
||
|
|
public void Initialize()
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var statusSubscription = _sshSessionService
|
||
|
|
.ObserveStatus(SessionId)
|
||
|
|
.ObserveOn(RxApp.MainThreadScheduler)
|
||
|
|
.Subscribe(status =>
|
||
|
|
{
|
||
|
|
Status = status;
|
||
|
|
});
|
||
|
|
|
||
|
|
_disposables.Add(statusSubscription);
|
||
|
|
|
||
|
|
var messageSubscription = _sshSessionService
|
||
|
|
.ObserveMessages(SessionId)
|
||
|
|
.ObserveOn(RxApp.MainThreadScheduler)
|
||
|
|
.Subscribe(message =>
|
||
|
|
{
|
||
|
|
var entry = SshMessageEntry.FromMessage(message);
|
||
|
|
_messages.Add(entry);
|
||
|
|
});
|
||
|
|
|
||
|
|
_disposables.Add(messageSubscription);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
_logger?.LogError(ex, "订阅 SSH 会话 {SessionId} 流失败。", SessionId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task SendAsync()
|
||
|
|
{
|
||
|
|
var text = OutgoingText;
|
||
|
|
if (string.IsNullOrWhiteSpace(text))
|
||
|
|
{
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var result = await _sshSessionService.SendAsync(SessionId, text, CancellationToken.None).ConfigureAwait(false);
|
||
|
|
if (result)
|
||
|
|
{
|
||
|
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||
|
|
{
|
||
|
|
OutgoingText = string.Empty;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private Task DisconnectAsync()
|
||
|
|
{
|
||
|
|
return _sshSessionService.StopSessionAsync(SessionId);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void Dispose()
|
||
|
|
{
|
||
|
|
_disposables.Dispose();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// UI 消息项
|
||
|
|
/// </summary>
|
||
|
|
public sealed record SshMessageEntry(
|
||
|
|
Guid SessionId,
|
||
|
|
string Timestamp,
|
||
|
|
SshMessageDirection Direction,
|
||
|
|
string Content,
|
||
|
|
bool IsError)
|
||
|
|
{
|
||
|
|
public static SshMessageEntry FromMessage(SshMessage message)
|
||
|
|
{
|
||
|
|
var timestamp = message.Timestamp.ToLocalTime().ToString("HH:mm:ss");
|
||
|
|
return new SshMessageEntry(
|
||
|
|
message.SessionId,
|
||
|
|
timestamp,
|
||
|
|
message.Direction,
|
||
|
|
message.Content,
|
||
|
|
message.IsError);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public enum SshAuthenticationMode
|
||
|
|
{
|
||
|
|
Password,
|
||
|
|
PrivateKey
|
||
|
|
}
|
||
|
|
|
||
|
|
|