using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reactive.Linq; using System.Reactive.Subjects; using System.Text; using System.Threading; using System.Threading.Tasks; using AuroraDesk.Core.Entities; using AuroraDesk.Core.Interfaces; using Microsoft.Extensions.Logging; namespace AuroraDesk.Infrastructure.Services; /// /// 基于 plink.exe 的多会话管理服务,实现实时消息收发 /// public sealed class PlinkSessionService : IPlinkSessionService { private readonly ConcurrentDictionary _sessions = new(); private readonly ILogger? _logger; private readonly TimeSpan _shutdownGracePeriod = TimeSpan.FromSeconds(2); public PlinkSessionService(ILogger? logger = null) { _logger = logger; } public async Task StartSessionAsync(PlinkSessionOptions 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 PlinkSessionInfo(sessionId, displayName, options.Host, options.Port, options.UserName, PlinkSessionStatus.Connecting, DateTime.UtcNow); var context = new SessionContext(info, options); if (!_sessions.TryAdd(sessionId, context)) { throw new InvalidOperationException("无法注册新的 Plink 会话。"); } try { await StartProcessAsync(context, cancellationToken).ConfigureAwait(false); context.UpdateStatus(PlinkSessionStatus.Connected); _logger?.LogInformation("Plink 会话 {SessionId} 已连接到 {User}@{Host}:{Port}", sessionId, options.UserName, options.Host, options.Port); return context.Info; } catch (Exception ex) { context.UpdateStatus(PlinkSessionStatus.Error); context.PublishSystemMessage($"启动会话失败: {ex.Message}", isError: true); _sessions.TryRemove(sessionId, out _); context.Dispose(); _logger?.LogError(ex, "启动 Plink 会话失败: {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(PlinkSessionStatus.Disconnected); context.PublishSystemMessage("会话已手动关闭。"); try { await ShutdownProcessAsync(context).ConfigureAwait(false); } catch (Exception ex) { _logger?.LogWarning(ex, "关闭 Plink 会话时出现异常: {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 != PlinkSessionStatus.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, PlinkMessageDirection.Outgoing); return true; } catch (Exception ex) { context.PublishSystemMessage($"发送失败: {ex.Message}", isError: true); _logger?.LogError(ex, "Plink 会话 {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, "释放 PlinkSessionService 时关闭会话 {SessionId} 失败。", sessionId); } } } private static void ValidateOptions(PlinkSessionOptions 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)); } } private async Task StartProcessAsync(SessionContext context, CancellationToken cancellationToken) { var options = context.Options; var startInfo = new ProcessStartInfo { FileName = string.IsNullOrWhiteSpace(options.PlinkExecutablePath) ? "plink" : options.PlinkExecutablePath, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; if (!string.IsNullOrWhiteSpace(options.PrivateKeyPath)) { startInfo.ArgumentList.Add("-i"); startInfo.ArgumentList.Add(options.PrivateKeyPath); } startInfo.ArgumentList.Add("-ssh"); startInfo.ArgumentList.Add("-P"); startInfo.ArgumentList.Add(options.Port.ToString()); startInfo.ArgumentList.Add("-l"); startInfo.ArgumentList.Add(options.UserName); if (!string.IsNullOrWhiteSpace(options.Password)) { startInfo.ArgumentList.Add("-pw"); startInfo.ArgumentList.Add(options.Password); } if (options.AllowAnyHostKey) { startInfo.ArgumentList.Add("-batch"); startInfo.ArgumentList.Add("-noagent"); } if (!string.IsNullOrWhiteSpace(options.AdditionalArguments)) { // 将附加参数拆分为 tokens,简化处理避免 shell 注入 foreach (var token in SplitArguments(options.AdditionalArguments)) { startInfo.ArgumentList.Add(token); } } startInfo.ArgumentList.Add(options.Host); var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; var processStarted = process.Start(); if (!processStarted) { throw new InvalidOperationException("无法启动 plink 进程。"); } process.StandardInput.AutoFlush = true; context.AttachProcess(process); process.Exited += (_, _) => { context.UpdateStatus(PlinkSessionStatus.Disconnected); context.PublishSystemMessage("plink 进程已退出。"); // 如果由外部事件触发退出,则确保从集合中移除 _sessions.TryRemove(context.Info.SessionId, out _); context.Complete(); context.Dispose(); }; _ = Task.Run(() => PumpStreamAsync(process.StandardOutput, context, PlinkMessageDirection.Incoming), cancellationToken); _ = Task.Run(() => PumpStreamAsync(process.StandardError, context, PlinkMessageDirection.System, isErrorChannel: true), cancellationToken); await Task.Delay(150, cancellationToken).ConfigureAwait(false); } private async Task ShutdownProcessAsync(SessionContext context) { if (context.Process is null) { return; } try { if (!context.Process.HasExited) { 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 (InvalidOperationException) { // 进程可能已经退出 } } private static async Task PumpStreamAsync(StreamReader reader, SessionContext context, PlinkMessageDirection direction, bool isErrorChannel = false) { try { while (true) { var line = await reader.ReadLineAsync().ConfigureAwait(false); if (line is null) { break; } context.PublishMessage(line, direction, isErrorChannel); } } catch (ObjectDisposedException) { // reader 已释放 } catch (IOException) { // 通信流关闭 } catch (Exception ex) { context.PublishSystemMessage($"读取 {(isErrorChannel ? "错误" : "输出")} 流失败: {ex.Message}", isError: true); } } private static IEnumerable SplitArguments(string arguments) { if (string.IsNullOrWhiteSpace(arguments)) { yield break; } var current = new StringBuilder(); var inQuotes = false; foreach (var ch in arguments) { switch (ch) { case '"': inQuotes = !inQuotes; break; case ' ' when !inQuotes: if (current.Length > 0) { yield return current.ToString(); current.Clear(); } break; default: current.Append(ch); break; } } if (current.Length > 0) { yield return current.ToString(); } } private sealed class SessionContext : IDisposable { private bool _disposed; public SessionContext(PlinkSessionInfo info, PlinkSessionOptions options) { Info = info; Options = options; StatusSubject = new BehaviorSubject(info.Status); MessageSubject = new ReplaySubject(bufferSize: 200); } public PlinkSessionInfo Info { get; private set; } public PlinkSessionOptions Options { get; } public Process? Process { get; private set; } public BehaviorSubject StatusSubject { get; } public ReplaySubject MessageSubject { get; } public SemaphoreSlim SendLock { get; } = new(1, 1); public void AttachProcess(Process process) { Process = process ?? throw new ArgumentNullException(nameof(process)); } public void UpdateStatus(PlinkSessionStatus status) { Info = Info with { Status = status }; StatusSubject.OnNext(status); } public void PublishMessage(string content, PlinkMessageDirection direction, bool isErrorChannel = false) { var message = new PlinkMessage(Info.SessionId, direction, content, DateTime.UtcNow, isErrorChannel && direction == PlinkMessageDirection.System); MessageSubject.OnNext(message); } public void PublishSystemMessage(string content, bool isError = false) { PublishMessage(content, PlinkMessageDirection.System, isError); } public void Complete() { if (!MessageSubject.IsDisposed) { MessageSubject.OnCompleted(); } if (!StatusSubject.IsDisposed) { StatusSubject.OnCompleted(); } } public void Dispose() { if (_disposed) { return; } _disposed = true; try { Process?.Dispose(); } catch { // ignored } SendLock.Dispose(); MessageSubject.Dispose(); StatusSubject.Dispose(); } } }