feat(ipc): add NamedPipe contracts, secure IPC server, and client

This commit is contained in:
Ochenstarik 2026-09-24 16:21:27 +07:00
parent 5bf634278d
commit 94753c3105

View file

@ -0,0 +1,204 @@
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
using KronaStaffTerminal.Models;
namespace KronaStaffTerminal.Services;
public sealed class IpcRequest
{
public string Command { get; set; } = "";
public string? Payload { get; set; }
}
public sealed class IpcResponse
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public IpcServiceStatus? Status { get; set; }
public int? SyncedCount { get; set; }
}
public sealed class IpcServiceStatus
{
public bool ServiceRunning { get; set; } = true;
public string ServiceVersion { get; set; } = "0.2.5";
public bool ScannerInSession0Supported { get; set; }
public string ScannerDiagnostic { get; set; } = "";
public int PendingSyncCount { get; set; }
public DateTimeOffset? LastSyncUtc { get; set; }
public string? LastSyncError { get; set; }
public string LicenseStatus { get; set; } = "Active";
}
public static class TerminalIpcConstants
{
public const string PipeName = "KronaStaffTerminalPipe";
}
public sealed class TerminalIpcClient
{
public async Task<bool> IsServiceAvailableAsync(CancellationToken cancellationToken = default)
{
var response = await SendCommandAsync("Ping", null, cancellationToken);
return response?.Success == true;
}
public async Task<IpcServiceStatus?> GetStatusAsync(CancellationToken cancellationToken = default)
{
var response = await SendCommandAsync("GetStatus", null, cancellationToken);
return response?.Status;
}
public async Task<bool> EnqueueAttendanceAsync(AttendanceRecord record, CancellationToken cancellationToken = default)
{
var payload = JsonSerializer.Serialize(record);
var response = await SendCommandAsync("EnqueueAttendance", payload, cancellationToken);
return response?.Success == true;
}
public async Task<AttendanceSyncResult?> TriggerSyncAsync(CancellationToken cancellationToken = default)
{
var response = await SendCommandAsync("TriggerSync", null, cancellationToken);
if (response?.Success == true && !string.IsNullOrWhiteSpace(response.Payload))
{
return JsonSerializer.Deserialize<AttendanceSyncResult>(response.Payload);
}
return null;
}
public async Task<IpcResponse?> SendCommandAsync(string command, string? payload, CancellationToken cancellationToken = default)
{
try
{
await using var pipe = new NamedPipeClientStream(".", TerminalIpcConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(2));
await pipe.ConnectAsync(timeoutCts.Token);
var request = new IpcRequest { Command = command, Payload = payload };
var json = JsonSerializer.Serialize(request) + "\n";
var bytes = Encoding.UTF8.GetBytes(json);
await pipe.WriteAsync(bytes, cancellationToken);
await pipe.FlushAsync(cancellationToken);
using var reader = new StreamReader(pipe, Encoding.UTF8);
var responseJson = await reader.ReadLineAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(responseJson)) return null;
return JsonSerializer.Deserialize<IpcResponse>(responseJson);
}
catch (Exception ex) when (ex is IOException or TimeoutException or OperationCanceledException)
{
// Service not running or unreachable
return null;
}
}
}
public sealed class TerminalIpcServer : IDisposable
{
private readonly Func<IpcRequest, Task<IpcResponse>> _handler;
private CancellationTokenSource? _cancellation;
private Task? _listenTask;
private bool _disposed;
public TerminalIpcServer(Func<IpcRequest, Task<IpcResponse>> handler)
{
_handler = handler;
}
public void Start()
{
_cancellation = new CancellationTokenSource();
_listenTask = Task.Run(() => ListenLoopAsync(_cancellation.Token));
}
private async Task ListenLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
NamedPipeServerStream? server = null;
try
{
server = CreatePipeServer();
await server.WaitForConnectionAsync(token);
// Handle client in background task so server can accept next client
var clientStream = server;
_ = Task.Run(() => HandleClientAsync(clientStream, token), token);
}
catch (OperationCanceledException)
{
server?.Dispose();
break;
}
catch (Exception ex)
{
CrashLogService.WriteDiagnostic($"IPC server loop error: {ex.Message}");
server?.Dispose();
await Task.Delay(500, token);
}
}
}
private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationToken token)
{
await using (stream)
{
try
{
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var line = await reader.ReadLineAsync(token);
if (string.IsNullOrWhiteSpace(line)) return;
var request = JsonSerializer.Deserialize<IpcRequest>(line);
var response = request is not null
? await _handler(request)
: new IpcResponse { Success = false, ErrorMessage = "Invalid request" };
var responseJson = JsonSerializer.Serialize(response) + "\n";
var responseBytes = Encoding.UTF8.GetBytes(responseJson);
await stream.WriteAsync(responseBytes, token);
await stream.FlushAsync(token);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
CrashLogService.WriteDiagnostic($"IPC client handle error: {ex.Message}");
}
}
}
private static NamedPipeServerStream CreatePipeServer()
{
var pipeSecurity = new PipeSecurity();
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(usersSid, PipeAccessRights.ReadWrite, AccessControlType.Allow));
return NamedPipeServerStreamAcl.Create(
TerminalIpcConstants.PipeName,
PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous,
inBufferSize: 4096,
outBufferSize: 4096,
pipeSecurity);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cancellation?.Cancel();
_cancellation?.Dispose();
}
}