diff --git a/src/W-Fix.Core/Abstractions/PairRepairContracts.cs b/src/W-Fix.Core/Abstractions/PairRepairContracts.cs new file mode 100644 index 0000000..e40fbd0 --- /dev/null +++ b/src/W-Fix.Core/Abstractions/PairRepairContracts.cs @@ -0,0 +1,154 @@ +using System.Net; +using WFix.Core.Models; + +namespace WFix.Core.Abstractions; + +public interface IPairInvitationValidator +{ + void Validate(PairInvitation invitation, DateTimeOffset now); +} + +public interface IPairFileService +{ + Task WriteInvitationAsync(string path, PairInvitation invitation, CancellationToken cancellationToken = default); + Task ReadInvitationAsync(string path, CancellationToken cancellationToken = default); + Task WriteOfflineSnapshotAsync(string path, PairEndpointSnapshot snapshot, CancellationToken cancellationToken = default); + Task ReadOfflineSnapshotAsync(string path, CancellationToken cancellationToken = default); +} + +public interface IPairSession : IAsyncDisposable +{ + PairInvitation Invitation { get; } + PairEndpointRole LocalRole { get; } + PairSessionState State { get; } + string ConfirmationCode { get; } + + Task ApproveAsync(bool approved, CancellationToken cancellationToken = default); + Task SendAsync(PairMessageKind kind, T message, CancellationToken cancellationToken = default); + Task ReceiveAsync(PairMessageKind expectedKind, CancellationToken cancellationToken = default); +} + +public interface IPairHost : IAsyncDisposable +{ + PairInvitation Invitation { get; } + Task AcceptAsync(CancellationToken cancellationToken = default); +} + +public sealed record PairHostOptions +{ + public string HostComputerName { get; init; } = Environment.MachineName; + public IReadOnlyList? ListenAddresses { get; init; } + public string? PrinterName { get; init; } + public string? ShareName { get; init; } + public TimeSpan InvitationLifetime { get; init; } = TimeSpan.FromMinutes(15); +} + +public interface IPairSessionTransport +{ + Task StartHostAsync(PairHostOptions options, CancellationToken cancellationToken = default); + Task JoinAsync(PairInvitation invitation, CancellationToken cancellationToken = default); +} + +public interface IPairFirewallLeaseService +{ + Task OpenAsync(string sessionId, int port, string executablePath, CancellationToken cancellationToken = default); + Task CleanupStaleAsync(CancellationToken cancellationToken = default); +} + +public interface IPairInventoryService +{ + Task CaptureAsync( + TargetDescriptor target, + PairEndpointRole role, + string peerName, + string? printerName = null, + string? shareName = null, + CancellationToken cancellationToken = default); +} + +public interface IPairDiagnosticRule +{ + string Id { get; } + Task> EvaluateAsync( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + CancellationToken cancellationToken = default); +} + +public interface IPairDiagnosticService +{ + Task> DiagnoseAsync( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + CancellationToken cancellationToken = default); +} + +public interface IPairRepairPlanner +{ + PairRepairPlan CreatePlan( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + PairTransportMode transportMode, + IReadOnlyList findings, + bool includeExpertActions = false); +} + +public sealed record PairActionContext( + TargetDescriptor Target, + PairRepairStep Step, + string RunDirectory, + IProgress? Progress = null); + +public interface IPairRepairAction +{ + string Id { get; } + string Name { get; } + RepairRisk Risk { get; } + bool ExpertOnly { get; } + bool IsIdempotent { get; } + + Task PrepareAsync(PairActionContext context, CancellationToken cancellationToken = default); + Task ExecuteAsync(PairActionContext context, CancellationToken cancellationToken = default); + Task VerifyAsync(PairActionContext context, CancellationToken cancellationToken = default); + Task RollbackAsync(PairActionContext context, PairActionCheckpoint checkpoint, CancellationToken cancellationToken = default); +} + +public interface IPairRepairActionRegistry +{ + IReadOnlyList GetAll(); + IPairRepairAction? Get(string actionId); +} + +public interface IPairActionDispatcher +{ + Task PrepareAsync(PairActionContext context, CancellationToken cancellationToken = default); + Task ExecuteAsync(PairActionContext context, CancellationToken cancellationToken = default); + Task VerifyAsync(PairActionContext context, CancellationToken cancellationToken = default); + Task RollbackAsync(PairActionContext context, PairActionCheckpoint checkpoint, CancellationToken cancellationToken = default); + Task CompleteAsync(bool commit, CancellationToken cancellationToken = default) => Task.CompletedTask; +} + +public interface IPairAgentCommandLoop +{ + Task RunAsync(IPairSession session, CancellationToken cancellationToken = default); +} + +public interface IPairRepairExecutor +{ + Task ExecuteAsync( + PairRepairPlan plan, + IReadOnlyDictionary targets, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +public interface INetworkCredentialProvisioner +{ + Task SaveForHostAsync(string hostName, NetworkCredential credential, CancellationToken cancellationToken = default); + Task DeleteForHostAsync(string hostName, CancellationToken cancellationToken = default); +} + +public interface IPairRunReportService +{ + Task WriteAsync(PairRun run, CancellationToken cancellationToken = default); +} diff --git a/src/W-Fix.Core/Infrastructure/WindowsNetworkCredentialProvisioner.cs b/src/W-Fix.Core/Infrastructure/WindowsNetworkCredentialProvisioner.cs new file mode 100644 index 0000000..c97e068 --- /dev/null +++ b/src/W-Fix.Core/Infrastructure/WindowsNetworkCredentialProvisioner.cs @@ -0,0 +1,98 @@ +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using WFix.Core.Abstractions; + +namespace WFix.Core.Infrastructure; + +public sealed class WindowsNetworkCredentialProvisioner : INetworkCredentialProvisioner +{ + private const int CredentialTypeDomainPassword = 2; + private const int CredentialPersistLocalMachine = 2; + private const int ErrorNotFound = 1168; + + public Task SaveForHostAsync(string hostName, NetworkCredential credential, CancellationToken cancellationToken = default) + { + var normalized = NormalizeHost(hostName); + ArgumentNullException.ThrowIfNull(credential); + if (string.IsNullOrWhiteSpace(credential.UserName) || string.IsNullOrEmpty(credential.Password)) + throw new ArgumentException("Для SMB-подключения нужны имя пользователя и непустой пароль.", nameof(credential)); + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => Write(normalized, credential), cancellationToken); + } + + public Task DeleteForHostAsync(string hostName, CancellationToken cancellationToken = default) + { + var normalized = NormalizeHost(hostName); + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => + { + if (CredDelete(normalized, CredentialTypeDomainPassword, 0)) return; + var error = Marshal.GetLastWin32Error(); + if (error != ErrorNotFound) throw new Win32Exception(error); + }, cancellationToken); + } + + private static void Write(string hostName, NetworkCredential credential) + { + var bytes = Encoding.Unicode.GetBytes(credential.Password); + if (bytes.Length > 512) throw new ArgumentException("Пароль превышает ограничение Windows Credential Manager.", nameof(credential)); + var pointer = Marshal.AllocCoTaskMem(bytes.Length); + try + { + Marshal.Copy(bytes, 0, pointer, bytes.Length); + var native = new NativeCredential + { + Type = CredentialTypeDomainPassword, + TargetName = hostName, + UserName = credential.UserName, + CredentialBlob = pointer, + CredentialBlobSize = bytes.Length, + Persist = CredentialPersistLocalMachine, + Comment = "W-Fix Pair Repair: authenticated SMB access to the selected printer host" + }; + if (!CredWrite(ref native, 0)) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + Marshal.FreeCoTaskMem(pointer); + } + } + + private static string NormalizeHost(string hostName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(hostName); + var value = hostName.Trim().TrimStart('\\').TrimEnd('.').ToUpperInvariant(); + if (value.Length is 0 or > 255 || value.Contains('\\') || value.Contains('/') || IPAddress.TryParse(value, out _)) + throw new ArgumentException("SMB credential должен быть привязан к имени компьютера, а не к IP или UNC-пути.", nameof(hostName)); + return value; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NativeCredential + { + public int Flags; + public int Type; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string? Comment; + public long LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string? TargetAlias; + [MarshalAs(UnmanagedType.LPWStr)] public string UserName; + } + + [DllImport("Advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredWrite([In] ref NativeCredential credential, int flags); + + [DllImport("Advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredDelete(string target, int type, int flags); +} diff --git a/src/W-Fix.Core/Models/PairRepairModels.cs b/src/W-Fix.Core/Models/PairRepairModels.cs new file mode 100644 index 0000000..18efe52 --- /dev/null +++ b/src/W-Fix.Core/Models/PairRepairModels.cs @@ -0,0 +1,232 @@ +using System.Text.Json.Serialization; + +namespace WFix.Core.Models; + +public enum PairEndpointRole +{ + Host, + Client +} + +public enum PairTransportMode +{ + DomainRemote, + LiveLan, + Offline +} + +public enum PairSessionState +{ + Waiting, + Connected, + AwaitingApproval, + Approved, + Closed, + Failed +} + +public sealed record PairEndpointDescriptor +{ + public required PairEndpointRole Role { get; init; } + public required string ComputerName { get; init; } + public string? Fqdn { get; init; } + public bool IsLocalAgent { get; init; } + + [JsonIgnore] + public string ConnectionName => string.IsNullOrWhiteSpace(Fqdn) ? ComputerName : Fqdn; +} + +public sealed record PairInvitation +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public required string SessionId { get; init; } + public required string HostComputerName { get; init; } + public IReadOnlyList HostAddresses { get; init; } = []; + public int Port { get; init; } + public required string CertificatePublicKeySha256 { get; init; } + public required string ConfirmationCode { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public string? PrinterName { get; init; } + public string? ShareName { get; init; } +} + +public sealed record PairOfflineBundle +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public required string BundleId { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public required string SnapshotPayloadBase64 { get; init; } + public required string SigningPublicKeyBase64 { get; init; } + public required string SignatureBase64 { get; init; } +} + +public sealed record PairEndpointSnapshot +{ + public required PairEndpointDescriptor Endpoint { get; init; } + public DateTimeOffset CapturedAt { get; init; } = DateTimeOffset.UtcNow; + public string OperatingSystem { get; init; } = ""; + public string OperatingSystemVersion { get; init; } = ""; + public int BuildNumber { get; init; } + public int UpdateBuildRevision { get; init; } + public bool DomainJoined { get; init; } + public string DomainOrWorkgroup { get; init; } = ""; + public string NetworkProfile { get; init; } = "Unknown"; + public IReadOnlyList Ipv4Addresses { get; init; } = []; + public bool PeerNameResolved { get; init; } + public bool SmbPortReachable { get; init; } + public bool RpcEndpointMapperReachable { get; init; } + public bool SpoolerRunning { get; init; } + public IReadOnlyDictionary ServiceStates { get; init; } = new Dictionary(); + public bool NetworkDiscoveryFirewallEnabled { get; init; } + public bool FileAndPrinterSharingFirewallEnabled { get; init; } + public bool SmbSigningRequired { get; init; } + public bool InsecureGuestLogonsEnabled { get; init; } + public bool HasConflictingSmbConnection { get; init; } + public string? SmbConnectionError { get; init; } + public bool RpcOverNamedPipes { get; init; } + public bool RpcListenerAllowsNamedPipes { get; init; } + public bool RpcPrivacyDisabled { get; init; } + public bool RestrictDriverInstallationToAdministrators { get; init; } = true; + public string? PrinterName { get; init; } + public string? PrinterShareName { get; init; } + public bool PrinterShared { get; init; } + public string? PrinterDriverName { get; init; } + public string? PrinterDriverVersion { get; init; } + public bool PrinterConnectionInstalled { get; init; } + public IReadOnlyList RecentErrors { get; init; } = []; +} + +public sealed record PairDiagnosticFinding +{ + public required string RuleId { get; init; } + public required string Title { get; init; } + public required string Description { get; init; } + public FindingSeverity Severity { get; init; } + public double Confidence { get; init; } + public IReadOnlyList AffectedEndpoints { get; init; } = []; + public IReadOnlyList Evidence { get; init; } = []; + public IReadOnlyList RecommendedActionIds { get; init; } = []; + public bool ExpertOnly { get; init; } + public Uri? OfficialSource { get; init; } +} + +public sealed record PairRepairStep +{ + public required string Id { get; init; } + public required string ActionId { get; init; } + public required PairEndpointRole Endpoint { get; init; } + public required string Title { get; init; } + public string Description { get; init; } = ""; + public RepairRisk Risk { get; init; } + public bool ExpertOnly { get; init; } + public IReadOnlyDictionary Parameters { get; init; } = new Dictionary(); + public IReadOnlyList DependsOn { get; init; } = []; + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(45); + + public bool RequiresAdditionalConfirmation => ExpertOnly || Risk is RepairRisk.Disruptive or RepairRisk.Irreversible; +} + +public sealed record PairRepairPlan +{ + public required string Id { get; init; } + public required PairEndpointDescriptor Host { get; init; } + public required PairEndpointDescriptor Client { get; init; } + public PairTransportMode TransportMode { get; init; } + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public IReadOnlyList Findings { get; init; } = []; + public IReadOnlyList Steps { get; init; } = []; + + public bool RequiresAdditionalConfirmation => Steps.Any(step => step.RequiresAdditionalConfirmation); +} + +public sealed record PairActionCheckpoint +{ + public required string ActionId { get; init; } + public required PairEndpointRole Endpoint { get; init; } + [JsonIgnore] + public string? SnapshotPath { get; init; } + [JsonIgnore] + public IReadOnlyDictionary State { get; init; } = new Dictionary(); +} + +public sealed record PairActionResult +{ + public bool Success { get; init; } + public bool Verified { get; init; } + public string Summary { get; init; } = ""; + public IReadOnlyList Output { get; init; } = []; + public bool RequiresReboot { get; init; } +} + +public sealed record PairStepResult +{ + public required string StepId { get; init; } + public required string ActionId { get; init; } + public required PairEndpointRole Endpoint { get; init; } + public bool Succeeded { get; init; } + public bool Verified { get; init; } + public bool RolledBack { get; init; } + public string Summary { get; init; } = ""; + public IReadOnlyList Output { get; init; } = []; +} + +public enum PairRunStatus +{ + Pending, + Running, + Succeeded, + Failed, + RolledBack, + Cancelled, + RecoveryRequired +} + +public sealed record PairRun +{ + public required string Id { get; init; } + public required PairEndpointDescriptor Host { get; init; } + public required PairEndpointDescriptor Client { get; init; } + public PairTransportMode TransportMode { get; init; } + public PairRunStatus Status { get; init; } + public DateTimeOffset StartedAt { get; init; } + public DateTimeOffset CompletedAt { get; init; } + public IReadOnlyList Findings { get; init; } = []; + public IReadOnlyList Steps { get; init; } = []; + public IReadOnlyList Warnings { get; init; } = []; + public bool PendingReboot { get; init; } + public string? ReportDirectory { get; init; } +} + +public enum PairMessageKind +{ + Hello, + Approval, + Snapshot, + Plan, + ActionRequest, + ActionResult, + RollbackRequest, + Commit, + Heartbeat, + Error +} + +public sealed record PairHello(string SessionId, string ComputerName); +public sealed record PairApproval(bool Approved); +public enum PairActionOperation +{ + Prepare, + Execute, + Verify, + Rollback, + Commit +} + +public sealed record PairActionRequest(string RequestId, PairActionOperation Operation, PairRepairStep Step); +public sealed record PairActionResponse(string RequestId, PairActionResult Result); +public sealed record PairControlMessage(string RunId, string? StepId = null, string? Message = null); diff --git a/src/W-Fix.Core/Pairing/PairDiagnostics.cs b/src/W-Fix.Core/Pairing/PairDiagnostics.cs new file mode 100644 index 0000000..b3c4698 --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairDiagnostics.cs @@ -0,0 +1,208 @@ +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class PairDiagnosticService(IEnumerable rules) : IPairDiagnosticService +{ + private readonly IReadOnlyList _rules = rules.ToArray(); + + public async Task> DiagnoseAsync( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + CancellationToken cancellationToken = default) + { + var findings = new List(); + foreach (var rule in _rules) + { + cancellationToken.ThrowIfCancellationRequested(); + findings.AddRange(await rule.EvaluateAsync(host, client, cancellationToken)); + } + return findings.OrderByDescending(finding => finding.Severity).ThenByDescending(finding => finding.Confidence).ToArray(); + } +} + +public sealed class PairDiscoveryDiagnosticRule : IPairDiagnosticRule +{ + public string Id => "pair.discovery.disabled"; + + public Task> EvaluateAsync( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var affected = new[] { host, client }.Where(snapshot => + !snapshot.PeerNameResolved || !snapshot.NetworkDiscoveryFirewallEnabled || + snapshot.ServiceStates.TryGetValue("FDResPub", out var resource) && resource != "Running" || + snapshot.ServiceStates.TryGetValue("fdPHost", out var provider) && provider != "Running").ToArray(); + if (affected.Length == 0) + return Task.FromResult>([]); + return Task.FromResult>([new PairDiagnosticFinding + { + RuleId = Id, + Title = "Компьютеры не готовы к сетевому обнаружению", + Description = "Разрешение имени, Function Discovery или точечные правила Firewall мешают двум выбранным ПК находить друг друга.", + Severity = FindingSeverity.Warning, + Confidence = 0.9, + AffectedEndpoints = affected.Select(snapshot => snapshot.Endpoint.Role).ToArray(), + Evidence = affected.Select(snapshot => $"{snapshot.Endpoint.Role}: DNS={snapshot.PeerNameResolved}, DiscoveryFirewall={snapshot.NetworkDiscoveryFirewallEnabled}").ToArray(), + RecommendedActionIds = ["pair.discovery.services", "pair.firewall.discovery"] + }]); + } +} + +public sealed class PairSmbDiagnosticRule : IPairDiagnosticRule +{ + public string Id => "pair.smb.connectivity"; + + public Task> EvaluateAsync( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var findings = new List(); + if (!client.SmbPortReachable || !host.FileAndPrinterSharingFirewallEnabled) + { + findings.Add(new PairDiagnosticFinding + { + RuleId = Id, + Title = "SMB хоста недоступен клиенту", + Description = "Без TCP 445 клиент не сможет аутентифицироваться и открыть общую очередь.", + Severity = FindingSeverity.Error, + Confidence = 0.98, + AffectedEndpoints = [PairEndpointRole.Host], + Evidence = [$"Client TCP/445={client.SmbPortReachable}", $"Host firewall={host.FileAndPrinterSharingFirewallEnabled}"], + RecommendedActionIds = ["pair.firewall.file-print"] + }); + } + if (client.HasConflictingSmbConnection) + { + findings.Add(new PairDiagnosticFinding + { + RuleId = "pair.smb.credential-conflict", + Title = "Обнаружено конфликтующее SMB-подключение", + Description = "Windows не допускает одновременные подключения к одному серверу с разными учётными данными.", + Severity = FindingSeverity.Error, + Confidence = 0.95, + AffectedEndpoints = [PairEndpointRole.Client], + Evidence = ["У клиента уже есть несколько SMB-сеансов к выбранному хосту."], + RecommendedActionIds = ["pair.smb.clear-conflict"] + }); + } + if (!string.IsNullOrWhiteSpace(client.SmbConnectionError)) + { + findings.Add(new PairDiagnosticFinding + { + RuleId = "pair.smb.authentication", + Title = "SMB-аутентификация не завершена", + Description = "Нужно использовать существующую учётную запись хоста и сохранить её только для выбранного имени ПК.", + Severity = FindingSeverity.Error, + Confidence = 0.85, + AffectedEndpoints = [PairEndpointRole.Client], + Evidence = [client.SmbConnectionError], + RecommendedActionIds = [] + }); + } + return Task.FromResult>(findings); + } +} + +public sealed class PairRpcDiagnosticRule : IPairDiagnosticRule +{ + public string Id => "pair.rpc.connectivity"; + + public Task> EvaluateAsync( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (client.RpcEndpointMapperReachable) + return Task.FromResult>([]); + return Task.FromResult>([new PairDiagnosticFinding + { + RuleId = Id, + Title = "Print RPC хоста недоступен", + Description = "Windows 11 использует RPC over TCP по умолчанию; блокировка Endpoint Mapper или Spooler ломает подключение общей очереди.", + Severity = FindingSeverity.Error, + Confidence = 0.95, + AffectedEndpoints = [PairEndpointRole.Host], + Evidence = ["Client TCP/135=False", $"Host Spooler={host.SpoolerRunning}"], + RecommendedActionIds = ["pair.firewall.file-print", "pair.spooler.start"] , + OfficialSource = new Uri("https://learn.microsoft.com/en-us/troubleshoot/windows-client/printing/windows-11-rpc-connection-updates-for-print") + }]); + } +} + +public sealed class PairPrinterShareDiagnosticRule : IPairDiagnosticRule +{ + public string Id => "pair.printer.share"; + + public Task> EvaluateAsync( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var findings = new List(); + if (!host.PrinterShared || string.IsNullOrWhiteSpace(host.PrinterShareName)) + { + findings.Add(new PairDiagnosticFinding + { + RuleId = Id, + Title = "Принтер хоста не опубликован", + Description = "Выбранная локальная очередь должна иметь стабильное ShareName и право Print.", + Severity = FindingSeverity.Error, + Confidence = 1, + AffectedEndpoints = [PairEndpointRole.Host], + Evidence = [$"Printer={host.PrinterName ?? "не выбран"}", $"Shared={host.PrinterShared}"], + RecommendedActionIds = ["pair.printer.share"] + }); + } + if (!client.PrinterConnectionInstalled) + { + findings.Add(new PairDiagnosticFinding + { + RuleId = "pair.printer.connection-missing", + Title = "Общая очередь не установлена на клиенте", + Description = "После восстановления SMB/RPC W-Fix подключит очередь по имени хоста.", + Severity = FindingSeverity.Warning, + Confidence = 0.95, + AffectedEndpoints = [PairEndpointRole.Client], + Evidence = [$"Host={host.Endpoint.ComputerName}", $"Share={host.PrinterShareName ?? client.PrinterShareName ?? "не задан"}"], + RecommendedActionIds = ["pair.printer.connect"] + }); + } + return Task.FromResult>(findings); + } +} + +public sealed class PairRpcCompatibilityDiagnosticRule : IPairDiagnosticRule +{ + public string Id => "pair.rpc.compatibility"; + + public Task> EvaluateAsync( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (host.RpcListenerAllowsNamedPipes == client.RpcOverNamedPipes) + return Task.FromResult>([]); + return Task.FromResult>([new PairDiagnosticFinding + { + RuleId = Id, + Title = "RPC-транспорт клиента и хоста не согласован", + Description = "Named Pipes не рекомендуется Microsoft и предлагается только как экспертный совместимый режим с rollback.", + Severity = FindingSeverity.Warning, + Confidence = 0.8, + AffectedEndpoints = [PairEndpointRole.Host, PairEndpointRole.Client], + Evidence = [$"Host listener Named Pipes={host.RpcListenerAllowsNamedPipes}", $"Client uses Named Pipes={client.RpcOverNamedPipes}"], + RecommendedActionIds = ["pair.rpc.named-pipes"], + ExpertOnly = true, + OfficialSource = new Uri("https://learn.microsoft.com/en-us/troubleshoot/windows-client/printing/windows-11-rpc-connection-updates-for-print") + }]); + } +} diff --git a/src/W-Fix.Core/Pairing/PairFileService.cs b/src/W-Fix.Core/Pairing/PairFileService.cs new file mode 100644 index 0000000..de527a4 --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairFileService.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using System.Text.Json; +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class PairFileService(IPairInvitationValidator invitationValidator) : IPairFileService +{ + private const int MaximumFileBytes = 1024 * 1024; + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + PropertyNameCaseInsensitive = false, + MaxDepth = 32 + }; + + public Task WriteInvitationAsync(string path, PairInvitation invitation, CancellationToken cancellationToken = default) + { + invitationValidator.Validate(invitation, DateTimeOffset.UtcNow); + return WriteAtomicAsync(path, invitation, cancellationToken); + } + + public async Task ReadInvitationAsync(string path, CancellationToken cancellationToken = default) + { + var invitation = await ReadAsync(path, cancellationToken); + invitationValidator.Validate(invitation, DateTimeOffset.UtcNow); + return invitation; + } + + public async Task WriteOfflineSnapshotAsync( + string path, + PairEndpointSnapshot snapshot, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + var payload = JsonSerializer.SerializeToUtf8Bytes(snapshot, Options); + if (payload.Length > MaximumFileBytes / 2) + throw new InvalidDataException("Снимок Pair Repair слишком велик для безопасного обмена."); + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var bundle = new PairOfflineBundle + { + BundleId = Guid.NewGuid().ToString("N"), + CreatedAt = DateTimeOffset.UtcNow, + SnapshotPayloadBase64 = Convert.ToBase64String(payload), + SigningPublicKeyBase64 = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()), + SignatureBase64 = Convert.ToBase64String(key.SignData(payload, HashAlgorithmName.SHA256)) + }; + await WriteAtomicAsync(path, bundle, cancellationToken); + } + + public async Task ReadOfflineSnapshotAsync( + string path, + CancellationToken cancellationToken = default) + { + var bundle = await ReadAsync(path, cancellationToken); + if (bundle.SchemaVersion != PairOfflineBundle.CurrentSchemaVersion || + !Guid.TryParseExact(bundle.BundleId, "N", out _) || + bundle.CreatedAt > DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2)) + throw new InvalidDataException("Некорректный формат offline pairing bundle."); + byte[] payload; + byte[] publicKey; + byte[] signature; + try + { + payload = Convert.FromBase64String(bundle.SnapshotPayloadBase64); + publicKey = Convert.FromBase64String(bundle.SigningPublicKeyBase64); + signature = Convert.FromBase64String(bundle.SignatureBase64); + } + catch (FormatException ex) + { + throw new InvalidDataException("Offline pairing bundle содержит повреждённые данные.", ex); + } + if (payload.Length is 0 or > MaximumFileBytes / 2 || publicKey.Length > 1024 || signature.Length > 1024) + throw new InvalidDataException("Offline pairing bundle превышает допустимые ограничения."); + using var key = ECDsa.Create(); + try + { + key.ImportSubjectPublicKeyInfo(publicKey, out var bytesRead); + if (bytesRead != publicKey.Length || !key.VerifyData(payload, signature, HashAlgorithmName.SHA256)) + throw new InvalidDataException("Подпись offline pairing bundle недействительна."); + } + catch (CryptographicException ex) + { + throw new InvalidDataException("Подпись offline pairing bundle недействительна.", ex); + } + return JsonSerializer.Deserialize(payload, Options) + ?? throw new InvalidDataException("Offline pairing bundle не содержит снимок."); + } + + private static async Task ReadAsync(string path, CancellationToken cancellationToken) + { + ValidatePath(path); + var info = new FileInfo(path); + if (!info.Exists || info.Length is <= 0 or > MaximumFileBytes) + throw new InvalidDataException("Pairing-файл отсутствует, пуст или превышает допустимый размер."); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true); + return await JsonSerializer.DeserializeAsync(stream, Options, cancellationToken) + ?? throw new InvalidDataException("Pairing-файл пуст."); + } + + private static async Task WriteAtomicAsync(string path, T value, CancellationToken cancellationToken) + { + ValidatePath(path); + var fullPath = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + var temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true)) + { + await JsonSerializer.SerializeAsync(stream, value, Options, cancellationToken); + await stream.FlushAsync(cancellationToken); + if (stream.Length > MaximumFileBytes) + throw new InvalidDataException("Pairing-файл превышает допустимый размер."); + } + File.Move(temporary, fullPath, true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + private static void ValidatePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Путь pairing-файла не задан.", nameof(path)); + } +} diff --git a/src/W-Fix.Core/Pairing/PairInventoryService.cs b/src/W-Fix.Core/Pairing/PairInventoryService.cs new file mode 100644 index 0000000..abe7d93 --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairInventoryService.cs @@ -0,0 +1,195 @@ +using System.Text; +using WFix.Core.Abstractions; +using WFix.Core.Models; +using WFix.Core.Remote; + +namespace WFix.Core.Pairing; + +public sealed class PairInventoryService(IRemoteSessionFactory sessionFactory) : IPairInventoryService +{ + public async Task CaptureAsync( + TargetDescriptor target, + PairEndpointRole role, + string peerName, + string? printerName = null, + string? shareName = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentException.ThrowIfNullOrWhiteSpace(peerName); + var script = BuildScript(role, peerName, printerName, shareName); + await using var session = await sessionFactory.CreateAsync(target, cancellationToken); + var result = await session.ExecutePowerShellAsync(script, cancellationToken, TimeSpan.FromSeconds(60)); + var dto = PowerShellJson.Deserialize(result); + return new PairEndpointSnapshot + { + Endpoint = new PairEndpointDescriptor + { + Role = role, + ComputerName = target.ComputerName, + Fqdn = target.Fqdn, + IsLocalAgent = target.Source == TargetSource.Local + }, + OperatingSystem = dto.OperatingSystem ?? "", + OperatingSystemVersion = dto.OperatingSystemVersion ?? "", + BuildNumber = dto.BuildNumber, + UpdateBuildRevision = dto.UpdateBuildRevision, + DomainJoined = dto.DomainJoined, + DomainOrWorkgroup = dto.DomainOrWorkgroup ?? "", + NetworkProfile = dto.NetworkProfile ?? "Unknown", + Ipv4Addresses = dto.Ipv4Addresses ?? [], + PeerNameResolved = dto.PeerNameResolved, + SmbPortReachable = dto.SmbPortReachable, + RpcEndpointMapperReachable = dto.RpcEndpointMapperReachable, + SpoolerRunning = dto.SpoolerRunning, + ServiceStates = dto.ServiceStates ?? new Dictionary(), + NetworkDiscoveryFirewallEnabled = dto.NetworkDiscoveryFirewallEnabled, + FileAndPrinterSharingFirewallEnabled = dto.FileAndPrinterSharingFirewallEnabled, + SmbSigningRequired = dto.SmbSigningRequired, + InsecureGuestLogonsEnabled = dto.InsecureGuestLogonsEnabled, + HasConflictingSmbConnection = dto.HasConflictingSmbConnection, + SmbConnectionError = dto.SmbConnectionError, + RpcOverNamedPipes = dto.RpcOverNamedPipes, + RpcListenerAllowsNamedPipes = dto.RpcListenerAllowsNamedPipes, + RpcPrivacyDisabled = dto.RpcPrivacyDisabled, + RestrictDriverInstallationToAdministrators = dto.RestrictDriverInstallationToAdministrators, + PrinterName = dto.PrinterName, + PrinterShareName = dto.PrinterShareName, + PrinterShared = dto.PrinterShared, + PrinterDriverName = dto.PrinterDriverName, + PrinterDriverVersion = dto.PrinterDriverVersion, + PrinterConnectionInstalled = dto.PrinterConnectionInstalled, + RecentErrors = dto.RecentErrors ?? [] + }; + } + + private static string BuildScript(PairEndpointRole role, string peerName, string? printerName, string? shareName) + { + static string Encoded(string? value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value ?? "")); + return $$""" + $ErrorActionPreference = 'Stop' + $role = '{{role}}' + $peerName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encoded(peerName)}}')) + $requestedPrinter = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encoded(printerName)}}')) + $requestedShare = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encoded(shareName)}}')) + $os = Get-CimInstance Win32_OperatingSystem + $computer = Get-CimInstance Win32_ComputerSystem + $ubr = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name UBR -ErrorAction SilentlyContinue).UBR + $profile = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.IPv4Connectivity -ne 'Disconnected' } | Select-Object -First 1 + $ipv4 = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { + $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' + } | Select-Object -ExpandProperty IPAddress) + $resolved = $false + try { $resolved = @([Net.Dns]::GetHostAddresses($peerName)).Count -gt 0 } catch {} + $smbReachable = Test-NetConnection -ComputerName $peerName -Port 445 -InformationLevel Quiet -WarningAction SilentlyContinue + $rpcReachable = Test-NetConnection -ComputerName $peerName -Port 135 -InformationLevel Quiet -WarningAction SilentlyContinue + $services = [ordered]@{} + foreach ($name in @('Spooler','LanmanServer','LanmanWorkstation','fdPHost','FDResPub')) { + $service = Get-Service -Name $name -ErrorAction SilentlyContinue + $services[$name] = if ($null -eq $service) { 'Missing' } else { [string]$service.Status } + } + $enabledRules = @(Get-NetFirewallRule -Enabled True -ErrorAction SilentlyContinue) + $discoveryFirewall = @($enabledRules | Where-Object { + $_.DisplayGroup -match 'Network Discovery|Обнаружение сети' -or $_.DisplayName -like 'W-Fix Pair Discovery*' + }).Count -gt 0 + $printFirewall = @($enabledRules | Where-Object { + $_.DisplayGroup -match 'File and Printer Sharing|Общий доступ к файлам и принтерам' -or $_.DisplayName -like 'W-Fix Pair Print*' + }).Count -gt 0 + $smbServer = Get-SmbServerConfiguration -ErrorAction SilentlyContinue + $smbClient = Get-SmbClientConfiguration -ErrorAction SilentlyContinue + $conflict = $false + $smbError = $null + if ($role -eq 'Client') { + try { $conflict = @(Get-SmbConnection -ServerName $peerName -ErrorAction SilentlyContinue).Count -gt 1 } catch { $smbError = $_.Exception.Message } + } + $rpcPolicy = Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\RPC' -ErrorAction SilentlyContinue + $rpcPrivacy = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue + $pointAndPrint = Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint' -Name RestrictDriverInstallationToAdministrators -ErrorAction SilentlyContinue + $printer = $null + if ($role -eq 'Host') { + $printer = if ($requestedPrinter) { Get-Printer -Name $requestedPrinter -ErrorAction SilentlyContinue } else { Get-Printer -ErrorAction SilentlyContinue | Where-Object Shared | Select-Object -First 1 } + } elseif ($requestedShare) { + $connectionName = '\\' + $peerName + '\' + $requestedShare + $printer = Get-Printer -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq $connectionName -or $_.ComputerName -eq $peerName -and $_.ShareName -eq $requestedShare } | Select-Object -First 1 + } + $driverVersion = $null + if ($null -ne $printer -and $printer.DriverName) { + $driver = Get-CimInstance Win32_PrinterDriver -ErrorAction SilentlyContinue | Where-Object Name -like ($printer.DriverName + '*') | Select-Object -First 1 + $driverVersion = $driver.Version + } + $events = @() + foreach ($log in @('Microsoft-Windows-PrintService/Admin','Microsoft-Windows-SMBClient/Connectivity')) { + try { + $events += Get-WinEvent -FilterHashtable @{LogName=$log; Level=1,2,3; StartTime=(Get-Date).AddDays(-7)} -MaxEvents 5 -ErrorAction Stop | + ForEach-Object { "$($_.Id):$($_.LevelDisplayName)" } + } catch {} + } + [ordered]@{ + OperatingSystem = $os.Caption + OperatingSystemVersion = $os.Version + BuildNumber = [int]$os.BuildNumber + UpdateBuildRevision = [int]$ubr + DomainJoined = [bool]$computer.PartOfDomain + DomainOrWorkgroup = [string]$computer.Domain + NetworkProfile = if ($null -eq $profile) { 'Unknown' } else { [string]$profile.NetworkCategory } + Ipv4Addresses = $ipv4 + PeerNameResolved = $resolved + SmbPortReachable = [bool]$smbReachable + RpcEndpointMapperReachable = [bool]$rpcReachable + SpoolerRunning = $services['Spooler'] -eq 'Running' + ServiceStates = $services + NetworkDiscoveryFirewallEnabled = $discoveryFirewall + FileAndPrinterSharingFirewallEnabled = $printFirewall + SmbSigningRequired = if ($role -eq 'Host') { [bool]$smbServer.RequireSecuritySignature } else { [bool]$smbClient.RequireSecuritySignature } + InsecureGuestLogonsEnabled = [bool]$smbClient.EnableInsecureGuestLogons + HasConflictingSmbConnection = $conflict + SmbConnectionError = $smbError + RpcOverNamedPipes = [int]$rpcPolicy.RpcUseNamedPipeProtocol -eq 1 + RpcListenerAllowsNamedPipes = (([int]$rpcPolicy.RpcProtocols -band 0x2) -ne 0) + RpcPrivacyDisabled = ($null -ne $rpcPrivacy) -and ([int]$rpcPrivacy.RpcAuthnLevelPrivacyEnabled -eq 0) + RestrictDriverInstallationToAdministrators = ($null -eq $pointAndPrint) -or ([int]$pointAndPrint.RestrictDriverInstallationToAdministrators -ne 0) + PrinterName = $printer.Name + PrinterShareName = $printer.ShareName + PrinterShared = [bool]$printer.Shared + PrinterDriverName = $printer.DriverName + PrinterDriverVersion = [string]$driverVersion + PrinterConnectionInstalled = ($role -eq 'Client') -and ($null -ne $printer) + RecentErrors = $events + } | ConvertTo-Json -Depth 6 -Compress + """; + } + + private sealed record PairInventoryDto + { + public string? OperatingSystem { get; init; } + public string? OperatingSystemVersion { get; init; } + public int BuildNumber { get; init; } + public int UpdateBuildRevision { get; init; } + public bool DomainJoined { get; init; } + public string? DomainOrWorkgroup { get; init; } + public string? NetworkProfile { get; init; } + public string[]? Ipv4Addresses { get; init; } + public bool PeerNameResolved { get; init; } + public bool SmbPortReachable { get; init; } + public bool RpcEndpointMapperReachable { get; init; } + public bool SpoolerRunning { get; init; } + public Dictionary? ServiceStates { get; init; } + public bool NetworkDiscoveryFirewallEnabled { get; init; } + public bool FileAndPrinterSharingFirewallEnabled { get; init; } + public bool SmbSigningRequired { get; init; } + public bool InsecureGuestLogonsEnabled { get; init; } + public bool HasConflictingSmbConnection { get; init; } + public string? SmbConnectionError { get; init; } + public bool RpcOverNamedPipes { get; init; } + public bool RpcListenerAllowsNamedPipes { get; init; } + public bool RpcPrivacyDisabled { get; init; } + public bool RestrictDriverInstallationToAdministrators { get; init; } + public string? PrinterName { get; init; } + public string? PrinterShareName { get; init; } + public bool PrinterShared { get; init; } + public string? PrinterDriverName { get; init; } + public string? PrinterDriverVersion { get; init; } + public bool PrinterConnectionInstalled { get; init; } + public string[]? RecentErrors { get; init; } + } +} diff --git a/src/W-Fix.Core/Pairing/PairInvitationValidator.cs b/src/W-Fix.Core/Pairing/PairInvitationValidator.cs new file mode 100644 index 0000000..f29addf --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairInvitationValidator.cs @@ -0,0 +1,34 @@ +using System.Net; +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class PairInvitationValidator : IPairInvitationValidator +{ + public void Validate(PairInvitation invitation, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(invitation); + if (invitation.SchemaVersion != PairInvitation.CurrentSchemaVersion) + throw new InvalidDataException($"Неподдерживаемая версия pairing-файла: {invitation.SchemaVersion}."); + if (!Guid.TryParseExact(invitation.SessionId, "N", out _)) + throw new InvalidDataException("Некорректный Pair Session ID."); + if (string.IsNullOrWhiteSpace(invitation.HostComputerName) || invitation.HostComputerName.Length > 255) + throw new InvalidDataException("В приглашении отсутствует корректное имя хоста."); + if (invitation.Port is < IPEndPoint.MinPort or > IPEndPoint.MaxPort) + throw new InvalidDataException("Некорректный TCP-порт pairing-сессии."); + if (invitation.HostAddresses.Count is < 1 or > 16 || invitation.HostAddresses.Any(address => !IPAddress.TryParse(address, out _))) + throw new InvalidDataException("Приглашение не содержит корректных адресов хоста."); + if (invitation.CertificatePublicKeySha256.Length != 64 || + invitation.CertificatePublicKeySha256.Any(character => !Uri.IsHexDigit(character))) + throw new InvalidDataException("Некорректный отпечаток временного TLS-ключа."); + if (invitation.ConfirmationCode.Length != 6 || invitation.ConfirmationCode.Any(character => !char.IsAsciiDigit(character))) + throw new InvalidDataException("Некорректный код подтверждения pairing-сессии."); + if (invitation.ExpiresAt <= invitation.CreatedAt || invitation.ExpiresAt - invitation.CreatedAt > TimeSpan.FromMinutes(30)) + throw new InvalidDataException("Некорректный срок действия приглашения."); + if (now < invitation.CreatedAt - TimeSpan.FromMinutes(2)) + throw new InvalidDataException("Часы компьютеров расходятся: приглашение создано в будущем."); + if (now >= invitation.ExpiresAt) + throw new InvalidDataException("Срок действия pairing-приглашения истёк."); + } +} diff --git a/src/W-Fix.Core/Pairing/PairProtocolSerializer.cs b/src/W-Fix.Core/Pairing/PairProtocolSerializer.cs new file mode 100644 index 0000000..29d3dc8 --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairProtocolSerializer.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +internal sealed class PairProtocolSerializer +{ + public const int ProtocolVersion = 1; + public const int MaximumFrameBytes = 1024 * 1024; + + private static readonly IReadOnlyDictionary AllowedTypes = + new Dictionary + { + [PairMessageKind.Hello] = typeof(PairHello), + [PairMessageKind.Approval] = typeof(PairApproval), + [PairMessageKind.Snapshot] = typeof(PairEndpointSnapshot), + [PairMessageKind.Plan] = typeof(PairRepairPlan), + [PairMessageKind.ActionRequest] = typeof(PairActionRequest), + [PairMessageKind.ActionResult] = typeof(PairActionResponse), + [PairMessageKind.RollbackRequest] = typeof(PairControlMessage), + [PairMessageKind.Commit] = typeof(PairControlMessage), + [PairMessageKind.Heartbeat] = typeof(PairControlMessage), + [PairMessageKind.Error] = typeof(PairControlMessage) + }; + + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = false, + MaxDepth = 32 + }; + + public byte[] Serialize(PairMessageKind kind, T message) + { + ValidateType(kind); + ValidateMessage(message); + var payload = JsonSerializer.SerializeToElement(message, Options); + var bytes = JsonSerializer.SerializeToUtf8Bytes(new Envelope(ProtocolVersion, kind, payload), Options); + if (bytes.Length > MaximumFrameBytes) + throw new InvalidDataException("Pairing-сообщение превышает допустимый размер."); + return bytes; + } + + public T Deserialize(PairMessageKind expectedKind, ReadOnlySpan bytes) + { + ValidateType(expectedKind); + if (bytes.Length is 0 or > MaximumFrameBytes) + throw new InvalidDataException("Некорректный размер pairing-сообщения."); + var envelope = JsonSerializer.Deserialize(bytes, Options) + ?? throw new InvalidDataException("Пустое pairing-сообщение."); + if (envelope.Version != ProtocolVersion) + throw new InvalidDataException($"Неподдерживаемая версия pairing-протокола: {envelope.Version}."); + if (envelope.Kind != expectedKind) + throw new InvalidDataException($"Ожидалось сообщение {expectedKind}, получено {envelope.Kind}."); + var value = envelope.Payload.Deserialize(Options) + ?? throw new InvalidDataException("Не удалось разобрать pairing-сообщение."); + ValidateMessage(value); + return value; + } + + private static void ValidateType(PairMessageKind kind) + { + if (!AllowedTypes.TryGetValue(kind, out var allowed) || allowed != typeof(T)) + throw new InvalidOperationException($"Тип {typeof(T).Name} не разрешён для сообщения {kind}."); + } + + private static void ValidateMessage(T message) + { + ArgumentNullException.ThrowIfNull(message); + if (message is PairActionRequest request) + { + if (!request.Step.ActionId.StartsWith("pair.", StringComparison.Ordinal) || request.Step.ActionId.Length > 128) + throw new InvalidDataException("Через pairing-транспорт разрешены только встроенные действия pair.*."); + if (request.Step.Parameters.Count > 16 || request.Step.Parameters.Any(pair => pair.Key.Length > 64 || pair.Value.Length > 1024)) + throw new InvalidDataException("Параметры pairing-действия превышают допустимые ограничения."); + } + } + + private sealed record Envelope(int Version, PairMessageKind Kind, JsonElement Payload); +} diff --git a/src/W-Fix.Core/Pairing/PairRepairActions.cs b/src/W-Fix.Core/Pairing/PairRepairActions.cs new file mode 100644 index 0000000..86c60b6 --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairRepairActions.cs @@ -0,0 +1,325 @@ +using System.Text; +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class PairRepairActionRegistry : IPairRepairActionRegistry +{ + private readonly IReadOnlyList _actions; + private readonly IReadOnlyDictionary _byId; + + public PairRepairActionRegistry(IRemoteSessionFactory sessionFactory) + { + _actions = + [ + new BuiltInPairRepairAction("pair.discovery.services", "Запустить службы обнаружения", RepairRisk.Reversible, false, true, sessionFactory), + new BuiltInPairRepairAction("pair.firewall.discovery", "Разрешить сетевое обнаружение", RepairRisk.Reversible, false, true, sessionFactory), + new BuiltInPairRepairAction("pair.firewall.file-print", "Разрешить SMB/RPC печати", RepairRisk.Reversible, false, true, sessionFactory), + new BuiltInPairRepairAction("pair.spooler.start", "Запустить диспетчер печати", RepairRisk.Reversible, false, true, sessionFactory), + new BuiltInPairRepairAction("pair.smb.clear-conflict", "Закрыть конфликтующий SMB-сеанс", RepairRisk.Irreversible, false, false, sessionFactory), + new BuiltInPairRepairAction("pair.printer.share", "Опубликовать очередь", RepairRisk.Reversible, false, true, sessionFactory), + new BuiltInPairRepairAction("pair.printer.connect", "Подключить общую очередь", RepairRisk.Reversible, false, true, sessionFactory), + new BuiltInPairRepairAction("pair.rpc.named-pipes", "Включить RPC over Named Pipes", RepairRisk.Disruptive, true, true, sessionFactory), + new BuiltInPairRepairAction("pair.rpc.disable-privacy", "Ослабить RPC privacy", RepairRisk.Disruptive, true, true, sessionFactory), + new BuiltInPairRepairAction("pair.smb.insecure-guest", "Разрешить insecure guest", RepairRisk.Disruptive, true, true, sessionFactory), + new BuiltInPairRepairAction("pair.smb.disable-signing", "Отключить обязательную SMB-подпись", RepairRisk.Disruptive, true, true, sessionFactory) + ]; + _byId = _actions.ToDictionary(action => action.Id, StringComparer.Ordinal); + } + + public IReadOnlyList GetAll() => _actions; + public IPairRepairAction? Get(string actionId) => _byId.GetValueOrDefault(actionId); +} + +public sealed class RegistryPairActionDispatcher(IPairRepairActionRegistry registry) : IPairActionDispatcher +{ + public Task PrepareAsync(PairActionContext context, CancellationToken cancellationToken = default) => + Resolve(context).PrepareAsync(context, cancellationToken); + + public Task ExecuteAsync(PairActionContext context, CancellationToken cancellationToken = default) => + Resolve(context).ExecuteAsync(context, cancellationToken); + + public Task VerifyAsync(PairActionContext context, CancellationToken cancellationToken = default) => + Resolve(context).VerifyAsync(context, cancellationToken); + + public Task RollbackAsync(PairActionContext context, PairActionCheckpoint checkpoint, CancellationToken cancellationToken = default) => + Resolve(context).RollbackAsync(context, checkpoint, cancellationToken); + + private IPairRepairAction Resolve(PairActionContext context) => + registry.Get(context.Step.ActionId) + ?? throw new InvalidOperationException($"Pair action '{context.Step.ActionId}' не зарегистрирован."); +} + +internal sealed class BuiltInPairRepairAction( + string id, + string name, + RepairRisk risk, + bool expertOnly, + bool isIdempotent, + IRemoteSessionFactory sessionFactory) : IPairRepairAction +{ + public string Id { get; } = id; + public string Name { get; } = name; + public RepairRisk Risk { get; } = risk; + public bool ExpertOnly { get; } = expertOnly; + public bool IsIdempotent { get; } = isIdempotent; + + public async Task PrepareAsync(PairActionContext context, CancellationToken cancellationToken = default) + { + ValidateContext(context); + var rawState = await RunForJsonAsync(context.Target, BuildPrepareScript(context), context.Step.Timeout, cancellationToken); + var snapshotDirectory = Path.Combine(context.RunDirectory, "snapshots"); + Directory.CreateDirectory(snapshotDirectory); + var safeName = context.Step.ActionId.Replace('.', '_') + "-" + context.Step.Endpoint.ToString().ToLowerInvariant() + ".json"; + var snapshotPath = Path.Combine(snapshotDirectory, safeName); + await File.WriteAllTextAsync(snapshotPath, rawState, new UTF8Encoding(false), cancellationToken); + return new PairActionCheckpoint + { + ActionId = Id, + Endpoint = context.Step.Endpoint, + SnapshotPath = snapshotPath, + State = new Dictionary { ["json"] = rawState } + }; + } + + public async Task ExecuteAsync(PairActionContext context, CancellationToken cancellationToken = default) + { + ValidateContext(context); + var result = await RunAsync(context.Target, BuildExecuteScript(context), context.Step.Timeout, cancellationToken); + return new PairActionResult + { + Success = result.Success, + Summary = result.Success ? $"{Name}: выполнено." : result.Error ?? $"{Name}: ошибка.", + Output = RedactOutput(result.Output), + RequiresReboot = false + }; + } + + public async Task VerifyAsync(PairActionContext context, CancellationToken cancellationToken = default) + { + ValidateContext(context); + var result = await RunAsync(context.Target, BuildVerifyScript(context), context.Step.Timeout, cancellationToken); + return result.Success && result.Output.Any(line => bool.TryParse(line.Trim(), out var value) && value); + } + + public async Task RollbackAsync( + PairActionContext context, + PairActionCheckpoint checkpoint, + CancellationToken cancellationToken = default) + { + ValidateContext(context); + if (!string.Equals(checkpoint.ActionId, Id, StringComparison.Ordinal) || checkpoint.Endpoint != context.Step.Endpoint) + throw new InvalidOperationException("Checkpoint не соответствует pairing-действию."); + if (Id == "pair.smb.clear-conflict") + return new PairActionResult { Success = false, Summary = "Закрытый SMB-сеанс нельзя восстановить без повторной аутентификации." }; + if (!checkpoint.State.TryGetValue("json", out var state) || string.IsNullOrWhiteSpace(state)) + return new PairActionResult { Success = false, Summary = "Checkpoint не содержит состояния для rollback." }; + var result = await RunAsync(context.Target, BuildRollbackScript(context, state), context.Step.Timeout, cancellationToken); + return new PairActionResult + { + Success = result.Success, + Summary = result.Success ? $"{Name}: rollback выполнен." : result.Error ?? $"{Name}: rollback не выполнен.", + Output = RedactOutput(result.Output) + }; + } + + private static void ValidateContext(PairActionContext context) + { + ArgumentNullException.ThrowIfNull(context); + if (!context.Step.ActionId.StartsWith("pair.", StringComparison.Ordinal)) + throw new InvalidOperationException("Разрешены только встроенные pairing-действия."); + if (context.Step.ExpertOnly && context.Step.Risk < RepairRisk.Disruptive) + throw new InvalidOperationException("Экспертное действие должно иметь повышенную категорию риска."); + } + + private string BuildPrepareScript(PairActionContext context) + { + var rulePrefix = RulePrefix(context); + return Id switch + { + "pair.discovery.services" => """ + $ErrorActionPreference='Stop' + $result=[ordered]@{} + foreach($name in @('fdPHost','FDResPub')) { $svc=Get-CimInstance Win32_Service -Filter "Name='$name'"; $result[$name+'Status']=$svc.State; $result[$name+'StartMode']=$svc.StartMode } + $result | ConvertTo-Json -Compress + """, + "pair.firewall.discovery" or "pair.firewall.file-print" => $$""" + $ErrorActionPreference='Stop' + $prefix=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(rulePrefix)}}')) + [ordered]@{Existing=@(Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object DisplayName -Like ($prefix+'*') | Select-Object -ExpandProperty DisplayName)} | ConvertTo-Json -Compress + """, + "pair.spooler.start" => """ + $ErrorActionPreference='Stop' + $svc=Get-CimInstance Win32_Service -Filter "Name='Spooler'" + [ordered]@{Status=$svc.State;StartMode=$svc.StartMode} | ConvertTo-Json -Compress + """, + "pair.smb.clear-conflict" => "[ordered]@{RollbackSupported=$false} | ConvertTo-Json -Compress", + "pair.printer.share" => $$""" + $ErrorActionPreference='Stop' + $name=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(Required(context, "printerName"))}}')) + $printer=Get-Printer -Name $name + [ordered]@{Shared=[bool]$printer.Shared;ShareName=[string]$printer.ShareName} | ConvertTo-Json -Compress + """, + "pair.printer.connect" => $$""" + $hostName=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(Required(context, "hostName"))}}')) + $share=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(Required(context, "shareName"))}}')) + $connection='\\'+$hostName+'\'+$share + [ordered]@{Existed=($null -ne (Get-Printer -Name $connection -ErrorAction SilentlyContinue));Connection=$connection} | ConvertTo-Json -Compress + """, + "pair.rpc.named-pipes" => RegistrySnapshot("HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Printers\\RPC", context.Step.Endpoint == PairEndpointRole.Host ? "RpcProtocols" : "RpcUseNamedPipeProtocol"), + "pair.rpc.disable-privacy" => RegistrySnapshot("HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Print", "RpcAuthnLevelPrivacyEnabled"), + "pair.smb.insecure-guest" => "[ordered]@{Value=[bool](Get-SmbClientConfiguration).EnableInsecureGuestLogons} | ConvertTo-Json -Compress", + "pair.smb.disable-signing" => context.Step.Endpoint == PairEndpointRole.Host + ? "[ordered]@{Value=[bool](Get-SmbServerConfiguration).RequireSecuritySignature} | ConvertTo-Json -Compress" + : "[ordered]@{Value=[bool](Get-SmbClientConfiguration).RequireSecuritySignature} | ConvertTo-Json -Compress", + _ => throw new InvalidOperationException($"Неизвестное pairing-действие: {Id}") + }; + } + + private string BuildExecuteScript(PairActionContext context) + { + var rulePrefix = RulePrefix(context); + return Id switch + { + "pair.discovery.services" => """ + $ErrorActionPreference='Stop' + foreach($name in @('fdPHost','FDResPub')) { Set-Service -Name $name -StartupType Automatic; Start-Service -Name $name } + 'OK' + """, + "pair.firewall.discovery" => $$""" + $ErrorActionPreference='Stop' + $prefix=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(rulePrefix)}}')) + if(-not (Get-NetFirewallRule -DisplayName ($prefix+' UDP') -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName ($prefix+' UDP') -Direction Inbound -Action Allow -Profile Domain,Private -RemoteAddress LocalSubnet -Protocol UDP -LocalPort 1900,3702,5355 | Out-Null } + if(-not (Get-NetFirewallRule -DisplayName ($prefix+' TCP') -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName ($prefix+' TCP') -Direction Inbound -Action Allow -Profile Domain,Private -RemoteAddress LocalSubnet -Protocol TCP -LocalPort 5357,5358 | Out-Null } + 'OK' + """, + "pair.firewall.file-print" => $$""" + $ErrorActionPreference='Stop' + $prefix=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(rulePrefix)}}')) + if(-not (Get-NetFirewallRule -DisplayName ($prefix+' SMB') -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName ($prefix+' SMB') -Direction Inbound -Action Allow -Profile Domain,Private -RemoteAddress LocalSubnet -Protocol TCP -LocalPort 445 | Out-Null } + if(-not (Get-NetFirewallRule -DisplayName ($prefix+' RPC') -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName ($prefix+' RPC') -Direction Inbound -Action Allow -Profile Domain,Private -RemoteAddress LocalSubnet -Protocol TCP -LocalPort 135 | Out-Null } + if(-not (Get-NetFirewallRule -DisplayName ($prefix+' Spooler') -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName ($prefix+' Spooler') -Direction Inbound -Action Allow -Profile Domain,Private -RemoteAddress LocalSubnet -Program "$env:SystemRoot\System32\spoolsv.exe" -Protocol TCP | Out-Null } + 'OK' + """, + "pair.spooler.start" => "Set-Service Spooler -StartupType Automatic; Start-Service Spooler; 'OK'", + "pair.smb.clear-conflict" => $$""" + $ErrorActionPreference='Stop' + $hostName=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(Required(context, "hostName"))}}')) + Get-SmbMapping -ErrorAction SilentlyContinue | Where-Object RemotePath -Like ('\\'+$hostName+'\*') | Remove-SmbMapping -Force -UpdateProfile -ErrorAction Stop + & net.exe use ('\\'+$hostName+'\IPC$') /delete /y 2>$null | Out-Null + 'OK' + """, + "pair.printer.share" => $$""" + $ErrorActionPreference='Stop' + $name=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(Required(context, "printerName"))}}')) + $share=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(Required(context, "shareName"))}}')) + Set-Printer -Name $name -Shared $true -ShareName $share + 'OK' + """, + "pair.printer.connect" => $$""" + $ErrorActionPreference='Stop' + $hostName=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(Required(context, "hostName"))}}')) + $share=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{Encode(Required(context, "shareName"))}}')) + $connection='\\'+$hostName+'\'+$share + if(-not (Get-Printer -Name $connection -ErrorAction SilentlyContinue)) { Add-Printer -ConnectionName $connection } + 'OK' + """, + "pair.rpc.named-pipes" => SetRegistryScript("HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Printers\\RPC", context.Step.Endpoint == PairEndpointRole.Host ? "RpcProtocols" : "RpcUseNamedPipeProtocol", context.Step.Endpoint == PairEndpointRole.Host ? 7 : 1), + "pair.rpc.disable-privacy" => SetRegistryScript("HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Print", "RpcAuthnLevelPrivacyEnabled", 0), + "pair.smb.insecure-guest" => "Set-SmbClientConfiguration -EnableInsecureGuestLogons $true -Force -Confirm:$false; 'OK'", + "pair.smb.disable-signing" => context.Step.Endpoint == PairEndpointRole.Host + ? "Set-SmbServerConfiguration -RequireSecuritySignature $false -Force -Confirm:$false; 'OK'" + : "Set-SmbClientConfiguration -RequireSecuritySignature $false -Force -Confirm:$false; 'OK'", + _ => throw new InvalidOperationException($"Неизвестное pairing-действие: {Id}") + }; + } + + private string BuildVerifyScript(PairActionContext context) + { + var rulePrefix = RulePrefix(context); + var encodedPrefix = Encode(rulePrefix); + var encodedHost = context.Step.Parameters.TryGetValue("hostName", out var hostName) ? Encode(hostName) : ""; + var encodedPrinter = context.Step.Parameters.TryGetValue("printerName", out var printerName) ? Encode(printerName) : ""; + var encodedShare = context.Step.Parameters.TryGetValue("shareName", out var shareName) ? Encode(shareName) : ""; + return Id switch + { + "pair.discovery.services" => "[bool]((Get-Service fdPHost).Status -eq 'Running' -and (Get-Service FDResPub).Status -eq 'Running')", + "pair.firewall.discovery" => "$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encodedPrefix + "')); [bool](@(Get-NetFirewallRule -Enabled True | Where-Object DisplayName -Like ($p+'*')).Count -ge 2)", + "pair.firewall.file-print" => "$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encodedPrefix + "')); [bool](@(Get-NetFirewallRule -Enabled True | Where-Object DisplayName -Like ($p+'*')).Count -ge 3)", + "pair.spooler.start" => "[bool]((Get-Service Spooler).Status -eq 'Running')", + "pair.smb.clear-conflict" => "$h=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encodedHost + "')); [bool](@(Get-SmbMapping -ErrorAction SilentlyContinue | Where-Object RemotePath -Like ('\\\\'+$h+'\\*')).Count -eq 0)", + "pair.printer.share" => "$n=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encodedPrinter + "'));$s=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encodedShare + "'));$p=Get-Printer -Name $n;[bool]($p.Shared -and $p.ShareName -eq $s)", + "pair.printer.connect" => "$h=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encodedHost + "'));$s=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encodedShare + "'));[bool]($null -ne (Get-Printer -Name ('\\\\'+$h+'\\'+$s) -ErrorAction SilentlyContinue))", + "pair.rpc.named-pipes" => context.Step.Endpoint == PairEndpointRole.Host + ? @"[bool](((Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\RPC').RpcProtocols -band 2) -ne 0)" + : @"[bool]((Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\RPC').RpcUseNamedPipeProtocol -eq 1)", + "pair.rpc.disable-privacy" => @"[bool]((Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print').RpcAuthnLevelPrivacyEnabled -eq 0)", + "pair.smb.insecure-guest" => "[bool](Get-SmbClientConfiguration).EnableInsecureGuestLogons", + "pair.smb.disable-signing" => context.Step.Endpoint == PairEndpointRole.Host + ? "[bool](-not (Get-SmbServerConfiguration).RequireSecuritySignature)" + : "[bool](-not (Get-SmbClientConfiguration).RequireSecuritySignature)", + _ => "[bool]$false" + }; + } + + private string BuildRollbackScript(PairActionContext context, string state) + { + var load = "$state=([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + Encode(state) + "')) | ConvertFrom-Json);"; + var rulePrefix = RulePrefix(context); + return Id switch + { + "pair.discovery.services" => load + "foreach($n in @('fdPHost','FDResPub')){$mode=$state.($n+'StartMode');$startup=if($mode -eq 'Auto'){'Automatic'}elseif($mode -eq 'Disabled'){'Disabled'}else{'Manual'};Set-Service $n -StartupType $startup;if($state.($n+'Status') -eq 'Running'){Start-Service $n}else{Stop-Service $n -Force -ErrorAction SilentlyContinue}};'OK'", + "pair.firewall.discovery" or "pair.firewall.file-print" => load + "$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + Encode(rulePrefix) + "'));$existing=@($state.Existing);Get-NetFirewallRule -ErrorAction SilentlyContinue|Where-Object{$_.DisplayName -like ($p+'*') -and $_.DisplayName -notin $existing}|Remove-NetFirewallRule;'OK'", + "pair.spooler.start" => load + "$startup=if($state.StartMode -eq 'Auto'){'Automatic'}elseif($state.StartMode -eq 'Disabled'){'Disabled'}else{'Manual'};Set-Service Spooler -StartupType $startup;if($state.Status -eq 'Running'){Start-Service Spooler}else{Stop-Service Spooler -Force};'OK'", + "pair.printer.share" => load + "$n=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + Encode(Required(context, "printerName")) + "'));Set-Printer -Name $n -Shared ([bool]$state.Shared) -ShareName ([string]$state.ShareName);'OK'", + "pair.printer.connect" => load + "if(-not [bool]$state.Existed){Remove-Printer -Name ([string]$state.Connection) -ErrorAction SilentlyContinue};'OK'", + "pair.rpc.named-pipes" => load + RestoreRegistryScript("HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\Printers\\RPC", context.Step.Endpoint == PairEndpointRole.Host ? "RpcProtocols" : "RpcUseNamedPipeProtocol"), + "pair.rpc.disable-privacy" => load + RestoreRegistryScript("HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Print", "RpcAuthnLevelPrivacyEnabled"), + "pair.smb.insecure-guest" => load + "Set-SmbClientConfiguration -EnableInsecureGuestLogons ([bool]$state.Value) -Force -Confirm:$false;'OK'", + "pair.smb.disable-signing" => load + (context.Step.Endpoint == PairEndpointRole.Host + ? "Set-SmbServerConfiguration -RequireSecuritySignature ([bool]$state.Value) -Force -Confirm:$false;'OK'" + : "Set-SmbClientConfiguration -RequireSecuritySignature ([bool]$state.Value) -Force -Confirm:$false;'OK'"), + _ => "throw 'Rollback не реализован.'" + }; + } + + private static string RegistrySnapshot(string path, string name) => + "$path='" + path + "';$name='" + name + "';$item=Get-ItemProperty $path -Name $name -ErrorAction SilentlyContinue;" + + "[ordered]@{Exists=($null -ne $item);Value=if($null -eq $item){$null}else{$item.$name}} | ConvertTo-Json -Compress"; + + private static string SetRegistryScript(string path, string name, int value) => + $"New-Item -Path '{path}' -Force | Out-Null;New-ItemProperty -Path '{path}' -Name '{name}' -PropertyType DWord -Value {value} -Force | Out-Null;'OK'"; + + private static string RestoreRegistryScript(string path, string name) => + $"if([bool]$state.Exists){{New-Item -Path '{path}' -Force|Out-Null;New-ItemProperty -Path '{path}' -Name '{name}' -PropertyType DWord -Value ([int]$state.Value) -Force|Out-Null}}else{{Remove-ItemProperty -Path '{path}' -Name '{name}' -ErrorAction SilentlyContinue}};'OK'"; + + private static string RulePrefix(PairActionContext context) => + $"W-Fix Pair {(context.Step.ActionId.Contains("discovery", StringComparison.Ordinal) ? "Discovery" : "Print")} {Path.GetFileName(context.RunDirectory)} {context.Step.Endpoint}"; + + private static string Required(PairActionContext context, string name) => + context.Step.Parameters.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : throw new InvalidOperationException($"Pair action '{context.Step.ActionId}' требует параметр '{name}'."); + + private static string Encode(string value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + + private async Task RunAsync(TargetDescriptor target, string script, TimeSpan timeout, CancellationToken cancellationToken) + { + await using var session = await sessionFactory.CreateAsync(target, cancellationToken); + return await session.ExecutePowerShellAsync(script, cancellationToken, timeout); + } + + private async Task RunForJsonAsync(TargetDescriptor target, string script, TimeSpan timeout, CancellationToken cancellationToken) + { + var result = await RunAsync(target, script, timeout, cancellationToken); + if (!result.Success) + throw new InvalidOperationException(result.Error ?? "Pair action snapshot failed."); + return result.Output.FirstOrDefault(line => line.TrimStart().StartsWith('{')) + ?? throw new InvalidDataException("Pair action snapshot не вернул JSON."); + } + + private static IReadOnlyList RedactOutput(IReadOnlyList output) => + output.Where(line => !line.Contains("password", StringComparison.OrdinalIgnoreCase) && + !line.Contains("credential", StringComparison.OrdinalIgnoreCase)).Take(100).ToArray(); +} diff --git a/src/W-Fix.Core/Pairing/PairRepairExecutor.cs b/src/W-Fix.Core/Pairing/PairRepairExecutor.cs new file mode 100644 index 0000000..a8c86a8 --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairRepairExecutor.cs @@ -0,0 +1,202 @@ +using System.Text.Json; +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class PairRepairExecutor( + IPairActionDispatcher dispatcher, + IPairRunReportService reportService, + string? runRootDirectory = null) : IPairRepairExecutor +{ + private static readonly JsonSerializerOptions JournalOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + private readonly string _runRootDirectory = runRootDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "W-Fix", "Runs"); + + public async Task ExecuteAsync( + PairRepairPlan plan, + IReadOnlyDictionary targets, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(targets); + ValidatePlan(plan, targets); + var startedAt = DateTimeOffset.UtcNow; + var results = new List(); + var completed = new Stack(); + var warnings = new List(); + var pendingReboot = false; + var runDirectory = GetRunDirectory(plan.Id); + Directory.CreateDirectory(runDirectory); + var journalPath = Path.Combine(runDirectory, "pair-run.pending.json"); + + Report(PairRunStatus.Running); + try + { + foreach (var step in plan.Steps) + { + cancellationToken.ThrowIfCancellationRequested(); + if (step.DependsOn.Any(dependency => !results.Any(result => result.StepId == dependency && result.Verified))) + throw new PairExecutionException($"Не выполнена зависимость шага '{step.Id}'."); + var target = targets[step.Endpoint]; + var context = new PairActionContext(target, step, runDirectory); + var checkpoint = await dispatcher.PrepareAsync(context, cancellationToken); + var completedStep = new CompletedStep(context, checkpoint); + completed.Push(completedStep); + await WriteJournalAsync(journalPath, plan, completed, startedAt, cancellationToken); + + var execution = await dispatcher.ExecuteAsync(context, cancellationToken); + var verified = execution.Success && await dispatcher.VerifyAsync(context, cancellationToken); + pendingReboot |= execution.RequiresReboot; + results.Add(new PairStepResult + { + StepId = step.Id, + ActionId = step.ActionId, + Endpoint = step.Endpoint, + Succeeded = execution.Success, + Verified = verified, + Summary = execution.Summary, + Output = execution.Output + }); + if (!verified) + throw new PairExecutionException($"Проверка шага '{step.Title}' не подтвердила исправление."); + Report(PairRunStatus.Running); + } + + await dispatcher.CompleteAsync(true, cancellationToken); + var succeeded = Build(PairRunStatus.Succeeded); + var persisted = await PersistAsync(succeeded, cancellationToken); + DeleteJournal(journalPath); + progress?.Report(persisted); + return persisted; + } + catch (OperationCanceledException) + { + var rolledBack = await RollbackAsync(completed, results, warnings); + var cancelled = Build(rolledBack ? PairRunStatus.RolledBack : PairRunStatus.RecoveryRequired); + var persisted = await PersistAsync(cancelled, CancellationToken.None); + if (rolledBack) DeleteJournal(journalPath); + progress?.Report(persisted); + return persisted; + } + catch (Exception ex) + { + warnings.Add(ex.Message); + var rolledBack = await RollbackAsync(completed, results, warnings); + var failed = Build(rolledBack ? PairRunStatus.RolledBack : PairRunStatus.RecoveryRequired); + var persisted = await PersistAsync(failed, CancellationToken.None); + if (rolledBack) DeleteJournal(journalPath); + progress?.Report(persisted); + return persisted; + } + + PairRun Build(PairRunStatus status) => new() + { + Id = plan.Id, + Host = plan.Host, + Client = plan.Client, + TransportMode = plan.TransportMode, + Status = status, + StartedAt = startedAt, + CompletedAt = DateTimeOffset.UtcNow, + Findings = plan.Findings, + Steps = results.ToArray(), + Warnings = warnings.ToArray(), + PendingReboot = pendingReboot, + ReportDirectory = runDirectory + }; + + void Report(PairRunStatus status) => progress?.Report(Build(status)); + } + + private async Task RollbackAsync( + Stack completed, + List results, + List warnings) + { + var allSucceeded = true; + while (completed.TryPop(out var item)) + { + try + { + var result = await dispatcher.RollbackAsync(item.Context, item.Checkpoint, CancellationToken.None); + var index = results.FindIndex(step => step.StepId == item.Context.Step.Id); + if (index >= 0) + results[index] = results[index] with { RolledBack = result.Success, Output = results[index].Output.Concat(result.Output).ToArray() }; + if (!result.Success) + { + allSucceeded = false; + warnings.Add($"Rollback '{item.Context.Step.Title}': {result.Summary}"); + } + } + catch (Exception ex) + { + allSucceeded = false; + warnings.Add($"Rollback '{item.Context.Step.Title}': {ex.Message}"); + } + } + return allSucceeded; + } + + private async Task PersistAsync(PairRun run, CancellationToken cancellationToken) + { + try + { + var directory = await reportService.WriteAsync(run, cancellationToken); + return run with { ReportDirectory = directory }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return run with { Warnings = run.Warnings.Concat([$"Не удалось сохранить PairRun: {ex.Message}"]).ToArray() }; + } + } + + private static void ValidatePlan(PairRepairPlan plan, IReadOnlyDictionary targets) + { + if (!Guid.TryParseExact(plan.Id, "N", out _)) + throw new InvalidDataException("Некорректный PairRun ID."); + if (!targets.ContainsKey(PairEndpointRole.Host) || !targets.ContainsKey(PairEndpointRole.Client)) + throw new ArgumentException("Для live/domain PairRun требуются цели Host и Client.", nameof(targets)); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var step in plan.Steps) + { + if (!ids.Add(step.Id)) throw new InvalidDataException($"Дублирующийся Pair step ID: {step.Id}"); + if (!step.ActionId.StartsWith("pair.", StringComparison.Ordinal)) throw new InvalidDataException("План содержит действие вне allowlist pair.*."); + if (step.DependsOn.Any(dependency => !ids.Contains(dependency))) throw new InvalidDataException($"Шаг '{step.Id}' ссылается на неизвестную или последующую зависимость."); + } + } + + private string GetRunDirectory(string runId) => Path.Combine(_runRootDirectory, "pair-" + runId); + + private static async Task WriteJournalAsync( + string path, + PairRepairPlan plan, + IEnumerable completed, + DateTimeOffset startedAt, + CancellationToken cancellationToken) + { + var journal = new RecoveryJournal( + plan.Id, + startedAt, + plan.Host, + plan.Client, + completed.Reverse().Select(item => new RecoveryEntry( + item.Context.Target with { Credential = null }, + item.Context.Step, + item.Checkpoint.SnapshotPath, + item.Checkpoint.State)).ToArray()); + await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); + await JsonSerializer.SerializeAsync(stream, journal, JournalOptions, cancellationToken); + } + + private static void DeleteJournal(string path) + { + if (File.Exists(path)) File.Delete(path); + } + + private sealed record CompletedStep(PairActionContext Context, PairActionCheckpoint Checkpoint); + private sealed record RecoveryJournal(string RunId, DateTimeOffset StartedAt, PairEndpointDescriptor Host, PairEndpointDescriptor Client, IReadOnlyList Entries); + private sealed record RecoveryEntry(TargetDescriptor Target, PairRepairStep Step, string? SnapshotPath, IReadOnlyDictionary State); + private sealed class PairExecutionException(string message) : Exception(message); +} diff --git a/src/W-Fix.Core/Pairing/PairRepairPlanner.cs b/src/W-Fix.Core/Pairing/PairRepairPlanner.cs new file mode 100644 index 0000000..7c49138 --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairRepairPlanner.cs @@ -0,0 +1,100 @@ +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class PairRepairPlanner : IPairRepairPlanner +{ + private static readonly IReadOnlyDictionary Definitions = + new Dictionary(StringComparer.Ordinal) + { + ["pair.discovery.services"] = new("Запустить службы обнаружения", RepairRisk.Reversible, false, FindingEndpoints), + ["pair.firewall.discovery"] = new("Разрешить точечное обнаружение", RepairRisk.Reversible, false, FindingEndpoints), + ["pair.firewall.file-print"] = new("Разрешить SMB/RPC печати на хосте", RepairRisk.Reversible, false, _ => [PairEndpointRole.Host]), + ["pair.spooler.start"] = new("Запустить диспетчер печати хоста", RepairRisk.Reversible, false, _ => [PairEndpointRole.Host]), + ["pair.smb.clear-conflict"] = new("Закрыть конфликтующий SMB-сеанс", RepairRisk.Irreversible, false, _ => [PairEndpointRole.Client]), + ["pair.printer.share"] = new("Опубликовать очередь хоста", RepairRisk.Reversible, false, _ => [PairEndpointRole.Host]), + ["pair.printer.connect"] = new("Подключить общую очередь", RepairRisk.Reversible, false, _ => [PairEndpointRole.Client]), + ["pair.rpc.named-pipes"] = new("Включить совместимый RPC over Named Pipes", RepairRisk.Disruptive, true, _ => [PairEndpointRole.Host, PairEndpointRole.Client]) + }; + + public PairRepairPlan CreatePlan( + PairEndpointSnapshot host, + PairEndpointSnapshot client, + PairTransportMode transportMode, + IReadOnlyList findings, + bool includeExpertActions = false) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(findings); + var printerName = host.PrinterName ?? client.PrinterName; + var plannedShareName = host.PrinterShareName ?? client.PrinterShareName ?? + (!string.IsNullOrWhiteSpace(printerName) ? CreateShareName(printerName) : null); + var steps = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var finding in findings.OrderByDescending(item => item.Severity).ThenByDescending(item => item.Confidence)) + { + foreach (var actionId in finding.RecommendedActionIds) + { + if (!Definitions.TryGetValue(actionId, out var definition) || definition.ExpertOnly && !includeExpertActions) + continue; + foreach (var endpoint in definition.ResolveEndpoints(finding)) + { + if (!seen.Add($"{actionId}:{endpoint}")) + continue; + var parameters = BuildParameters(host, printerName, plannedShareName); + steps.Add(new PairRepairStep + { + Id = $"pair-step-{steps.Count + 1:00}", + ActionId = actionId, + Endpoint = endpoint, + Title = definition.Title, + Description = finding.Description, + Risk = definition.Risk, + ExpertOnly = definition.ExpertOnly, + Parameters = parameters, + DependsOn = steps.Count == 0 ? [] : [steps[^1].Id] + }); + } + } + } + return new PairRepairPlan + { + Id = Guid.NewGuid().ToString("N"), + Host = host.Endpoint, + Client = client.Endpoint, + TransportMode = transportMode, + Findings = findings, + Steps = steps + }; + } + + private static IReadOnlyList FindingEndpoints(PairDiagnosticFinding finding) => finding.AffectedEndpoints; + + private static IReadOnlyDictionary BuildParameters( + PairEndpointSnapshot host, + string? printerName, + string? shareName) + { + var values = new Dictionary(StringComparer.Ordinal) + { + ["hostName"] = host.Endpoint.ComputerName + }; + if (!string.IsNullOrWhiteSpace(printerName)) values["printerName"] = printerName; + if (!string.IsNullOrWhiteSpace(shareName)) values["shareName"] = shareName; + return values; + } + + internal static string CreateShareName(string printerName) + { + var sanitized = new string(printerName.Where(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_').Take(48).ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? "WFixPrinter" : sanitized; + } + + private sealed record ActionDefinition( + string Title, + RepairRisk Risk, + bool ExpertOnly, + Func> ResolveEndpoints); +} diff --git a/src/W-Fix.Core/Pairing/PairSessionActionDispatcher.cs b/src/W-Fix.Core/Pairing/PairSessionActionDispatcher.cs new file mode 100644 index 0000000..9f8dfe0 --- /dev/null +++ b/src/W-Fix.Core/Pairing/PairSessionActionDispatcher.cs @@ -0,0 +1,213 @@ +using System.Collections.Concurrent; +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class PairSessionActionDispatcher( + IPairSession session, + PairEndpointRole remoteEndpoint, + IPairActionDispatcher localDispatcher) : IPairActionDispatcher +{ + private readonly ConcurrentDictionary _requestIds = new(StringComparer.Ordinal); + + public async Task PrepareAsync(PairActionContext context, CancellationToken cancellationToken = default) + { + if (context.Step.Endpoint != remoteEndpoint) + return await localDispatcher.PrepareAsync(context, cancellationToken); + var requestId = Guid.NewGuid().ToString("N"); + var response = await InvokeRemoteAsync(requestId, PairActionOperation.Prepare, context.Step, cancellationToken); + if (!response.Result.Success) + throw new InvalidOperationException(response.Result.Summary); + _requestIds[context.Step.Id] = requestId; + return new PairActionCheckpoint + { + ActionId = context.Step.ActionId, + Endpoint = context.Step.Endpoint, + State = new Dictionary { ["remoteRequestId"] = requestId } + }; + } + + public async Task ExecuteAsync(PairActionContext context, CancellationToken cancellationToken = default) + { + if (context.Step.Endpoint != remoteEndpoint) + return await localDispatcher.ExecuteAsync(context, cancellationToken); + var response = await InvokeRemoteAsync(RequestId(context.Step.Id), PairActionOperation.Execute, context.Step, cancellationToken); + return response.Result; + } + + public async Task VerifyAsync(PairActionContext context, CancellationToken cancellationToken = default) + { + if (context.Step.Endpoint != remoteEndpoint) + return await localDispatcher.VerifyAsync(context, cancellationToken); + var response = await InvokeRemoteAsync(RequestId(context.Step.Id), PairActionOperation.Verify, context.Step, cancellationToken); + return response.Result.Success && response.Result.Verified; + } + + public async Task RollbackAsync( + PairActionContext context, + PairActionCheckpoint checkpoint, + CancellationToken cancellationToken = default) + { + if (context.Step.Endpoint != remoteEndpoint) + return await localDispatcher.RollbackAsync(context, checkpoint, cancellationToken); + var requestId = checkpoint.State.GetValueOrDefault("remoteRequestId") ?? RequestId(context.Step.Id); + var response = await InvokeRemoteAsync(requestId, PairActionOperation.Rollback, context.Step, cancellationToken); + _requestIds.TryRemove(context.Step.Id, out _); + return response.Result; + } + + public async Task CompleteAsync(bool commit, CancellationToken cancellationToken = default) + { + if (!commit) return; + var step = new PairRepairStep + { + Id = "pair-session-commit", + ActionId = "pair.session.commit", + Endpoint = remoteEndpoint, + Title = "Commit PairRun" + }; + var response = await InvokeRemoteAsync(Guid.NewGuid().ToString("N"), PairActionOperation.Commit, step, cancellationToken); + if (!response.Result.Success) throw new InvalidOperationException(response.Result.Summary); + _requestIds.Clear(); + await localDispatcher.CompleteAsync(true, cancellationToken); + } + + private async Task InvokeRemoteAsync( + string requestId, + PairActionOperation operation, + PairRepairStep step, + CancellationToken cancellationToken) + { + await session.SendAsync(PairMessageKind.ActionRequest, new PairActionRequest(requestId, operation, step), cancellationToken); + var response = await session.ReceiveAsync(PairMessageKind.ActionResult, cancellationToken); + if (!string.Equals(response.RequestId, requestId, StringComparison.Ordinal)) + throw new InvalidDataException("Ответ pairing-агента не соответствует запросу."); + return response; + } + + private string RequestId(string stepId) => + _requestIds.TryGetValue(stepId, out var value) + ? value + : throw new InvalidOperationException($"Remote checkpoint для шага '{stepId}' не найден."); +} + +public sealed class PairAgentCommandLoop(IPairActionDispatcher localDispatcher) : IPairAgentCommandLoop +{ + public async Task RunAsync(IPairSession session, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + if (session.LocalRole != PairEndpointRole.Host) + throw new InvalidOperationException("Agent command loop должен выполняться на стороне Host."); + var runDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "W-Fix", "Runs", "pair-" + session.Invitation.SessionId); + Directory.CreateDirectory(runDirectory); + var prepared = new Dictionary(StringComparer.Ordinal); + var committed = false; + try + { + while (!cancellationToken.IsCancellationRequested) + { + var request = await session.ReceiveAsync(PairMessageKind.ActionRequest, cancellationToken); + PairActionResult result; + try + { + if (request.Operation == PairActionOperation.Commit) + { + prepared.Clear(); + committed = true; + result = new PairActionResult { Success = true, Verified = true, Summary = "PairRun committed." }; + await session.SendAsync(PairMessageKind.ActionResult, new PairActionResponse(request.RequestId, result), cancellationToken); + return; + } + var context = new PairActionContext(TargetDescriptor.Local(), request.Step, runDirectory); + result = request.Operation switch + { + PairActionOperation.Prepare => await PrepareAsync(request, context, prepared, cancellationToken), + PairActionOperation.Execute => await ExecuteAsync(request, context, prepared, cancellationToken), + PairActionOperation.Verify => await VerifyAsync(request, context, prepared, cancellationToken), + PairActionOperation.Rollback => await RollbackAsync(request, context, prepared, cancellationToken), + _ => throw new InvalidOperationException("Неподдерживаемая операция pairing-агента.") + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + result = new PairActionResult { Success = false, Summary = ex.Message }; + } + await session.SendAsync(PairMessageKind.ActionResult, new PairActionResponse(request.RequestId, result), cancellationToken); + } + } + finally + { + if (!committed) + { + foreach (var item in prepared.Values.Reverse()) + { + try { await localDispatcher.RollbackAsync(item.Context, item.Checkpoint, CancellationToken.None); } + catch { /* Recovery journal and report preserve the remaining manual recovery requirement. */ } + } + } + } + } + + private async Task PrepareAsync( + PairActionRequest request, + PairActionContext context, + Dictionary prepared, + CancellationToken cancellationToken) + { + if (prepared.ContainsKey(request.RequestId)) + throw new InvalidOperationException("Pair action request уже подготовлен."); + var checkpoint = await localDispatcher.PrepareAsync(context, cancellationToken); + prepared.Add(request.RequestId, new PreparedRemoteAction(context, checkpoint)); + return new PairActionResult { Success = true, Summary = "Host snapshot created." }; + } + + private async Task ExecuteAsync( + PairActionRequest request, + PairActionContext context, + IReadOnlyDictionary prepared, + CancellationToken cancellationToken) + { + EnsurePrepared(request, context, prepared); + return await localDispatcher.ExecuteAsync(context, cancellationToken); + } + + private async Task VerifyAsync( + PairActionRequest request, + PairActionContext context, + IReadOnlyDictionary prepared, + CancellationToken cancellationToken) + { + EnsurePrepared(request, context, prepared); + var verified = await localDispatcher.VerifyAsync(context, cancellationToken); + return new PairActionResult { Success = verified, Verified = verified, Summary = verified ? "Host verification passed." : "Host verification failed." }; + } + + private async Task RollbackAsync( + PairActionRequest request, + PairActionContext context, + Dictionary prepared, + CancellationToken cancellationToken) + { + EnsurePrepared(request, context, prepared); + var item = prepared[request.RequestId]; + var result = await localDispatcher.RollbackAsync(item.Context, item.Checkpoint, cancellationToken); + if (result.Success) prepared.Remove(request.RequestId); + return result; + } + + private static void EnsurePrepared( + PairActionRequest request, + PairActionContext context, + IReadOnlyDictionary prepared) + { + if (!prepared.TryGetValue(request.RequestId, out var item) || + !string.Equals(item.Context.Step.Id, context.Step.Id, StringComparison.Ordinal) || + !string.Equals(item.Context.Step.ActionId, context.Step.ActionId, StringComparison.Ordinal)) + throw new InvalidOperationException("Pair action request не имеет соответствующего checkpoint."); + } + + private sealed record PreparedRemoteAction(PairActionContext Context, PairActionCheckpoint Checkpoint); +} diff --git a/src/W-Fix.Core/Pairing/TlsPairSessionTransport.cs b/src/W-Fix.Core/Pairing/TlsPairSessionTransport.cs new file mode 100644 index 0000000..0d3f9e0 --- /dev/null +++ b/src/W-Fix.Core/Pairing/TlsPairSessionTransport.cs @@ -0,0 +1,301 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class TlsPairSessionTransport(IPairInvitationValidator validator) : IPairSessionTransport +{ + public Task StartHostAsync(PairHostOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + cancellationToken.ThrowIfCancellationRequested(); + if (options.InvitationLifetime < TimeSpan.FromMinutes(1) || options.InvitationLifetime > TimeSpan.FromMinutes(30)) + throw new ArgumentOutOfRangeException(nameof(options), "Приглашение должно действовать от 1 до 30 минут."); + + var advertised = options.ListenAddresses?.Distinct().ToArray() ?? GetAdvertisedAddresses(); + if (advertised.Length == 0) + throw new InvalidOperationException("Не найден локальный IPv4-адрес для pairing-сессии."); + var bindAddress = advertised.All(IPAddress.IsLoopback) ? IPAddress.Loopback : IPAddress.Any; + var listener = new TcpListener(bindAddress, 0); + listener.Start(1); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var certificate = CreateEphemeralCertificate(options.HostComputerName, options.InvitationLifetime); + var publicKeyHash = GetPublicKeyHash(certificate); + var createdAt = DateTimeOffset.UtcNow; + var sessionId = Guid.NewGuid().ToString("N"); + var confirmationCode = CreateConfirmationCode(sessionId, publicKeyHash); + var invitation = new PairInvitation + { + SessionId = sessionId, + HostComputerName = options.HostComputerName, + HostAddresses = advertised.Select(address => address.ToString()).ToArray(), + Port = port, + CertificatePublicKeySha256 = publicKeyHash, + ConfirmationCode = confirmationCode, + CreatedAt = createdAt, + ExpiresAt = createdAt + options.InvitationLifetime, + PrinterName = options.PrinterName, + ShareName = options.ShareName + }; + validator.Validate(invitation, createdAt); + return Task.FromResult(new PairHost(listener, certificate, invitation)); + } + + public async Task JoinAsync(PairInvitation invitation, CancellationToken cancellationToken = default) + { + validator.Validate(invitation, DateTimeOffset.UtcNow); + Exception? lastError = null; + foreach (var addressText in invitation.HostAddresses) + { + cancellationToken.ThrowIfCancellationRequested(); + var client = new TcpClient(AddressFamily.InterNetwork); + try + { + await client.ConnectAsync(IPAddress.Parse(addressText), invitation.Port, cancellationToken); + var ssl = new SslStream(client.GetStream(), false); + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = invitation.HostComputerName, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + RemoteCertificateValidationCallback = (_, certificate, _, _) => + certificate is not null && FixedTimeEqualsHex(GetPublicKeyHash(new X509Certificate2(certificate)), invitation.CertificatePublicKeySha256) + }, cancellationToken); + var session = new TlsPairSession(client, ssl, invitation, PairEndpointRole.Client); + await session.SendHandshakeAsync(new PairHello(invitation.SessionId, Environment.MachineName), cancellationToken); + var hostHello = await session.ReceiveHandshakeAsync(cancellationToken); + if (!string.Equals(hostHello.SessionId, invitation.SessionId, StringComparison.Ordinal)) + throw new AuthenticationException("Pair Session ID хоста не совпадает с приглашением."); + session.MarkAwaitingApproval(); + return session; + } + catch (OperationCanceledException) + { + client.Dispose(); + throw; + } + catch (Exception ex) + { + lastError = ex; + client.Dispose(); + } + } + throw new IOException("Не удалось подключиться ни к одному адресу pairing-хоста.", lastError); + } + + private static IPAddress[] GetAdvertisedAddresses() => + NetworkInterface.GetAllNetworkInterfaces() + .Where(network => network.OperationalStatus == OperationalStatus.Up && network.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .SelectMany(network => network.GetIPProperties().UnicastAddresses) + .Select(address => address.Address) + .Where(address => address.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(address) && !address.ToString().StartsWith("169.254.", StringComparison.Ordinal)) + .Distinct() + .ToArray(); + + private static X509Certificate2 CreateEphemeralCertificate(string hostName, TimeSpan lifetime) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest($"CN={hostName}", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new("1.3.6.1.5.5.7.3.1") }, true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName(hostName); + request.CertificateExtensions.Add(san.Build()); + using var created = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow + lifetime + TimeSpan.FromMinutes(2)); + // Windows Schannel cannot use an ephemeral CNG private key for server authentication. + // Re-import without PersistKeySet: the temporary key container is removed when the certificate is disposed. + var password = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)); + var pfx = created.Export(X509ContentType.Pkcs12, password); + try + { + return new X509Certificate2(pfx, password, X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable); + } + finally + { + CryptographicOperations.ZeroMemory(pfx); + } + } + + private static string GetPublicKeyHash(X509Certificate2 certificate) + { + using var key = certificate.GetECDsaPublicKey() + ?? throw new AuthenticationException("Pairing certificate must use ECDSA."); + return Convert.ToHexString(SHA256.HashData(key.ExportSubjectPublicKeyInfo())).ToLowerInvariant(); + } + + private static string CreateConfirmationCode(string sessionId, string publicKeyHash) + { + var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sessionId + publicKeyHash)); + var value = BinaryPrimitives.ReadUInt32BigEndian(hash) % 1_000_000; + return value.ToString("D6"); + } + + private static bool FixedTimeEqualsHex(string actual, string expected) + { + try + { + return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(actual), Convert.FromHexString(expected)); + } + catch (FormatException) + { + return false; + } + } + + private sealed class PairHost(TcpListener listener, X509Certificate2 certificate, PairInvitation invitation) : IPairHost + { + private int _accepted; + + public PairInvitation Invitation { get; } = invitation; + + public async Task AcceptAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _accepted, 1) != 0) + throw new InvalidOperationException("Pairing-приглашение уже было использовано."); + var remaining = Invitation.ExpiresAt - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) + throw new InvalidDataException("Срок действия pairing-приглашения истёк."); + using var expiry = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + expiry.CancelAfter(remaining); + TcpClient? client = null; + try + { + client = await listener.AcceptTcpClientAsync(expiry.Token); + listener.Stop(); + var ssl = new SslStream(client.GetStream(), false); + await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + ClientCertificateRequired = false, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck + }, expiry.Token); + var session = new TlsPairSession(client, ssl, Invitation, PairEndpointRole.Host); + var clientHello = await session.ReceiveHandshakeAsync(expiry.Token); + if (!string.Equals(clientHello.SessionId, Invitation.SessionId, StringComparison.Ordinal)) + throw new AuthenticationException("Pair Session ID клиента не совпадает с приглашением."); + await session.SendHandshakeAsync(new PairHello(Invitation.SessionId, Environment.MachineName), expiry.Token); + session.MarkAwaitingApproval(); + return session; + } + catch + { + client?.Dispose(); + throw; + } + } + + public ValueTask DisposeAsync() + { + listener.Stop(); + certificate.Dispose(); + return ValueTask.CompletedTask; + } + } + + private sealed class TlsPairSession( + TcpClient client, + SslStream stream, + PairInvitation invitation, + PairEndpointRole localRole) : IPairSession + { + private readonly PairProtocolSerializer _serializer = new(); + private readonly SemaphoreSlim _writeGate = new(1, 1); + private int _disposed; + + public PairInvitation Invitation { get; } = invitation; + public PairEndpointRole LocalRole { get; } = localRole; + public PairSessionState State { get; private set; } = PairSessionState.Connected; + public string ConfirmationCode => Invitation.ConfirmationCode; + + public async Task ApproveAsync(bool approved, CancellationToken cancellationToken = default) + { + if (State != PairSessionState.AwaitingApproval) + throw new InvalidOperationException("Pairing-сессия не ожидает подтверждения."); + await SendCoreAsync(PairMessageKind.Approval, new PairApproval(approved), cancellationToken); + var peer = await ReceiveCoreAsync(PairMessageKind.Approval, cancellationToken); + var accepted = approved && peer.Approved; + State = accepted ? PairSessionState.Approved : PairSessionState.Closed; + return accepted; + } + + public Task SendAsync(PairMessageKind kind, T message, CancellationToken cancellationToken = default) + { + EnsureApproved(); + return SendCoreAsync(kind, message, cancellationToken); + } + + public Task ReceiveAsync(PairMessageKind expectedKind, CancellationToken cancellationToken = default) + { + EnsureApproved(); + return ReceiveCoreAsync(expectedKind, cancellationToken); + } + + internal Task SendHandshakeAsync(PairHello hello, CancellationToken cancellationToken) => + SendCoreAsync(PairMessageKind.Hello, hello, cancellationToken); + + internal Task ReceiveHandshakeAsync(CancellationToken cancellationToken) => + ReceiveCoreAsync(PairMessageKind.Hello, cancellationToken); + + internal void MarkAwaitingApproval() => State = PairSessionState.AwaitingApproval; + + private async Task SendCoreAsync(PairMessageKind kind, T message, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + var payload = _serializer.Serialize(kind, message); + var header = new byte[sizeof(int)]; + BinaryPrimitives.WriteInt32BigEndian(header, payload.Length); + await _writeGate.WaitAsync(cancellationToken); + try + { + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(payload, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + finally + { + _writeGate.Release(); + } + } + + private async Task ReceiveCoreAsync(PairMessageKind expectedKind, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + var header = new byte[sizeof(int)]; + await stream.ReadExactlyAsync(header, cancellationToken); + var length = BinaryPrimitives.ReadInt32BigEndian(header); + if (length is <= 0 or > PairProtocolSerializer.MaximumFrameBytes) + throw new InvalidDataException("Некорректный размер pairing frame."); + var payload = new byte[length]; + await stream.ReadExactlyAsync(payload, cancellationToken); + return _serializer.Deserialize(expectedKind, payload); + } + + private void EnsureApproved() + { + if (State != PairSessionState.Approved) + throw new InvalidOperationException("Pairing-сессия ещё не подтверждена обеими сторонами."); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + State = PairSessionState.Closed; + _writeGate.Dispose(); + await stream.DisposeAsync(); + client.Dispose(); + } + } +} diff --git a/src/W-Fix.Core/Pairing/WindowsPairFirewallLeaseService.cs b/src/W-Fix.Core/Pairing/WindowsPairFirewallLeaseService.cs new file mode 100644 index 0000000..68c8ff9 --- /dev/null +++ b/src/W-Fix.Core/Pairing/WindowsPairFirewallLeaseService.cs @@ -0,0 +1,68 @@ +using System.Text; +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Pairing; + +public sealed class WindowsPairFirewallLeaseService(IRemoteSessionFactory sessionFactory) : IPairFirewallLeaseService +{ + private const string RulePrefix = "W-Fix Pair Session "; + + public async Task OpenAsync( + string sessionId, + int port, + string executablePath, + CancellationToken cancellationToken = default) + { + if (!Guid.TryParseExact(sessionId, "N", out _)) + throw new ArgumentException("Некорректный Pair Session ID.", nameof(sessionId)); + if (port is < 1 or > 65535) + throw new ArgumentOutOfRangeException(nameof(port)); + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + var fullPath = Path.GetFullPath(executablePath); + var ruleName = RulePrefix + sessionId; + var encodedName = Encode(ruleName); + var encodedPath = Encode(fullPath); + var script = $$""" + $ErrorActionPreference='Stop' + $public=@(Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.IPv4Connectivity -ne 'Disconnected' -and $_.NetworkCategory -eq 'Public' }) + if($public.Count -gt 0){ throw 'Live pairing запрещён для Public network profile.' } + $name=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{encodedName}}')) + $program=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{encodedPath}}')) + if(Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue){ Remove-NetFirewallRule -DisplayName $name } + New-NetFirewallRule -DisplayName $name -Description 'Temporary W-Fix pairing listener; removed when the session ends.' -Direction Inbound -Action Allow -Profile Domain,Private -RemoteAddress LocalSubnet -Protocol TCP -LocalPort {{port}} -Program $program | Out-Null + """; + await ExecuteAsync(script, cancellationToken); + return new Lease(this, ruleName); + } + + public Task CleanupStaleAsync(CancellationToken cancellationToken = default) => + ExecuteAsync("Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object DisplayName -Like 'W-Fix Pair Session *' | Remove-NetFirewallRule", cancellationToken); + + private async Task RemoveAsync(string ruleName) + { + var encoded = Encode(ruleName); + await ExecuteAsync("$n=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + encoded + "'));Get-NetFirewallRule -DisplayName $n -ErrorAction SilentlyContinue|Remove-NetFirewallRule", CancellationToken.None); + } + + private async Task ExecuteAsync(string script, CancellationToken cancellationToken) + { + await using var session = await sessionFactory.CreateAsync(TargetDescriptor.Local(), cancellationToken); + var result = await session.ExecutePowerShellAsync(script, cancellationToken, TimeSpan.FromSeconds(20)); + if (!result.Success) + throw new InvalidOperationException(result.Error ?? "Не удалось изменить временное pairing-правило Firewall."); + } + + private static string Encode(string value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + + private sealed class Lease(WindowsPairFirewallLeaseService owner, string ruleName) : IAsyncDisposable + { + private int _disposed; + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + await owner.RemoveAsync(ruleName); + } + } +} diff --git a/src/W-Fix.Core/Reporting/PairRunReportService.cs b/src/W-Fix.Core/Reporting/PairRunReportService.cs new file mode 100644 index 0000000..1aa42ef --- /dev/null +++ b/src/W-Fix.Core/Reporting/PairRunReportService.cs @@ -0,0 +1,51 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using WFix.Core.Abstractions; +using WFix.Core.Models; + +namespace WFix.Core.Reporting; + +public sealed class PairRunReportService : IPairRunReportService +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + private readonly string _rootDirectory; + + public PairRunReportService(string? rootDirectory = null) + { + _rootDirectory = rootDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "W-Fix", "Runs"); + } + + public async Task WriteAsync(PairRun run, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(run); + if (!Guid.TryParseExact(run.Id, "N", out _)) + throw new InvalidDataException("Некорректный PairRun ID."); + var directory = Path.Combine(_rootDirectory, "pair-" + run.Id); + Directory.CreateDirectory(directory); + var persisted = run with { ReportDirectory = directory }; + await using (var stream = new FileStream(Path.Combine(directory, "pair-run.json"), FileMode.Create, FileAccess.Write, FileShare.Read, 81920, true)) + await JsonSerializer.SerializeAsync(stream, persisted, Options, cancellationToken); + await File.WriteAllTextAsync(Path.Combine(directory, "pair-report.html"), BuildHtml(persisted), cancellationToken); + return directory; + } + + internal static string BuildHtml(PairRun run) + { + var encoder = HtmlEncoder.Default; + var steps = string.Join(Environment.NewLine, run.Steps.Select(step => + $"{encoder.Encode(step.Endpoint.ToString())}{encoder.Encode(step.ActionId)}{encoder.Encode(step.Summary)}{(step.Verified ? "✓" : "✗")}{(step.RolledBack ? "✓" : "—")}")); + var findings = string.Join(Environment.NewLine, run.Findings.Select(finding => + $"
  • {encoder.Encode(finding.Title)}
    {encoder.Encode(finding.Description)}
  • ")); + var warnings = string.Join(Environment.NewLine, run.Warnings.Select(warning => $"
  • {encoder.Encode(warning)}
  • ")); + return $$""" + W-Fix Pair {{encoder.Encode(run.Id)}} + +

    W-Fix — отчёт парного ремонта

    +
    Host: {{encoder.Encode(run.Host.ConnectionName)}}
    Client: {{encoder.Encode(run.Client.ConnectionName)}}
    Transport: {{run.TransportMode}}
    Status: {{run.Status}}
    +

    Диагностика

      {{findings}}
    +

    Действия

    {{steps}}
    ПКActionРезультатПровереноRollback
    +

    Предупреждения

      {{warnings}}
    + """; + } +} diff --git a/tests/W-Fix.Core.Tests/PairRepairCoreTests.cs b/tests/W-Fix.Core.Tests/PairRepairCoreTests.cs new file mode 100644 index 0000000..3cb75a1 --- /dev/null +++ b/tests/W-Fix.Core.Tests/PairRepairCoreTests.cs @@ -0,0 +1,324 @@ +using System.Net; +using System.Text.Json.Nodes; +using WFix.Core.Abstractions; +using WFix.Core.Models; +using WFix.Core.Pairing; +using WFix.Core.Reporting; + +namespace WFix.Core.Tests; + +public sealed class PairInvitationValidatorTests +{ + [Fact] + public void Validate_accepts_current_unexpired_invitation() + { + var now = DateTimeOffset.UtcNow; + new PairInvitationValidator().Validate(CreateInvitation(now), now); + } + + [Fact] + public void Validate_rejects_expired_invitation() + { + var now = DateTimeOffset.UtcNow; + var invitation = CreateInvitation(now - TimeSpan.FromMinutes(20)); + Assert.Throws(() => new PairInvitationValidator().Validate(invitation, now)); + } + + [Fact] + public void Validate_rejects_unknown_schema() + { + var now = DateTimeOffset.UtcNow; + var invitation = CreateInvitation(now) with { SchemaVersion = 99 }; + Assert.Throws(() => new PairInvitationValidator().Validate(invitation, now)); + } + + private static PairInvitation CreateInvitation(DateTimeOffset createdAt) => new() + { + SessionId = Guid.NewGuid().ToString("N"), + HostComputerName = "PRINT-HOST", + HostAddresses = ["192.0.2.10"], + Port = 43123, + CertificatePublicKeySha256 = new string('a', 64), + ConfirmationCode = "123456", + CreatedAt = createdAt, + ExpiresAt = createdAt + TimeSpan.FromMinutes(15) + }; +} + +public sealed class PairProtocolSerializerTests +{ + [Fact] + public void Serializer_round_trips_allowlisted_message() + { + var serializer = new PairProtocolSerializer(); + var message = new PairHello(Guid.NewGuid().ToString("N"), "CLIENT-PC"); + var bytes = serializer.Serialize(PairMessageKind.Hello, message); + Assert.Equal(message, serializer.Deserialize(PairMessageKind.Hello, bytes)); + } + + [Fact] + public void Serializer_rejects_non_pair_action() + { + var serializer = new PairProtocolSerializer(); + var request = new PairActionRequest("r1", PairActionOperation.Execute, new PairRepairStep + { + Id = "s1", + ActionId = "legacy:spooler", + Endpoint = PairEndpointRole.Host, + Title = "bad" + }); + Assert.Throws(() => serializer.Serialize(PairMessageKind.ActionRequest, request)); + } + + [Fact] + public void Serializer_rejects_wrong_dto_for_message_kind() + { + var serializer = new PairProtocolSerializer(); + Assert.Throws(() => serializer.Serialize(PairMessageKind.Plan, new PairHello("id", "pc"))); + } +} + +public sealed class PairFileServiceTests +{ + [Fact] + public async Task Offline_snapshot_round_trip_verifies_signature() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wfixpair"); + try + { + var service = new PairFileService(new PairInvitationValidator()); + var snapshot = new PairEndpointSnapshot + { + Endpoint = new PairEndpointDescriptor { Role = PairEndpointRole.Host, ComputerName = "HOST" }, + PrinterName = "Office Printer" + }; + await service.WriteOfflineSnapshotAsync(path, snapshot); + var restored = await service.ReadOfflineSnapshotAsync(path); + Assert.Equal(snapshot.Endpoint.ComputerName, restored.Endpoint.ComputerName); + Assert.Equal(snapshot.PrinterName, restored.PrinterName); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task Offline_snapshot_rejects_tampering() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wfixpair"); + try + { + var service = new PairFileService(new PairInvitationValidator()); + await service.WriteOfflineSnapshotAsync(path, new PairEndpointSnapshot + { + Endpoint = new PairEndpointDescriptor { Role = PairEndpointRole.Host, ComputerName = "HOST" } + }); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + var payload = document["snapshotPayloadBase64"]!.GetValue(); + document["snapshotPayloadBase64"] = (payload[0] == 'A' ? "B" : "A") + payload[1..]; + await File.WriteAllTextAsync(path, document.ToJsonString()); + await Assert.ThrowsAsync(() => service.ReadOfflineSnapshotAsync(path)); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } +} + +public sealed class TlsPairSessionTransportTests +{ + [Fact] + public async Task Loopback_session_requires_both_approvals_and_exchanges_typed_messages() + { + var transport = new TlsPairSessionTransport(new PairInvitationValidator()); + await using var host = await transport.StartHostAsync(new PairHostOptions + { + HostComputerName = "localhost", + ListenAddresses = [IPAddress.Loopback], + InvitationLifetime = TimeSpan.FromMinutes(2) + }); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var accept = host.AcceptAsync(timeout.Token); + IPairSession client; + try + { + client = await transport.JoinAsync(host.Invitation, timeout.Token); + } + catch (Exception clientError) + { + var serverError = await Assert.ThrowsAnyAsync(async () => await accept); + throw new AggregateException(clientError, serverError); + } + await using var clientScope = client; + await using var server = await accept; + + var approvals = await Task.WhenAll( + server.ApproveAsync(true, timeout.Token), + clientScope.ApproveAsync(true, timeout.Token)); + Assert.All(approvals, Assert.True); + Assert.Equal(PairSessionState.Approved, server.State); + Assert.Equal(server.ConfirmationCode, clientScope.ConfirmationCode); + + var snapshot = Snapshot(PairEndpointRole.Client, "CLIENT"); + await clientScope.SendAsync(PairMessageKind.Snapshot, snapshot, timeout.Token); + var received = await server.ReceiveAsync(PairMessageKind.Snapshot, timeout.Token); + Assert.Equal("CLIENT", received.Endpoint.ComputerName); + } + + [Fact] + public async Task Join_rejects_certificate_pin_mismatch() + { + var transport = new TlsPairSessionTransport(new PairInvitationValidator()); + await using var host = await transport.StartHostAsync(new PairHostOptions + { + HostComputerName = "localhost", + ListenAddresses = [IPAddress.Loopback], + InvitationLifetime = TimeSpan.FromMinutes(2) + }); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var accept = host.AcceptAsync(timeout.Token); + var tampered = host.Invitation with { CertificatePublicKeySha256 = new string('0', 64) }; + await Assert.ThrowsAsync(() => transport.JoinAsync(tampered, timeout.Token)); + await Assert.ThrowsAnyAsync(async () => await accept); + } + + private static PairEndpointSnapshot Snapshot(PairEndpointRole role, string name) => new() + { + Endpoint = new PairEndpointDescriptor { Role = role, ComputerName = name, IsLocalAgent = true } + }; +} + +public sealed class PairDiagnosticsAndPlannerTests +{ + [Fact] + public async Task Diagnostics_build_secure_host_and_client_repair_plan() + { + var host = Snapshot(PairEndpointRole.Host, "HOST") with + { + PrinterName = "USB Printer", + PrinterShared = false, + FileAndPrinterSharingFirewallEnabled = false, + RpcEndpointMapperReachable = true, + PeerNameResolved = true, + NetworkDiscoveryFirewallEnabled = true, + ServiceStates = RunningServices() + }; + var client = Snapshot(PairEndpointRole.Client, "CLIENT") with + { + SmbPortReachable = false, + RpcEndpointMapperReachable = false, + PeerNameResolved = true, + NetworkDiscoveryFirewallEnabled = true, + ServiceStates = RunningServices() + }; + var service = new PairDiagnosticService([ + new PairDiscoveryDiagnosticRule(), new PairSmbDiagnosticRule(), new PairRpcDiagnosticRule(), + new PairPrinterShareDiagnosticRule(), new PairRpcCompatibilityDiagnosticRule() + ]); + var findings = await service.DiagnoseAsync(host, client); + var plan = new PairRepairPlanner().CreatePlan(host, client, PairTransportMode.LiveLan, findings); + + Assert.Contains(plan.Steps, step => step.ActionId == "pair.firewall.file-print" && step.Endpoint == PairEndpointRole.Host); + Assert.Contains(plan.Steps, step => step.ActionId == "pair.printer.share" && step.Endpoint == PairEndpointRole.Host); + Assert.Contains(plan.Steps, step => step.ActionId == "pair.printer.connect" && step.Endpoint == PairEndpointRole.Client); + var shareStep = Assert.Single(plan.Steps, step => step.ActionId == "pair.printer.share"); + var connectStep = Assert.Single(plan.Steps, step => step.ActionId == "pair.printer.connect"); + Assert.Equal(shareStep.Parameters["shareName"], connectStep.Parameters["shareName"]); + Assert.DoesNotContain(plan.Steps, step => step.ExpertOnly); + Assert.All(plan.Steps.Skip(1), step => Assert.Single(step.DependsOn)); + } + + [Fact] + public void Planner_adds_named_pipes_only_in_expert_mode() + { + var host = Snapshot(PairEndpointRole.Host, "HOST"); + var client = Snapshot(PairEndpointRole.Client, "CLIENT"); + var finding = new PairDiagnosticFinding + { + RuleId = "pair.rpc.compatibility", + Title = "RPC", + Description = "RPC mismatch", + AffectedEndpoints = [PairEndpointRole.Host, PairEndpointRole.Client], + RecommendedActionIds = ["pair.rpc.named-pipes"], + ExpertOnly = true + }; + var planner = new PairRepairPlanner(); + Assert.Empty(planner.CreatePlan(host, client, PairTransportMode.LiveLan, [finding]).Steps); + Assert.Equal(2, planner.CreatePlan(host, client, PairTransportMode.LiveLan, [finding], true).Steps.Count); + } + + private static PairEndpointSnapshot Snapshot(PairEndpointRole role, string name) => new() + { + Endpoint = new PairEndpointDescriptor { Role = role, ComputerName = name, IsLocalAgent = true } + }; + + private static IReadOnlyDictionary RunningServices() => new Dictionary + { + ["FDResPub"] = "Running", + ["fdPHost"] = "Running" + }; +} + +public sealed class PairRepairExecutorTests +{ + [Fact] + public async Task Verification_failure_rolls_back_both_endpoints_in_reverse_order() + { + var root = Path.Combine(Path.GetTempPath(), "wfix-pair-tests", Guid.NewGuid().ToString("N")); + try + { + var dispatcher = new FakeDispatcher(failVerificationFor: "s2"); + var executor = new PairRepairExecutor(dispatcher, new PairRunReportService(root), root); + var plan = Plan(); + var targets = new Dictionary + { + [PairEndpointRole.Host] = TargetDescriptor.Local(), + [PairEndpointRole.Client] = TargetDescriptor.Local() + }; + var run = await executor.ExecuteAsync(plan, targets); + Assert.Equal(PairRunStatus.RolledBack, run.Status); + Assert.Equal(["s2", "s1"], dispatcher.RollbackOrder); + Assert.Contains(run.Steps, step => step.StepId == "s1" && step.RolledBack); + Assert.DoesNotContain("credential", await File.ReadAllTextAsync(Path.Combine(run.ReportDirectory!, "pair-run.json")), StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + + private static PairRepairPlan Plan() => new() + { + Id = Guid.NewGuid().ToString("N"), + Host = new PairEndpointDescriptor { Role = PairEndpointRole.Host, ComputerName = "HOST" }, + Client = new PairEndpointDescriptor { Role = PairEndpointRole.Client, ComputerName = "CLIENT" }, + TransportMode = PairTransportMode.LiveLan, + Steps = + [ + new PairRepairStep { Id = "s1", ActionId = "pair.test.one", Endpoint = PairEndpointRole.Host, Title = "one" }, + new PairRepairStep { Id = "s2", ActionId = "pair.test.two", Endpoint = PairEndpointRole.Client, Title = "two", DependsOn = ["s1"] } + ] + }; + + private sealed class FakeDispatcher(string failVerificationFor) : IPairActionDispatcher + { + public List RollbackOrder { get; } = []; + + public Task PrepareAsync(PairActionContext context, CancellationToken cancellationToken = default) => + Task.FromResult(new PairActionCheckpoint { ActionId = context.Step.ActionId, Endpoint = context.Step.Endpoint }); + + public Task ExecuteAsync(PairActionContext context, CancellationToken cancellationToken = default) => + Task.FromResult(new PairActionResult { Success = true, Summary = "done" }); + + public Task VerifyAsync(PairActionContext context, CancellationToken cancellationToken = default) => + Task.FromResult(context.Step.Id != failVerificationFor); + + public Task RollbackAsync(PairActionContext context, PairActionCheckpoint checkpoint, CancellationToken cancellationToken = default) + { + RollbackOrder.Add(context.Step.Id); + return Task.FromResult(new PairActionResult { Success = true, Summary = "rolled back" }); + } + } +}