using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading; using System.Threading.Tasks; using AuroraDesk.Core.Entities; using AuroraDesk.Core.Interfaces; using Microsoft.Extensions.Logging; namespace AuroraDesk.Infrastructure.Services; /// /// 基于系统 ssh 可执行文件的多会话管理服务,实现实时消息收发 /// public sealed class SshSessionService : ISshSessionService { private readonly ConcurrentDictionary _sessions = new(); private readonly ILogger? _logger; private readonly TimeSpan _shutdownGracePeriod = TimeSpan.FromSeconds(2); public SshSessionService(ILogger? logger = null) { _logger = logger; } public async Task StartSessionAsync(SshSessionOptions options, CancellationToken cancellationToken = default) { if (options is null) { throw new ArgumentNullException(nameof(options)); } ValidateOptions(options); var sessionId = Guid.NewGuid(); var displayName = string.IsNullOrWhiteSpace(options.DisplayName) ? $"{options.UserName}@{options.Host}:{options.Port}" : options.DisplayName; var info = new SshSessionInfo(sessionId, displayName, options.Host, options.Port, options.UserName, SshSessionStatus.Connecting, DateTime.UtcNow); var context = new SessionContext(info, options); if (!_sessions.TryAdd(sessionId, context)) { throw new InvalidOperationException("无法注册新的 SSH 会话。"); } try { await StartProcessAsync(context, cancellationToken).ConfigureAwait(false); context.UpdateStatus(SshSessionStatus.Connected); _logger?.LogInformation("SSH 会话 {SessionId} 已连接到 {User}@{Host}:{Port}", sessionId, options.UserName, options.Host, options.Port); return context.Info; } catch (Exception ex) { context.UpdateStatus(SshSessionStatus.Error); context.PublishSystemMessage($"启动会话失败: {ex.Message}", isError: true); _sessions.TryRemove(sessionId, out _); context.Dispose(); _logger?.LogError(ex, "启动 SSH 会话失败: {User}@{Host}:{Port}", options.UserName, options.Host, options.Port); throw; } } public async Task StopSessionAsync(Guid sessionId) { if (!_sessions.TryRemove(sessionId, out var context)) { return; } context.UpdateStatus(SshSessionStatus.Disconnected); context.PublishSystemMessage("会话已手动关闭。"); try { if (context.Process is { HasExited: false }) { await context.SendLock.WaitAsync().ConfigureAwait(false); try { await context.Process.StandardInput.WriteLineAsync("exit").ConfigureAwait(false); await context.Process.StandardInput.FlushAsync().ConfigureAwait(false); } finally { context.SendLock.Release(); } if (!context.Process.WaitForExit((int)_shutdownGracePeriod.TotalMilliseconds)) { context.Process.Kill(entireProcessTree: true); } } } catch (Exception ex) { _logger?.LogWarning(ex, "关闭 SSH 会话时出现异常: {SessionId}", sessionId); } finally { context.Complete(); context.Dispose(); } } public async Task SendAsync(Guid sessionId, string payload, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(payload)) { return false; } if (!_sessions.TryGetValue(sessionId, out var context)) { return false; } if (context.StatusSubject.Value != SshSessionStatus.Connected || context.Process is null || context.Process.HasExited) { context.PublishSystemMessage("会话未连接,无法发送数据。", isError: true); return false; } await context.SendLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { await context.Process.StandardInput.WriteLineAsync(payload).ConfigureAwait(false); await context.Process.StandardInput.FlushAsync().ConfigureAwait(false); context.PublishMessage(payload, SshMessageDirection.Outgoing); return true; } catch (Exception ex) { context.PublishSystemMessage($"发送失败: {ex.Message}", isError: true); _logger?.LogError(ex, "SSH 会话 {SessionId} 发送数据失败。", sessionId); return false; } finally { context.SendLock.Release(); } } public IReadOnlyCollection GetSessions() { return _sessions.Values.Select(context => context.Info).ToArray(); } public IObservable ObserveStatus(Guid sessionId) { if (_sessions.TryGetValue(sessionId, out var context)) { return context.StatusSubject.AsObservable(); } throw new KeyNotFoundException($"未找到会话 {sessionId}"); } public IObservable ObserveMessages(Guid sessionId) { if (_sessions.TryGetValue(sessionId, out var context)) { return context.MessageSubject.AsObservable(); } throw new KeyNotFoundException($"未找到会话 {sessionId}"); } public void Dispose() { foreach (var sessionId in _sessions.Keys.ToArray()) { try { StopSessionAsync(sessionId).GetAwaiter().GetResult(); } catch (Exception ex) { _logger?.LogWarning(ex, "释放 SshSessionService 时关闭会话 {SessionId} 失败。", sessionId); } } } private static void ValidateOptions(SshSessionOptions options) { if (string.IsNullOrWhiteSpace(options.Host)) { throw new ArgumentException("Host 不能为空。", nameof(options)); } if (options.Port is < 1 or > 65535) { throw new ArgumentException("端口必须在 1~65535 之间。", nameof(options)); } if (string.IsNullOrWhiteSpace(options.UserName)) { throw new ArgumentException("UserName 不能为空。", nameof(options)); } if (string.IsNullOrWhiteSpace(options.Password) && string.IsNullOrWhiteSpace(options.PrivateKeyPath)) { throw new ArgumentException("至少需要提供密码或私钥路径。", nameof(options)); } } private async Task StartProcessAsync(SessionContext context, CancellationToken cancellationToken) { var options = context.Options; var startInfo = new ProcessStartInfo { FileName = string.IsNullOrWhiteSpace(options.SshExecutablePath) ? "ssh" : options.SshExecutablePath, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; startInfo.ArgumentList.Add("-tt"); if (options.AllowAnyHostKey) { startInfo.ArgumentList.Add("-o"); startInfo.ArgumentList.Add("StrictHostKeyChecking=no"); startInfo.ArgumentList.Add("-o"); var nullDevice = OperatingSystem.IsWindows() ? "NUL" : "/dev/null"; startInfo.ArgumentList.Add($"UserKnownHostsFile={nullDevice}"); } startInfo.ArgumentList.Add("-p"); startInfo.ArgumentList.Add(options.Port.ToString(CultureInfo.InvariantCulture)); if (!string.IsNullOrWhiteSpace(options.PrivateKeyPath)) { startInfo.ArgumentList.Add("-i"); startInfo.ArgumentList.Add(options.PrivateKeyPath!); } startInfo.ArgumentList.Add($"{options.UserName}@{options.Host}"); if (!string.IsNullOrWhiteSpace(options.PreferredShell)) { startInfo.ArgumentList.Add(options.PreferredShell!); } var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; var started = process.Start(); if (!started) { throw new InvalidOperationException("无法启动 ssh 进程。"); } process.StandardInput.AutoFlush = true; context.AttachProcess(process); var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); context.AttachCancellation(linkedCts); _ = PumpStreamAsync(process, process.StandardOutput, context, SshMessageDirection.Incoming, linkedCts.Token, treatAsError: false); _ = PumpStreamAsync(process, process.StandardError, context, SshMessageDirection.System, linkedCts.Token, treatAsError: true); process.Exited += (_, _) => { linkedCts.Cancel(); var exitCode = process.ExitCode; var isError = exitCode != 0 && context.StatusSubject.Value != SshSessionStatus.Disconnected; var nextStatus = isError ? SshSessionStatus.Error : SshSessionStatus.Disconnected; context.UpdateStatus(nextStatus); context.PublishSystemMessage($"ssh 进程已退出 (代码 {exitCode}).", isError); _sessions.TryRemove(context.Info.SessionId, out _); context.Complete(); context.Dispose(); }; await Task.CompletedTask.ConfigureAwait(false); } private Task PumpStreamAsync( Process process, StreamReader reader, SessionContext context, SshMessageDirection direction, CancellationToken cancellationToken, bool treatAsError) { return Task.Run(async () => { try { while (!cancellationToken.IsCancellationRequested) { var line = await reader.ReadLineAsync().ConfigureAwait(false); if (line is null) { if (reader.EndOfStream) { break; } await Task.Delay(25, cancellationToken).ConfigureAwait(false); continue; } if (direction == SshMessageDirection.System) { if (!context.PasswordSent && !string.IsNullOrWhiteSpace(context.Options.Password) && ContainsPrompt(line, "password")) { await SendSecretAsync(context, context.Options.Password!, cancellationToken).ConfigureAwait(false); context.PasswordSent = true; context.PublishSystemMessage("已自动响应密码提示。"); continue; } if (!context.PassphraseSent && !string.IsNullOrWhiteSpace(context.Options.PrivateKeyPassphrase) && ContainsPrompt(line, "enter passphrase")) { await SendSecretAsync(context, context.Options.PrivateKeyPassphrase!, cancellationToken).ConfigureAwait(false); context.PassphraseSent = true; context.PublishSystemMessage("已自动响应私钥口令提示。"); continue; } if (line.Contains("Permission denied", StringComparison.OrdinalIgnoreCase)) { context.UpdateStatus(SshSessionStatus.Error); } } context.PublishMessage(line, direction, treatAsError); } } catch (OperationCanceledException) { // ignored } catch (Exception ex) { _logger?.LogError(ex, "SSH 会话 {SessionId} 读取流失败。", context.Info.SessionId); context.PublishSystemMessage($"读取数据失败: {ex.Message}", isError: true); context.UpdateStatus(SshSessionStatus.Error); } }, cancellationToken); } private static bool ContainsPrompt(string line, string keyword) { return line.Contains(keyword, StringComparison.OrdinalIgnoreCase) || line.TrimEnd().EndsWith(keyword + ":", StringComparison.OrdinalIgnoreCase); } private static async Task SendSecretAsync(SessionContext context, string secret, CancellationToken cancellationToken) { if (context.Process is null || context.Process.HasExited) { return; } await context.SendLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { await context.Process.StandardInput.WriteLineAsync(secret).ConfigureAwait(false); await context.Process.StandardInput.FlushAsync().ConfigureAwait(false); } finally { context.SendLock.Release(); } } private sealed class SessionContext : IDisposable { private readonly ReplaySubject _messageSubject = new(bufferSize: 200); private readonly BehaviorSubject _statusSubject; private readonly object _disposeLock = new(); private bool _disposed; public SessionContext(SshSessionInfo info, SshSessionOptions options) { Info = info; Options = options; _statusSubject = new BehaviorSubject(info.Status); SendLock = new SemaphoreSlim(1, 1); } public SshSessionInfo Info { get; private set; } public SshSessionOptions Options { get; } public Process? Process { get; private set; } public BehaviorSubject StatusSubject => _statusSubject; public ReplaySubject MessageSubject => _messageSubject; public SemaphoreSlim SendLock { get; } public CancellationTokenSource? Cancellation { get; private set; } public bool PasswordSent { get; set; } public bool PassphraseSent { get; set; } public void AttachProcess(Process process) { Process = process ?? throw new ArgumentNullException(nameof(process)); } public void AttachCancellation(CancellationTokenSource cts) { Cancellation = cts; } public void UpdateStatus(SshSessionStatus status) { Info = Info with { Status = status }; _statusSubject.OnNext(status); } public void PublishMessage(string content, SshMessageDirection direction, bool isErrorChannel = false) { var message = new SshMessage( Info.SessionId, direction, content, DateTime.UtcNow, isErrorChannel && direction == SshMessageDirection.System && !IsNonFatalWarning(content)); _messageSubject.OnNext(message); } public void PublishSystemMessage(string content, bool isError = false) { PublishMessage(content, SshMessageDirection.System, isError); } public void Complete() { if (!_messageSubject.IsDisposed) { _messageSubject.OnCompleted(); } if (!_statusSubject.IsDisposed) { _statusSubject.OnCompleted(); } } public void Dispose() { lock (_disposeLock) { if (_disposed) { return; } _disposed = true; try { Cancellation?.Cancel(); } catch { // ignored } finally { Cancellation?.Dispose(); } try { Process?.Dispose(); } catch { // ignored } SendLock.Dispose(); _messageSubject.Dispose(); _statusSubject.Dispose(); } } private static bool IsNonFatalWarning(string? content) { if (string.IsNullOrWhiteSpace(content)) { return false; } var trimmed = content.TrimStart(); return trimmed.StartsWith("Warning: Permanently added", StringComparison.OrdinalIgnoreCase); } } }